import * as openpgp from "openpgp"; import { base45Encode, base45Decode } from "./base45"; import { crc16CcittFalse } from "./crc16"; import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types"; function nonEmptyTrimmed(s?: string): string | undefined { if (!s) return undefined; const t = s.trim(); return t.length ? t : undefined; } export function normalizeMnemonic(words: string): string { return words.trim().toLowerCase().replace(/\s+/g, " "); } export function buildPlaintext( mnemonic: string, bip39PassphraseUsed: boolean, recipientFingerprints?: string[] ): SeedPgpPlaintext { const plain: SeedPgpPlaintext = { v: 1, t: "bip39", w: normalizeMnemonic(mnemonic), l: "en", pp: bip39PassphraseUsed ? 1 : 0, }; if (recipientFingerprints && recipientFingerprints.length > 0) { plain.fpr = recipientFingerprints; } return plain; } export function frameEncode(pgpBinary: Uint8Array): string { const crc = crc16CcittFalse(pgpBinary); const b45 = base45Encode(pgpBinary); return `SEEDPGP1:0:${crc}:${b45}`; } export function frameParse(text: string): ParsedSeedPgpFrame { const s = text.trim().replace(/^["']|["']$/g, "").replace(/[\n\r\t]/g, ""); if (!s.startsWith("SEEDPGP1:")) throw new Error("Missing SEEDPGP1: prefix"); const parts = s.split(":"); if (parts.length < 4) { throw new Error("Invalid frame format (need at least 4 colon-separated parts)"); } const prefix = parts[0]; const frame = parts[1]; const crc16 = parts[2].toUpperCase(); const b45 = parts.slice(3).join(":"); if (prefix !== "SEEDPGP1") throw new Error("Invalid prefix"); if (frame !== "0") throw new Error("Multipart frames not supported in this prototype"); if (!/^[0-9A-F]{4}$/.test(crc16)) throw new Error("Invalid CRC16 format (must be 4 hex chars)"); return { kind: "single", crc16, b45 }; } export function frameDecodeToPgpBytes(frameText: string): Uint8Array { const f = frameParse(frameText); const pgp = base45Decode(f.b45); const crc = crc16CcittFalse(pgp); if (crc !== f.crc16) { throw new Error(`CRC16 mismatch! Expected: ${f.crc16}, Got: ${crc}. QR scan may be corrupted.`); } return pgp; } export async function encryptToSeedPgp(params: { plaintext: SeedPgpPlaintext; publicKeyArmored?: string; messagePassword?: string; }): Promise<{ framed: string; pgpBytes: Uint8Array; recipientFingerprint?: string }> { const pub = nonEmptyTrimmed(params.publicKeyArmored); const pw = nonEmptyTrimmed(params.messagePassword); if (!pub && !pw) { throw new Error("Provide either a PGP public key or a message password (or both)."); } let encryptionKeys: openpgp.PublicKey[] = []; let recipientFingerprint: string | undefined; if (pub) { const pubKey = await openpgp.readKey({ armoredKey: pub }); try { await pubKey.getEncryptionKey(); } catch { throw new Error("This public key has no usable encryption subkey (E)."); } recipientFingerprint = pubKey.getFingerprint().toUpperCase(); encryptionKeys = [pubKey]; } const message = await openpgp.createMessage({ text: JSON.stringify(params.plaintext) }); const encrypted = await openpgp.encrypt({ message, encryptionKeys, passwords: pw ? [pw] : [], format: "binary", config: { preferredSymmetricAlgorithm: openpgp.enums.symmetric.aes256, }, }); const pgpBytes = new Uint8Array(encrypted as Uint8Array); return { framed: frameEncode(pgpBytes), pgpBytes, recipientFingerprint }; } export async function decryptSeedPgp(params: { frameText: string; privateKeyArmored?: string; privateKeyPassphrase?: string; messagePassword?: string; }): Promise { const pgpBytes = frameDecodeToPgpBytes(params.frameText); const message = await openpgp.readMessage({ binaryMessage: pgpBytes }); const encKeyIds: openpgp.KeyID[] = message.getEncryptionKeyIDs?.() ?? []; const privArmored = nonEmptyTrimmed(params.privateKeyArmored); const privPw = nonEmptyTrimmed(params.privateKeyPassphrase); const msgPw = nonEmptyTrimmed(params.messagePassword); let decryptionKeys: openpgp.PrivateKey[] = []; if (privArmored) { let privKey = await openpgp.readPrivateKey({ armoredKey: privArmored }); if (!privKey.isDecrypted()) { if (!privPw) { throw new Error("Private key is still locked. Enter the private key passphrase."); } privKey = await openpgp.decryptKey({ privateKey: privKey, passphrase: privPw }); if (!privKey.isDecrypted()) { throw new Error("Private key passphrase incorrect (key still locked)."); } } // Preflight: validate the private key matches a recipient if (encKeyIds.length) { const dec = await privKey.getDecryptionKeys(); const decArr = Array.isArray(dec) ? dec : [dec]; const decKeyIds = decArr.map((k: any) => k.getKeyID().toHex().toUpperCase()); const want = new Set(encKeyIds.map((kid: openpgp.KeyID) => kid.toHex().toUpperCase())); const matched = decKeyIds.some((id: string) => want.has(id)); if (!matched) { throw new Error( "This payload is not encrypted to the provided private key (no matching recipient KeyID)." ); } } decryptionKeys = [privKey]; } let decryptResult; try { const decryptOptions: any = { message, format: "utf8", }; if (decryptionKeys.length > 0) { decryptOptions.decryptionKeys = decryptionKeys; } if (msgPw) { decryptOptions.passwords = [msgPw]; } decryptResult = await openpgp.decrypt(decryptOptions); } catch (err: any) { console.error("SeedPGP: decrypt failed:", err.message); throw err; } const { data } = decryptResult; const obj = JSON.parse(data as string) as SeedPgpPlaintext; if (obj.v !== 1) throw new Error(`Unsupported version: ${obj.v}`); if (obj.t !== "bip39") throw new Error(`Unsupported type: ${obj.t}`); if (obj.l !== "en") throw new Error(`Unsupported language: ${obj.l}`); return obj; }