@tinytars/vault 0.1.15 → 0.1.17

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.
@@ -0,0 +1,19 @@
1
+ import type { VaultSession } from "./vault-session";
2
+ import { wrapDEKForPublicKey } from "./crypto";
3
+ import { bytesToB64 } from "./base64";
4
+ import { getVaultPrincipals, putRecoveryEnvelope } from "./auth-recovery";
5
+
6
+ // Backfill the org-recovery envelope for accounts that predate it (or missed it at signup).
7
+ // Best-effort: it must never block or fail an unlock.
8
+ export async function ensureOrgRecoveryEnvelope(session: VaultSession): Promise<void> {
9
+ if (!session.dek) return;
10
+ try {
11
+ const principals = await getVaultPrincipals();
12
+ if (principals.orgRecoveryRevokedAt !== null) return;
13
+ if (principals.envelopePrincipalIds.includes(principals.orgAccountId)) return;
14
+ const e = await wrapDEKForPublicKey(session.dek, principals.orgPublicKeyJwk);
15
+ await putRecoveryEnvelope({ wrappedDEK: bytesToB64(e.wrappedDEK), ephemeralPublicKeyJwk: e.ephemeralPublicKeyJwk });
16
+ } catch {
17
+ /* best-effort — never block or fail an unlock */
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/vault",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",
@@ -35,6 +35,13 @@
35
35
  "./envelope-access": "./envelope-access.ts",
36
36
  "./break-glass": "./break-glass.ts",
37
37
  "./blob-store": "./blob-store.ts",
38
+ "./auth-client": "./auth-client.ts",
39
+ "./auth-recovery": "./auth-recovery.ts",
40
+ "./auth-support": "./auth-support.ts",
41
+ "./auth-grants": "./auth-grants.ts",
42
+ "./org-recovery": "./org-recovery.ts",
43
+ "./vault-session": "./vault-session.ts",
44
+ "./base64": "./base64.ts",
38
45
  "./adapters/d1": "./adapters/d1/index.ts",
39
46
  "./adapters/r2": "./adapters/r2.ts",
40
47
  "./adapters/memory": "./adapters/memory.ts",
@@ -45,8 +52,11 @@
45
52
  "test": "vitest run",
46
53
  "typecheck": "tsc --noEmit"
47
54
  },
55
+ "dependencies": {
56
+ "@simplewebauthn/browser": "^13.3.0"
57
+ },
48
58
  "devDependencies": {
49
- "vitest": "^4.1.8",
50
- "typescript": "~6.0.2"
59
+ "typescript": "~6.0.2",
60
+ "vitest": "^4.1.8"
51
61
  }
52
62
  }
package/stores.ts CHANGED
@@ -1,10 +1,8 @@
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; `adapters/d1/` in this package is one implementation of
4
- // these contracts, not the definition of them.
5
- //
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.
3
+ // these to whatever database they run. `adapters/d1/` in this package is one implementation of
4
+ // these contracts, not the definition of them; an adopter's own server code should import the
5
+ // types below rather than redeclaring them, so there is exactly one definition of each shape.
8
6
 
9
7
  export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
10
8
  export type AuthMethod = "passkey" | "google" | "password" | "recovery";
