v0.1.0-pre.10 — audit-and-cleanup batch (#79–#117)
Pre-releaseAudit-and-cleanup batch
A 2026-05-09 deep-review of the pre.9 surface (security + API consistency) filed 15 issues; iterative code review of the resulting fixes filed 4 more; one in-flight symmetry close. 20 PRs land in this release, addressing 18 issues.
Security (P0)
-
STRICT_POLICY enroll-user / revoke-user gates are no longer dead-coded (#79) —
db.grantanddb.revokenow invokecheckGate('enroll-user', factors)andcheckGate('revoke-user', factors)on top of the legacycheckPolicyOperation. Adds optionalfactors?: FactorProofBundleparameter to both methods. Behavior change for STRICT_POLICY consumers: grants and revokes without a factor proof now correctly throwPolicyDeniedError(the documented contract). PERSONAL_POLICY (default) is unchanged — its gates areminTier: 1with no factor requirement. -
db.changeSecretvalidates passphrase strength by default (#80) —assertStrongPassphrasefires unconditionally unlessallowWeakPassphrase: trueis passed. Pre-fix,changeSecretwas opt-in (validate: true) and the publicdb.changeSecretnever opted in — bypassable from the consumer surface even after pre.5 #7 shipped phrase strength validation. Breaking change: existing consumers passing weak passphrases throughdb.changeSecretwill throwWeakPassphraseError. Pass{ allowWeakPassphrase: true }to preserve old behavior; for fresh code, usedb.rotatePassphrasewhich has the same validation contract end-to-end. Thedb.changeSecretsignature gains an optional options argument:changeSecret(vault, newPassphrase, options?: PassphrasePolicy & { allowWeakPassphrase? }). -
grant()rejects when caller's kek is null (#81) — closes the tier-2 capability matrix violation. Pre-fix,grant()iteratedcallerKeyring.deksand wrapped under the new user'snewKekwithout ever readingcallerKeyring.kek, so a tier-2 wrap-DEKs session (@noy-db/on-password) or tier-3 PIN-resume session (@noy-db/on-pin) could create new user keyrings. The documented contract (perauth-landscape.md) is that those tiers cannot perform privileged admin operations. Now mirrorspersistKeyring's null-kekguard at the head ofgrant(). Same fix applied tobuildRecipientKeyringFile(#112, bundle-recipient mint) — adjacent site flagged by code review of the original fix. -
onInvalidKey: 'reset'no longer destroys valid keyrings on partial corruption (#82) — the audit's highest-impact P0 (silent data loss). Pre-fix,loadKeyringwalked the wrapped-DEK set in a barefor...of; the first corrupted byte killed the load withInvalidKeyError, andonInvalidKey: 'reset'(#6, pre.7) destroyed the keyring even when the KEK was correct. Now each DEK unwraps independently — mixed success ⇒ corruption (newKeyringCorruptError, reset does NOT fire); all-fail ⇒ wrong key (reset fires as documented). NewKeyringCorruptErrorclass carriesfailedCollections: readonly string[]andintactCount: numberfor targeted recovery UI. Exported from@noy-db/hubforinstanceofchecks.listAccessibleVaultsupdated to skipKeyringCorruptErrorlike the other expected-failure modes (single corrupt vault no longer poisons the enumeration). -
Passphrase canary closes the single-DEK + all-DEKs-corrupt ambiguity from #82 (#113) — additive
KeyringFile.canary?: stringfield. The canary is a fixed 256-bit AES-GCM key wrapped under the keyring's KEK with AES-KW. AES-KW is deterministic, so each write site mints fresh on persist without round-tripping acanaryfield throughUnlockedKeyring.loadKeyringverifies the canary first; combined with each-DEK try/catch, this distinguishes wrong-passphrase from corruption even when ALL DEKs (including a single-DEK keyring's sole DEK) are corrupted. Pre-#113 keyrings without the field load via the legacy multi-DEK heuristic from #99 — backward compatible, no migration required.
Atomicity / contract holes (P1)
-
rotatePassphraseslot ceremony validateswrapKind(#83) — extends pre.8 #29's anti-slot-swap guard with a third equality check onwrapKindalongsideidandmethod. Closes the hole where a buggy or hostile ceremony could change the slot's session-tier contract under cover of rotation:'kek' → 'deks'downgrade silently produceskek: nullat unlock;'deks' → 'kek'upgrade bricks the slot via an AES-KW failure. -
recoverPassphraseburns the paper recovery code BEFORE rewriting the keyring (#84) — atomicity reordering. Pre-fix, a store error after the keyring write left the user on the new passphrase but the consumed paper code remained valid (anyone with the same paper sheet could reuse it — security regression). Post-fix, the failure mode flips from security to usability: code burned + keyring not rewritten ⇒ user keeps old passphrase, loses one code (recoverable via admin / another code). -
UpdateUserOptions.displayNameacceptsnullto clear the field (#85) — alignsdb.updateUserwith thenull-as-clear convention pre.9 #57 shipped forUserApi.updateMe. Type widens fromstring | undefinedtostring | null | undefined.nullclears (stored as the empty string; UI consumers typically render the empty case by falling back to the user id).permissionsstays full-replacement at the map level (documented invariant). -
RecoverPassphraseInput.recoveryProofTS-narrowed to'paper'(#86) — matchesdb.enrollRecovery's TS-narrow discipline. Pre-fix, the type accepted a 4-variant union (paper | shamir | multi-channel | admin-mediated) and three of the four threwRecoveryProfileNotImplementedErrorat runtime. The runtime guard remains —as unknown as RecoveryProofbypasses the type but still hits the error. Breaking-but-narrowing: a consumer withrecoveryProoftyped as the wide union (e.g. ferrying through helper code) will get a TS error after this lands.
DX / surface coherence (P2)
-
docs/subsystems/plaintext-bypass.mdinvariant catalog (#87) — every collection that stores JSON in cleartext (_keyring/<userId>,_meta/policy,_meta/recovery-paper,_meta/handle,_meta/public-envelope,_meta/invite-audit-<id>,_meta/sync-credentials, ledger, consent, blob index) listed with rationale, plus a threat-model surface ("what an attacker with store-only access can learn"), plus an explicit checklist for adding or removing a bypass. -
db.getKeyring()returns a defensive copy (#88, #114) — pre-fix, the returnedUnlockedKeyring'sdeksMap (typedreadonly, but the Map itself isn't) was the live cached reference. A consumer calling.deks.set()corrupted the hub's internal state. Now returns a defensive shallow copy with freshMap, freshauthenticatorsarray, and per-element clones ofmeta. Hub-internal callers use a newprivate getKeyringInternalthat returns the live ref so mutations fromensureCollectionDEKstill land on the cache. CryptoKey handles insidedeksstay shared (opaque references; encrypt/decrypt opaque). 14 internal call sites switched. -
FactorProofBundleunifies the gate-method param shape (#89) — same shape{ factors?, sharedDevice? }was inlined at 12 sites with the parameter name alternatingfactors/presented. Now exported as a named type from@noy-db/hub(re-exported from thepolicysubpath); param name converges tofactorseverywhere. -
Subpath barrels (
team/,i18n/,query/,session/,bundle/,store/) populated (#90) — pre-fix,@noy-db/hub/teamexported onlyUnlockedKeyring+ sync helpers; the rest of the team API (rotate/recover, authenticator family, paper recovery primitives, magic-link grant, peer-recover, listUsers) was reachable only through the root barrel. Per-domain errors (SessionExpiredError,JoinTooLargeError,BundleIntegrityError,StoreCapabilityError, the i18n trio) couldn't beinstanceof-checked from a subpath import. All subpaths now own their domain's full export set. -
KeyringAuthenticatorvariant types re-exported from index.ts (#91) —KeyringAuthenticatorWrappingKEK,KeyringAuthenticatorWrappingDEKs,EnrollAuthenticatorWrappingKEKOptions,EnrollAuthenticatorWrappingDEKsOptions.@noy-db/on-*package authors writing variant-specific helpers can now name the type directly instead of reconstructing viaExtract<KeyringAuthenticator, { wrapKind: 'deks' }>. -
Adapter/Compartment naming residue cleaned up (#92) — user-visible strings (
session/dev-unlock.ts,collection.ts), JSDoc intypes.ts, and three sed-truncation artefacts (team/index.ts,index.ts,errors.ts). The internalsyncAdapterfield name on Collection / Vault / PresenceHandle is intentionally NOT renamed in this release — internal-only but touches multiple constructors and their tests. -
Leftover
null as unknown as CryptoKeycasts in showcases (#93) — pre.8 #41 tightenedUnlockedKeyring.kektoCryptoKey | null. The hub source was correctly migrated; three showcase fixtures (23-on-webauthn,24-on-oidc,30-on-pin) still carried casts. Replaced with literalnull.
Documentation
docs/subsystems/auth-landscape.md§ Package boundaries (#43) — names the layering between@noy-db/hub(cryptosystem) and the@noy-db/on-*packages (user-facing input format) explicitly. Closes #43 as wontfix-by-design — foldingon-recoveryinto a@noy-db/hub/recovery-codessubpath would anchor Base32 as the canonical format and break consumer swap-ability for no real bundle saving.
Issues closed
#43 (wontfix-by-design), #79, #80, #81, #82, #83, #84, #85, #86, #87, #88, #89, #90, #91, #92, #93, #112, #113, #114