TypeScript Core Technical Specification
On this page
Date: 2026-08-08 · Status: Draft 1 · Purpose: the TypeScript/Node binding of docs/09-core-architecture.md. Second Phase 1 implementation; it is also the independent verifier of the Python-generated vectors before freeze (docs/08 §7), so it MUST be written against the vector inputs and the spec, not against the Python code.
Library-fact caveat: as in the Python spec, dependency claims were marked [VERIFY] where they had to be re-confirmed at implementation time. All of this document’s are now resolved — by the M2 report (docs/18) as the core was built, and the last one, on node:crypto’s lack of XChaCha, closed in the 2026-09-09 currency sweep. None remain here.
1. Package identity and toolchain
| Item | Decision | Notes |
|---|---|---|
| Package name | @fieldseal/core | Claim the npm @fieldseal scope before first publish (PRD naming note) |
| Runtime target | Node ≥ 24.7 (package.json engines) — the floor is set by crypto.argon2Sync, which landed in 24.7; earlier drafts said ≥ 20 LTS with the floor to be verified. The asynchronous crypto.argon2 the §2 companions use landed in the same release, so shipping them did not move the floor | Server-only. Browser/edge runtimes are explicitly out of scope for v0: the sync API + KMS provider model presumes a server process, and Web Crypto’s AEAD API is async-only, which conflicts with spec §11.1. Say this in the README rather than letting bundler users discover it |
| Language/build | TypeScript strict mode; tsc emit, no bundler | Small surface; keep the toolchain boring |
| Module format | ESM with exports map; CJS compatibility decided at implementation [flag] — TypeORM/older Prisma toolchains still commonly require(); if CJS is dropped, document the interop path | |
| Tests | vitest | Same harness contract as Python (docs/08 §5) |
| Lint/format | eslint + prettier (or biome — implementer’s choice, pinned in repo config) |
2. Dependencies
| Purpose | Dependency | Status |
|---|---|---|
| AES-256-GCM, HKDF-SHA-512, HMAC, CSPRNG, constant-time compare | node:crypto | createCipheriv("aes-256-gcm", key, nonce, { authTagLength: 16 }) + setAAD; createHmac("sha512", …); randomBytes; timingSafeEqual. Zero external deps for suite 0xFF01. HKDF is RFC 5869 over createHmac, not hkdfSync (revised 2026-08-22): both hkdfSync and Web Crypto deriveBits refuse an info longer than 1024 bytes, and info here is canonical_context, whose tenant_id/row_id are unbounded (spec §6.1) — an envelope another core writes under a ~1 KiB context would be unreadable. Pinned to RFC 5869 A.1–A.3 and to hkdfSync on every input it accepts (tests/primitives.test.ts) |
| XChaCha20-Poly1305 (suite 0xFF02) | @noble/ciphers, optional peer/optional dependency | node:crypto supports chacha20-poly1305 but not the XChaCha (24-byte-nonce) variant — confirmed 2026-08-22 on Node 24.16 / OpenSSL 3.5.6 (docs/18 D-row on dependency deviations), and the flag is removed here rather than left standing over a fact the M2 report already settled. @noble/ciphers is audited, pure-JS, and implements the libsodium-compatible construction. Alternative: sodium-native (faster, native build cost). Decision deferred to implementation with this default: @noble/ciphers, because an optional suite should not impose a native toolchain |
| Argon2id raw output, both forms | Decided at implementation: node:crypto’s argon2Sync and argon2 (Node ≥ 24.7) — sync, raw output, explicit 16-byte salt, parallelism settable to 1, no K/X exposed, zero dependencies. The earlier candidates (sodium-native, @node-rs/argon2, npm argon2) are superseded; the 2026-08-22 narrowing that removed K from spec §7.3 is what made a no-dependency backend sufficient | Still behind an internal backend seam so a runtime without these could be served by another backend without API change; the shipped core does not carry one. The seam carries both forms as required members (argon2id, argon2idAsync) since the companion shipped on 2026-09-04: one implementation exists, and an optional async member would make “this backend has no async form” a silent runtime branch. Spec §11.1 forbids the converse shortcut, so idf never routes through argon2idAsync |
| KMS wrappers | @fieldseal/kms-aws etc., separate packages | Same rule as Python: required dependency set for suite 0xFF01 is empty beyond node:crypto |
Event-loop honesty (must appear in the package README): a synchronous Argon2id blocks the Node event loop for 10–100 ms per query term (spec §7.3’s quoted cost at its pinned t = 3 / 32 MiB invocation; 41 ms measured with argon2Sync on 2026-08-23). That is far worse for Node than for threaded runtimes, and it is now measured rather than quoted: 20 derivations let the loop take 1 turn in 871 ms (2026-08-31; docs/07 §7). Guidance, in order: (a) prefer HMAC indexes wherever §7.3’s domain table permits — microseconds instead of milliseconds; (b) where the domain requires Argon2id, use the async companion §2 shipped, and size UV_THREADPOOL_SIZE at or above the expected concurrent-derivation count, because the companion moves the cost to the libuv threadpool rather than removing it; (c) application-layer worker threads remain possible and carry a cost §5 makes explicit — a client, a DEK cache and its KMS traffic per worker.
Async companion (blindIndexAsync) — DECIDED 2026-08-31, SHIPPED 2026-09-04. Fieldseal.blindIndexAsync(value, ctx) and Fieldseal.unindexableMarkerAsync(ctx), and those two only: they are the Argon2id derivations, and encrypt/decrypt/rotate/isCiphertext stay synchronous by specification because the frameworks in the value path cannot await (§7). G9 ([#9]) closed 2026-08-09, so spec §11.1 already permits one; what was missing was evidence. The WS-C benchmark (docs/07 §6, run 2026-08-31, both legs recorded in docs/07 §7) supplies it, and the answer is not close.
The sync path stalls the process, measured rather than reasoned, on two machines. Twenty derivations at the spec §7.3 minima (t=3, m=32 MiB, p=1) let the event loop take exactly 1 turn on both — 871 ms on an x86-64 Zen 4 desktop at ~44 ms/call, and 1373 ms on an arm64 Mac at ~70 ms/call, against ~832 000 and ~108 000 turns idle respectively. The per-call cost is hardware-dependent and sits inside §7.3’s quoted 10–100 ms; the total starvation is not hardware-dependent, because it follows from a synchronous CPU-bound call on a single-threaded loop. Through the Prisma adapter, a findMany on a table with no encrypted column at all went from p99 0.8 ms unloaded to 352 ms under eight concurrent indexed lookups — a 440× regression on requests that asked for nothing. That is spec §11.1’s own justification (“stalls every concurrent request in the process”) as a number.
The async form is better everywhere and is not a clean fix, and the difference matters. node:crypto.argon2 already exists and offloads to the libuv threadpool. On x86-64/Windows the same twenty derivations left the loop at 98% of its idle turn count with the same wall time, and eight concurrent ones finished 3.2× faster. On arm64/macOS the same code left the loop at 52% of idle, and under eight-way concurrency it still stalled for 561 ms. Async beats sync on both machines — always better than one turn in the whole window — but “the async path frees the loop” was a claim from one platform, and the second platform does not support it. What is safe to say is that it converts a total stall into a partial one whose size is hardware-dependent.
It also relocates cost rather than removing it: the derivation now occupies a libuv threadpool slot, which fs, dns and zlib share. Confirmed on both machines with the default UV_THREADPOOL_SIZE=4: four concurrent derivations pushed an unrelated fs.readFile from p50 ~0.28 ms to 67 ms on x86-64/Windows and 402 ms on arm64/macOS. How it degrades further differs by platform (sixteen concurrent reached 818 ms on Windows; on macOS the median fell back to 72 ms with a 1202 ms maximum), so the shape is platform-specific and the contention is not. An adapter depending on the companion MUST document the threadpool sizing obligation — a companion that silently starved every file read in the process would not be an improvement. (First published as 45.6 ms and 690 ms from a probe that awaited each read before issuing the next — coordinated omission, which under-sampled precisely the loaded cases it was measuring and scored the worst one from two samples. The probe is open-loop now. Found by running the benchmark on a second machine, where the broken sampling inverted the result entirely: macOS appeared to show no contention, and with correct sampling shows the worst of it.)
Why not the two alternatives this paragraph used to offer. HMAC domains remain right wherever §7.3’s table permits them and cost microseconds — but §7.3 requires Argon2id precisely for the low-entropy domains where a blind index is most needed, so “use HMAC” is not available exactly where the problem is. Worker threads are worse than they look here: §5 below states the DEK cache is per-instance and not shared across workers, and there is no SharedArrayBuffer key storage — so a pool of four means four clients, four §5.5 caches, four times the KMS unwrap traffic, and a max_uses counter fragmented across instances. The threadpool route needs none of that: one client, one cache, no key material crossing a thread boundary. That difference is invisible to a stopwatch and is the substantive reason.
Outcome 3 did not trigger. “Untenable at every parameter set” would be a §7.3 cost problem rather than an API one. The minima are the cheapest point (42 ms; the sweep reaches 728 ms at t=6 / 256 MiB) and being off the loop rescues them, so the cost is a cost and the specification is not implicated.
What shipping it cost, and what was paid. §11.1 and docs/08 §5 item 10 require byte-identical output, identical error codes, the whole vector suite run a second time through the async path, and a sync blindIndex that is not a blocking wait on the async one. The second pass is in the report: 178 results became 356, every #async result twinned with its synchronous original, and async_companions reads true (docs/14 §4). The two assertions that price implies and that byte-identity alone does not buy are in tests/async-companions.test.ts: a setImmediate-driven counter must advance during an in-flight blindIndexAsync (otherwise async f() { return this.blindIndex(...) } passes the entire second suite and is a lie), and a spy on the Argon2Backend seam must show each form calling its own primitive and not the other’s. The same counter reads 0 across the synchronous call, but that reading is a weak control and the test says so: it is 0 for any callable that does not yield, a no-op included, so it excludes a blindIndex that yields and says nothing about blocking. Both real assertions were verified by breaking them deliberately: routing idfAsync through backend.argon2id fails exactly those two tests and nothing else. Naming stays this core’s choice, as G9 left it: the Async suffix on the camelCase operation name (§4).
3. Module layout
core/typescript/src/
index.ts exports: Fieldseal, FieldContext, errors, providers — no testing exports
api.ts Fieldseal client class
envelope.ts parse/serialize/isCiphertext
registry.ts frozen suite table
context.ts FieldContext, canonicalContext(), aad()
kdf.ts
aead/gcm.ts
aead/xchacha.ts lazy-imported so @noble/ciphers stays optional
commitment.ts pending G1
blindindex.ts pending G2 for argon2id; hmac path complete (truncate pinned, spec §7.2)
keyprovider.ts
cache.ts
config.ts
errors.ts
testing/index.ts exposed ONLY via the "./testing" subpath export; inert unless armed —
every function throws unless FIELDSEAL_TEST_MODE=1 is set (docs/08 §6)
package.json exports: "." → main API; "./testing" → encrypt_with_materials (docs/08 §6 — deliberately snake_case, contrary to local convention: docs/09 §12 fixes the same function name across languages so the injection surface is greppable in any repo). The main entry has no code path that reaches testing/, and the testing module’s doc comment carries the consequence verbatim: “an implementation that accepts a caller-supplied nonce or seed outside of vector-test mode is non-conformant” (vectors/README.md).
4. Public API shape
import { Fieldseal, FieldContext } from "@fieldseal/core";
const fs = new Fieldseal({
keyProvider, // EnvelopeKeyProvider carries the §5.5 cache policy in its own options
allowedSuites: [0xFF01],
writeSuite: 0xFF01,
readMode: "strict",
indexes: [ ... ],
});
fs.encrypt(pt: Uint8Array, ctx: FieldContext): Buffer // sync
fs.decrypt(ct: Uint8Array, ctx: FieldContext): Buffer // sync
fs.blindIndex(v: string | Uint8Array, ctx: FieldContext): Buffer // sync; text OR bytes
fs.unindexableMarker(ctx: FieldContext): Buffer // sync; this column's reserved bucket
fs.isCiphertext(v: Uint8Array): boolean // sync
fs.rotate(ct: Uint8Array, ctx: FieldContext): Buffer // sync
await fs.warm(ctxs: Iterable<FieldContext>): Promise<void> // spec §11.2 prefetch
// spec §11.1 asynchronous companions (§2), Argon2id derivations only
await fs.blindIndexAsync(v: string | Uint8Array, ctx: FieldContext): Promise<Buffer>
await fs.unindexableMarkerAsync(ctx: FieldContext): Promise<Buffer>
// docs/09 §2 configuration reflection
fs.readMode: ReadMode
fs.writeSuite: number
fs.allowedSuites: ReadonlySet<number>
fs.provisionalArmed: boolean
fs.indexes: ReadonlyMap<string, ValidatedIndex> // keyed by indexRegistryKey(...)
- Inputs typed
Uint8Array(acceptsBuffer); returnsBuffer(aUint8Arraysubclass) per Node convention. Strings are not accepted — except byblindIndex, which requires them (docs/09 §7.1; G16 part A). An implicitutf8coercion on the envelope operations would be exactly the cross-language divergence the vectors exist to catch, and that reasoning still holds forencrypt,decrypt,rotateandisCiphertext. It does not hold for index derivation, and inverting there was the point of G16 part A:TextEncodersubstitutes U+FFFD for an unpaired surrogate rather than failing, so a caller who encodes first has already collapsed two distinct values into one before this core is entered. Passing the string keeps the refusal where the information still exists. This core previously refused strings with a message directing callers to encode themselves, which named the lossy conversion as the supported route. - The
encrypt/ Normalization is a text operation; encryption is not. Index derivation is the only operation whose answer depends on the difference between a string and its encoding, so it is the only one that needs to see the string. The Python core has had the same asymmetry (blindIndexasymmetry is intended.encrypt(plaintext: bytes)againstblind_index(value: str | bytes)) since it was written; this core now matches it rather than diverging from it. - Well-formed text and its own encoding must produce the same index — the widening must not fork the function.
tests/index-boundary.test.tspins that, together with the distinguishable refusal of two different unpaired surrogates. - Deviation from docs/09 §2’s config sketch: there is no client-level
cachefield. The §5.5 cache policy isEnvelopeKeyProvider’s own requiredcacheoption (docs/09 §2’s “required for EnvelopeKeyProvider”, enforced at provider construction); acachekey present in the client config is refused with aConfigurationErrorrather than accepted and ignored. - Errors:
FieldsealErrorsubclasses, each withcodematching the §9 strings ("TAG_INVALID", …) for the vector harness mapping. - Method naming is the docs/09 §12 casing rule applied:
blindIndex/isCiphertextare the camelCase renderings of the fixed operation names. A §11.1 companion takes that name plus anAsyncsuffix (blindIndexAsync,unindexableMarkerAsync). Spec §11.1 fixes neither the name nor the signature of a companion (G9), so this is this binding’s convention and not a portable name —docs/09§12 says so, and a core in another language is free to spell it differently. - The companions refuse exactly what the synchronous forms refuse, in the same order, and refuse it as a rejection. Both entry points run one shared prelude (value type → context validation → the fail-closed registry lookup) and one shared input step (key acquisition → index-key derivation → normalization), so the two cannot drift;
KEY_UNAVAILABLEstill precedes theINVALID_ARGUMENTa refused value would raise. Both methods are declaredasync, so a refusal that happens before anyawaitstill arrives as a rejection rather than a synchronous throw — a caller writing.catch(…)would otherwise miss it. - Configuration reflection (docs/09 §2). The first four accessors predate G18;
indexesis what that issue added, and it is the one an adapter needs — before it,docs/12§5’s E006 registry check was unimplementable in this language at all, not merely awkwardly:#cfgis a hard private field on an instance the constructor freezes, so there is no bad option to fall back on the way Python’s_indexesoffers one.ValidatedIndex,validateIndexDeclarationandindexRegistryKeyare exported for the comparison. Three runtime guarantees are load-bearing here and none comes from a type.indexesreturnsnew Map(this.#cfg.indexes)andallowedSuitesreturnsnew Set(this.#cfg.allowedSuites), becauseReadonlyMap/ReadonlySetare erased at runtime: both accessors would otherwise hand out the very collections the value path consults, and oneas Map/as Setcast would let a caller clear the registry or change what the client will decrypt. Both copies are O(small) on startup-time calls, and internal code keeps reading#cfgdirectly, so neither touches the value path. AndvalidateIndexDeclarationfreezes what it returns, becausereadonlyon an interface member is a compile-time claim only — a caller with the record in hand could otherwise rewritetruncateBitsthrough a single cast and change what the client derives.allowedSuiteswas missed on the first pass and caught in review: it predates G18, so the clause this section documents made an accessor that already existed non-conformant. Worth stating, because it is the general hazard of adding a rule to a surface that grew before it — the new members get the treatment and the old ones are assumed to have had it.
5. Security-relevant implementation notes
- Zeroization honesty:
Buffer.fill(0)on evicted DEKs overwrites the visible allocation; V8 may have copied during prior operations andnode:cryptomay hold internal copies. Same honest-limitation wording rule as Python (docs/09 §8.3). Nomlock(documented deviation). Provider-returned material is never zeroized by the core, on either path — not the candidate arrays fromdecryptionKeysand not the key fromencryptionKey. This is now the rule rather than an exception: docs/09 §8.1 makes that material provider-owned, on the reasoning this binding’s decrypt path had already recorded in a comment (a custom provider may return a reference to a buffer it still needs). The mechanism that makes this true on the write path is the defensive copy in#encryptionKey(api.ts:196): it validates the provider’s return — key length,key_idlength, provider exceptions mapped toKEY_UNAVAILABLE— and hands backnew Uint8Array(ek.key), so the laterek.key.fill(0)erases the core’s own copy and never the provider’s buffer. The same helper serves the blind-index path (api.ts:390). That copy is load-bearing and must not be refactored away: without it,.fill(0)would destroy the material of any provider that returns a reference to its own cache. Under G17 (issue #67) it stops being incidental —providers.test.ts(“key-material ownership”) drives all three paths with a provider that deliberately hands out references and asserts its buffers survive. The cost of the rule is that the shipped providers’ per-call copies reach GC unzeroized — copies of material the cache holds and erases on eviction anyway. What the core does zeroize is what it derived itself: the record key on both paths, the intermediate plaintext buffer, the untruncated IDF output, and the Argon2id salt — that last one added 2026-09-04, because spec §7.3 leaves the column’s whole keying in it. Note thatreadonly key: Uint8ArrayonEncryptionKeydoes not enforce any of this — TypeScript’sreadonlyis shallow, soek.key.fill(0)type-checks; the rule is a specification obligation, and the regression test inproviders.test.tsis what actually holds it. - What crosses the
awaitin a companion.idfAsyncderives the 16-byte Argon2id salt before its firstawait, so both the tenant index key’s copy and the per-column index key are erased as soon asidfAsyncreturns, with nothing the in-flight derivation holds reading them. Stated as an ordering claim about submission this would be false, and was:idfAsynchas noawaitof its own andnode:cryptoqueues the threadpool job synchronously inside the call, so the job is already queued when the erasure runs. The property that holds is about reads — the job captured the salt and the copy, never the key. What remains in flight is that salt and a private copy of the normalized value, and the salt is itself erased once the derivation completes: spec §7.3 forbids Argon2’sKandX, so keying “rests entirely on the salt” and those 16 bytes carry the column index key’s full strength. Erasing the key while leaving the salt to GC would have protected nothing. The erasure happens on completion rather than on submission because that is all theArgon2Backendcontract promises about argument lifetime. - What the normalized-value copy is worth. Less than the first version of this bullet claimed. On the shipped backend
node:cryptocopies itsmessageargument synchronously — verified on Node 24.16, zeroing the buffer on the line after the call still yields the reference tag — so the caller’s array is not read across the await with or without the copy. What the copy makes safe is thefill(0)that follows it: underidentitythe normalized value is the caller’s own array, and on the marker path it is the process-wideUNINDEXABLE_PREIMAGEsingleton, so zeroing the original would destroy a caller’s buffer or every subsequent marker in the process. Copy and erasure are therefore a pair — removing only one is a defect, removing both passes the tests — and the pair is kept as insulation against a backend that reads its arguments lazily, which the contract permits until the derivation completes.tests/async-companions.test.tsholds both cases, and node’s own internal copies remain outside this core’s reach exactly as the zeroization-honesty bullet above says. - Buffer maximum, and which bound binds first (docs/09 §4). Measured and recorded in
docs/18§4 rather than restated here, so one number does not exist twice:buffer.constants.MAX_LENGTHis 2⁵³−1 on Node 24 x64, spec §3.5’s 2³¹−1 binds first, and a 2³¹-byteUint8Arrayallocates lazily, which is what let the bound be verified directly instead of recorded unverifiable. The ceiling-not-a-guarantee clause therefore never engages on this platform; the refusal is the core’s, not the allocator’s, and the report’sspec/3.5/length-boundout-of-band entries carry it on both sides. - Constant-time:
crypto.timingSafeEqualfor commitment/tag-adjacent comparisons; it throws on length mismatch, so length-check first with a public-length rationale comment. Bufferaliasing: never return aBufferthat aliases an internal buffer (nosubarrayon cache-held material — always copy out). “Internal” includes Node’s sharedBufferpool:Buffer.from(bytes)and smallBuffer.allocUnsafeallocations are views into a poolArrayBuffershared with unrelated allocations, reachable from the returned value as.buffer. EveryBufferthe client returns is therefore an unpooledBuffer.alloccopy whoseArrayBufferis exactly the returned bytes.- Worker threads: the client is safe to construct per-worker; DEK cache is per-instance and not shared across workers (no
SharedArrayBufferkey storage — key material in aSharedArrayBufferwould widen the memory-exposure surface for no functional gain).
6. Testing plan
Mirrors the Python plan (docs/10 §6) with vitest: vector harness with schema validation and shared report format · both-direction envelope runs · exact error-code mapping · fuzz/property pass over the codec (fast-check) · cross-output producer script · a build-level test that import "@fieldseal/core" resolves no module under testing/ · a runtime test that encrypt_with_materials throws when FIELDSEAL_TEST_MODE is unset.
One addition specific to this core’s Phase 1 role: the independence rule. Until the first vector freeze, the TypeScript implementer works from docs/02-spec-v0.1.md + docs/08 + docs/09 + this document only — no reading core/python/** or tools/vector-gen/**. Divergences found this way are the review mechanism working (docs/08 §7); record each in docs/06-verification-log.md style in the implementation plan’s decision log.
The rule is now a protocol rather than a sentence: docs/17-m2-implementer-brief.md is the handoff to give the implementer, and it carries the prohibition, the reading path, the order-of-work rule that keeps a mismatch from being quietly tuned away, and the deliverables. Hand that over rather than paraphrasing this paragraph — the paraphrase is where the order-of-work rule gets dropped, and it is the part that does the work.
7. Non-goals
No browser build, no Deno/Bun claims until CI covers them, no Prisma awareness (that’s adapters/prisma), no WASM crypto fallbacks. Async companions exist for the two Argon2id derivations only (§2, shipped 2026-09-04): encrypt, decrypt, rotate and isCiphertext deliberately have none. Spec §11.1 permits them, but the reason the core API is synchronous is that Django, SQLAlchemy, TypeORM, Hibernate, Rails and Sequelize cannot await in the value path; an async encrypt would be an API nobody in that list can call, offloading microseconds of AES-GCM. The index derivation is the one operation where the cost is milliseconds and the caller — a query builder, not a field mapper — is already asynchronous.