mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-06 17:37:51 +08:00
- Implement cv25519 PGP encryption/decryption - Add Base45 encoding with CRC16 integrity checks - Create SEEDPGP1 frame format for QR codes - Support BIP39 passphrase flag indicator - Add comprehensive test suite with Trezor BIP39 vectors - 15 passing tests covering all core functionality
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
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<SeedPgpPlaintext> {
|
|
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;
|
|
}
|