From 6d6ad5e2c538a0357e368d29a42e723b5d665c7a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:23:56 -0400 Subject: [PATCH] TS port slice 2: entry schemas, password KDF, legacy vault formats - Zod schemas for all 9 entry kinds (schema_version 4): validate and roundtrip the Python EntryManager-generated fixture unchanged; refuse future schema versions. - Password KDF parity: PBKDF2-SHA256 (NFKD+strip, fingerprint salt, 50k/100k iters) and Argon2id via @noble/hashes, both fixture-verified. - Legacy vault decrypt chain matching EncryptionManager.decrypt_data: V3 GCM -> V2 GCM -> V2-header-over-Fernet -> raw Fernet (decrypt-only), plus the JSON kdf/ct file wrapper with bare-ciphertext fallback; the parent-seed file decrypts end-to-end from password. - Fixture generator grows password_kdf.json and legacy_payloads.json (pinned IVs/nonces, fixture-only). - 100 parity tests green in Node and jsdom. Co-Authored-By: Claude Fable 5 --- docs/typescript_port_compatibility_matrix.md | 14 +- js/packages/core/package.json | 3 +- js/packages/core/src/index.ts | 27 +++ js/packages/core/src/schema/entries.ts | 199 ++++++++++++++++++ js/packages/core/src/vault/fernet.ts | 91 ++++++++ js/packages/core/src/vault/passwordKdf.ts | 67 ++++++ js/packages/core/src/vault/payload.ts | 81 +++++++ js/packages/core/test/vault.test.ts | 138 ++++++++++++ .../fixtures/legacy_payloads.json | 15 ++ .../test-vectors/fixtures/manifest.json | 4 +- .../test-vectors/fixtures/password_kdf.json | 72 +++++++ js/packages/test-vectors/src/index.ts | 37 ++++ js/pnpm-lock.yaml | 8 + scripts/generate_ts_port_fixtures.py | 119 +++++++++++ 14 files changed, 868 insertions(+), 7 deletions(-) create mode 100644 js/packages/core/src/schema/entries.ts create mode 100644 js/packages/core/src/vault/fernet.ts create mode 100644 js/packages/core/src/vault/passwordKdf.ts create mode 100644 js/packages/core/src/vault/payload.ts create mode 100644 js/packages/core/test/vault.test.ts create mode 100644 js/packages/test-vectors/fixtures/legacy_payloads.json create mode 100644 js/packages/test-vectors/fixtures/password_kdf.json diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 8a314b20..48053231 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -19,12 +19,16 @@ until a later milestone. | Password derivation v2 | P0 | green | `passwords_v2.json` | Same case grid as v1. | | Fingerprints | P0 | green | `fingerprints.json` | Includes normalization case (strip + lower). Non-ASCII normalization unverified (Python `str.lower()` vs JS `toLowerCase()`); BIP-39 mnemonics are ASCII so not P0. | | Vault index key (seed-only) | P0 | green | `index_keys.json` | HKDF-SHA256 chain, zero salt, urlsafe base64. | -| Vault V3 payload decrypt | P0 | green | `vault_v3_payload.json` | AES-256-GCM via WebCrypto; tamper-rejection tested. Fixture nonce is pinned (fixture-only). Legacy V2/Fernet formats: todo (migration path, Milestone 3). | -| Entry schema roundtrip | P0 | partial | `entries_index.json` | One entry of each kind generated by the real Python `EntryManager`, timestamps pinned. TS currently validates key structure only; typed schema validation + roundtrip is Milestone 1/3 work. | +| Vault V3 payload decrypt | P0 | green | `vault_v3_payload.json` | AES-256-GCM via WebCrypto; tamper-rejection tested. Fixture nonce is pinned (fixture-only). | +| Legacy V2/Fernet payload decrypt | P0 | green | `legacy_payloads.json` | Full `decrypt_data` fallback chain: V3 -> V2 GCM -> V2-header-over-Fernet -> raw Fernet. Decrypt-only by design. | +| Encrypted file wrapper (kdf/ct JSON) | P0 | green | `legacy_payloads.json` | `_serialize`/`_deserialize` parity incl. legacy bare-ciphertext fallback; parent-seed decrypt verified end-to-end from password. | +| Entry schema roundtrip | P0 | green | `entries_index.json` | Zod schemas for all 9 kinds validate the Python `EntryManager`-generated index and roundtrip it unchanged; future schema_version refused. Older-version migrations (0-3): todo. | | TOTP secret derivation + codes | P1 | green | `totp.json` | Path child is `0x544F5450` (= `int.from_bytes(b"TOTP")`) — note: an earlier draft of this doc/code said 1414812756; correct value is 1414485072. RFC 6238 SHA-1 codes verified at fixed timestamps. | | Nostr keys (app 1237) | P1 | green | `nostr_keys.json` | Private/public hex (x-only), npub, nsec. Legacy fingerprint-hash and app-0 derivations: todo. | | Managed seeds (BIP-85 child mnemonics) | P0 | green | `managed_seeds.json` | 12/24 words from 12- and 24-word parents, plus child fingerprints. 18-word: untested (Python supports; add cases when needed). | -| KDF metadata parsing | P0 | partial | `kdf_metadata.json` | Shapes documented; TS parser/Argon2id implementation not started (Milestone 3; Argon2 needs a reviewed WASM/JS impl). | +| Password KDF (PBKDF2-SHA256) | P0 | green | `password_kdf.json` | NFKD+strip normalization incl. a unicode/whitespace case; 50k and 100k iterations; fingerprint-derived salt. | +| Password KDF (Argon2id) | P0 | green | `password_kdf.json` | `@noble/hashes` argon2id (pure JS, audited family); default (t=2, m=64MiB, p=8) and light params verified. | +| KDF metadata parsing | P0 | green | `kdf_metadata.json`, `legacy_payloads.json` | KdfConfig schema with Python-default fallbacks. | | SSH key derivation | P1 | todo | — | Ed25519 from BIP-85 app 32; PEM serialization parity risk. | | PGP key derivation | P1/P2 | todo | — | Highest parity risk (PGPy serialization); import/roundtrip first. | | Nostr sync events 30070/30071/30072 | P0 | todo | — | Milestone 4. | @@ -35,8 +39,8 @@ until a later milestone. | Environment | Status | |---|---| -| Node 22 (vitest) | green — 82/82 | -| jsdom browser-like env | green — 82/82 | +| Node 22 (vitest) | green — 100/100 | +| jsdom browser-like env | green — 100/100 | | Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI | ## Dependency notes (to expand into docs/typescript_dependency_review.md) diff --git a/js/packages/core/package.json b/js/packages/core/package.json index d5dc9109..c356fb88 100644 --- a/js/packages/core/package.json +++ b/js/packages/core/package.json @@ -18,7 +18,8 @@ "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.0", - "@scure/bip39": "^2.0.0" + "@scure/bip39": "^2.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@seedpass/test-vectors": "workspace:*", diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 221afd04..078a89aa 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -28,4 +28,31 @@ export { export { generateFingerprint } from "./derive/fingerprint.js"; export { deriveIndexKey, deriveIndexKeyBytes } from "./vault/indexKey.js"; export { decryptV3, encryptV3, isV3Payload } from "./vault/aead.js"; +export { + deriveKeyFromPassword, + deriveKeyFromPasswordArgon2, + kdfConfigSchema, + type KdfConfig, +} from "./vault/passwordKdf.js"; +export { fernetDecrypt, FernetError } from "./vault/fernet.js"; +export { decryptPayload, parseEncryptedFile, type EncryptedFile } from "./vault/payload.js"; +export { + entrySchema, + vaultIndexSchema, + parseVaultIndex, + customFieldSchema, + passwordEntrySchema, + totpEntrySchema, + sshEntrySchema, + seedEntrySchema, + pgpEntrySchema, + nostrEntrySchema, + keyValueEntrySchema, + managedAccountEntrySchema, + documentEntrySchema, + UnsupportedSchemaVersionError, + CURRENT_SCHEMA_VERSION, + type Entry, + type VaultIndex, +} from "./schema/entries.js"; export * from "./util/bytes.js"; diff --git a/js/packages/core/src/schema/entries.ts b/js/packages/core/src/schema/entries.ts new file mode 100644 index 00000000..453459ea --- /dev/null +++ b/js/packages/core/src/schema/entries.ts @@ -0,0 +1,199 @@ +/** + * Entry and vault-index schemas, current schema_version 4. + * + * Parity targets: src/seedpass/core/entry_types.py, entry_management.py and + * migrations.py. Schemas are intentionally tolerant of unknown extra fields + * (loose objects) so that entries written by a newer minor revision survive a + * read/write cycle — but an index whose schema_version is above + * CURRENT_SCHEMA_VERSION must be refused, never silently rewritten + * (plan section 13, Milestone 3 exit criteria). + */ + +import { z } from "zod"; + +export const CURRENT_SCHEMA_VERSION = 4; + +export const customFieldSchema = z + .object({ + label: z.string(), + value: z.string(), + is_hidden: z.boolean().optional(), + }) + .loose(); + +/** Fields shared by every entry kind (docs/entry_types.md). */ +const baseEntry = { + type: z.string(), + kind: z.string(), + label: z.string(), + archived: z.boolean().default(false), + date_added: z.string().optional(), + date_modified: z.string().optional(), + modified_ts: z.number().int().optional(), + notes: z.string().default(""), + tags: z.array(z.string()).default([]), + links: z.array(z.unknown()).default([]), + custom_fields: z.array(customFieldSchema).optional(), + origin: z.string().optional(), +}; + +export const passwordEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("password"), + kind: z.literal("password"), + length: z.number().int().min(8).max(128), + // Absent gen_version means v1 (frozen); see password_generation.py + gen_version: z.number().int().optional(), + username: z.string().optional(), + url: z.string().optional(), + }) + .loose(); + +export const totpEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("totp"), + kind: z.literal("totp"), + deterministic: z.boolean().optional(), + period: z.number().int().positive().default(30), + digits: z.number().int().positive().default(6), + /** Derivation index — present for deterministic entries. */ + index: z.number().int().nonnegative().optional(), + /** Imported base32 secret — present for non-deterministic entries. */ + secret: z.string().optional(), + }) + .loose() + .check((ctx) => { + if (ctx.value.index === undefined && ctx.value.secret === undefined) { + ctx.issues.push({ + code: "custom", + message: "totp entry needs a derivation index or an imported secret", + input: ctx.value, + }); + } + }); + +export const sshEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("ssh"), + kind: z.literal("ssh"), + index: z.number().int().nonnegative(), + }) + .loose(); + +export const seedEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("seed"), + kind: z.literal("seed"), + index: z.number().int().nonnegative(), + word_count: z.union([z.literal(12), z.literal(18), z.literal(24)]), + }) + .loose(); + +export const pgpEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("pgp"), + kind: z.literal("pgp"), + index: z.number().int().nonnegative(), + key_type: z.string().default("ed25519"), + user_id: z.string().default(""), + }) + .loose(); + +export const nostrEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("nostr"), + kind: z.literal("nostr"), + index: z.number().int().nonnegative(), + }) + .loose(); + +export const keyValueEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("key_value"), + kind: z.literal("key_value"), + key: z.string(), + value: z.string(), + }) + .loose(); + +export const managedAccountEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("managed_account"), + kind: z.literal("managed_account"), + index: z.number().int().nonnegative(), + word_count: z.union([z.literal(12), z.literal(18), z.literal(24)]), + fingerprint: z.string().regex(/^[0-9A-F]{16}$/), + }) + .loose(); + +export const documentEntrySchema = z + .object({ + ...baseEntry, + type: z.literal("document"), + kind: z.literal("document"), + content: z.string(), + file_type: z.string().default("txt"), + }) + .loose(); + +export const entrySchema = z.discriminatedUnion("kind", [ + passwordEntrySchema, + totpEntrySchema, + sshEntrySchema, + seedEntrySchema, + pgpEntrySchema, + nostrEntrySchema, + keyValueEntrySchema, + managedAccountEntrySchema, + documentEntrySchema, +]); + +export type Entry = z.infer; +export type PasswordEntry = z.infer; +export type TotpEntry = z.infer; + +export const vaultIndexSchema = z + .object({ + schema_version: z.number().int(), + entries: z.record(z.string(), entrySchema), + }) + .loose(); + +export type VaultIndex = z.infer; + +export class UnsupportedSchemaVersionError extends Error { + constructor(public readonly version: number) { + super( + `Vault index schema_version ${version} is newer than supported ` + + `${CURRENT_SCHEMA_VERSION}; refusing to read it. Upgrade this client.`, + ); + this.name = "UnsupportedSchemaVersionError"; + } +} + +/** + * Parse a decrypted vault index. Refuses future schema versions instead of + * silently writing incompatible data; older versions are a migration concern + * (Python migrations.py) not yet ported. + */ +export function parseVaultIndex(data: unknown): VaultIndex { + const versionProbe = z.object({ schema_version: z.number().int() }).loose().parse(data); + if (versionProbe.schema_version > CURRENT_SCHEMA_VERSION) { + throw new UnsupportedSchemaVersionError(versionProbe.schema_version); + } + if (versionProbe.schema_version < CURRENT_SCHEMA_VERSION) { + throw new Error( + `Vault index schema_version ${versionProbe.schema_version} needs ` + + `migration; migrations are not ported yet`, + ); + } + return vaultIndexSchema.parse(data); +} diff --git a/js/packages/core/src/vault/fernet.ts b/js/packages/core/src/vault/fernet.ts new file mode 100644 index 00000000..0e1f3592 --- /dev/null +++ b/js/packages/core/src/vault/fernet.ts @@ -0,0 +1,91 @@ +/** + * Fernet token decryption (legacy vault format, migration only). + * + * Spec: https://github.com/fernet/spec — version 0x80, AES-128-CBC with + * PKCS7 padding, HMAC-SHA256 over version||timestamp||iv||ciphertext. + * The 32-byte key splits into signing key (first 16) and encryption key + * (last 16). TTL checking is not implemented — SeedPass never sets one. + * + * Decrypt-only by design: new data is always written as V3 AES-GCM. + */ + +import { hmac } from "@noble/hashes/hmac.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { base64url } from "@scure/base"; + +const VERSION = 0x80; +const HEADER_LENGTH = 1 + 8 + 16; // version + timestamp + IV +const HMAC_LENGTH = 32; + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; + return diff === 0; +} + +export class FernetError extends Error { + constructor(message: string) { + super(message); + this.name = "FernetError"; + } +} + +async function aesCbcDecrypt( + key: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array, +): Promise { + const cryptoKey = await globalThis.crypto.subtle.importKey( + "raw", + key as BufferSource, + { name: "AES-CBC" }, + false, + ["decrypt"], + ); + const plain = await globalThis.crypto.subtle.decrypt( + { name: "AES-CBC", iv: iv as BufferSource }, + cryptoKey, + ciphertext as BufferSource, + ); + return new Uint8Array(plain); +} + +/** + * Decrypt a Fernet token. `token` is the base64url token as stored on disk + * (either the ASCII bytes or the decoded string); `key` is the raw 32 bytes. + */ +export async function fernetDecrypt( + key: Uint8Array, + token: Uint8Array | string, +): Promise { + if (key.length !== 32) throw new FernetError("Fernet key must be 32 bytes"); + const tokenStr = typeof token === "string" ? token : new TextDecoder().decode(token); + let raw: Uint8Array; + try { + raw = base64url.decode(tokenStr.trim()); + } catch { + throw new FernetError("invalid token encoding"); + } + if (raw.length < HEADER_LENGTH + HMAC_LENGTH || raw[0] !== VERSION) { + throw new FernetError("invalid token structure"); + } + + const signed = raw.slice(0, raw.length - HMAC_LENGTH); + const tag = raw.slice(raw.length - HMAC_LENGTH); + const expected = hmac(sha256, key.slice(0, 16), signed); + if (!constantTimeEqual(tag, expected)) { + throw new FernetError("invalid token HMAC"); + } + + const iv = raw.slice(1 + 8, HEADER_LENGTH); + const ciphertext = raw.slice(HEADER_LENGTH, raw.length - HMAC_LENGTH); + if (ciphertext.length === 0 || ciphertext.length % 16 !== 0) { + throw new FernetError("invalid ciphertext length"); + } + try { + return await aesCbcDecrypt(key.slice(16, 32), iv, ciphertext); + } catch { + throw new FernetError("decryption failed"); + } +} diff --git a/js/packages/core/src/vault/passwordKdf.ts b/js/packages/core/src/vault/passwordKdf.ts new file mode 100644 index 00000000..773e15b8 --- /dev/null +++ b/js/packages/core/src/vault/passwordKdf.ts @@ -0,0 +1,67 @@ +/** + * Password-based key derivation for vault/parent-seed encryption. + * + * Parity targets: src/utils/key_derivation.py::derive_key_from_password and + * derive_key_from_password_argon2. Both normalize the password with NFKD and + * strip surrounding whitespace, derive 32 bytes, and return the key in + * URL-safe base64 (Fernet-compatible form). + */ + +import { pbkdf2 } from "@noble/hashes/pbkdf2.js"; +import { argon2id } from "@noble/hashes/argon2.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { base64, base64url } from "@scure/base"; +import { utf8 } from "../util/bytes.js"; +import { z } from "zod"; + +export const kdfConfigSchema = z + .object({ + // Defaults mirror Python KdfConfig(**{}) construction + name: z.string().default("argon2id"), + version: z.number().int().default(1), + params: z.record(z.string(), z.unknown()).default({}), + salt_b64: z.string().default(""), + }) + .loose(); + +export type KdfConfig = z.infer; + +function normalizePassword(password: string): Uint8Array { + if (!password) throw new Error("Password cannot be empty."); + return utf8(password.normalize("NFKD").trim()); +} + +/** + * PBKDF2-HMAC-SHA256 key from password + fingerprint. + * Salt = SHA256(fingerprint)[:16] (or the raw bytes when given directly). + */ +export function deriveKeyFromPassword( + password: string, + fingerprint: string | Uint8Array, + iterations = 100_000, +): string { + const salt = + typeof fingerprint === "string" ? sha256(utf8(fingerprint)).slice(0, 16) : fingerprint; + const key = pbkdf2(sha256, normalizePassword(password), salt, { + c: iterations, + dkLen: 32, + }); + return base64url.encode(key); +} + +/** Argon2id key from password + KdfConfig (salt and tuning from the config). */ +export function deriveKeyFromPasswordArgon2(password: string, kdf: KdfConfig): string { + const params = kdf.params as { + time_cost?: number; + memory_cost?: number; + parallelism?: number; + }; + const salt = base64.decode(kdf.salt_b64); + const key = argon2id(normalizePassword(password), salt, { + t: params.time_cost ?? 2, + m: params.memory_cost ?? 64 * 1024, + p: params.parallelism ?? 8, + dkLen: 32, + }); + return base64url.encode(key); +} diff --git a/js/packages/core/src/vault/payload.ts b/js/packages/core/src/vault/payload.ts new file mode 100644 index 00000000..3a0194ef --- /dev/null +++ b/js/packages/core/src/vault/payload.ts @@ -0,0 +1,81 @@ +/** + * Vault payload decryption with legacy fallbacks, mirroring + * EncryptionManager.decrypt_data, plus the JSON kdf/ct file wrapper from + * _serialize/_deserialize (src/seedpass/core/encryption.py). + * + * Order of attempts, identical to Python: + * 1. "V3|" prefix -> AES-256-GCM + * 2. "V2:" prefix -> AES-256-GCM; on auth failure, Fernet on the rest + * 3. otherwise -> legacy Fernet token + */ + +import { base64 } from "@scure/base"; +import { decryptV3, isV3Payload } from "./aead.js"; +import { fernetDecrypt } from "./fernet.js"; +import { kdfConfigSchema, type KdfConfig } from "./passwordKdf.js"; +import { utf8 } from "../util/bytes.js"; + +const V2_PREFIX = utf8("V2:"); +const NONCE_LENGTH = 12; + +function hasPrefix(payload: Uint8Array, prefix: Uint8Array): boolean { + return payload.length >= prefix.length && prefix.every((b, i) => payload[i] === b); +} + +async function decryptGcmBody(key: Uint8Array, payload: Uint8Array): Promise { + // Reuse the V3 path by re-prefixing the body: both formats are + // prefix + nonce(12) + ciphertext||tag with the same cipher. + const body = payload.slice(3); + if (body.length < NONCE_LENGTH + 16) throw new Error("AES-GCM payload too short"); + const reframed = new Uint8Array(3 + body.length); + reframed.set(utf8("V3|"), 0); + reframed.set(body, 3); + return decryptV3(key, reframed); +} + +/** Decrypt any supported vault payload format with `key` (raw 32 bytes). */ +export async function decryptPayload(key: Uint8Array, payload: Uint8Array): Promise { + if (isV3Payload(payload)) { + return decryptV3(key, payload); + } + if (hasPrefix(payload, V2_PREFIX)) { + try { + return await decryptGcmBody(key, payload); + } catch { + // Legacy files sometimes carried a wrong "V2:" header over a Fernet + // token; Python falls back the same way. + return fernetDecrypt(key, payload.slice(3)); + } + } + return fernetDecrypt(key, payload); +} + +export interface EncryptedFile { + kdf: KdfConfig; + ciphertext: Uint8Array; +} + +/** + * Parse a stored encrypted file: JSON {"kdf": {...}, "ct": ""} wrapper, + * or (legacy) the bare ciphertext with an assumed HKDF config. + */ +export function parseEncryptedFile(blob: Uint8Array): EncryptedFile { + try { + const obj = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob)) as { + kdf?: unknown; + ct?: string; + }; + if (obj && typeof obj === "object" && typeof obj.ct === "string") { + const ciphertext = base64.decode(obj.ct); + if (ciphertext.length > 0) { + return { kdf: kdfConfigSchema.parse(obj.kdf ?? {}), ciphertext }; + } + } + } catch { + // fall through to legacy + } + return { + kdf: { name: "hkdf", version: 1, params: {}, salt_b64: "" }, + ciphertext: blob, + }; +} diff --git a/js/packages/core/test/vault.test.ts b/js/packages/core/test/vault.test.ts new file mode 100644 index 00000000..8a6ef9db --- /dev/null +++ b/js/packages/core/test/vault.test.ts @@ -0,0 +1,138 @@ +/** + * Vault compatibility parity: entry schemas, password KDF, legacy payload + * formats, and the encrypted-file wrapper. Expected values are + * Python-generated fixtures. + */ + +import { describe, expect, it } from "vitest"; +import { base64 } from "@scure/base"; +import { + entriesIndex, + pbkdf2Cases, + argon2idCases, + legacyPayloads, + mnemonics, +} from "@seedpass/test-vectors"; +import { + parseVaultIndex, + vaultIndexSchema, + UnsupportedSchemaVersionError, + deriveKeyFromPassword, + deriveKeyFromPasswordArgon2, + fernetDecrypt, + FernetError, + decryptPayload, + parseEncryptedFile, + deriveIndexKeyBytes, +} from "@seedpass/core"; +import { base64url } from "@scure/base"; + +const indexKey = () => deriveIndexKeyBytes(mnemonics[legacyPayloads.mnemonic_id]!); + +describe("entry schemas (schema_version 4)", () => { + it("validates the Python-generated index with every entry kind", () => { + const parsed = parseVaultIndex(entriesIndex.entries); + const kinds = Object.values(parsed.entries).map((e) => e.kind); + expect(new Set(kinds)).toEqual( + new Set([ + "password", + "totp", + "ssh", + "nostr", + "key_value", + "document", + "seed", + "managed_account", + "pgp", + ]), + ); + }); + + it("roundtrips the fixture without altering it", () => { + const parsed = vaultIndexSchema.parse(entriesIndex.entries); + expect(JSON.parse(JSON.stringify(parsed))).toEqual(entriesIndex.entries); + }); + + it("refuses future schema versions", () => { + expect(() => parseVaultIndex({ schema_version: 5, entries: {} })).toThrow( + UnsupportedSchemaVersionError, + ); + }); + + it("rejects malformed entries", () => { + const bad = { + schema_version: 4, + entries: { + "0": { type: "password", kind: "password", label: "x", length: 4 }, + }, + }; + expect(() => parseVaultIndex(bad)).toThrow(); + }); +}); + +describe("password KDF", () => { + it.each(pbkdf2Cases)("pbkdf2 iters=$iterations '$password'", (c) => { + expect(deriveKeyFromPassword(c.password, c.fingerprint, c.iterations)).toBe( + c.key_urlsafe_b64, + ); + }); + + it.each(argon2idCases)( + "argon2id t=$kdf.params.time_cost m=$kdf.params.memory_cost", + (c) => { + expect(deriveKeyFromPasswordArgon2(c.password, c.kdf)).toBe(c.key_urlsafe_b64); + }, + ); +}); + +describe("legacy payload formats", () => { + // Compare as text, not TypedArray: cross-realm Uint8Array comparison is + // unreliable under jsdom even when the bytes are identical. + const text = (b: Uint8Array) => new TextDecoder().decode(b); + + it("decrypts a raw Fernet token", async () => { + const token = base64.decode(legacyPayloads.fernet_token_b64); + expect(text(await fernetDecrypt(indexKey(), token))).toBe(legacyPayloads.plaintext_utf8); + expect(text(await decryptPayload(indexKey(), token))).toBe(legacyPayloads.plaintext_utf8); + }); + + it("decrypts a V2-prefixed AES-GCM payload", async () => { + const payload = base64.decode(legacyPayloads.v2_gcm_payload_b64); + expect(text(await decryptPayload(indexKey(), payload))).toBe(legacyPayloads.plaintext_utf8); + }); + + it("decrypts a V2-prefixed Fernet payload via fallback", async () => { + const payload = base64.decode(legacyPayloads.v2_fernet_payload_b64); + expect(text(await decryptPayload(indexKey(), payload))).toBe(legacyPayloads.plaintext_utf8); + }); + + it("rejects a Fernet token with a bad HMAC", async () => { + const token = base64.decode(legacyPayloads.fernet_token_b64); + const str = new TextDecoder().decode(token); + const raw = base64url.decode(str); + raw[raw.length - 1]! ^= 0x01; + const tampered = base64url.encode(raw); + await expect(fernetDecrypt(indexKey(), tampered)).rejects.toThrow(FernetError); + }); +}); + +describe("encrypted file wrapper", () => { + it("parses the parent-seed wrapper and decrypts with the password key", async () => { + const spec = legacyPayloads.parent_seed_file; + const wrapper = base64.decode(spec.wrapper_b64); + const { kdf, ciphertext } = parseEncryptedFile(wrapper); + expect(kdf.name).toBe("pbkdf2-sha256"); + + const keyB64 = deriveKeyFromPassword(spec.password, spec.fingerprint); + const key = base64url.decode(keyB64); + const seed = new TextDecoder().decode(await decryptPayload(key, ciphertext)); + expect(seed).toBe(mnemonics[spec.expected_seed_mnemonic_id]); + }); + + it("falls back to legacy for non-JSON blobs", () => { + const blob = base64.decode(legacyPayloads.fernet_token_b64); + const { kdf, ciphertext } = parseEncryptedFile(blob); + expect(kdf.name).toBe("hkdf"); + expect(ciphertext).toEqual(blob); + }); +}); diff --git a/js/packages/test-vectors/fixtures/legacy_payloads.json b/js/packages/test-vectors/fixtures/legacy_payloads.json new file mode 100644 index 00000000..30283927 --- /dev/null +++ b/js/packages/test-vectors/fixtures/legacy_payloads.json @@ -0,0 +1,15 @@ +{ + "description": "Legacy/migration payload formats the TS port must read: raw Fernet token, V2-prefixed AES-GCM, V2-prefixed Fernet (wrong-header case), and the JSON kdf/ct file wrapper around a V3 blob. IVs/nonces/timestamps pinned for fixtures only.", + "fernet_token_b64": "Z0FBQUFBQmxVX0VBRUl2aGEzal9oSk5vN0FvN3ZWWHRVR0IzMmd0TFdtaFZKSUh1c1FrUmJya1dDU29jNXZmRzZ4ODRyX09pdUdVajhpQnBsaWthYWpiRlM0MUlYLXYwQmM5NVEwVktKOUtxMU9taEsxWFE2Y1FmdER0aGo4RERPR1V5dWc5LTg5V3E=", + "fixture_version": 1, + "mnemonic_id": "abandon12", + "parent_seed_file": { + "expected_seed_mnemonic_id": "abandon12", + "fingerprint": "C557EEC878DFD852", + "password": "fixture-password", + "wrapper_b64": "eyJrZGYiOnsibmFtZSI6InBia2RmMi1zaGEyNTYiLCJ2ZXJzaW9uIjoxLCJwYXJhbXMiOnsiaXRlcmF0aW9ucyI6MTAwMDAwfSwic2FsdF9iNjQiOiIifSwiY3QiOiJWak44WTc3TnlqWHlmQVVNdDBZR1huVDlKQ24rMFh1RDhQbDNkSm4vYWFPdUZVVjdKR2k0dGZCRzFrdHVXN29RYlBwbzI0eXNoS3k4YUprVFB5ZGNldHg5dzhZaXlyOGl3L0lENEcwWUIwaDRLUXpZZ2RSTHhEYlB4UGlVUkQvYWVOM3B4NWNHZFJWc3lsa3RiVkZqaENXM01kRXFXZVdidmpUZ1dBPT0ifQ==" + }, + "plaintext_utf8": "{\"legacy\": true, \"hello\": \"fixture\"}", + "v2_fernet_payload_b64": "VjI6Z0FBQUFBQmxVX0VBRUl2aGEzal9oSk5vN0FvN3ZWWHRVR0IzMmd0TFdtaFZKSUh1c1FrUmJya1dDU29jNXZmRzZ4ODRyX09pdUdVajhpQnBsaWthYWpiRlM0MUlYLXYwQmM5NVEwVktKOUtxMU9taEsxWFE2Y1FmdER0aGo4RERPR1V5dWc5LTg5V3E=", + "v2_gcm_payload_b64": "VjI6XJEC/e68AV++OljKbkWm7L2iE/wwcyA3etcmBeFDyOKWs5NqeDRIOomms+lp7hDNl1T8cWLexqgx6ahy+ln5mA==" +} diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index c6c5ab55..2435fe1b 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -7,8 +7,10 @@ "fingerprints.json", "index_keys.json", "kdf_metadata.json", + "legacy_payloads.json", "managed_seeds.json", "nostr_keys.json", + "password_kdf.json", "passwords_v1.json", "passwords_v2.json", "totp.json", @@ -16,6 +18,6 @@ ], "fixture_version": 1, "generator": "scripts/generate_ts_port_fixtures.py", - "python_commit": "9a4299d1ae6089631a88ec2437b0ac04e61e514c", + "python_commit": "f720d16208074003b10719a7e180c6fec4251c73", "redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile." } diff --git a/js/packages/test-vectors/fixtures/password_kdf.json b/js/packages/test-vectors/fixtures/password_kdf.json new file mode 100644 index 00000000..15c0915b --- /dev/null +++ b/js/packages/test-vectors/fixtures/password_kdf.json @@ -0,0 +1,72 @@ +{ + "argon2id_cases": [ + { + "kdf": { + "name": "argon2id", + "params": { + "memory_cost": 65536, + "parallelism": 8, + "time_cost": 2 + }, + "salt_b64": "Zml4dHVyZS1zYWx0LTE2Yg==", + "version": 1 + }, + "key_urlsafe_b64": "kOetayBVH0lqgiKEZoduzgp1wSN6Sr0uYO1c8qY84jY=", + "password": "fixture-password" + }, + { + "kdf": { + "name": "argon2id", + "params": { + "memory_cost": 8192, + "parallelism": 1, + "time_cost": 1 + }, + "salt_b64": "Zml4dHVyZS1zYWx0LTE2Yg==", + "version": 1 + }, + "key_urlsafe_b64": "PkbCivI-Zyl-WamZ8hsHb9xTDeEMJrbiMMMcze9Zrnk=", + "password": "fixture-password" + } + ], + "description": "Password-based key derivation. PBKDF2: NFKD-normalized+stripped password, salt = SHA256(fingerprint)[:16], PBKDF2-HMAC-SHA256, 32 bytes, urlsafe b64. Argon2id: NFKD+strip, salt from config, hash_len 32, urlsafe b64. Salts pinned for fixtures only.", + "fixture_version": 1, + "pbkdf2_cases": [ + { + "fingerprint": "C557EEC878DFD852", + "iterations": 50000, + "key_urlsafe_b64": "ZBUI9O_vfhYEE-Hhq6csLpihWzregbuLnXbifaLE3lg=", + "password": "fixture-password" + }, + { + "fingerprint": "C557EEC878DFD852", + "iterations": 100000, + "key_urlsafe_b64": "jruA49jkHzZAnNR7uqIznJjySs9CzS2C8drQWkcyuY8=", + "password": "fixture-password" + }, + { + "fingerprint": "C557EEC878DFD852", + "iterations": 50000, + "key_urlsafe_b64": "mWJNQU1UYvjHY1dpjnXutzoRIj_IUZsGG8kwCQp0Qsg=", + "password": "correct horse battery staple" + }, + { + "fingerprint": "C557EEC878DFD852", + "iterations": 100000, + "key_urlsafe_b64": "2KcQMPdKRgUZNRUG1A8PetAh5BfjmL-OYBLZU8ziIpo=", + "password": "correct horse battery staple" + }, + { + "fingerprint": "C557EEC878DFD852", + "iterations": 50000, + "key_urlsafe_b64": "zKjk8qcTuGEQ9kHNVtL05ANedDKx6IcJBlL-ejMY8Xg=", + "password": " p\u00e4ssw\u00f6rd " + }, + { + "fingerprint": "C557EEC878DFD852", + "iterations": 100000, + "key_urlsafe_b64": "QQ9Dcp9T5VkrSyU1ITgFF-FnRU6r0GZST4FdKxrh6xk=", + "password": " p\u00e4ssw\u00f6rd " + } + ] +} diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 484627e2..a904babc 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -20,6 +20,8 @@ import indexKeysJson from "../fixtures/index_keys.json"; import kdfMetadataJson from "../fixtures/kdf_metadata.json"; import entriesIndexJson from "../fixtures/entries_index.json"; import vaultV3Json from "../fixtures/vault_v3_payload.json"; +import passwordKdfJson from "../fixtures/password_kdf.json"; +import legacyPayloadsJson from "../fixtures/legacy_payloads.json"; export interface Bip39Case { id: string; @@ -125,3 +127,38 @@ export const vaultV3Payload = vaultV3Json as { payload_b64: string; plaintext_sha256: string; }; + +export interface Pbkdf2Case { + password: string; + fingerprint: string; + iterations: number; + key_urlsafe_b64: string; +} + +export interface Argon2Case { + password: string; + kdf: { + name: string; + version: number; + params: { time_cost: number; memory_cost: number; parallelism: number }; + salt_b64: string; + }; + key_urlsafe_b64: string; +} + +export const pbkdf2Cases = passwordKdfJson.pbkdf2_cases as Pbkdf2Case[]; +export const argon2idCases = passwordKdfJson.argon2id_cases as Argon2Case[]; + +export const legacyPayloads = legacyPayloadsJson as { + mnemonic_id: string; + plaintext_utf8: string; + fernet_token_b64: string; + v2_gcm_payload_b64: string; + v2_fernet_payload_b64: string; + parent_seed_file: { + password: string; + fingerprint: string; + wrapper_b64: string; + expected_seed_mnemonic_id: string; + }; +}; diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 15cd28d0..6eee5f8e 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@scure/bip39': specifier: ^2.0.0 version: 2.3.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@seedpass/test-vectors': specifier: workspace:* @@ -787,6 +790,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@asamuzakjp/css-color@6.0.7': @@ -1407,3 +1413,5 @@ snapshots: xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + + zod@4.4.3: {} diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index 60f4bec3..5c80cfc4 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -426,6 +426,123 @@ def gen_entries_and_vault() -> tuple[dict, dict]: return entries_fixture, vault_fixture +def gen_password_kdf() -> dict: + from utils.key_derivation import ( + KdfConfig, + derive_key_from_password, + derive_key_from_password_argon2, + ) + + fp = generate_fingerprint(MNEMONICS[PRIMARY]) + pbkdf2_cases = [] + for password in ("fixture-password", "correct horse battery staple", " pässwörd "): + for iterations in (50_000, 100_000): + key = derive_key_from_password(password, fp, iterations=iterations) + pbkdf2_cases.append( + { + "password": password, + "fingerprint": fp, + "iterations": iterations, + "key_urlsafe_b64": key.decode(), + } + ) + + argon2_cases = [] + for params in ( + {"time_cost": 2, "memory_cost": 64 * 1024, "parallelism": 8}, + {"time_cost": 1, "memory_cost": 8 * 1024, "parallelism": 1}, + ): + kdf = KdfConfig( + name="argon2id", + version=1, + params=params, + salt_b64=base64.b64encode(b"fixture-salt-16b").decode(), + ) + key = derive_key_from_password_argon2("fixture-password", kdf) + argon2_cases.append( + { + "password": "fixture-password", + "kdf": { + "name": kdf.name, + "version": kdf.version, + "params": kdf.params, + "salt_b64": kdf.salt_b64, + }, + "key_urlsafe_b64": key.decode(), + } + ) + + return { + "description": ( + "Password-based key derivation. PBKDF2: NFKD-normalized+stripped " + "password, salt = SHA256(fingerprint)[:16], PBKDF2-HMAC-SHA256, " + "32 bytes, urlsafe b64. Argon2id: NFKD+strip, salt from config, " + "hash_len 32, urlsafe b64. Salts pinned for fixtures only." + ), + "pbkdf2_cases": pbkdf2_cases, + "argon2id_cases": argon2_cases, + } + + +def gen_legacy_payloads() -> dict: + from cryptography.fernet import Fernet + + mnemonic = MNEMONICS[PRIMARY] + key_b64 = derive_index_key(mnemonic) + raw_key = base64.urlsafe_b64decode(key_b64) + fernet = Fernet(key_b64) + + plaintext = b'{"legacy": true, "hello": "fixture"}' + iv = hashlib.sha256(b"seedpass-ts-fixture-fernet-iv").digest()[:16] + token = fernet._encrypt_from_parts(plaintext, FIXED_UNIX, iv) + + nonce = hashlib.sha256(b"seedpass-ts-fixture-v2-nonce").digest()[:12] + v2_gcm = b"V2:" + nonce + AESGCM(raw_key).encrypt(nonce, plaintext, None) + v2_fernet = b"V2:" + token + + # Serialized parent-seed file: JSON {"kdf": ..., "ct": b64(V3 blob)}, + # encrypted with the password-derived key (not the index key). + from utils.key_derivation import derive_key_from_password + + fp = generate_fingerprint(mnemonic) + seed_key_b64 = derive_key_from_password("fixture-password", fp) + seed_key = base64.urlsafe_b64decode(seed_key_b64) + seed_nonce = hashlib.sha256(b"seedpass-ts-fixture-seed-nonce").digest()[:12] + seed_ct = b"V3|" + seed_nonce + AESGCM(seed_key).encrypt( + seed_nonce, mnemonic.encode(), None + ) + kdf_dict = { + "name": "pbkdf2-sha256", + "version": 1, + "params": {"iterations": 100000}, + "salt_b64": "", + } + wrapper = json.dumps( + {"kdf": kdf_dict, "ct": base64.b64encode(seed_ct).decode()}, + separators=(",", ":"), + ).encode() + + return { + "description": ( + "Legacy/migration payload formats the TS port must read: raw " + "Fernet token, V2-prefixed AES-GCM, V2-prefixed Fernet " + "(wrong-header case), and the JSON kdf/ct file wrapper around a " + "V3 blob. IVs/nonces/timestamps pinned for fixtures only." + ), + "mnemonic_id": PRIMARY, + "plaintext_utf8": plaintext.decode(), + "fernet_token_b64": base64.b64encode(token).decode(), + "v2_gcm_payload_b64": base64.b64encode(v2_gcm).decode(), + "v2_fernet_payload_b64": base64.b64encode(v2_fernet).decode(), + "parent_seed_file": { + "password": "fixture-password", + "fingerprint": generate_fingerprint(mnemonic), + "wrapper_b64": base64.b64encode(wrapper).decode(), + "expected_seed_mnemonic_id": PRIMARY, + }, + } + + def gen_kdf_metadata() -> dict: return { "description": ( @@ -468,6 +585,8 @@ def main() -> None: "fingerprints.json": gen_fingerprints(), "index_keys.json": gen_index_keys(), "kdf_metadata.json": gen_kdf_metadata(), + "password_kdf.json": gen_password_kdf(), + "legacy_payloads.json": gen_legacy_payloads(), } entries_fixture, vault_fixture = gen_entries_and_vault() files["entries_index.json"] = entries_fixture