@tinytars/vault 0.1.4 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # `@tinytars/vault`
2
2
 
3
+ [![CI](https://github.com/tinytars/vault/actions/workflows/ci.yml/badge.svg)](https://github.com/tinytars/vault/actions/workflows/ci.yml)
3
4
  [![Community Health](https://img.shields.io/badge/dynamic/json?url=https://api.github.com/repos/tinytars/vault/community/profile&query=$.health_percentage&suffix=%25&label=community%20health)](https://github.com/tinytars/vault/community)
4
5
 
5
6
  A storage operator who can read what it stores hasn't encrypted the data — it's obfuscated it,
@@ -70,7 +70,7 @@ export async function getAccountByEmail(db: D1Database, email: string): Promise<
70
70
  /**
71
71
  * The instant before which this account's session cookies are no longer accepted, or null.
72
72
  *
73
- * W71 — read on every authenticated request (see `requireSession`). One indexed lookup by primary
73
+ * Read on every authenticated request, by the adopter's own session-check middleware. One indexed lookup by primary
74
74
  * key is what buys revocability: the cookie is self-contained, so without a server-side fact to
75
75
  * check against, nothing short of rotating SESSION_SECRET for the entire deployment can invalidate one.
76
76
  */
@@ -149,7 +149,7 @@ export async function tombstoneAccount(db: D1Database, accountId: string, at: st
149
149
  }
150
150
 
151
151
  /**
152
- * W73 — stamps when the account's email address changed.
152
+ * Stamps when the account's email address changed.
153
153
  *
154
154
  * Read by the recovery routes, which refuse to issue a grant while the address is still new: a stolen
155
155
  * cookie that repoints the mailbox should not be able to convert that into a recovery code minutes
@@ -6,7 +6,7 @@ export type { AccessEvent };
6
6
  // bookkeeping are app-specific concerns that stay in the app's identity-audit.ts (see stores.ts's
7
7
  // own docstring on AuditStore).
8
8
 
9
- // W44 P4b — FTC-HBNR (§I) PHI-access/disclosure audit log. Records WHO (actor) accessed WHOSE (subject)
9
+ // FTC-HBNR (§I) PHI-access/disclosure audit log. Records WHO (actor) accessed WHOSE (subject)
10
10
  // vault and WHY (action + consent_ref), so a breach can be scoped to affected individuals. NO PHI.
11
11
  interface AccessEventRow {
12
12
  id: string;
@@ -99,8 +99,8 @@ export async function getCredential(db: D1Database, accountId: string, method: A
99
99
  return row ? mapCredential(row) : null;
100
100
  }
101
101
 
102
- // W44 P8 — the account's key-bearing methods (password/passkey/recovery), for the Account screen and the
103
- // "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
102
+ // The account's key-bearing methods (password/passkey/recovery), for the account-settings screen and
103
+ // the "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
104
104
  // row independently wraps the same private key); identities lacks a recovery row.
105
105
  export async function listCredentials(db: D1Database, accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]> {
106
106
  const { results } = await db
@@ -118,7 +118,7 @@ export async function deleteIdentity(db: D1Database, accountId: string, method:
118
118
  await db.prepare("DELETE FROM identities WHERE account_id = ? AND method = ?").bind(accountId, method).run();
119
119
  }
120
120
 
121
- // W44 P3 — bump the passkey authenticator's signature counter after a successful login
121
+ // Bump the passkey authenticator's signature counter after a successful login
122
122
  // (replay-attack detection). Merges into the existing kdf_params rather than a raw column
123
123
  // update so the wrapped key row stays a single INSERT OR REPLACE-shaped record.
124
124
  export async function updatePasskeyCounter(db: D1Database, accountId: string, counter: number): Promise<void> {
@@ -68,7 +68,7 @@ export async function updateProviderLinkStatus(db: D1Database, id: string, statu
68
68
  await db.prepare("UPDATE provider_links SET status = ? WHERE id = ?").bind(status, id).run();
69
69
  }
70
70
 
71
- // W44 P4b — a patient approving a support request: flip the link active, stamp its time-box + consent.
71
+ // An owner approving a support request: flip the link active, stamp its time-box + consent.
72
72
  export async function grantSupportLink(
73
73
  db: D1Database,
74
74
  id: string,
@@ -53,7 +53,7 @@ export async function getVault(db: D1Database, vaultId: string): Promise<VaultRo
53
53
  return row ? mapVault(row) : null;
54
54
  }
55
55
 
56
- /** W75 — the vault whose in-flight rotation has reserved this key. See `commitRotation`. */
56
+ /** The vault whose in-flight rotation has reserved this key. See `commitRotation`. */
57
57
  export async function getVaultByStagingR2Key(db: D1Database, r2Key: string): Promise<VaultRow | null> {
58
58
  const row = await db.prepare("SELECT * FROM vaults WHERE rotation_staging_r2_key = ?").bind(r2Key).first<VaultRowRaw>();
59
59
  return row ? mapVault(row) : null;
@@ -106,7 +106,7 @@ export async function putEnvelope(
106
106
  }
107
107
 
108
108
  /**
109
- * W71 — swap a vault's whole envelope set atomically.
109
+ * Swap a vault's whole envelope set atomically.
110
110
  *
111
111
  * The rotation route used to delete every envelope in a loop and then build the replacements one at a
112
112
  * time, decoding each `wrappedDEK` as it went. Two ways that ended in an unrecoverable vault: a
@@ -139,7 +139,7 @@ export async function replaceEnvelopes(
139
139
  }
140
140
 
141
141
  /**
142
- * W75 — the whole re-key, committed as one D1 batch: swap the envelope set AND repoint the vault at
142
+ * The whole re-key, committed as one D1 batch: swap the envelope set AND repoint the vault at
143
143
  * the freshly-written object, in one implicit transaction.
144
144
  *
145
145
  * The atomicity is the point. The rotation used to re-encrypt IN PLACE — new DEK into the same r2
package/adapters/r2.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import type { BlobStore, BlobConditional, StoredBlob } from "../blob-store";
2
2
 
3
3
  // Minimal structural type for the R2 binding — no @cloudflare/workers-types dependency, and
4
- // trivially mockable in tests. Moved from apps/health-dash-web/functions/api/vault/[id].ts, which
5
- // now imports R2Bucket/R2BlobStore from here instead of declaring its own copy.
4
+ // trivially mockable in tests. An adopter's own Pages Function route should import R2Bucket/
5
+ // R2BlobStore from here instead of declaring its own copy.
6
6
  export interface R2ObjectBody {
7
7
  body: ReadableStream;
8
- /** W70 — the version token. Handed to the browser on GET and sent back as If-Match on PUT. */
8
+ /** The version token. Handed to the browser on GET and sent back as If-Match on PUT. */
9
9
  etag: string;
10
10
  }
11
- /** A precondition on a write. Verified against real workerd in tests/unit/r2-conditional-put.test.ts. */
11
+ /** A precondition on a write. Verify this against real `workerd` in an adopter's own test suite — see ARCHITECTURE.md's "Adapters" section. */
12
12
  export interface R2Conditional {
13
13
  etagMatches?: string;
14
14
  etagDoesNotMatch?: string;
package/break-glass.ts CHANGED
@@ -1,8 +1,9 @@
1
- // The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern, used by
2
- // three routes that used to each hardcode their own TTL clamp, consent-string prefix, and audit shape:
3
- // functions/api/support/approve.ts (grant, with envelope), functions/api/providers/approve-support.ts
4
- // (grant, metadata-only), functions/api/support/access.ts (check/expire), and
5
- // functions/api/providers/[link].ts (revoke). See docs/cross-app/10-open-source-info-security.md step 5.
1
+ // The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern. It
2
+ // exists because this package's first adopter had several routes that each hardcoded their own TTL
3
+ // clamp, consent-string prefix, and audit shape — one for granting with an envelope, one for
4
+ // granting metadata-only access, one for checking/expiring, one for revoking. Centralizing the
5
+ // pattern here means every one of those routes shares the same TTL clamp, consent format, and audit
6
+ // trail instead of drifting independently.
6
7
 
7
8
  import type { AuditStore, EnvelopeStore, ProviderLink, ProviderLinkStore } from "./stores";
8
9
 
package/crypto.ts CHANGED
@@ -14,10 +14,11 @@ const deriveKey = (passphrase: string, salt: Uint8Array) => deriveAesKey(subtle,
14
14
 
15
15
  // Deterministic, non-reversible bearer derived from the passphrase: base64url(SHA-256).
16
16
  //
17
- // No longer called from the browser: it gated /api/chat and /api/raw via CHAT_TOKEN/RAW_TOKEN
18
- // until those routes moved to requireSession, and both secrets were deleted 2026-08-26. It is
19
- // still the generator for the one bearer allowlist that survivesscripts/chat-allowlist.ts
20
- // prints the VAULT_TOKEN value, which vault/[id].ts:73,155 checks and scripts/vault-sync.ts sends.
17
+ // This is a generator for a stable, non-reversible token an adopter can use to gate a script or an
18
+ // allowlisted bearer-token integration off the same passphrase used for the interactive login path,
19
+ // without storing the passphrase itself anywhere. It is not session authan interactive route
20
+ // should be protected by a real session cookie/token, not a static bearer derived here. Keep this
21
+ // only for a script-facing bearer allowlist you still rely on; drop it once you don't.
21
22
  export async function deriveBearerToken(passphrase: string): Promise<string> {
22
23
  const digest = new Uint8Array(await subtle.digest("SHA-256", toAB(enc.encode(passphrase))));
23
24
  let bin = "";
@@ -67,7 +68,7 @@ export async function decryptVault<T = Record<string, unknown>>(blob: Uint8Array
67
68
  return JSON.parse(dec.decode(plaintext)) as T;
68
69
  }
69
70
 
70
- // ── W44 envelope encryption (v2) ─────────────────────────────────────────────
71
+ // ── Envelope encryption (v2) ──────────────────────────────────────────────────
71
72
  // v1 above (passphrase → PBKDF2 → AES key → blob) is untouched. v2 splits the key:
72
73
  // a random per-vault DEK encrypts the blob, and the DEK is wrapped per principal
73
74
  // (owner, provider, support) to their ECDH public key. The passphrase path is gone;
@@ -138,17 +139,18 @@ export async function unwrapPrivateKey(blob: Uint8Array, kek: CryptoKey): Promis
138
139
  } catch {
139
140
  throw new Error("cannot unwrap private key (wrong KEK or corrupt blob)");
140
141
  }
141
- // Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate the
142
- // recovery code (W44 P8/P8b), which wrap this same key under a new KEK. Same rationale/posture as the
142
+ // Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate a
143
+ // recovery code, both of which wrap this same key under a new KEK. Same rationale/posture as the
143
144
  // DEK returned by unwrapDEKWithPrivateKey (also extractable for re-granting). The key stays in-memory
144
145
  // only, same trust boundary as the session's DEK.
145
146
  return subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveKey", "deriveBits"]);
146
147
  }
147
148
 
148
149
  // Import a raw PKCS8 ECDH private key (extractable, so it can be re-wrapped to add a login method —
149
- // same trust boundary as unwrapPrivateKey's output). W45: the Google path moves the plaintext key
150
- // over the wire (server-custody), so both server (re-wrap under the server KEK) and client (recover
151
- // the DEK) import it here instead of unwrapping a KEK-wrapped blob.
150
+ // same trust boundary as unwrapPrivateKey's output). A server-custody login path (e.g. SSO, where
151
+ // the server itself bootstraps the session) moves the plaintext key over the wire instead of a
152
+ // KEK-wrapped blob, so both server (re-wrap under the server KEK) and client (recover the DEK)
153
+ // import it here rather than unwrapping.
152
154
  export async function importPrivateKeyPkcs8(pkcs8: Uint8Array): Promise<CryptoKey> {
153
155
  return subtle.importKey("pkcs8", toAB(pkcs8), EC_PARAMS, true, ["deriveKey", "deriveBits"]);
154
156
  }
@@ -204,7 +206,7 @@ export async function unwrapDEKWithPrivateKey(
204
206
  return subtle.importKey("raw", raw, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
205
207
  }
206
208
 
207
- // W73 — the DEK wrapped under a KEK, for a provider-issued recovery grant.
209
+ // The DEK wrapped under a KEK, for a provider-issued recovery grant.
208
210
  //
209
211
  // The raw-key twin of wrapPrivateKey/unwrapPrivateKey above: same AES-GCM, same iv‖ct blob shape, same
210
212
  // reasoning. It exists because a recovery grant hands the DEK to a one-time code rather than to a
package/kdf.ts CHANGED
@@ -1,17 +1,18 @@
1
- // The passphrase→key derivation shared by every passphrase-encrypted blob in this monorepo.
1
+ // The passphrase→key derivation shared by every passphrase-encrypted blob that builds on this
2
+ // package.
2
3
  //
3
- // W72. Two envelope formats derive their key identically and independently: the QBO token vault
4
- // (`EB1`, packages/qbo/crypto.ts) and the health-dash v1 vault plus its account KEKs (`HD1`,
5
- // apps/health-dash-web/src/lib/crypto.ts). Same PBKDF2-SHA256, same 200_000 iterations, same
6
- // AES-GCM-256, same 16-byte salt and 12-byte IV written out twice, so a decision to raise the
7
- // iteration count could be taken in one file and silently not the other. Only the derivation is
8
- // shared here.
4
+ // This derivation is deliberately factored out on its own, separate from any particular envelope
5
+ // format: PBKDF2-SHA256, 200_000 iterations, AES-GCM-256, a 16-byte salt and a 12-byte IV. An
6
+ // adopter building a second, unrelated encrypted-blob format alongside this package's own HD1
7
+ // envelope (`crypto.ts`) should reuse this file rather than reimplementing the derivation
8
+ // reimplementing it independently is exactly how a later decision to raise the iteration count
9
+ // gets taken in one copy and silently not the other.
9
10
  //
10
- // WHAT IS DELIBERATELY NOT SHARED: the envelope. `EB1` and `HD1` keep their own magic bytes, their
11
- // own version handling and their own framing, in their own modules. A unified envelope would make a
12
- // QBO token blob a syntactically valid input to the vault reader, which is a confusion this
13
- // separation prevents for free. If you are here to "finish the job" by merging the two files — that
14
- // is the bug this comment exists to stop.
11
+ // WHAT IS DELIBERATELY NOT SHARED: the envelope. HD1 keeps its own magic bytes, its own version
12
+ // handling and its own framing in its own module a sibling format should do the same in its own.
13
+ // A unified envelope would make a foreign blob a syntactically valid input to this package's vault
14
+ // reader, which is a confusion this separation prevents for free. If you are here to "finish the
15
+ // job" by merging formats — that is the bug this comment exists to stop.
15
16
  //
16
17
  // RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. One caller is
17
18
  // Node-only (`node:crypto`'s webcrypto), the others run in the browser and in Workers, so the
package/key-store.ts CHANGED
@@ -1,4 +1,4 @@
1
- // W49 — persist the account private key across a browser refresh so a valid hd_session doesn't force
1
+ // Persist the account private key across a browser refresh so a valid session doesn't force
2
2
  // re-auth. The key is stored as a NON-EXTRACTABLE CryptoKey (structured-cloned into IndexedDB): it can
3
3
  // still unwrap the vault DEK (ECDH deriveKey) on the next load, but its raw bytes can't be read back
4
4
  // out, so an XSS payload can't exfiltrate it (it could still USE it while the page is open — inherent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/vault",
3
- "version": "0.1.4",
3
+ "version": "0.1.15",
4
4
  "description": "Runtime-agnostic key derivation, authenticated envelope encryption, and storage-agnostic access-control contracts for per-user encrypted data.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/stores.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  // Storage-agnostic contracts for the identity/vault/access-control data this package's crypto
2
2
  // operates over. No Cloudflare or D1 dependency here by design (see README.md) — an adopter wires
3
- // these to whatever database they run; health-dash-web's D1 adapter lives at
4
- // apps/health-dash-web/functions/_lib/d1-identity-store.ts and is one implementation of these,
5
- // not the definition of them.
3
+ // these to whatever database they run; `adapters/d1/` in this package is one implementation of
4
+ // these contracts, not the definition of them.
6
5
  //
7
- // health-dash-web's own functions/_lib/identity-*.ts modules re-export the types below rather than
8
- // redeclaring them, so there is exactly one definition of each shape.
6
+ // An adopter's own route handlers should re-export the types below rather than redeclaring them,
7
+ // so there is exactly one definition of each shape.
9
8
 
10
9
  export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
11
10
  export type AuthMethod = "passkey" | "google" | "password" | "recovery";
package/vault-sink.ts CHANGED
@@ -8,13 +8,14 @@ export interface VaultSink {
8
8
  }
9
9
 
10
10
  /**
11
- * W70 — the version of each vault blob this browser context last saw.
11
+ * The version of each vault blob this browser context last saw.
12
12
  *
13
- * Deliberately held HERE rather than threaded through callers. `saveVaultV2` has SEVEN call sites in
14
- * App.svelte (the queued edit path plus key rotation, leaf-regen persist, Finding refresh, report
15
- * import, raw upload and onboarding), and only one of them goes through the save queue. Since the
16
- * precondition is now REQUIRED on the browser path, a call site that forgot to pass an etag would not
17
- * degrade — it would 428, i.e. a patient unable to save their own record. Keeping the token where the
13
+ * Deliberately held HERE rather than threaded through callers. A real adopter typically has several
14
+ * independent call sites that all persist through `saveVaultV2` — a queued-edit path plus things
15
+ * like key rotation, background regeneration, imports, raw uploads and onboarding and usually only
16
+ * one of them goes through a save queue. Since the precondition is REQUIRED on the browser path, a
17
+ * call site that forgot to pass an etag would not degrade — it would 428, i.e. an owner unable to
18
+ * save their own record. Keeping the token where the
18
19
  * request is built makes every path correct by construction instead of by remembering.
19
20
  *
20
21
  * One entry per vault id, which is exactly the domain: a browser context has one current view of a
@@ -48,10 +49,10 @@ export class VaultConflictError extends Error {
48
49
  /**
49
50
  * One hook, so every save path reports a conflict — not just the queued one.
50
51
  *
51
- * Six of `saveVaultV2`'s seven call sites are direct `await`s outside the save queue (key rotation,
52
- * leaf-regen persist, Finding refresh, report import, raw upload, onboarding). Wiring the conflict
53
- * state through each would be six chances to miss one, and a missed one is an unhandled rejection on a
54
- * health record. Every save funnels through this sink, so this is the one place that sees them all.
52
+ * Most real `saveVaultV2` call sites are direct `await`s outside any save queue (key rotation,
53
+ * background regeneration, import, raw upload, onboarding). Wiring the conflict state through each
54
+ * would be one chance to miss it per call site, and a missed one is an unhandled rejection on a
55
+ * record someone owns. Every save funnels through this sink, so this is the one place that sees them all.
55
56
  * The error still throws afterwards, so existing per-path error handling is unchanged.
56
57
  */
57
58
  let onConflict: ((e: VaultConflictError) => void) | null = null;
@@ -59,10 +60,9 @@ export function setVaultConflictHandler(fn: ((e: VaultConflictError) => void) |
59
60
  onConflict = fn;
60
61
  }
61
62
 
62
- // Dev-only sink: POSTs the blob to the Vite dev-server middleware, which writes
63
- // public/data-{id}.enc to disk (see vite.config.ts). Absent from the deployed
64
- // build there is no such endpoint in production. The R2 sink (W6) is the
65
- // second implementation of this interface for remote/mobile save.
63
+ // Dev-only sink: POSTs the blob to a local dev-server middleware, which writes it to disk.
64
+ // Absent from the deployed build — there is no such endpoint in production. The R2 sink below is
65
+ // the second implementation of this interface, for a real remote/mobile save.
66
66
  export const localSink: VaultSink = {
67
67
  async put(id, blob) {
68
68
  const res = await fetch(`/__save-vault?id=${encodeURIComponent(id)}`, {
@@ -76,9 +76,9 @@ export const localSink: VaultSink = {
76
76
  },
77
77
  };
78
78
 
79
- // Remote sink: PUTs the encrypted blob to the R2-backed Pages Function (W6, re-gated W44).
80
- // `/api/vault/{id}` only exists in the deployed build; the guard now accepts the hd_session
81
- // cookie (owner/granted-provider envelope check) — same-origin fetch sends it automatically.
79
+ // Remote sink: PUTs the encrypted blob to an R2-backed Pages Function.
80
+ // `/api/vault/{id}` only exists in the deployed build; the route guard checks the adopter's own
81
+ // session cookie (owner/granted-provider envelope check) — same-origin fetch sends it automatically.
82
82
  // The Function stores opaque ciphertext — same as localSink, never plaintext or a key.
83
83
  /**
84
84
  * One write at a time per vault, so the app never conflicts with ITSELF.
@@ -139,7 +139,7 @@ async function putConditional(id: string, blob: Uint8Array): Promise<void> {
139
139
  // wrangler-pages-dev e2e harness) persists to R2 and never references /__save-vault.
140
140
  export const vaultSink: VaultSink = import.meta.env.DEV ? localSink : r2Sink;
141
141
 
142
- // W44 — encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's
142
+ // Encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's
143
143
  // random data key, unwrapped at login from the caller's key envelope; it stays in memory.
144
144
  export async function saveVaultV2<T = Record<string, unknown>>(vault: T, id: string, dek: CryptoKey, sink: VaultSink): Promise<void> {
145
145
  const blob = await encryptVaultV2(vault, dek);