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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-13 10:23:56 -04:00
1 parent f720d16208
commit 6d6ad5e2c5
14 files changed
+868 -7

No files matched your search

+9 -5
View File
@@ -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)
+2 -1
View File
@@ -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:*",
+27
View File
@@ -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";
+199
View File
@@ -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<typeof entrySchema>;
export type PasswordEntry = z.infer<typeof passwordEntrySchema>;
export type TotpEntry = z.infer<typeof totpEntrySchema>;
export const vaultIndexSchema = z
.object({
schema_version: z.number().int(),
entries: z.record(z.string(), entrySchema),
})
.loose();
export type VaultIndex = z.infer<typeof vaultIndexSchema>;
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);
}
+91
View File
@@ -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<Uint8Array> {
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<Uint8Array> {
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");
}
}
+67
View File
@@ -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<typeof kdfConfigSchema>;
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);
}
+81
View File
@@ -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<Uint8Array> {
// 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<Uint8Array> {
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": "<b64>"} 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,
};
}
+138
View File
@@ -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);
});
});
@@ -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=="
}
@@ -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."
}
@@ -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 "
}
]
}
+37
View File
@@ -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;
};
};
+8
View File
@@ -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:
[email protected]:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
[email protected]:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
'@asamuzakjp/[email protected]':
@@ -1407,3 +1413,5 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
+119
View File
@@ -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