123456789101112131415import { deflate } from "pako";1617import { isProUser } from "./license";18import * as vault from "./vault";19import {20 type CodexRestorePayload,21 type CodexVaultSnapshot,22 CHUNK_SIZE,23 CODES_PER_PAGE,24 computeParityShards,25 STORAGE_KEY_LAST_RESTORED,26 STORAGE_KEY_VERIFY_CODE,27} from "../shared/codex-types";28import { IMPORT_LIMITS } from "../shared/import-limits";29303132const VERIFY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; 33343536const VERIFY_CODE_PATTERN = /^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/;3738394041424344function mintVerifyCode(): string {45 const random = new Uint8Array(8);46 crypto.getRandomValues(random);47 let group = "";48 for (let i = 0; i < 8; i++) {49 group += VERIFY_CODE_ALPHABET[random[i] % VERIFY_CODE_ALPHABET.length];50 }51 return `PX-${group.slice(0, 4)}-${group.slice(4, 8)}`;52}5354async function getOrCreateVerifyCode(): Promise<string> {55 const raw = await chrome.storage.local.get(STORAGE_KEY_VERIFY_CODE);56 const existing = (raw as Record<string, unknown>)[STORAGE_KEY_VERIFY_CODE];57 if (typeof existing === "string" && existing.length > 0) {58 return existing;59 }60 const fresh = mintVerifyCode();61 await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: fresh });62 return fresh;63}6465666768697071function base64urlToString(b64url: string): string {72 const pad = b64url.length % 4 === 0 ? "" : "=".repeat(4 - (b64url.length % 4));73 const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + pad;74 const binary = atob(b64);75 const bytes = new Uint8Array(binary.length);76 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);77 return new TextDecoder().decode(bytes);78}798081828384858687export async function handleGetCodexVaultData(): Promise<{88 ok: boolean;89 error?: string;90 data?: CodexVaultSnapshot;91}> {92 if (!(await isProUser())) {93 return { ok: false, error: "pro_required" };94 }95 if (!vault.isUnlocked()) {96 return { ok: false, error: "VAULT_LOCKED: Unlock your vault first." };97 }9899 const raw = await chrome.storage.local.get([100 "peach_vault_a",101 "peach_vault_b",102 "peach_last_modified_at",103 ]);104 const slotARaw = (raw as Record<string, unknown>)["peach_vault_a"];105 const slotBRaw = (raw as Record<string, unknown>)["peach_vault_b"];106 const lastModRaw = (raw as Record<string, unknown>)["peach_last_modified_at"];107108 if (!slotARaw) {109 return { ok: false, error: "VAULT_EMPTY: No vault to print." };110 }111112 113 114 const slotA = JSON.stringify(slotARaw);115 const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;116 const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;117118 119 120 121 let recoveryWords: string[] | null = null;122 try {123 recoveryWords = await vault.getRecoveryWords();124 } catch {125 recoveryWords = null;126 }127128 const verifyCode = await getOrCreateVerifyCode();129 const entryCount = vault.getEntries().length;130131 const snapshot: CodexVaultSnapshot = {132 slotA,133 slotB,134 lastModified,135 entryCount,136 recoveryWords,137 verifyCode,138 vaultUnlocked: true,139 };140 return { ok: true, data: snapshot };141}142143144145146147148149150151export async function handleEstimateCodexPages(): Promise<{152 ok: boolean;153 error?: string;154 pages?: number;155 parityShards?: number;156}> {157 if (!(await isProUser())) {158 return { ok: false, error: "pro_required" };159 }160 try {161 const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);162 const slotA = (raw as Record<string, unknown>)["peach_vault_a"];163 const slotB = (raw as Record<string, unknown>)["peach_vault_b"];164 if (!slotA) return { ok: true, pages: 3, parityShards: 2 };165166 167 168 169 170 const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });171 const compressed = deflate(new TextEncoder().encode(blobJson));172 const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));173 const parityShards = computeParityShards(dataChunks);174 const totalChunks = dataChunks + parityShards;175 const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));176 return { ok: true, pages, parityShards };177 } catch {178 return { ok: true, pages: 3, parityShards: 2 };179 }180}181182183184185186187188189export async function handleRestoreFromCodex(190 payload: CodexRestorePayload,191): Promise<{192 ok: boolean;193 error?: string;194 data?: { entryCount: number; verifyCode: string };195}> {196 197 198 199 if (!VERIFY_CODE_PATTERN.test(payload.verifyCode)) {200 return { ok: false, error: "INVALID_VERIFY_CODE" };201 }202 if (!Number.isFinite(payload.timestamp)) {203 return { ok: false, error: "INVALID_VAULT_BLOB" };204 }205 if (payload.vaultBlob.length > IMPORT_LIMITS.MAX_JSON_BYTES) {206 return { ok: false, error: "INVALID_VAULT_BLOB" };207 }208209 210 211 let slotAJson: string;212 let slotBJson: string | null;213 try {214 const decoded = base64urlToString(payload.vaultBlob);215 const parsed = JSON.parse(decoded) as { a: unknown; b: unknown };216 if (!parsed || typeof parsed !== "object" || parsed.a == null) {217 return { ok: false, error: "INVALID_VAULT_BLOB" };218 }219 220 221 222 223 slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a);224 slotBJson =225 parsed.b == null226 ? null227 : typeof parsed.b === "string"228 ? parsed.b229 : JSON.stringify(parsed.b);230 } catch {231 return { ok: false, error: "INVALID_VAULT_BLOB" };232 }233234 const result = await vault.unlockFromCodex({235 slotA: slotAJson,236 slotB: slotBJson,237 timestamp: payload.timestamp,238 masterPassphrase: payload.masterPassphrase,239 });240241 if (!result.ok) {242 if (result.reason === "wrong_passphrase") {243 return { ok: false, error: "wrong_passphrase" };244 }245 return { ok: false, error: "INVALID_VAULT_BLOB" };246 }247248 249 250 251 await chrome.storage.local.set({252 [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,253 [STORAGE_KEY_LAST_RESTORED]: Date.now(),254 });255256 return {257 ok: true,258 data: {259 entryCount: result.entryCount,260 verifyCode: payload.verifyCode,261 },262 };263}