mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-10 14:52:29 +00:00
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:
1 parent
f720d16208
commit
6d6ad5e2c5
14 files changed
+868
-7
No files matched your search
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user