mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 18:02:05 +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
@@ -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)
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 "
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
Generated
+8
@@ -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]: {}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user