storage.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * mavi vault in this browser only (localStorage).
  3. * Secrets are encrypted; never send vault contents to krypco servers.
  4. */
  5. import {
  6. decryptVaultSecret,
  7. encryptVaultSecret,
  8. isVaultRecord,
  9. type CreatedWallet,
  10. type VaultRecord,
  11. walletFromMnemonic,
  12. } from "@krypco/mavi-core";
  13. const VAULT_KEY = "mavi_vault_v1";
  14. const SESSION_KEY = "mavi_unlocked_v1";
  15. export type UnlockedMavi = {
  16. address: `0x${string}`;
  17. mnemonic: string;
  18. privateKey: `0x${string}`;
  19. wordCount: 12 | 24;
  20. };
  21. export function readVault(): VaultRecord | null {
  22. if (typeof window === "undefined") return null;
  23. try {
  24. const raw = window.localStorage.getItem(VAULT_KEY);
  25. if (!raw) return null;
  26. const parsed: unknown = JSON.parse(raw);
  27. return isVaultRecord(parsed) ? parsed : null;
  28. } catch {
  29. return null;
  30. }
  31. }
  32. export function hasVault(): boolean {
  33. return readVault() !== null;
  34. }
  35. export async function saveNewVault(
  36. password: string,
  37. wallet: CreatedWallet,
  38. ): Promise<VaultRecord> {
  39. const { salt, iv, ciphertext } = await encryptVaultSecret(password, {
  40. mnemonic: wallet.mnemonic,
  41. });
  42. const record: VaultRecord = {
  43. version: 1,
  44. brand: "mavi",
  45. address: wallet.address,
  46. wordCount: wallet.wordCount,
  47. createdAt: new Date().toISOString(),
  48. salt,
  49. iv,
  50. ciphertext,
  51. };
  52. window.localStorage.setItem(VAULT_KEY, JSON.stringify(record));
  53. return record;
  54. }
  55. export async function unlockVault(password: string): Promise<UnlockedMavi> {
  56. const record = readVault();
  57. if (!record) throw new Error("No mavi wallet on this browser yet");
  58. const secret = await decryptVaultSecret(password, record);
  59. const wallet = walletFromMnemonic(secret.mnemonic, record.wordCount);
  60. const unlocked: UnlockedMavi = {
  61. address: wallet.address,
  62. mnemonic: wallet.mnemonic,
  63. privateKey: wallet.privateKey,
  64. wordCount: wallet.wordCount,
  65. };
  66. writeUnlockedSession(unlocked);
  67. return unlocked;
  68. }
  69. function writeUnlockedSession(unlocked: UnlockedMavi): void {
  70. // Session-only (this browser tab/window group): lock clears it.
  71. try {
  72. window.sessionStorage.setItem(
  73. SESSION_KEY,
  74. JSON.stringify({
  75. address: unlocked.address,
  76. wordCount: unlocked.wordCount,
  77. mnemonic: unlocked.mnemonic,
  78. privateKey: unlocked.privateKey,
  79. unlockedAt: new Date().toISOString(),
  80. }),
  81. );
  82. window.dispatchEvent(new Event("mavi-unlock"));
  83. } catch {
  84. /* private browsing quota */
  85. }
  86. }
  87. /** True when this tab already has an unlocked session (no password again). */
  88. export function isUnlocked(): boolean {
  89. return readUnlockedSession() !== null;
  90. }
  91. /**
  92. * Prefer existing unlocked session; otherwise unlock with password.
  93. * Use from Send / any gated action so one unlock covers the whole tab.
  94. */
  95. export async function ensureUnlocked(
  96. password?: string,
  97. ): Promise<UnlockedMavi> {
  98. const existing = readUnlockedSession();
  99. if (existing) return existing;
  100. if (!password) {
  101. throw new Error("Unlock mavi first (enter your password).");
  102. }
  103. return unlockVault(password);
  104. }
  105. export function readUnlockedSession(): UnlockedMavi | null {
  106. if (typeof window === "undefined") return null;
  107. try {
  108. const raw = window.sessionStorage.getItem(SESSION_KEY);
  109. if (!raw) return null;
  110. const p = JSON.parse(raw) as Partial<UnlockedMavi>;
  111. if (
  112. typeof p.address === "string" &&
  113. typeof p.mnemonic === "string" &&
  114. typeof p.privateKey === "string" &&
  115. (p.wordCount === 12 || p.wordCount === 24)
  116. ) {
  117. return {
  118. address: p.address as `0x${string}`,
  119. mnemonic: p.mnemonic,
  120. privateKey: p.privateKey as `0x${string}`,
  121. wordCount: p.wordCount,
  122. };
  123. }
  124. return null;
  125. } catch {
  126. return null;
  127. }
  128. }
  129. export function lockSession(): void {
  130. if (typeof window === "undefined") return;
  131. try {
  132. window.sessionStorage.removeItem(SESSION_KEY);
  133. } catch {
  134. /* ignore */
  135. }
  136. }
  137. /** Danger: removes the encrypted vault from this browser. Recovery phrase still works if written down. */
  138. export function deleteVaultLocal(): void {
  139. if (typeof window === "undefined") return;
  140. try {
  141. window.localStorage.removeItem(VAULT_KEY);
  142. } catch {
  143. /* ignore */
  144. }
  145. lockSession();
  146. }
  147. export function shortAddress(address: string): string {
  148. if (address.length < 12) return address;
  149. return `${address.slice(0, 6)}…${address.slice(-4)}`;
  150. }