@@ -0,0 +1,59 @@
1
+ import { decryptVaultV2 } from "./crypto";
2
+
3
+ /**
4
+ * A vault this session may open on someone else's behalf, and the envelope that opens it. The shape
5
+ * the provider roster and the support console both hand to the host — one drill-in, not two that
6
+ * agree by coincidence.
7
+ */
8
+ export interface VaultEntry {
9
+ patientAccountId: string;
10
+ displayName: string;
11
+ email: string | null;
12
+ r2Key: string;
13
+ envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey };
14
+ }
15
+
16
+ export interface VaultSession {
17
+ /** The current vault's data key, or null when no vault is open. */
18
+ readonly dek: CryptoKey | null;
19
+ /** The current vault's R2 id (slug of its r2_key), or null. */
20
+ readonly r2Id: string | null;
21
+ /**
22
+ * The signed-in owner's account private key, retained for the session so Account settings can
23
+ * re-wrap it when adding a login method. Null in a provider or support session.
24
+ */
25
+ readonly ownerKey: CryptoKey | null;
26
+ /** A provider account's private key, which unwraps each patient's envelope. Null for an owner. */
27
+ readonly providerKey: CryptoKey | null;
28
+ /** True only when a vault is genuinely open — derived, never tracked separately. */
29
+ readonly isOpen: boolean;
30
+
31
+ /** Opens a vault: the id and the key that decrypts it, together or not at all. */
32
+ open(r2Id: string, dek: CryptoKey): void;
33
+ /** Retains the owner's account key for this session. */
34
+ setOwnerKey(key: CryptoKey | null): void;
35
+ /** Retains a provider's account key for this session. */
36
+ setProviderKey(key: CryptoKey | null): void;
37
+ /**
38
+ * Closes the open vault. Clears the data key and the id together.
39
+ *
40
+ * Does NOT clear the provider key: a provider who leaves one patient is still signed in and still
41
+ * needs their own key to open the next. That asymmetry was already the behaviour of
42
+ * `backToRoster()`; stating it here is what stops it being re-derived incorrectly later.
43
+ */
44
+ close(): void;
45
+ /** Ends the whole session — every key, including the provider's. */
46
+ signOut(): void;
47
+ }
48
+
49
+ /**
50
+ * Decrypts a vault blob and opens the session on it in one step, shared by every path that unlocks
51
+ * a vault — the signed-in owner's own, and a provider's drill-in to a patient's. `fetchBlob` stays a
52
+ * caller-supplied thunk because fetching it (the R2 route, the ETag it must remember for later saves)
53
+ * is app-local, not frame-generic.
54
+ */
55
+ export async function openVault<V>(session: VaultSession, id: string, dek: CryptoKey, fetchBlob: (id: string) => Promise<Uint8Array>): Promise<V> {
56
+ const vault = await decryptVaultV2<V>(await fetchBlob(id), dek);
57
+ session.open(id, dek);
58
+ return vault;
59
+ }
package/vault-sink.ts CHANGED
@@ -10,13 +10,12 @@ export interface VaultSink {
10
10
  /**
11
11
  * The version of each vault blob this browser context last saw.
12
12
  *
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
19
- * request is built makes every path correct by construction instead of by remembering.
13
+ * Deliberately held HERE rather than threaded through callers. `saveVaultV2` can have several call
14
+ * sites in an adopting app (a queued edit path plus one-off writes — key rotation, a background
15
+ * regen, an import, a raw upload), and not all of them necessarily go through the same queue. Since
16
+ * the precondition is REQUIRED on the browser path, a call site that forgot to pass an etag would not
17
+ * degrade silently — it would 428, i.e. a user unable to save their own data. Keeping the token where
18
+ * the request is built makes every path correct by construction instead of by remembering.
20
19
  *
21
20
  * One entry per vault id, which is exactly the domain: a browser context has one current view of a
22
21
  * given blob.
@@ -49,10 +48,10 @@ export class VaultConflictError extends Error {
49
48
  /**
50
49
  * One hook, so every save path reports a conflict — not just the queued one.
51
50
  *
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.
51
+ * Most of `saveVaultV2`'s call sites in a typical adopting app are direct `await`s outside any save
52
+ * queue (key rotation, a background regen, an import, a raw upload). Wiring the conflict state
53
+ * through each would be one chance per site to miss it, and a missed one is an unhandled rejection on
54
+ * a user's data. Every save funnels through this sink, so this is the one place that sees them all.
56
55
  * The error still throws afterwards, so existing per-path error handling is unchanged.
57
56
  */
58
57
  let onConflict: ((e: VaultConflictError) => void) | null = null;
@@ -60,9 +59,9 @@ export function setVaultConflictHandler(fn: ((e: VaultConflictError) => void) |
60
59
  onConflict = fn;
61
60
  }
62
61
 
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.
62
+ // Dev-only sink: POSTs the blob to a local dev-server middleware that writes it to disk. Absent
63
+ // from the deployed build — there is no such endpoint in production. r2Sink below is the second
64
+ // implementation of this interface, for remote/mobile save.
66
65
  export const localSink: VaultSink = {
67
66
  async put(id, blob) {
68
67
  const res = await fetch(`/__save-vault?id=${encodeURIComponent(id)}`, {
@@ -76,21 +75,20 @@ export const localSink: VaultSink = {
76
75
  },
77
76
  };
78
77
 
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
- // The Function stores opaque ciphertext — same as localSink, never plaintext or a key.
78
+ // Remote sink: PUTs the encrypted blob to an R2-backed server route.
79
+ // `/api/vault/{id}` only exists in the deployed build; the route is expected to authenticate the
80
+ // caller via whatever session cookie the adopter's auth layer sets — same-origin fetch sends it
81
+ // automatically. The route stores opaque ciphertext — same as localSink, never plaintext or a key.
83
82
  /**
84
83
  * One write at a time per vault, so the app never conflicts with ITSELF.
85
84
  *
86
- * `vaultSave` serializes the queued edit path, but six of `saveVaultV2`'s seven call sites bypass it
87
- * and `await` directly — so a user edit and a leaf-regen persist could be in flight together. Each
88
- * reads the version token when it builds its request, so the second would send one the first had
89
- * already superseded, and the guard would correctly report a conflict against a tab that is only
90
- * racing itself. CI found exactly that: five specs that save and then trigger a regen went red with
91
- * the conflict panel intercepting pointer events.
85
+ * A queued edit path is one likely caller, but most adopting apps also have one-off writes that
86
+ * bypass any queue and `await` directly — so a user edit and a background regen could end up in
87
+ * flight together. Each reads the version token when it builds its request, so the second would send
88
+ * one the first had already superseded, and the guard would correctly report a conflict against a
89
+ * caller that is only racing itself.
92
90
  *
93
- * Serializing HERE rather than in vaultSave is deliberate: this is the only place every write passes
91
+ * Serializing HERE rather than upstream is deliberate: this is the only place every write passes
94
92
  * through, and the version token lives here too — the token must be read after the previous write has
95
93
  * settled, which is precisely what a chain guarantees.
96
94
  */
@@ -139,8 +137,8 @@ async function putConditional(id: string, blob: Uint8Array): Promise<void> {
139
137
  // wrangler-pages-dev e2e harness) persists to R2 and never references /__save-vault.
140
138
  export const vaultSink: VaultSink = import.meta.env.DEV ? localSink : r2Sink;
141
139
 
142
- // Encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's
143
- // random data key, unwrapped at login from the caller's key envelope; it stays in memory.
140
+ // Encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's random
141
+ // data key, unwrapped at login from the caller's key envelope; it stays in memory.
144
142
  export async function saveVaultV2<T = Record<string, unknown>>(vault: T, id: string, dek: CryptoKey, sink: VaultSink): Promise<void> {
145
143
  const blob = await encryptVaultV2(vault, dek);
146
144
  await sink.put(id, blob);