diff --git a/packages/argon2/src/argon2.ts b/packages/argon2/src/argon2.ts index efac420..2610b75 100644 --- a/packages/argon2/src/argon2.ts +++ b/packages/argon2/src/argon2.ts @@ -1,21 +1,16 @@ -/** - * Argon2d and Argon2id, implemented per - * [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106). See SPEC.md. - * - * This module holds the algorithm core; the public, validated entry points - * live in `index.ts`. The 64-bit arithmetic uses `bigint` for correctness. - */ +/** Argon2d/Argon2id per RFC 9106 (see SPEC.md). Algorithm core only — +validated public entry points live in index.ts; 64-bit math uses bigint. */ import { blake2b } from './blake2b.ts'; const A2_M32 = 0xffffffffn; const A2_M64 = (1n << 64n) - 1n; -/** Argon2 type code: 0 = Argon2d, 2 = Argon2id (RFC 9106, Section 3.1). */ +// Argon2 type code: 0 = Argon2d, 2 = Argon2id (RFC 9106, Section 3.1). export const ARGON2_D = 0; export const ARGON2_ID = 2; -/** Argon2 version numbers (RFC 9106). 0x13 (19) is current. */ +// Argon2 version numbers (RFC 9106). 0x13 (19) is current. export const ARGON2_VERSION_10 = 0x10; export const ARGON2_VERSION_13 = 0x13; @@ -29,12 +24,12 @@ export interface CoreParams { secret: Uint8Array; associatedData: Uint8Array; parallelism: number; - /** Memory size in KiB. */ + // Memory size in KiB. memory: number; iterations: number; tagLength: number; version: number; - /** 0 for Argon2d, 2 for Argon2id. */ + // 0 for Argon2d, 2 for Argon2id. type: number; } @@ -86,9 +81,7 @@ function a2_blockToBytes(block: BigUint64Array): Uint8Array { return out; } -/** - * Variable-length hash function H' (RFC 9106, Section 3.3), built on BLAKE2b. - */ +// Variable-length hash H' (RFC 9106 §3.3), built on BLAKE2b. function a2_hPrime(outLength: number, input: Uint8Array): Uint8Array { if (outLength <= 64) { return blake2b(outLength, a2_concatBytes(a2_le32(outLength), input)); @@ -107,9 +100,7 @@ function a2_hPrime(outLength: number, input: Uint8Array): Uint8Array { return out; } -// Index patterns for the BLAKE2b permutation P over a 1024-byte block, viewed -// as an 8x8 matrix of 16-byte registers (RFC 9106, Section 3.5). Rows are 16 -// consecutive words; columns stride through the block. +// P's index patterns over the 1024-byte block as an 8x8 register matrix (RFC 9106 §3.5). const A2_ROWS: number[][] = []; const A2_COLS: number[][] = []; for (let r = 0; r < 8; r++) { @@ -126,7 +117,7 @@ for (let r = 0; r < 8; r++) { A2_COLS.push(col); } -/** GB, the modified BLAKE2b mixing function with multiplications (Section 3.6). */ +// GB, the modified BLAKE2b mixing function with multiplications (Section 3.6). function a2_gb(v: BigUint64Array, a: number, b: number, c: number, d: number): void { let va = v[a] as bigint; let vb = v[b] as bigint; @@ -146,7 +137,7 @@ function a2_gb(v: BigUint64Array, a: number, b: number, c: number, d: number): v v[d] = vd; } -/** Permutation P applied to the 16 words named by `q` (RFC 9106, Section 3.6). */ +// Permutation P applied to the 16 words named by `q` (RFC 9106, Section 3.6). function a2_permute(v: BigUint64Array, q: number[]): void { const i = (k: number): number => q[k] as number; a2_gb(v, i(0), i(4), i(8), i(12)); @@ -159,11 +150,7 @@ function a2_permute(v: BigUint64Array, q: number[]): void { a2_gb(v, i(3), i(4), i(9), i(14)); } -/** - * Compression function G (RFC 9106, Section 3.5): - * `next = (with_xor ? next : 0) XOR R XOR P_columns(P_rows(R))`, - * where `R = ref XOR prev`. - */ +// G (RFC 9106 §3.5): next = (with_xor?next:0) XOR R XOR P_columns(P_rows(R)), where R=ref XOR prev. function a2_fillBlock( prev: BigUint64Array, ref: BigUint64Array, @@ -193,10 +180,7 @@ function a2_fillBlock( } } -/** - * Map a pseudo-random value to a reference block index within a lane - * (RFC 9106, Section 3.4.2; reference implementation `index_alpha`). - */ +// Map a pseudo-random value to a reference block index in a lane (RFC 9106 §3.4.2, index_alpha). function a2_indexAlpha( pass: number, slice: number, @@ -232,7 +216,7 @@ function a2_indexAlpha( return Number((BigInt(startPosition) + relative) % BigInt(laneLength)); } -/** Run the full Argon2 operation and return the tag. */ +// Run the full Argon2 operation and return the tag. export function argon2Core(params: CoreParams): Uint8Array { const { password, salt, secret, associatedData } = params; const lanes = params.parallelism; diff --git a/packages/argon2/src/blake2b.ts b/packages/argon2/src/blake2b.ts index db809db..d56b777 100644 --- a/packages/argon2/src/blake2b.ts +++ b/packages/argon2/src/blake2b.ts @@ -1,15 +1,9 @@ -/** - * BLAKE2b ([RFC 7693](https://www.rfc-editor.org/rfc/rfc7693)), the underlying - * hash function `H` used by Argon2 (RFC 9106, Section 3.2). - * - * This is an unkeyed implementation with a variable output length of 1..64 - * bytes, which is all Argon2 requires. The 64-bit arithmetic is implemented - * with `bigint` for correctness; see SPEC.md for the optimization trade-off. - */ +/** BLAKE2b (RFC 7693), Argon2's H (RFC 9106 §3.2) — unkeyed, 1..64-byte +output. 64-bit math uses bigint; see SPEC.md for the trade-off. */ const B2_MASK64 = (1n << 64n) - 1n; -/** BLAKE2b initialization vector (RFC 7693, Section 2.6). */ +// BLAKE2b initialization vector (RFC 7693, Section 2.6). const B2_IV: readonly bigint[] = [ 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, @@ -21,7 +15,7 @@ const B2_IV: readonly bigint[] = [ 0x5be0cd19137e2179n, ]; -/** Message word schedule per round (RFC 7693, Section 2.7). */ +// Message word schedule per round (RFC 7693, Section 2.7). const B2_SIGMA: readonly (readonly number[])[] = [ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], @@ -41,7 +35,7 @@ function b2_rotr64(x: bigint, n: bigint): bigint { return ((x >> n) | (x << (64n - n))) & B2_MASK64; } -/** The BLAKE2b mixing function G (RFC 7693, Section 3.1). */ +// The BLAKE2b mixing function G (RFC 7693, Section 3.1). function b2_mix( v: bigint[], a: number, @@ -69,7 +63,7 @@ function b2_mix( v[d] = vd; } -/** The BLAKE2b compression function F (RFC 7693, Section 3.2). */ +// The BLAKE2b compression function F (RFC 7693, Section 3.2). function b2_compress(h: bigint[], m: bigint[], counter: bigint, last: boolean): void { const v = new Array(16); for (let i = 0; i < 8; i++) { @@ -98,12 +92,9 @@ function b2_compress(h: bigint[], m: bigint[], counter: bigint, last: boolean): } } -/** - * Compute the unkeyed BLAKE2b digest of `input`. - * - * @param outLength desired digest length in bytes, 1..64. - * @param input message to hash. - */ +/** Unkeyed BLAKE2b digest of `input`. +@param outLength digest length in bytes, 1..64. +@param input message to hash. */ export function blake2b(outLength: number, input: Uint8Array): Uint8Array { if (!Number.isInteger(outLength) || outLength < 1 || outLength > 64) { throw new RangeError(`BLAKE2b output length must be an integer in 1..64, got ${outLength}`); diff --git a/packages/argon2/src/index.ts b/packages/argon2/src/index.ts index f4ada3e..bc34196 100644 --- a/packages/argon2/src/index.ts +++ b/packages/argon2/src/index.ts @@ -1,10 +1,5 @@ -/** - * `argon2` — Argon2d and Argon2id key derivation per - * [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106). - * - * Argon2i is intentionally not implemented (see README.md); RFC 9106 requires - * only Argon2id, and KDBX 4.x uses Argon2d or Argon2id. - */ +/** `argon2` — Argon2d/Argon2id per RFC 9106. Argon2i isn't implemented (see +README.md): RFC 9106 only requires Argon2id, and that's all KDBX 4.x uses. */ import { ARGON2_D, ARGON2_ID, ARGON2_VERSION_13, argon2Core } from './argon2.ts'; @@ -12,34 +7,34 @@ const A2_MAX_U24 = 0xffffff; const A2_MAX_U32 = 0xffffffff; const A2_EMPTY = new Uint8Array(0); -/** Argon2 variant. */ +// Argon2 variant. export type Argon2Type = 'argon2d' | 'argon2id'; -/** Options for {@link argon2}. Parameter names and bounds follow RFC 9106. */ +// Options for {@link argon2}. Parameter names and bounds follow RFC 9106. export interface Argon2Options { - /** Message P. For KDBX this is the composite key. */ + // Message P. For KDBX this is the composite key. password: Uint8Array; - /** Nonce S (salt). */ + // Nonce S (salt). salt: Uint8Array; - /** Degree of parallelism p (lanes), an integer in 1..2^24-1. */ + // Degree of parallelism p (lanes), an integer in 1..2^24-1. parallelism: number; - /** Memory size m in KiB, an integer in 8*parallelism..2^32-1. */ + // Memory size m in KiB, an integer in 8*parallelism..2^32-1. memory: number; - /** Number of passes t, an integer in 1..2^32-1. */ + // Number of passes t, an integer in 1..2^32-1. iterations: number; - /** Desired tag length T in bytes, an integer in 4..2^32-1. */ + // Desired tag length T in bytes, an integer in 4..2^32-1. tagLength: number; - /** Variant to use. */ + // Variant to use. type: Argon2Type; - /** Optional secret value K. */ + // Optional secret value K. secret?: Uint8Array; - /** Optional associated data X. */ + // Optional associated data X. associatedData?: Uint8Array; - /** Version number; defaults to 0x13 (the current version). */ + // Version number; defaults to 0x13 (the current version). version?: number; } -/** Options for {@link argon2d} / {@link argon2id} (no `type` field). */ +// Options for {@link argon2d} / {@link argon2id} (no `type` field). export type Argon2VariantOptions = Omit; function a2_requireInteger(name: string, value: number, min: number, max: number): void { diff --git a/packages/chacha20/src/index.ts b/packages/chacha20/src/index.ts index 2d9e62b..6049080 100644 --- a/packages/chacha20/src/index.ts +++ b/packages/chacha20/src/index.ts @@ -1,25 +1,12 @@ -/** - * `chacha20` — ChaCha20 (RFC 8439) and Salsa20 (D. J. Bernstein) stream - * ciphers. - * - * Scope is intentionally limited to the raw stream ciphers. Poly1305 and the - * ChaCha20-Poly1305 AEAD construction from RFC 8439 are NOT implemented: KDBX - * authenticates with HMAC-SHA256, so the AEAD is not needed by keepass-web. - * - * Both ciphers expose three layers: - * - a pure 64-byte block function (`chacha20Block` / `salsa20Block`); - * - a one-shot helper (`chacha20` / `salsa20`) for whole buffers; - * - a stateful class (`ChaCha20` / `Salsa20`) whose `encrypt`/`decrypt` - * consume a single continuous keystream across successive calls. The - * stateful form is what KDBX inner-stream (protected-field) processing - * needs, since protected values are XORed against one running keystream in - * document order. - */ +/** `chacha20` — ChaCha20 (RFC 8439) and Salsa20 stream ciphers. No +Poly1305/AEAD: KDBX authenticates with HMAC-SHA256 instead. Each exposes +a block function, a one-shot helper, and a stateful class sharing one +running keystream across calls — what inner-stream processing needs. */ -/** A 32-bit unsigned word. */ +// A 32-bit unsigned word. type Word = number; -/** Result of a quarter round: four updated 32-bit words. */ +// Result of a quarter round: four updated 32-bit words. type QuarterRound = [Word, Word, Word, Word]; const CC_KEY_BYTES = 32; @@ -27,12 +14,12 @@ const CC_BLOCK_BYTES = 64; const CC_CHACHA_NONCE_BYTES = 12; const CC_SALSA_NONCE_BYTES = 8; -/** "expand 32-byte k" as four little-endian 32-bit words. */ +// "expand 32-byte k" as four little-endian 32-bit words. const CC_SIGMA: readonly [Word, Word, Word, Word] = [ 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, ]; -/** Left-rotate a 32-bit word by `n` bits. */ +// Left-rotate a 32-bit word by `n` bits. const cc_rotl = (x: Word, n: number): Word => ((x << n) | (x >>> (32 - n))) >>> 0; function cc_assertLength(bytes: Uint8Array, expected: number, name: string): void { diff --git a/packages/embed-protocol/src/index.ts b/packages/embed-protocol/src/index.ts index e3776ae..5786e6f 100644 --- a/packages/embed-protocol/src/index.ts +++ b/packages/embed-protocol/src/index.ts @@ -1,25 +1,8 @@ -/** - * `embed-protocol` — the same-origin postMessage contract between a - * keepass-web implementation (currently only `0x67`) and whatever host page - * embeds it in an iframe (the local-file chooser, the Google Drive - * connector, and future sources). - * - * Message shapes and guards used to be hand-written twice: once inline in - * 0x67/page.ts (the app side) and once in each host's own logic.ts (the host - * side). Nothing but manual care kept those two hand-written copies in sync. - * Centralizing both the guards *and* the builders here means the two ends of - * the protocol are provably using the same wire format, not just similarly - * shaped code. - * - * Six message types, each read by exactly one side and built by the other: - * app → host : kw-ready (built by the app, read by the host) - * host → app : kw-open (built by the host, read by the app) - * app → host : kw-save (built by the app, read by the host) - * host → app : kw-saved (built by the host, read by the app) - * host → app : kw-close-request (built by the host, read by the app) - * app → host : kw-close-ack (built by the app, read by the host) - * app → host : kw-close (built by the app, read by the host) - */ +/** `embed-protocol` — the same-origin postMessage contract between a +keepass-web implementation and whatever host embeds it in an iframe. +Centralizes shapes/guards/builders (previously duplicated per side) so +both ends provably agree on the wire format: kw-ready, kw-open, kw-save, +kw-saved, kw-close-request, kw-close-ack, kw-close. */ export interface ReadyMessage { type: 'kw-ready'; diff --git a/packages/kdbx/src/bytes.ts b/packages/kdbx/src/bytes.ts index 0c34252..d426803 100644 --- a/packages/kdbx/src/bytes.ts +++ b/packages/kdbx/src/bytes.ts @@ -1,25 +1,19 @@ -/** - * Byte-level primitives shared by the KDBX reader and writer. - * - * The KDBX format stores all integers in little-endian byte order; the readers - * and writers below honour that. 64-bit integers are surfaced as `bigint` so - * that values above 2^53 (e.g. large KDF iteration counts) round-trip exactly. - */ +// Byte primitives for KDBX (little-endian); 64-bit ints use bigint to round-trip exactly. const kx_textEncoder = new TextEncoder(); const kx_textDecoder = new TextDecoder('utf-8', { fatal: false }); -/** Encode a string as UTF-8 bytes (no BOM, no null terminator). */ +// Encode a string as UTF-8 bytes (no BOM, no null terminator). export function utf8Encode(value: string): Uint8Array { return kx_textEncoder.encode(value); } -/** Decode UTF-8 bytes to a string. */ +// Decode UTF-8 bytes to a string. export function utf8Decode(bytes: Uint8Array): string { return kx_textDecoder.decode(bytes); } -/** Concatenate byte arrays into a single new array. */ +// Concatenate byte arrays into a single new array. export function concatBytes(...parts: Uint8Array[]): Uint8Array { let total = 0; for (const part of parts) { @@ -34,7 +28,7 @@ export function concatBytes(...parts: Uint8Array[]): Uint8Array { return out; } -/** Whether two byte arrays have identical contents. */ +// Whether two byte arrays have identical contents. export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) { return false; @@ -47,27 +41,20 @@ export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { return true; } -/** - * Constant-time comparison of two byte arrays of equal length. - * - * Used for verifying HMAC tags, where short-circuiting on the first differing - * byte would leak timing information. - */ +// Constant-time compare, for HMAC verification where short-circuiting would leak timing. export function bytesEqualConstantTime(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) { return false; } let diff = 0; for (let i = 0; i < a.length; i += 1) { - // Both arrays were just confirmed to be the same length as a.length, so - // index i is always in range for both; the cast only satisfies - // noUncheckedIndexedAccess and doesn't change behavior. + // Lengths are already confirmed equal; the cast only satisfies noUncheckedIndexedAccess. diff |= (a[i] as number) ^ (b[i] as number); } return diff === 0; } -/** Lowercase hexadecimal encoding. */ +// Lowercase hexadecimal encoding. export function toHex(bytes: Uint8Array): string { let out = ''; for (const byte of bytes) { @@ -76,7 +63,7 @@ export function toHex(bytes: Uint8Array): string { return out; } -/** Decode a hexadecimal string (whitespace is ignored). */ +// Decode a hexadecimal string (whitespace is ignored). export function fromHex(hex: string): Uint8Array { const clean = hex.replace(/\s+/g, ''); if (clean.length % 2 !== 0) { @@ -102,17 +89,12 @@ const KX_BASE64_LOOKUP: Int16Array = (() => { return table; })(); -/** - * Standard Base64 encoding (RFC 4648). Implemented directly so that the same - * code runs in browsers and in Node without depending on `btoa`/`Buffer`. - */ +// Standard Base64 (RFC 4648), implemented directly to run identically in browsers and Node. export function toBase64(bytes: Uint8Array): string { const c = (index: number): string => KX_BASE64_CHARS.charAt(index); let out = ''; let i = 0; - // Every index used below is guaranteed in range by the loop condition or - // the `remaining` check that selects it, so the casts (rather than `?? 0`) - // only satisfy noUncheckedIndexedAccess and don't change behavior. + // Indices below are already guaranteed in range; casts only satisfy noUncheckedIndexedAccess. for (; i + 2 < bytes.length; i += 3) { const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number); diff --git a/packages/kdbx/src/constants.ts b/packages/kdbx/src/constants.ts index edc432b..b3cfdb5 100644 --- a/packages/kdbx/src/constants.ts +++ b/packages/kdbx/src/constants.ts @@ -1,37 +1,37 @@ -/** Format signatures, identifiers, and magic values defined by the KDBX format. */ +// Format signatures, identifiers, and magic values defined by the KDBX format. import { fromHex } from './bytes.ts'; -/** First 32-bit signature (UInt32, little-endian on disk). */ +// First 32-bit signature (UInt32, little-endian on disk). export const SIGNATURE_1 = 0x9aa2d903; -/** Second 32-bit signature, identifying the KDBX 2.x format family (3.1/4.x). */ +// Second 32-bit signature, identifying the KDBX 2.x format family (3.1/4.x). export const SIGNATURE_2 = 0xb54bfb67; -/** Outer header field IDs. */ +// Outer header field IDs. export const HeaderFieldId = { EndOfHeader: 0, Comment: 1, CipherId: 2, CompressionFlags: 3, MasterSeed: 4, - /** KDBX 3.1 only: AES-KDF transform seed. */ + // KDBX 3.1 only: AES-KDF transform seed. TransformSeed: 5, - /** KDBX 3.1 only: AES-KDF rounds. */ + // KDBX 3.1 only: AES-KDF rounds. TransformRounds: 6, EncryptionIv: 7, - /** KDBX 3.1 only: inner random stream key. */ + // KDBX 3.1 only: inner random stream key. ProtectedStreamKey: 8, - /** KDBX 3.1 only: expected first bytes of the decrypted payload. */ + // KDBX 3.1 only: expected first bytes of the decrypted payload. StreamStartBytes: 9, - /** KDBX 3.1 only: inner random stream cipher ID. */ + // KDBX 3.1 only: inner random stream cipher ID. InnerRandomStreamId: 10, - /** KDBX 4.x only: KDF parameters (VariantDictionary). */ + // KDBX 4.x only: KDF parameters (VariantDictionary). KdfParameters: 11, - /** KDBX 4.x only: public custom data (VariantDictionary). */ + // KDBX 4.x only: public custom data (VariantDictionary). PublicCustomData: 12, } as const; -/** Inner (encrypted) header field IDs — KDBX 4.x. */ +// Inner (encrypted) header field IDs — KDBX 4.x. export const InnerHeaderFieldId = { EndOfHeader: 0, InnerRandomStreamId: 1, @@ -39,43 +39,43 @@ export const InnerHeaderFieldId = { Binary: 3, } as const; -/** Compression algorithm IDs (header field 3). */ +// Compression algorithm IDs (header field 3). export const Compression = { None: 0, GZip: 1, } as const; -/** Inner random stream cipher IDs. */ +// Inner random stream cipher IDs. export const InnerStreamCipher = { - /** RC4 variant — obsolete, not supported. */ + // RC4 variant — obsolete, not supported. ArcFourVariant: 1, Salsa20: 2, ChaCha20: 3, } as const; -/** Value of the End-of-Header field in the outer header. */ +// Value of the End-of-Header field in the outer header. export const HEADER_END_MARKER = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); -/** Fixed Salsa20 nonce used by the KDBX 3.1 inner random stream. */ +// Fixed Salsa20 nonce used by the KDBX 3.1 inner random stream. export const SALSA20_NONCE = new Uint8Array([0xe8, 0x30, 0x09, 0x4b, 0x97, 0x20, 0x5d, 0x2a]); -/** Cipher UUIDs (header field 2). */ +// Cipher UUIDs (header field 2). export const CipherId = { Aes256: fromHex('31C1F2E6BF714350BE5805216AFC5AFF'), ChaCha20: fromHex('D6038A2B8B6F4CB5A524339A31DBB59A'), } as const; -/** Key derivation function UUIDs (KDF parameter `$UUID`). */ +// Key derivation function UUIDs (KDF parameter `$UUID`). export const KdfId = { Aes: fromHex('C9D9F39A628A4460BF740D08C18A4FEA'), Argon2d: fromHex('EF636DDF8C29444B91F7A9A403E30A0C'), Argon2id: fromHex('9E298B1956DB4773B23DFC3EC6F0A1E6'), } as const; -/** VariantDictionary keys used inside KDF parameters. */ +// VariantDictionary keys used inside KDF parameters. export const KdfParam = { Uuid: '$UUID', - /** AES-KDF rounds / Argon2 salt share key letters with distinct meanings per KDF. */ + // AES-KDF rounds / Argon2 salt share key letters with distinct meanings per KDF. AesRounds: 'R', AesSeed: 'S', Argon2Salt: 'S', @@ -87,7 +87,7 @@ export const KdfParam = { Argon2AssocData: 'A', } as const; -/** Argon2 version numbers as stored in the KDF parameters. */ +// Argon2 version numbers as stored in the KDF parameters. export const Argon2Version = { V10: 0x10, V13: 0x13, diff --git a/packages/kdbx/src/credentials.ts b/packages/kdbx/src/credentials.ts index a7d6d13..3bbdf19 100644 --- a/packages/kdbx/src/credentials.ts +++ b/packages/kdbx/src/credentials.ts @@ -1,24 +1,18 @@ -/** - * Composite master key assembly. - * - * KeePass builds the composite key by SHA-256-hashing the concatenation of the - * key components the user provides, each in a fixed order: the SHA-256 of the - * password, then the key drawn from a key file. (Key-provider plugins and the - * Windows DPAPI component are out of scope here.) - */ +/** Composite master key: SHA-256(password) then key-file bytes, concatenated +and SHA-256'd, in that fixed order. (Key-provider plugins/DPAPI: out of scope.) */ import { concatBytes, fromBase64, fromHex, toHex, utf8Encode } from './bytes.ts'; import { sha256 } from './crypto.ts'; -/** Inputs accepted when constructing {@link Credentials}. */ +// Inputs accepted when constructing {@link Credentials}. export interface CredentialsInput { - /** Master password, as text or as raw bytes. */ + // Master password, as text or as raw bytes. password?: string | Uint8Array; - /** Key file contents (raw bytes of the file on disk). */ + // Key file contents (raw bytes of the file on disk). keyFile?: Uint8Array; } -/** A set of credentials from which a composite key can be derived. */ +// A set of credentials from which a composite key can be derived. export class Credentials { readonly #password: Uint8Array | undefined; readonly #keyFile: Uint8Array | undefined; @@ -32,12 +26,12 @@ export class Credentials { this.#keyFile = input.keyFile; } - /** Convenience constructor for a password-only credential. */ + // Convenience constructor for a password-only credential. static fromPassword(password: string): Credentials { return new Credentials({ password }); } - /** Compute the 32-byte composite key for these credentials. */ + // Compute the 32-byte composite key for these credentials. async getCompositeKey(): Promise { const components: Uint8Array[] = []; if (this.#password !== undefined) { @@ -52,7 +46,7 @@ export class Credentials { const KX_HEX_64 = /^[0-9a-fA-F]{64}$/; -/** Derive the 32-byte key-file component, mirroring KeePass's detection order. */ +// Derive the 32-byte key-file component, mirroring KeePass's detection order. export async function keyFileComponent(bytes: Uint8Array): Promise { const xmlKey = await kx_tryParseXmlKeyFile(bytes); if (xmlKey !== undefined) { @@ -77,13 +71,8 @@ function kx_tryDecodeAscii(bytes: Uint8Array): string | undefined { return new TextDecoder('ascii').decode(bytes); } -/** - * Parse a KeePass XML key file. Version 2.x stores the key as hex, and its - * `` attribute holds the first 4 bytes of SHA-256 of that key - * (a corruption check, independent of whether the key opens any database); - * version 1.x stores 32 bytes as Base64 with no such check. Returns - * `undefined` if the bytes are not an XML key file. - */ +/** Parse a KeePass XML key file. v2.x hex-encodes the key with a `Data +Hash` corruption check; v1.x is unchecked Base64. `undefined` if not XML. */ async function kx_tryParseXmlKeyFile(bytes: Uint8Array): Promise { const text = kx_tryDecodeUtf8(bytes); if (text === undefined || !text.includes(' tag's attribute text, always captured - // (possibly empty) whenever dataMatch[2] matched; the cast only satisfies - // noUncheckedIndexedAccess. + // Always captured alongside dataMatch[2]; cast only satisfies noUncheckedIndexedAccess. const hashMatch = (dataMatch[1] as string).match(/\bHash="([0-9a-fA-F]+)"/); if (hashMatch?.[1] !== undefined) { const expectedHash = hashMatch[1].toLowerCase(); diff --git a/packages/kdbx/src/crypto.ts b/packages/kdbx/src/crypto.ts index daa9161..1c4ad9b 100644 --- a/packages/kdbx/src/crypto.ts +++ b/packages/kdbx/src/crypto.ts @@ -1,13 +1,5 @@ -/** - * Cryptographic primitives backed by WebCrypto and the Web Streams compression - * API, so the same code runs in browsers and in modern Node without any - * external dependency. - * - * The stream ciphers (ChaCha20/Salsa20) and the memory-hard KDFs - * (Argon2d/Argon2id) are not available in WebCrypto; those come from the - * sibling `chacha20` and `argon2` packages and are wired in by the modules - * that need them. - */ +/** Crypto primitives via WebCrypto + Web Streams compression, no external deps. +Stream ciphers and Argon2 aren't in WebCrypto — those come from chacha20/argon2. */ import { concatBytes } from './bytes.ts'; @@ -19,33 +11,29 @@ function kx_getCrypto(): Crypto { return c; } -/** - * WebCrypto's `BufferSource` is parameterized over `ArrayBuffer` (not - * `SharedArrayBuffer`). Our buffers are always `ArrayBuffer`-backed, so this - * narrowing cast is sound and avoids copying large payloads. - */ +// Our buffers are always ArrayBuffer-backed, so this BufferSource narrowing cast is sound. function kx_buf(data: Uint8Array): Uint8Array { return data as Uint8Array; } -/** Fill a fresh array of `length` bytes with cryptographically strong randomness. */ +// Fill a fresh array of `length` bytes with cryptographically strong randomness. export function getRandomBytes(length: number): Uint8Array { const out = new Uint8Array(length); kx_getCrypto().getRandomValues(out); return out; } -/** SHA-256 digest. */ +// SHA-256 digest. export async function sha256(data: Uint8Array): Promise { return new Uint8Array(await kx_getCrypto().subtle.digest('SHA-256', kx_buf(data))); } -/** SHA-512 digest. */ +// SHA-512 digest. export async function sha512(data: Uint8Array): Promise { return new Uint8Array(await kx_getCrypto().subtle.digest('SHA-512', kx_buf(data))); } -/** HMAC-SHA-256 of `data` under `key`. */ +// HMAC-SHA-256 of `data` under `key`. export async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { const subtle = kx_getCrypto().subtle; const cryptoKey = await subtle.importKey( @@ -58,10 +46,7 @@ export async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { const reader = new ByteReader(data); const chunks: Uint8Array[] = []; @@ -40,7 +34,7 @@ export async function readHashedBlockStream(data: Uint8Array): Promise= 4; } -/** Parse the outer header from the start of a KDBX buffer. */ +// Parse the outer header from the start of a KDBX buffer. export function readOuterHeader(data: Uint8Array): ParsedOuterHeader { const reader = new ByteReader(data); if (reader.readU32() !== SIGNATURE_1 || reader.readU32() !== SIGNATURE_2) { diff --git a/packages/kdbx/src/hmac-block-stream.ts b/packages/kdbx/src/hmac-block-stream.ts index 41cf354..8b47da7 100644 --- a/packages/kdbx/src/hmac-block-stream.ts +++ b/packages/kdbx/src/hmac-block-stream.ts @@ -1,18 +1,12 @@ -/** - * HMAC-protected block stream (KDBX 4.x). - * - * The (encrypted) payload is split into blocks; each stored block is - * HMAC-SHA-256(index_u64le ‖ size_i32le ‖ data) ‖ size_i32le ‖ data - * where the per-block HMAC key depends on the block index. The stream ends with - * a block whose size is 0. Verification uses an Encrypt-then-MAC scheme, so the - * integrity/authenticity check happens before decryption. - */ +/** KDBX 4.x HMAC block stream: blocks of `HMAC-SHA-256(index‖size‖data)‖ +size‖data`, keyed per block index, ending with a size-0 block. +Encrypt-then-MAC — integrity is checked before decryption. */ import { ByteReader, ByteWriter, bytesEqualConstantTime, concatBytes } from './bytes.ts'; import { hmacSha256 } from './crypto.ts'; import { deriveBlockHmacKey } from './key.ts'; -/** Block size used when writing (KeePass uses 1 MiB for all but the last block). */ +// Block size used when writing (KeePass uses 1 MiB for all but the last block). const KX_HMS_BLOCK_SIZE = 1024 * 1024; async function kx_blockMac( @@ -26,7 +20,7 @@ async function kx_blockMac( return hmacSha256(blockKey, concatBytes(indexBytes, sizeBytes, data)); } -/** Verify and concatenate an HMAC-protected block stream into its payload. */ +// Verify and concatenate an HMAC-protected block stream into its payload. export async function readHmacBlockStream( data: Uint8Array, hmacBaseKey: Uint8Array, @@ -54,7 +48,7 @@ export async function readHmacBlockStream( return concatBytes(...chunks); } -/** Frame a payload as an HMAC-protected block stream. */ +// Frame a payload as an HMAC-protected block stream. export async function writeHmacBlockStream( payload: Uint8Array, hmacBaseKey: Uint8Array, diff --git a/packages/kdbx/src/index.ts b/packages/kdbx/src/index.ts index a000bd2..099c018 100644 --- a/packages/kdbx/src/index.ts +++ b/packages/kdbx/src/index.ts @@ -1,11 +1,5 @@ -/** - * `kdbx` — a KDBX 3.1 and 4.x parser and serializer. - * - * The entry points are {@link Kdbx} (load/save/create) and {@link Credentials} - * (composite key). Lower-level building blocks (headers, block streams, the XML - * tree, VariantDictionary, crypto helpers) are also exported for callers that - * need to work below the database abstraction. - */ +/** `kdbx` — a KDBX 3.1/4.x parser and serializer. Entry points: `Kdbx` +and `Credentials`; lower-level pieces are exported too for callers that need them. */ export { ByteReader, diff --git a/packages/kdbx/src/inner-header.ts b/packages/kdbx/src/inner-header.ts index 824f28c..cbe578a 100644 --- a/packages/kdbx/src/inner-header.ts +++ b/packages/kdbx/src/inner-header.ts @@ -1,35 +1,29 @@ -/** - * The inner (encrypted) header — KDBX 4.x only. - * - * It precedes the XML document inside the decrypted, decompressed payload and - * carries the inner random stream cipher ID and key (used to protect sensitive - * fields) plus any binary attachments referenced by the XML. - */ +// Inner header (4.x only): the stream cipher ID/key, plus binaries referenced by the XML. import { ByteReader, ByteWriter, concatBytes } from './bytes.ts'; import { InnerHeaderFieldId, InnerStreamCipher } from './constants.ts'; -/** A binary attachment stored in the inner header. */ +// A binary attachment stored in the inner header. export interface InnerBinary { - /** Flags byte; 0x01 marks content that should be memory-protected. */ + // Flags byte; 0x01 marks content that should be memory-protected. flags: number; data: Uint8Array; } -/** Decoded inner header. */ +// Decoded inner header. export interface InnerHeader { innerRandomStreamId: number; innerRandomStreamKey: Uint8Array; binaries: InnerBinary[]; } -/** Result of reading the inner header: the header plus the XML bytes that follow. */ +// Result of reading the inner header: the header plus the XML bytes that follow. export interface ParsedInnerHeader { inner: InnerHeader; xml: Uint8Array; } -/** Parse the inner header from the start of a decrypted, decompressed payload. */ +// Parse the inner header from the start of a decrypted, decompressed payload. export function readInnerHeader(payload: Uint8Array): ParsedInnerHeader { const reader = new ByteReader(payload); const binaries: InnerBinary[] = []; @@ -64,7 +58,7 @@ export function readInnerHeader(payload: Uint8Array): ParsedInnerHeader { }; } -/** Serialize an inner header to its byte encoding. */ +// Serialize an inner header to its byte encoding. export function writeInnerHeader(inner: InnerHeader): Uint8Array { const writer = new ByteWriter(); const writeField = (id: number, value: Uint8Array): void => { diff --git a/packages/kdbx/src/kdbx.ts b/packages/kdbx/src/kdbx.ts index 53f1533..e950482 100644 --- a/packages/kdbx/src/kdbx.ts +++ b/packages/kdbx/src/kdbx.ts @@ -1,14 +1,4 @@ -/** - * High-level KDBX database: parsing (`load`), serialization (`save`), and - * creation (`create`) for KDBX 3.1 and 4.x. - * - * The flow mirrors the specification's overall structure: - * load — read outer header, verify header SHA-256/HMAC (4.x) → de-chunk → - * decrypt → decompress → read inner header (4.x) → parse XML → - * unprotect fields. - * save — protect fields → serialize XML → prepend inner header (4.x) → - * compress → encrypt → chunk → prepend header + SHA-256 + HMAC (4.x). - */ +// High-level KDBX database: load/save/create for KDBX 3.1/4.x, per the spec's structure. import { ChaCha20 } from '../../../build/packages/chacha20/src/index.js'; import { @@ -68,30 +58,30 @@ import { createProtectedStreamCipher } from './protected-stream.ts'; import type { VariantDictionary, VdValue } from './variant-dictionary.ts'; import { parseXml, serializeXml, type XmlElement } from './xml.ts'; -/** Outer cipher choices. */ +// Outer cipher choices. export type KdbxCipher = 'aes' | 'chacha20'; -/** Key derivation function choices (KDBX 4.x). */ +// Key derivation function choices (KDBX 4.x). export type KdbxKdf = 'argon2id' | 'argon2d' | 'aes'; -/** Options for {@link Kdbx.create}. */ +// Options for {@link Kdbx.create}. export interface KdbxCreateOptions { databaseName?: string; - /** Format version family: 3 (KDBX 3.1) or 4 (KDBX 4.x). Default 4. */ + // Format version family: 3 (KDBX 3.1) or 4 (KDBX 4.x). Default 4. version?: 3 | 4; - /** Outer cipher. Default `chacha20` for v4; AES is forced for v3. */ + // Outer cipher. Default `chacha20` for v4; AES is forced for v3. cipher?: KdbxCipher; - /** KDF. Default `argon2id` for v4; AES-KDF is forced for v3. */ + // KDF. Default `argon2id` for v4; AES-KDF is forced for v3. kdf?: KdbxKdf; - /** GZip-compress the payload. Default true. */ + // GZip-compress the payload. Default true. compression?: boolean; - /** Argon2 tuning (KDBX 4.x). */ + // Argon2 tuning (KDBX 4.x). argon2?: { memoryBytes?: bigint; iterations?: bigint; parallelism?: number; version?: number; }; - /** AES-KDF rounds. */ + // AES-KDF rounds. aesKdfRounds?: bigint; } @@ -107,16 +97,7 @@ function kx_bytes(value: Uint8Array): VdValue { return { type: 'bytes', value }; } -/** - * Classify a cipher ID, or throw if it names neither outer cipher this - * package supports. This is the single point where "supported outer cipher" - * is defined; `kx_ivLengthFor`/`kx_encryptPayload`/`kx_decryptPayload` all - * dispatch through it rather than each re-checking the same two IDs, so - * there's exactly one "unsupported outer cipher" error site instead of three - * copies (which, in kx_encryptPayload's case, could never actually be - * reached: the only caller, `#save4`, always calls `kx_ivLengthFor` on the - * same `cipherId` first and would already have thrown by then). - */ +// Classify a cipher ID, or throw — the single place "supported cipher" is decided. function kx_cipherKind(cipherId: Uint8Array): 'aes' | 'chacha20' { if (bytesEqual(cipherId, CipherId.Aes256)) { return 'aes'; @@ -153,7 +134,7 @@ async function kx_decryptPayload( : new ChaCha20(cipherKey, iv).decrypt(data); } -/** Transform the composite key according to the header's KDF settings. */ +// Transform the composite key according to the header's KDF settings. async function kx_transformKey(header: OuterHeader, compositeKey: Uint8Array): Promise { if (header.version.major >= 4) { if (!header.kdfParameters) { @@ -167,22 +148,18 @@ async function kx_transformKey(header: OuterHeader, compositeKey: Uint8Array): P return aesKdf(compositeKey, header.transformSeed, header.transformRounds); } -/** An in-memory KDBX database. */ +// An in-memory KDBX database. export class Kdbx { - /** The outer header. Random fields (seeds, IVs, salts) are regenerated on save. */ + // The outer header. Random fields (seeds, IVs, salts) are regenerated on save. header: OuterHeader; - /** The `` document root, with protected fields held as plaintext. */ + // The `` document root, with protected fields held as plaintext. root: XmlElement; - /** - * Binary attachments. Read from / written to the encrypted inner header - * (KDBX 4.x) or `Meta/Binaries` (KDBX 3.1) — see meta-binaries.ts. Either - * way, Ref values in the XML always address this pool by array index. - */ + // Binary attachments. XML `Ref` values index this pool (see meta-binaries.ts for KDBX 3.1). binaries: InnerBinary[]; #credentials: Credentials; #innerStreamId: number; - /** Inner random stream key (KDBX 4.x); for 3.1 the protected-stream key is in the header. */ + // Inner random stream key (KDBX 4.x); for 3.1 the protected-stream key is in the header. #innerStreamKey: Uint8Array; private constructor(init: { @@ -201,7 +178,7 @@ export class Kdbx { this.#innerStreamKey = init.innerStreamKey; } - /** The root `` element under ``. */ + // The root `` element under ``. getRootGroup(): XmlElement { const rootElement = getChild(this.root, 'Root'); const group = rootElement ? getChild(rootElement, 'Group') : undefined; @@ -211,39 +188,27 @@ export class Kdbx { return group; } - /** Replace the credentials used when the database is next saved. */ + // Replace the credentials used when the database is next saved. setCredentials(credentials: Credentials): void { this.#credentials = credentials; } - /** - * Add a binary attachment to the pool, reusing an existing identical one - * (by content) rather than growing the pool without bound. Returns the - * pool index — reference it from an entry via a - * `` child (see model.ts's - * addEntryAttachment). - */ + // Add a binary attachment, reusing an identical one if present; returns its pool index. addBinary(data: Uint8Array): number { for (let i = 0; i < this.binaries.length; i++) { - // i < this.binaries.length, so this index is always populated; the - // cast only satisfies noUncheckedIndexedAccess. + // Index is always in range here; the cast only satisfies noUncheckedIndexedAccess. if (bytesEqual((this.binaries[i] as InnerBinary).data, data)) return i; } this.binaries.push({ flags: 0, data }); return this.binaries.length - 1; } - /** The bytes for a pool index, or undefined if out of range. */ + // The bytes for a pool index, or undefined if out of range. getBinaryData(ref: number): Uint8Array | undefined { return this.binaries[ref]?.data; } - /** - * Remove any binary pool entries no longer referenced by any entry's - * `` child — including History revisions, not just an entry's - * live state — and remap the survivors' Ref indices to stay contiguous. - * Called on every save; a no-op when the pool is already empty. - */ + // Remove unreferenced binaries (incl. History) and remap survivors' Refs; runs on every save. #pruneUnreferencedBinaries(): void { if (this.binaries.length === 0) return; @@ -276,7 +241,7 @@ export class Kdbx { this.binaries = kept; } - /** Parse a KDBX database from bytes. */ + // Parse a KDBX database from bytes. static async load(data: Uint8Array, credentials: Credentials): Promise { const { header, rawHeader, offset } = readOuterHeader(data); const compositeKey = await credentials.getCompositeKey(); @@ -384,7 +349,7 @@ export class Kdbx { }); } - /** Serialize the database to KDBX bytes (regenerating all random material). */ + // Serialize the database to KDBX bytes (regenerating all random material). async save(): Promise { return this.header.version.major >= 4 ? this.#save4() : this.#save3(); } @@ -480,7 +445,7 @@ export class Kdbx { return concatBytes(rawHeader, encrypted); } - /** Create a new, empty database with the given credentials. */ + // Create a new, empty database with the given credentials. static async create(credentials: Credentials, options: KdbxCreateOptions = {}): Promise { const version = options.version ?? 4; const databaseName = options.databaseName ?? 'Database'; diff --git a/packages/kdbx/src/kdf.ts b/packages/kdbx/src/kdf.ts index 4e5eab0..c49935c 100644 --- a/packages/kdbx/src/kdf.ts +++ b/packages/kdbx/src/kdf.ts @@ -1,11 +1,5 @@ -/** - * Key derivation functions used by KDBX to transform the composite key. - * - * - KDBX 3.1 always uses AES-KDF, parameterized by the outer-header transform - * seed and round count. - * - KDBX 4.x stores the KDF choice and parameters in a VariantDictionary; the - * built-in functions are AES-KDF, Argon2d, and Argon2id. - */ +/** KDFs for the composite key: KDBX 3.1 always uses AES-KDF; 4.x picks +AES-KDF, Argon2d, or Argon2id via a VariantDictionary. */ import { type Argon2Type, argon2 } from '../../../build/packages/argon2/src/index.js'; import { bytesEqual } from './bytes.ts'; @@ -16,24 +10,14 @@ import { type VariantDictionary, vdRequireBytes, vdRequireInt } from './variant- const KX_ARGON2_TAG_LENGTH = 32; const KX_BYTES_PER_KIB = 1024n; -/** - * Sanity ceiling on Argon2 cost parameters read from a file's (untrusted, - * pre-authentication) KDF parameters. RFC 9106's own legal ranges — enforced - * separately by the argon2 package itself — allow up to 2^32-1 KiB (~4 TiB) - * of memory and 2^32-1 iterations, since that package has no opinion on what - * a caller finds reasonable. Left unchecked here, a crafted file could force - * an attempted multi-gigabyte-or-larger allocation, or a computation that - * never realistically finishes, just by being opened — with any password - * attempt, since the KDF runs before the file's authenticity is verified. - * These values are generous relative to any real-world KDBX configuration - * (KeePass's own defaults are far below them) while keeping a worst-case - * unlock attempt bounded to something a browser tab can actually survive. - */ +/** Ceiling on Argon2 cost params read from the file before auth. RFC 9106 +allows up to ~4TiB memory / 2^32-1 iterations; unchecked, a crafted file +could force a huge allocation before any password is even checked. */ const KX_MAX_ARGON2_MEMORY_KIB = 2 * 1024 * 1024; // 2 GiB const KX_MAX_ARGON2_ITERATIONS = 64; const KX_MAX_ARGON2_PARALLELISM = 64; -/** Transform a 32-byte composite key with AES-KDF (KDBX 3.1 and the AES-KDF KDF). */ +// Transform a 32-byte composite key with AES-KDF (KDBX 3.1 and the AES-KDF KDF). export async function aesKdf( compositeKey: Uint8Array, seed: Uint8Array, @@ -42,10 +26,7 @@ export async function aesKdf( return aesKdfTransform(compositeKey, seed, rounds); } -/** - * Transform a composite key using the KDF described by a KDBX 4.x KDF-parameter - * VariantDictionary. - */ +// Transform a composite key using the KDF named in a KDBX 4.x KDF-parameter VariantDictionary. export async function transformWithKdfParameters( compositeKey: Uint8Array, params: VariantDictionary, diff --git a/packages/kdbx/src/key.ts b/packages/kdbx/src/key.ts index 572cead..cb58b05 100644 --- a/packages/kdbx/src/key.ts +++ b/packages/kdbx/src/key.ts @@ -1,14 +1,10 @@ -/** - * Derivation of the concrete keys used during encryption and authentication, - * from the master seed (outer header) and the KDF-transformed composite key. - * - * See the "Computation of Keys" section of the KDBX specification. - */ +/** Derives the encryption/auth keys from the master seed and the +KDF-transformed composite key (spec: "Computation of Keys"). */ import { ByteWriter, concatBytes } from './bytes.ts'; import { sha256, sha512 } from './crypto.ts'; -/** Final encryption key = SHA-256(masterSeed ‖ transformedKey). */ +// Final encryption key = SHA-256(masterSeed ‖ transformedKey). export async function deriveCipherKey( masterSeed: Uint8Array, transformedKey: Uint8Array, @@ -16,10 +12,7 @@ export async function deriveCipherKey( return sha256(concatBytes(masterSeed, transformedKey)); } -/** - * Base value for the HMAC keys = SHA-512(masterSeed ‖ transformedKey ‖ 0x01). - * Used (with a block index) to derive the header and per-block HMAC keys. - */ +// HMAC base = SHA-512(masterSeed‖transformedKey‖0x01); derives header/per-block HMAC keys. export async function deriveHmacBaseKey( masterSeed: Uint8Array, transformedKey: Uint8Array, @@ -27,13 +20,13 @@ export async function deriveHmacBaseKey( return sha512(concatBytes(masterSeed, transformedKey, new Uint8Array([0x01]))); } -/** Header HMAC key = SHA-512(0xFFFFFFFFFFFFFFFF ‖ base). */ +// Header HMAC key = SHA-512(0xFFFFFFFFFFFFFFFF ‖ base). export async function deriveHeaderHmacKey(hmacBaseKey: Uint8Array): Promise { const index = new Uint8Array(8).fill(0xff); return sha512(concatBytes(index, hmacBaseKey)); } -/** Per-block HMAC key = SHA-512(index_u64le ‖ base). */ +// Per-block HMAC key = SHA-512(index_u64le ‖ base). export async function deriveBlockHmacKey( hmacBaseKey: Uint8Array, index: bigint, diff --git a/packages/kdbx/src/meta-binaries.ts b/packages/kdbx/src/meta-binaries.ts index 62fa8b7..0eb089d 100644 --- a/packages/kdbx/src/meta-binaries.ts +++ b/packages/kdbx/src/meta-binaries.ts @@ -1,18 +1,7 @@ -/** - * Binary attachments for KDBX 3.1. - * - * 3.1 stores attachment content inline in `Meta/Binaries` as Base64 (each - * entry optionally individually gzip-compressed, marked `Compressed="True"`), - * referenced from entries by a `Meta/Binaries/Binary`'s `ID` attribute. KDBX - * 4.x instead stores content in the encrypted inner header, referenced by - * pool position (see inner-header.ts). Both versions use the same entry-side - * shape — `name` — so the only - * difference this module needs to bridge is where the content lives and how - * it's addressed: on load, on-disk IDs are remapped to pool indices so the - * rest of this library (Kdbx#binaries, the attachment helpers in model.ts) - * can treat both versions identically; on save, indices are written back out - * as IDs. - */ +/** KDBX 3.1 binary attachments: stored inline in `Meta/Binaries` as Base64 +(optionally gzipped), referenced by ID (4.x instead uses the inner header, +referenced by pool position). Both share the same entry-side `` +shape, so this module just remaps IDs to pool indices on load, and back on save. */ import { fromBase64, toBase64 } from './bytes.ts'; import { gunzip } from './crypto.ts'; @@ -29,17 +18,13 @@ import { } from './model.ts'; import type { XmlElement } from './xml.ts'; -/** Result of reading `Meta/Binaries`: the pool, plus how on-disk IDs map to it. */ +// Result of reading `Meta/Binaries`: the pool, plus how on-disk IDs map to it. export interface ParsedMetaBinaries { binaries: InnerBinary[]; idToIndex: Map; } -/** - * Read `Meta/Binaries`, decoding each `` (Base64, gunzipped if - * marked `Compressed="True"`) into the pool in document order. Does not - * modify `root` — see {@link removeMetaBinariesElement}. - */ +// Read Meta/Binaries into the pool, gunzipping if Compressed="True"; doesn't modify root. export async function readMetaBinaries(root: XmlElement): Promise { const binaries: InnerBinary[] = []; const idToIndex = new Map(); @@ -66,7 +51,7 @@ export async function readMetaBinaries(root: XmlElement): Promise` from the on-disk ID in `idToIndex` to the pool index it maps to. - * A `Ref` with no matching ID (a stale/malformed reference) is left as-is. - */ +// Remap every Binary Ref (incl. History) from on-disk ID to pool index; leaves unmatched Refs as-is. export function remapEntryBinaryRefs(root: XmlElement, idToIndex: Map): void { const rootElement = getChild(root, 'Root'); const rootGroup = rootElement && getChild(rootElement, 'Group'); @@ -104,14 +85,8 @@ export function remapEntryBinaryRefs(root: XmlElement, idToIndex: Map` - * elements are marked `Protected="True"`; in memory they hold plaintext, and - * they are (re-)encrypted against the inner random stream only when saving. - */ +/** Helpers over the KDBX XML tree: navigation, protected-value (de)cryption, +and builders for new databases, groups, and entries. `Protected="True"` +values hold plaintext in memory and are encrypted only on save. */ import { fromBase64, toBase64, utf8Decode, utf8Encode } from './bytes.ts'; import { getRandomBytes } from './crypto.ts'; @@ -16,7 +10,7 @@ import type { XmlElement, XmlNode } from './xml.ts'; const KX_PROTECTED_ATTRIBUTE = 'Protected'; const KX_GENERATOR = 'keepass-web'; -/** A value that is encrypted by the inner random stream when stored on disk. */ +// A value that is encrypted by the inner random stream when stored on disk. export class ProtectedValue { readonly #text: string; @@ -39,7 +33,7 @@ export class ProtectedValue { // --- Element construction and navigation ------------------------------------ -/** Create an element, optionally with a single text child. */ +// Create an element, optionally with a single text child. export function createElement(name: string, text?: string): XmlElement { const element: XmlElement = { type: 'element', name, attributes: [], children: [] }; if (text !== undefined) { @@ -48,7 +42,7 @@ export function createElement(name: string, text?: string): XmlElement { return element; } -/** First direct child element with the given name. */ +// First direct child element with the given name. export function getChild(element: XmlElement, name: string): XmlElement | undefined { for (const child of element.children) { if (child.type === 'element' && child.name === name) { @@ -58,7 +52,7 @@ export function getChild(element: XmlElement, name: string): XmlElement | undefi return undefined; } -/** All direct child elements with the given name. */ +// All direct child elements with the given name. export function getChildren(element: XmlElement, name: string): XmlElement[] { const out: XmlElement[] = []; for (const child of element.children) { @@ -69,7 +63,7 @@ export function getChildren(element: XmlElement, name: string): XmlElement[] { return out; } -/** Concatenated text content of an element. */ +// Concatenated text content of an element. export function getText(element: XmlElement): string { let text = ''; for (const child of element.children) { @@ -80,12 +74,12 @@ export function getText(element: XmlElement): string { return text; } -/** Replace an element's children with a single text node. */ +// Replace an element's children with a single text node. export function setText(element: XmlElement, text: string): void { element.children = [{ type: 'text', value: text, cdata: false }]; } -/** Value of a named attribute, or `undefined`. */ +// Value of a named attribute, or `undefined`. export function getAttribute(element: XmlElement, name: string): string | undefined { for (const [attr, value] of element.attributes) { if (attr === name) { @@ -95,7 +89,7 @@ export function getAttribute(element: XmlElement, name: string): string | undefi return undefined; } -/** Set (or replace) a named attribute. */ +// Set (or replace) a named attribute. export function setAttribute(element: XmlElement, name: string, value: string): void { for (const pair of element.attributes) { if (pair[0] === name) { @@ -106,13 +100,13 @@ export function setAttribute(element: XmlElement, name: string, value: string): element.attributes.push([name, value]); } -/** Append a child node and return the parent. */ +// Append a child node and return the parent. export function appendChild(parent: XmlElement, child: XmlNode): XmlElement { parent.children.push(child); return parent; } -/** Deep-clone an element tree. */ +// Deep-clone an element tree. export function cloneElement(element: XmlElement): XmlElement { return { type: 'element', @@ -137,10 +131,8 @@ function kx_walkProtected(element: XmlElement, visit: (el: XmlElement) => void): } } -/** - * Decrypt every `Protected="True"` value in document order, replacing the - * Base64 ciphertext with plaintext (the marker attribute is kept). - */ +/** Decrypt every `Protected="True"` value in document order, replacing the +Base64 ciphertext with plaintext (the marker attribute is kept). */ export function applyInboundProtection(root: XmlElement, cipher: ProtectedStreamCipher): void { kx_walkProtected(root, (element) => { const ciphertext = fromBase64(getText(element)); @@ -148,11 +140,7 @@ export function applyInboundProtection(root: XmlElement, cipher: ProtectedStream }); } -/** - * Encrypt every `Protected="True"` value in document order, replacing plaintext - * with Base64 ciphertext. Operate on a clone so the in-memory tree stays - * readable. - */ +// Encrypts every Protected="True" value in place; call on a clone to keep the original readable. export function applyOutboundProtection(root: XmlElement, cipher: ProtectedStreamCipher): void { kx_walkProtected(root, (element) => { const ciphertext = cipher.process(utf8Encode(getText(element))); @@ -187,21 +175,21 @@ function kx_createTimes(): XmlElement { return times; } -/** A field on a new entry. */ +// A field on a new entry. export interface EntryField { key: string; value: string; protect?: boolean; } -/** Standard fields recognised by {@link createEntry}. */ +// Standard fields recognised by {@link createEntry}. export interface EntryInput { title?: string; username?: string; password?: string; url?: string; notes?: string; - /** Additional custom string fields. */ + // Additional custom string fields. fields?: EntryField[]; } @@ -216,7 +204,7 @@ function kx_createStringField(field: EntryField): XmlElement { return string; } -/** Build an `` element from the given fields. */ +// Build an `` element from the given fields. export function createEntry(input: EntryInput): XmlElement { const entry = createElement('Entry'); appendChild(entry, createElement('UUID', kx_newUuid())); @@ -237,12 +225,7 @@ export function createEntry(input: EntryInput): XmlElement { return entry; } -/** - * An entry's tags, from its `` element — KeePass's own `;`-joined - * text format. Empty (`[]`) when the element is absent, matching how real - * KeePass omits it entirely on a tagless entry rather than writing an empty - * one. - */ +// An entry's `;`-joined tags from ; [] if absent, matching real KeePass. export function getEntryTags(entry: XmlElement): string[] { const tagsEl = getChild(entry, 'Tags'); if (!tagsEl) return []; @@ -252,10 +235,7 @@ export function getEntryTags(entry: XmlElement): string[] { .filter((tag) => tag.length > 0); } -/** - * Replace an entry's tags. An empty list removes the `` element - * entirely rather than leaving one with empty text, matching real KeePass. - */ +// Replace an entry's tags; an empty list removes entirely, matching KeePass. export function setEntryTags(entry: XmlElement, tags: string[]): void { const cleaned = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0); const existing = getChild(entry, 'Tags'); @@ -275,9 +255,7 @@ export function setEntryTags(entry: XmlElement, tags: string[]): void { } } -/** An entry's Times, as plain data — ISO-UTC timestamps, KeePass's own - * on-disk format. Fields are `''`/`false` when the Times element (or a - * field within it) is missing, e.g. from a hand-built or malformed entry. */ +// An entry's Times as plain ISO-UTC data; fields default to ''/false if missing. export interface EntryTimes { created: string; modified: string; @@ -299,12 +277,7 @@ export function getEntryTimes(entry: XmlElement): EntryTimes { }; } -/** - * Update an entry's expiration. `expiryTimeIso`, if given, replaces - * ExpiryTime; an empty string leaves the existing ExpiryTime untouched - * (e.g. when the caller only means to flip Expires off). Does nothing on an - * entry with no Times element at all. - */ +// Update expiration; blank expiryTimeIso keeps ExpiryTime. No-op without a Times element. export function setEntryExpiry(entry: XmlElement, expires: boolean, expiryTimeIso: string): void { const times = getChild(entry, 'Times'); if (!times) return; @@ -318,20 +291,14 @@ export function setEntryExpiry(entry: XmlElement, expires: boolean, expiryTimeIs } } -/** Bump an entry's LastModificationTime to now — real KeePass does this on - * every edit; this app's own applyEntryEdits() only ever touched fields. */ +// Bump an entry's LastModificationTime to now, matching real KeePass. export function touchLastModified(entry: XmlElement): void { const times = getChild(entry, 'Times'); const modEl = times && getChild(times, 'LastModificationTime'); if (modEl) setText(modEl, kx_nowIso()); } -/** - * Visit every `` under `group` — direct children of every - * (sub)group, plus each entry's own `History` revisions — in that order. - * Used wherever a binary attachment `Ref` needs to be found across the - * whole database, not just its live (non-history) entries. - */ +// Visit every Entry under group, including History revisions (for finding Refs database-wide). export function walkAllEntries(group: XmlElement, visit: (entry: XmlElement) => void): void { for (const entry of getChildren(group, 'Entry')) { visit(entry); @@ -344,19 +311,14 @@ export function walkAllEntries(group: XmlElement, visit: (entry: XmlElement) => } } -/** An entry's attachment: a name paired with a Kdbx#binaries pool index. */ +// An entry's attachment: a name paired with a Kdbx#binaries pool index. export interface EntryAttachment { name: string; ref: number; } -/** - * An entry's attachments, from its `` children - * (`name`). `Ref` addresses - * Kdbx#binaries by pool index for both KDBX 3.1 and 4.x — see - * {@link Kdbx.addBinary} in kdbx.ts and meta-binaries.ts for how 3.1's - * on-disk `Meta/Binaries` IDs get mapped to that index on load. - */ +/** An entry's attachments from `` children; `Ref` indexes +`Kdbx#binaries` (see meta-binaries.ts for 3.1's on-disk ID mapping). */ export function getEntryAttachments(entry: XmlElement): EntryAttachment[] { const out: EntryAttachment[] = []; for (const binaryEl of getChildren(entry, 'Binary')) { @@ -370,7 +332,7 @@ export function getEntryAttachments(entry: XmlElement): EntryAttachment[] { return out; } -/** Attach a binary pool reference (see Kdbx#addBinary) to an entry under the given name. */ +// Attach a binary pool reference (see Kdbx#addBinary) to an entry under the given name. export function addEntryAttachment(entry: XmlElement, name: string, ref: number): void { const binaryEl = createElement('Binary'); appendChild(binaryEl, createElement('Key', name)); @@ -380,7 +342,7 @@ export function addEntryAttachment(entry: XmlElement, name: string, ref: number) appendChild(entry, binaryEl); } -/** Rename an entry's attachment, matched by its current name. */ +// Rename an entry's attachment, matched by its current name. export function renameEntryAttachment(entry: XmlElement, oldName: string, newName: string): void { for (const binaryEl of getChildren(entry, 'Binary')) { const keyEl = getChild(binaryEl, 'Key'); @@ -391,11 +353,7 @@ export function renameEntryAttachment(entry: XmlElement, oldName: string, newNam } } -/** - * Remove an entry's attachment, matched by name. Does not touch the binary - * pool itself — Kdbx#save() drops pool entries no longer referenced by any - * entry. - */ +// Remove an entry's attachment by name; save() later drops unreferenced pool entries. export function removeEntryAttachment(entry: XmlElement, name: string): void { entry.children = entry.children.filter((child) => { if (child.type !== 'element' || child.name !== 'Binary') return true; @@ -406,20 +364,14 @@ export function removeEntryAttachment(entry: XmlElement, name: string): void { const KX_DEFAULT_HISTORY_MAX_ITEMS = 10; -/** An entry's past versions, from its `` child, oldest first — - * matching how real KeePass appends to it. `[]` when it has none. */ +// An entry's past versions from , oldest first; [] if none. export function getEntryHistory(entry: XmlElement): XmlElement[] { const historyEl = getChild(entry, 'History'); return historyEl ? getChildren(historyEl, 'Entry') : []; } -/** - * Snapshot `entry`'s current state (everything except its own History) onto - * its History, trimmed to `document`'s Meta/HistoryMaxItems (defaulting to - * 10, matching real KeePass, for a database that predates that field). - * Must be called with the pre-edit state still in place — real KeePass - * snapshots before applying an edit, not after. - */ +/** Snapshot `entry`'s current state onto its History, trimmed to +`HistoryMaxItems` (default 10). Call before applying an edit, not after. */ export function pushHistorySnapshot(document: XmlElement, entry: XmlElement): void { const snapshot = cloneElement(entry); snapshot.children = snapshot.children.filter( @@ -450,13 +402,8 @@ export function pushHistorySnapshot(document: XmlElement, entry: XmlElement): vo } } -/** - * Restore `entry` to a past version from its History: the entry's current - * state is snapshotted first (so it isn't lost), then its fields are - * replaced with the historical version's — matching real KeePass, where - * restoring is itself a further edit, not a rewind. `snapshot` must be one - * of the elements `getEntryHistory(entry)` returned. - */ +/** Restore `entry` to a past version: snapshots current state first, then +replaces its fields. `snapshot` must come from `getEntryHistory(entry)`. */ export function restoreHistoryEntry( document: XmlElement, entry: XmlElement, @@ -477,14 +424,14 @@ export function restoreHistoryEntry( entry.children = newChildren; } -/** Permanently remove one past version from an entry's History. */ +// Permanently remove one past version from an entry's History. export function deleteHistoryEntry(entry: XmlElement, snapshot: XmlElement): void { const historyEl = getChild(entry, 'History'); if (!historyEl) return; historyEl.children = historyEl.children.filter((child) => child !== snapshot); } -/** Build a `` element with the given name. */ +// Build a `` element with the given name. export function createGroup(name: string): XmlElement { const group = createElement('Group'); appendChild(group, createElement('UUID', kx_newUuid())); @@ -513,7 +460,7 @@ function kx_containsGroup(ancestor: XmlElement, target: XmlElement): boolean { return getChildren(ancestor, 'Group').some((sub) => kx_containsGroup(sub, target)); } -/** The database's recycle bin group, if `Meta/RecycleBinUUID` names one that still exists. */ +// The database's recycle bin group, if `Meta/RecycleBinUUID` names one that still exists. function kx_findRecycleBin(document: XmlElement): XmlElement | undefined { const meta = getChild(document, 'Meta'); const uuidEl = meta && getChild(meta, 'RecycleBinUUID'); @@ -523,12 +470,7 @@ function kx_findRecycleBin(document: XmlElement): XmlElement | undefined { return kx_findGroupByUuid(rootGroup, getText(uuidEl)); } -/** - * Find the database's recycle bin group, creating it as a child of the root - * group the first time anything is trashed and recording its UUID in - * `Meta/RecycleBinUUID` — matching real KeePass, which has no recycle bin - * group in a fresh database until one is needed. - */ +// Find (or lazily create) the recycle bin group, recording its UUID in Meta/RecycleBinUUID. export function findOrCreateRecycleBin(document: XmlElement): XmlElement { const existing = kx_findRecycleBin(document); if (existing) return existing; @@ -554,16 +496,13 @@ export function findOrCreateRecycleBin(document: XmlElement): XmlElement { return bin; } -/** True if `group` is the database's recycle bin, or nested inside it. */ +// True if `group` is the database's recycle bin, or nested inside it. export function isInRecycleBin(document: XmlElement, group: XmlElement): boolean { const bin = kx_findRecycleBin(document); return bin !== undefined && kx_containsGroup(bin, group); } -/** - * Build a complete `` document with a Meta section and a root group - * (optionally pre-populated by `build`). - */ +// Build a complete document with Meta and a root group, optionally via build(). export function createDatabaseDocument( databaseName: string, build?: (rootGroup: XmlElement) => void, diff --git a/packages/kdbx/src/protected-stream.ts b/packages/kdbx/src/protected-stream.ts index 59b86df..6e970e4 100644 --- a/packages/kdbx/src/protected-stream.ts +++ b/packages/kdbx/src/protected-stream.ts @@ -1,27 +1,17 @@ -/** - * The inner random stream that protects sensitive fields (e.g. passwords) in - * the KDBX XML document. - * - * KDBX 3.1 uses Salsa20 with a fixed nonce and a key of SHA-256(streamKey). - * KDBX 4.x uses ChaCha20 with key/nonce derived from SHA-512(streamKey). In - * both cases the cipher produces one continuous keystream; protected values are - * XORed against it in document order, so the order of processing matters. - */ +/** Inner random stream protecting sensitive fields: 3.1 uses Salsa20 +(key = SHA-256(streamKey)); 4.x uses ChaCha20 (from SHA-512(streamKey)). +One continuous keystream XORed in document order — order matters. */ import { ChaCha20, Salsa20 } from '../../../build/packages/chacha20/src/index.js'; import { InnerStreamCipher, SALSA20_NONCE } from './constants.ts'; import { sha256, sha512 } from './crypto.ts'; -/** A stateful XOR transform over the inner random stream's keystream. */ +// A stateful XOR transform over the inner random stream's keystream. export interface ProtectedStreamCipher { process(data: Uint8Array): Uint8Array; } -/** - * Create the inner random stream cipher for the given stream ID and key. A - * fresh cipher must be created for each full read or write pass, since the - * keystream is consumed in order across all protected values. - */ +// A fresh cipher is needed per full pass, since the keystream is consumed in order. export async function createProtectedStreamCipher( streamId: number, streamKey: Uint8Array, diff --git a/packages/kdbx/src/variant-dictionary.ts b/packages/kdbx/src/variant-dictionary.ts index cefc3e3..fa29fe8 100644 --- a/packages/kdbx/src/variant-dictionary.ts +++ b/packages/kdbx/src/variant-dictionary.ts @@ -1,16 +1,10 @@ -/** - * VariantDictionary — the name/value container used by KDBX 4.x for KDF - * parameters (header field 11) and public custom data (header field 12). - * - * Layout (all integers little-endian): - * UInt16 version (current 0x0100; the high byte is the critical major version) - * zero or more items, each: type byte ‖ Int32 name size ‖ name ‖ Int32 value size ‖ value - * a terminating null byte (0x00) - */ +/** VariantDictionary — KDBX 4.x's name/value container for KDF params +(field 11) and custom data (field 12). Little-endian layout: UInt16 +version; items of type‖namesize‖name‖valuesize‖value; terminating 0x00. */ import { ByteReader, ByteWriter, utf8Decode, utf8Encode } from './bytes.ts'; -/** VariantDictionary value type tags. */ +// VariantDictionary value type tags. export const VdType = { UInt32: 0x04, UInt64: 0x05, @@ -21,7 +15,7 @@ export const VdType = { Bytes: 0x42, } as const; -/** A tagged VariantDictionary value. */ +// A tagged VariantDictionary value. export type VdValue = | { readonly type: 'uint32'; readonly value: number } | { readonly type: 'uint64'; readonly value: bigint } @@ -31,13 +25,13 @@ export type VdValue = | { readonly type: 'string'; readonly value: string } | { readonly type: 'bytes'; readonly value: Uint8Array }; -/** A VariantDictionary, preserving insertion order. */ +// A VariantDictionary, preserving insertion order. export type VariantDictionary = Map; const KX_CURRENT_MAJOR = 1; const KX_CURRENT_VERSION = 0x0100; -/** Parse a VariantDictionary from its byte encoding. */ +// Parse a VariantDictionary from its byte encoding. export function readVariantDictionary(bytes: Uint8Array): VariantDictionary { const reader = new ByteReader(bytes); const version = reader.readU16(); diff --git a/packages/kdbx/src/xml.ts b/packages/kdbx/src/xml.ts index d17bedd..896cd64 100644 --- a/packages/kdbx/src/xml.ts +++ b/packages/kdbx/src/xml.ts @@ -1,23 +1,16 @@ -/** - * A small XML parser and serializer covering the subset of XML that KeePass - * produces: elements, attributes, text, CDATA sections, comments, and the XML - * declaration. It does not support namespaces, DTD validation, or processing - * instructions beyond the declaration — none of which appear in KDBX documents. - * - * `DOMParser`/`XMLSerializer` are not available outside browsers, so this keeps - * the package isomorphic and dependency-free. - */ +/** Minimal XML parser/serializer for what KeePass actually produces (no +namespaces/DTD/PIs) — keeps this isomorphic without DOMParser/XMLSerializer. */ -/** A parsed XML element. */ +// A parsed XML element. export interface XmlElement { readonly type: 'element'; name: string; - /** Attributes in document order. */ + // Attributes in document order. attributes: Array<[string, string]>; children: XmlNode[]; } -/** A parsed run of text (possibly originating from a CDATA section). */ +// A parsed run of text (possibly originating from a CDATA section). export interface XmlText { readonly type: 'text'; value: string; @@ -98,10 +91,7 @@ class KX_XmlParser { #readName(): string { const start = this.#i; - // The `&&` short-circuits on `this.#i < this.#s.length`, so `this.#i` is - // always in range whenever the index is read; the cast (rather than - // `?? ''`) only satisfies noUncheckedIndexedAccess and doesn't change - // behavior. + // The && already guarantees this.#i is in range; the cast only satisfies noUncheckedIndexedAccess. while (this.#i < this.#s.length && !KX_NAME_END.has(this.#s[this.#i] as string)) { this.#i += 1; } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 473efbb..0071c76 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -1,36 +1,17 @@ -/** - * `router` — identifies a KDBX-family file from its first 8 bytes and names - * the implementation (a page like `0x67.html`) that reads it. - * - * This package is shared, unmodified, across every chooser page (local file, - * Google Drive, and future sources): the decision "which implementation - * understands these bytes" doesn't depend on where the bytes came from, so - * it lives once here rather than being re-decided per chooser. - * - * Deliberately does not import `kdbx`: its whole job is routing based on 8 - * bytes, and it stays independently auditable by owning that logic outright - * rather than pulling in the full parser to do it. - */ +/** `router` — identifies a KDBX-family file from its first 8 bytes and +names the implementation that reads it; shared, unmodified, by every +chooser page. Doesn't import `kdbx`, so it stays auditable on its own. */ -/** First 32-bit signature shared by every KDBX-family file (little-endian on disk). */ +// First 32-bit signature shared by every KDBX-family file (little-endian on disk). const SIGNATURE_1 = 0x9aa2d903; -/** - * Top three bytes shared by every KDBX-family secondary signature; the low - * byte identifies the sub-format (see KNOWN_SECONDARY_SIGNATURES). - */ +// Top 3 bytes shared by every secondary signature; the low byte identifies the sub-format. const SIGNATURE_2_PREFIX = 0xb54bfb00; -/** - * Sub-formats identified by the secondary signature's low byte. `implementation` - * is present only for a format this app actually has a reader for, and names - * the page a chooser should embed to open it — not a page to navigate to. - * - * A Map, not a plain object, specifically so the keys can stay hexadecimal - * literals (matching how the format's own spec refers to them) without - * tripping Biome's useSimpleNumberKeys rule, which only applies to object - * literal keys. - */ +/** Sub-formats by the secondary signature's low byte. `implementation` is +set only when this app can read the format, naming the page to embed +(not navigate to). A Map keeps keys as hex literals; Biome's +useSimpleNumberKeys only applies to object literals. */ const KNOWN_SECONDARY_SIGNATURES: ReadonlyMap = new Map([ [0x65, { label: 'KeePass 1.x (.kdb)' }], @@ -42,11 +23,7 @@ export type FormatResult = | { kind: 'invalid' } | { kind: 'recognized'; secondaryByte: number; label: string; implementation?: string }; -/** - * Identify a KDBX-family file from its first 8 bytes alone: the two - * signature UInt32s. Reads nothing else — the router's whole job is - * routing, not parsing. - */ +// Identify a file from its first 8 bytes (the two signature UInt32s) alone — routing, not parsing. export function identifyFormat(header: Uint8Array): FormatResult { if (header.length < 8) { return { kind: 'invalid' }; diff --git a/pages/0x67/logic.ts b/pages/0x67/logic.ts index 9e1de8e..514f65e 100644 --- a/pages/0x67/logic.ts +++ b/pages/0x67/logic.ts @@ -1,30 +1,10 @@ -/** - * Pure logic for the 0x67 app: field/name lookups, tree traversal, entry - * search, and entry-edit commit, all over a decoded KDBX document. None of - * these touch the DOM, so unlike page.ts they can be — and are — unit - * tested directly under plain Node (see tests/0x67-logic.test.ts). - * - * This is a real ES module (imports kdbx's build output, same convention - * used between argon2/chacha20/kdbx themselves — see e.g. kdbx/src/kdf.ts) - * so it can be exercised with ordinary imports in tests. For the browser - * build, bundle-iife strips the import below and hoists this file's exports - * onto globalThis, right alongside the kdbx library itself — this file is - * one of the concatenated "files" in 0x67/bundle-iife.json. page.ts consumes - * these functions as globals, not via import — see globals.d.ts. - * - * Imports go directly to kdbx's model.ts/xml.ts build output, not its - * index.ts barrel, on purpose: the barrel re-exports the whole library, - * including kdbx.ts, whose own cross-package import of chacha20's build - * output is written relative to *its source* location and breaks (resolves - * one directory too shallow) when actually resolved from *its build* - * location — a real, latent bug, undiscovered until now because nothing - * previously resolved kdbx's build output as a genuine, executed import - * chain (kdbx's own tests run its source; bundle-iife only text-strips - * import lines, never resolves them). model.ts/xml.ts have no such - * dependency, so importing them directly sidesteps the bug rather than - * fixing it here, which would mean touching already-shipped crypto wiring - * for an unrelated change — noted, not routed around silently. - */ +/** Pure logic for 0x67: field/tree lookups, search, and edit-commit over a +decoded KDBX doc — no DOM, so unit-tested directly. A real ES module; +bundle-iife hoists its exports onto globalThis alongside kdbx. + +Imports kdbx's model.ts/xml.ts directly, not the index.ts barrel, to +sidestep a latent bug: kdbx.ts's own chacha20 import resolves relative to +source, not build, and breaks when actually run from build output. */ import { appendChild, @@ -57,19 +37,14 @@ export function groupName(group: XmlElement): string { return n ? getText(n) : '(unnamed)'; } -/** An entry or group's IconID, as text — direct children, not String fields. */ +// An entry or group's IconID, as text — direct children, not String fields. export function elementIconId(element: XmlElement): string { const iconEl = getChild(element, 'IconID'); return iconEl ? getText(iconEl) : '0'; } -/** - * A small curated set of icons for entries and groups — an internal palette, - * not KeePass's own bitmap icon spritesheet, which this text-only app has no - * reason to vendor. IDs 0 and 49 match createEntry/createGroup's defaults. - * A file whose IconID isn't in this palette (e.g. one edited by real - * KeePass) still round-trips fine — it just falls back to a generic icon. - */ +/** Curated icon palette (not KeePass's bitmap spritesheet). IDs 0/49 match +createEntry/createGroup's defaults; unknown IDs round-trip via a generic icon. */ export const ICON_PALETTE: ReadonlyArray<{ id: number; emoji: string; label: string }> = [ { id: 0, emoji: '🔑', label: 'Key' }, { id: 1, emoji: '🌐', label: 'Web' }, @@ -96,13 +71,13 @@ export const ICON_PALETTE: ReadonlyArray<{ id: number; emoji: string; label: str const ICON_FALLBACK = '❔'; -/** The emoji for a given IconID text value, or a generic fallback. */ +// The emoji for a given IconID text value, or a generic fallback. export function iconEmoji(iconId: string): string { const id = Number.parseInt(iconId, 10); return ICON_PALETTE.find((icon) => icon.id === id)?.emoji ?? ICON_FALLBACK; } -/** Find the group that directly contains the given entry. */ +// Find the group that directly contains the given entry. export function findEntryParent(rootGroup: XmlElement, entry: XmlElement): XmlElement | null { for (const e of getChildren(rootGroup, 'Entry')) { if (e === entry) return rootGroup; @@ -114,8 +89,7 @@ export function findEntryParent(rootGroup: XmlElement, entry: XmlElement): XmlEl return null; } -/** Find the group that directly contains the given subgroup, or null if - * `group` is `rootGroup` itself (which has no parent). */ +// Find the group directly containing the given subgroup, or null for rootGroup itself. export function findGroupParent(rootGroup: XmlElement, group: XmlElement): XmlElement | null { for (const sub of getChildren(rootGroup, 'Group')) { if (sub === group) return rootGroup; @@ -125,8 +99,7 @@ export function findGroupParent(rootGroup: XmlElement, group: XmlElement): XmlEl return null; } -/** True if `candidate` is `ancestor` itself, or nested anywhere inside it — - * used to block moving a group into its own subtree. */ +// True if candidate is ancestor itself or nested inside it (blocks moving a group into itself). export function isDescendantGroup(ancestor: XmlElement, candidate: XmlElement): boolean { if (ancestor === candidate) return true; return getChildren(ancestor, 'Group').some((sub) => isDescendantGroup(sub, candidate)); @@ -137,7 +110,7 @@ export interface EntryWithGroup { group: XmlElement; } -/** Collect every entry in the tree, paired with its containing group. */ +// Collect every entry in the tree, paired with its containing group. export function collectAllEntries( group: XmlElement, results: EntryWithGroup[] = [], @@ -151,7 +124,7 @@ export function collectAllEntries( return results; } -/** Return the group path from rootGroup to target as an array of names. */ +// Return the group path from rootGroup to target as an array of names. export function groupPathTo( rootGroup: XmlElement, target: XmlElement, @@ -166,10 +139,7 @@ export function groupPathTo( return null; } -/** - * Keep only entries with a String field or tag whose value contains the - * query, case-insensitively. - */ +// Keep only entries with a String field or tag matching the query, case-insensitively. export function filterEntriesByQuery(entries: EntryWithGroup[], query: string): EntryWithGroup[] { const q = query.toLowerCase(); return entries.filter(({ entry }) => { @@ -195,8 +165,7 @@ function entrySortKey(entry: XmlElement, field: EntrySortField): string { } } -/** Sort entries by title, username, or last-modified time. Ties keep their - * original relative order (Array#sort is stable). */ +// Sort by title, username, or modified time; ties keep original order (stable sort). export function sortEntries( entries: EntryWithGroup[], field: EntrySortField, @@ -210,7 +179,7 @@ export function sortEntries( ); } -/** Optional table-view columns; Title is always shown and isn't one of these. */ +// Optional table-view columns; Title is always shown and isn't one of these. export type EntryColumnKey = | 'username' | 'password' @@ -220,8 +189,7 @@ export type EntryColumnKey = | 'modified' | 'created'; -/** Unformatted, unmasked — page.ts formats/masks for display but copies this - * on double-click. */ +// Unformatted/unmasked — page.ts formats for display but copies this raw value. export function entryColumnValue(entry: XmlElement, column: EntryColumnKey): string { switch (column) { case 'username': @@ -255,20 +223,13 @@ function exportFields(entry: XmlElement, group: XmlElement): [string, string][] ]; } -/** A leading `=`, `+`, `-`, `@`, tab, or CR makes Excel, Sheets, and - * LibreOffice Calc treat a CSV field as a formula rather than literal text — - * CWE-1236. Entry data (a Title, URL, or Notes field) is attacker-reachable - * in a way a spreadsheet's own cells normally aren't, so it can't be assumed - * safe. */ +/** A leading =, +, -, @, tab, or CR makes spreadsheets treat a CSV field +as a formula (CWE-1236); entry data is attacker-controlled here. */ const CSV_FORMULA_TRIGGER = /^[=+\-@\t\r]/; -/** Quote a CSV field only when it needs it (contains a comma, quote, or - * newline), doubling any internal quotes — RFC 4180. RFC 4180 quoting alone - * does not stop a spreadsheet application from evaluating a quoted field's - * content as a formula, so a leading formula-trigger character is neutralized - * first by prefixing a literal apostrophe — the standard mitigation every - * mainstream spreadsheet app already treats as "force text" (the same effect - * as typing `'123` into a cell by hand). */ +/** Quote a CSV field only when needed (RFC 4180), doubling internal quotes. +A leading formula-trigger char is also neutralized with a leading +apostrophe — the standard "force text" mitigation spreadsheets honor. */ function csvField(value: string): string { const safe = CSV_FORMULA_TRIGGER.test(value) ? `'${value}` : value; if (/[",\r\n]/.test(safe)) { @@ -277,9 +238,7 @@ function csvField(value: string): string { return safe; } -/** Serialize entries (Group, Title, UserName, Password, URL, Notes, Tags) as - * plaintext CSV — see the caller for the "this is unencrypted" warning this - * always needs. */ +// Serialize entries as plaintext CSV — see the caller for the required "unencrypted" warning. export function toCsv(entries: EntryWithGroup[]): string { const header = ['Group', 'Title', 'UserName', 'Password', 'URL', 'Notes', 'Tags']; const lines = [header.join(',')]; @@ -302,8 +261,7 @@ function xmlEscape(value: string): string { .replace(/'/g, '''); } -/** Serialize entries as plaintext XML — same fields and the same "this is - * unencrypted" caveat as {@link toCsv}. Not a KDBX document. */ +// Serialize entries as plaintext XML, same caveat as toCsv; not a KDBX document. export function toXml(entries: EntryWithGroup[]): string { const rows = entries.map(({ entry, group }) => { const body = exportFields(entry, group) diff --git a/pages/0x67/page.ts b/pages/0x67/page.ts index 823f528..87184ca 100644 --- a/pages/0x67/page.ts +++ b/pages/0x67/page.ts @@ -1,6 +1,4 @@ -// ============================================================ // Application state -// ============================================================ type EntryView = 'tile' | 'table'; @@ -30,8 +28,7 @@ const app: AppState = { dirty: false, sortField: 'title', sortDir: 'asc', - // Tile view reads better than a dense table on a narrow phone screen; - // this only sets the initial default, the view toggle still overrides it. + // Tile view reads better on narrow phones; this only sets the initial default. entryView: window.innerWidth <= 700 ? 'tile' : 'table', columnVisibility: { username: true, @@ -44,18 +41,12 @@ const app: AppState = { }, }; -/** True once a trusted same-origin parent frame has handed this app a vault to - * open (see the "Host integration" section). Stays false in standalone use, so - * every screen behaves exactly as it does without a host. */ +// True once a trusted parent frame has handed this app a vault (see "Host integration"). let hostSession = false; -// ============================================================ // DOM helpers -// ============================================================ -/** Unwrap a possibly-missing lookup, or fail loudly. The app's screens are - * generated from its own templates, so a missing element means a real bug, - * not a state to handle gracefully. */ +// Unwrap a possibly-missing lookup, or fail loudly — a missing element means a real bug. function must(value: T | null | undefined): T { if (value === null || value === undefined) { throw new Error('expected element not found'); @@ -67,27 +58,24 @@ function byId(id: string): T { return must(document.getElementById(id) as T | null); } -/** Clone a