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

+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,
};
}