@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.
package/README.md CHANGED
@@ -120,6 +120,23 @@ against the D1 adapter passes against it, unmodified), and `adapters/pages-http`
120
120
  wrapper, not a rewrite, for Cloudflare Pages Functions' request shape). Full picture:
121
121
  [`ARCHITECTURE.md` § Adapters](ARCHITECTURE.md#adapters).
122
122
 
123
+ ### Reference client: auth flows wired to a real API
124
+
125
+ `auth-client.ts`, `auth-recovery.ts`, `auth-support.ts`, `auth-grants.ts`, and `org-recovery.ts`:
126
+ browser-side orchestration for signup, password/passkey login, Google SSO bootstrap, recovery-code
127
+ issuance and redemption, provider/support access grants, and account-settings method management —
128
+ built on `crypto.ts`'s primitives, calling a specific set of `/api/auth/*`, `/api/account/*`,
129
+ `/api/support/*`, and `/api/providers/*` routes. `vault-session.ts` holds the `VaultEntry`/
130
+ `VaultSession` types and `openVault()` these flows share to open a decrypted vault once a key is in
131
+ hand.
132
+
133
+ Unlike the four pieces above, this layer is a **reference implementation, not a portable
134
+ primitive** — it's wired to one server API shape (documented in
135
+ [`ARCHITECTURE.md` § Reference auth client](ARCHITECTURE.md#reference-auth-client-auth-clientts-and-friends)),
136
+ the same way `vault-sink.ts`'s `r2Sink`/`localSink` are wired to specific save-vault routes. Read
137
+ it as a worked example of composing `crypto.ts` into real signup/login/recovery/support flows, not
138
+ something you import and point at your own backend unless your routes happen to match.
139
+
123
140
  ## Install
124
141
 
125
142
  ```
@@ -248,6 +265,13 @@ is entirely your own auth middleware's job. See `THREAT_MODEL.md`'s "trust bound
248
265
  | `adapters/memory.ts` | In-memory implementations of all five `stores.ts` contracts — the reference adapter that proves the interfaces are actually storage-agnostic, not just Cloudflare-shaped |
249
266
  | `adapters/pages-http.ts` | `pagesHandler()` — wraps a portable `(request, deps) => Promise<Response>` handler into Cloudflare Pages Functions' `onRequestX({request, env, params})` shape |
250
267
  | `adapters/conformance.ts` | Shared vitest contract suites for each `stores.ts` interface, run against every adapter above so "storage-agnostic" is proven, not asserted |
268
+ | `auth-client.ts` | Password/passkey/Google signup, login, session resume, account-settings method management — the browser-side orchestration wiring `crypto.ts` to a specific `/api/auth/*`/`/api/account/*` API. Reference client, not a portable primitive |
269
+ | `auth-recovery.ts` | Recovery-code issuance/redemption (owner and provider-issued), DEK rotation, access-event log fetch — same reference-client caveat as `auth-client.ts` |
270
+ | `auth-support.ts` | Audited support-agent access: patient approves a pending request, support enters via an audited endpoint; support→provider roster access |
271
+ | `auth-grants.ts` | Provider/clinician grant CRUD from the patient side: lookup, grant, revoke |
272
+ | `org-recovery.ts` | Backfills the org-recovery envelope for accounts that predate or missed it at signup — best-effort, never blocks an unlock |
273
+ | `vault-session.ts` | `VaultEntry`/`VaultSession` types plus `openVault()` — the decrypt-and-open-session step every unlock path shares |
274
+ | `base64.ts` | Byte ↔ base64 codec used throughout the client layer |
251
275
 
252
276
  Full design, including the exact envelope byte layout and why extractable keys are a deliberate
253
277
  choice, is in `ARCHITECTURE.md` — see **What it does** above for the featureset summary and the
@@ -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
- * Read on every authenticated request, by the adopter's own session-check middleware. One indexed lookup by primary
73
+ * Read on every authenticated request (see `requireSession`). 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
  */
@@ -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
- // FTC-HBNR (§I) PHI-access/disclosure audit log. Records WHO (actor) accessed WHOSE (subject)
9
+ // FTC Health Breach Notification Rule 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
- // 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
102
+ // 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
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
@@ -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
- // An owner approving a support request: flip the link active, stamp its time-box + consent.
71
+ // A patient 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,
package/adapters/r2.ts CHANGED
@@ -1,14 +1,13 @@
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. An adopter's own Pages Function route should import R2Bucket/
5
- // R2BlobStore from here instead of declaring its own copy.
4
+ // trivially mockable in tests.
6
5
  export interface R2ObjectBody {
7
6
  body: ReadableStream;
8
7
  /** The version token. Handed to the browser on GET and sent back as If-Match on PUT. */
9
8
  etag: string;
10
9
  }
11
- /** A precondition on a write. Verify this against real `workerd` in an adopter's own test suite — see ARCHITECTURE.md's "Adapters" section. */
10
+ /** A precondition on a write. Real workerd conditional-write semantics are verified in an adopter's own test suite, not here — see CHANGELOG.md. */
12
11
  export interface R2Conditional {
13
12
  etagMatches?: string;
14
13
  etagDoesNotMatch?: string;
@@ -17,8 +16,8 @@ export interface R2Bucket {
17
16
  get(key: string): Promise<R2ObjectBody | null>;
18
17
  /**
19
18
  * Returns the stored object (carrying its NEW etag), or `null` when an `onlyIf` precondition fails.
20
- * Null-on-failure rather than a throw is observed behaviour, not an assumption — see
21
- * tests/unit/r2-conditional-put.test.ts, which pins it against workerd.
19
+ * Null-on-failure rather than a throw is observed behaviour against workerd, not an assumption —
20
+ * an adopter's own test suite is where this gets pinned; see CHANGELOG.md.
22
21
  */
23
22
  put(key: string, value: Uint8Array, options?: { onlyIf?: R2Conditional }): Promise<{ etag: string } | null>;
24
23
  delete(key: string): Promise<void>;
package/auth-client.ts ADDED
@@ -0,0 +1,422 @@
1
+ // Browser-side orchestration for password signup/login. All crypto primitives
2
+ // live in ./crypto (do not reimplement); this file only wires them to fetch calls against
3
+ // an adopter's own password-auth endpoints. The server never sees a password, a KEK, or a DEK.
4
+ import {
5
+ generateAccountKeypair,
6
+ deriveKekFromPassword,
7
+ deriveAuthHash,
8
+ wrapPrivateKey,
9
+ unwrapPrivateKey,
10
+ generateDEK,
11
+ encryptVaultV2,
12
+ wrapDEKForPublicKey,
13
+ unwrapDEKWithPrivateKey,
14
+ kekFromPrfSecret,
15
+ importPrivateKeyPkcs8,
16
+ } from "./crypto";
17
+ import { startRegistration, startAuthentication, base64URLStringToBuffer } from "@simplewebauthn/browser";
18
+ import type {
19
+ PublicKeyCredentialCreationOptionsJSON,
20
+ PublicKeyCredentialRequestOptionsJSON,
21
+ } from "@simplewebauthn/browser";
22
+ import { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
23
+ export { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
24
+
25
+ export const KDF_ITERATIONS = 200_000;
26
+ export const rand = (n: number) => globalThis.crypto.getRandomValues(new Uint8Array(n));
27
+
28
+ export function bytesToHex(bytes: Uint8Array): string {
29
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
30
+ }
31
+
32
+ export function hexToBytes(hex: string): Uint8Array {
33
+ const bytes = new Uint8Array(hex.length / 2);
34
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
35
+ return bytes;
36
+ }
37
+
38
+ // The org-recovery public key, fetched pre-session (signup has no session yet; a public key
39
+ // is public regardless of who asks for it).
40
+ /**
41
+ * The error the server actually wrote, when it wrote one.
42
+ *
43
+ * Nearly every branch below threw `${what} failed: ${res.status}`, discarding a message the
44
+ * endpoint had already composed for exactly this moment ("that recovery code has expired", "this
45
+ * clinician already has access", "no access to this vault") and showing a patient a bare number
46
+ * instead. These are the paths a person is on during the worst day they will have with this app, and
47
+ * a status code tells them nothing about whether to retry, wait, or ask someone.
48
+ *
49
+ * The status is kept in the fallback so a failure with no body is still diagnosable. Deliberately NOT
50
+ * used on the salt lookups: those return a decoy for an unknown address, so any detail there would be
51
+ * an answer about whether the account exists.
52
+ */
53
+ export async function failed(res: Response, what: string): Promise<Error> {
54
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
55
+ return new Error(body?.error?.trim() || `${what} (${res.status})`);
56
+ }
57
+
58
+ export async function getOrgKey(): Promise<{ orgAccountId: string; orgPublicKeyJwk: JsonWebKey }> {
59
+ const res = await fetch("/api/vault/org-key");
60
+ if (!res.ok) throw await failed(res, "org key failed");
61
+ return res.json();
62
+ }
63
+
64
+ // Everything below this point that authenticates a stranger — signup, both login paths, the Google
65
+ // bootstrap, and redeeming a recovery code — deliberately does NOT use `failed()`. A reason there is
66
+ // an answer about whether an address is registered, which is the enumeration oracle the decoy salt at
67
+ // /api/auth/salt exists to close — an adopter's own tests are where "the enumeration oracle stays
68
+ // closed" gets pinned. Once a session exists there is nobody left to enumerate to, and the server's
69
+ // message helps.
70
+ export async function signupPassword(
71
+ email: string,
72
+ displayName: string,
73
+ password: string
74
+ ): Promise<{ accountId: string; vaultId: string }> {
75
+ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
76
+
77
+ const salt = rand(16);
78
+ const kek = await deriveKekFromPassword(password, salt);
79
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
80
+ const authHash = await deriveAuthHash(password, salt);
81
+
82
+ const dek = await generateDEK();
83
+ const vaultBlob = await encryptVaultV2({ clients: {} }, dek);
84
+ const ownerEnvelope = await wrapDEKForPublicKey(dek, publicKeyJwk);
85
+ const { orgPublicKeyJwk } = await getOrgKey();
86
+ const orgEnvelope = await wrapDEKForPublicKey(dek, orgPublicKeyJwk);
87
+
88
+ // No recovery credential at signup; the user mints one on demand from the Account menu.
89
+ const res = await fetch("/api/auth/password/signup", {
90
+ method: "POST",
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify({
93
+ email,
94
+ displayName,
95
+ publicKeyJwk,
96
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
97
+ kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
98
+ authHash,
99
+ vaultBlob: bytesToBase64(vaultBlob),
100
+ ownerEnvelope: {
101
+ wrappedDEK: bytesToBase64(ownerEnvelope.wrappedDEK),
102
+ ephemeralPublicKeyJwk: ownerEnvelope.ephemeralPublicKeyJwk,
103
+ },
104
+ orgEnvelope: {
105
+ wrappedDEK: bytesToBase64(orgEnvelope.wrappedDEK),
106
+ ephemeralPublicKeyJwk: orgEnvelope.ephemeralPublicKeyJwk,
107
+ },
108
+ }),
109
+ });
110
+ if (!res.ok) throw new Error(`signup failed: ${res.status}`);
111
+ const { accountId, vaultId } = (await res.json()) as { accountId: string; vaultId: string };
112
+ return { accountId, vaultId };
113
+ }
114
+
115
+ export async function loginPassword(
116
+ email: string,
117
+ password: string
118
+ ): Promise<{
119
+ accountId: string;
120
+ vaultId: string | null;
121
+ r2Key: string | null;
122
+ privateKey: CryptoKey;
123
+ dek: CryptoKey | null;
124
+ rotationPending: boolean;
125
+ }> {
126
+ // Salt is public, but the client needs it before it can derive authHash — look it up
127
+ // by email ahead of the login POST.
128
+ const saltRes = await fetch(`/api/auth/password/salt?email=${encodeURIComponent(email)}`);
129
+ // NOT "unknown account" any more. The endpoint returns a decoy salt for an address it does
130
+ // not know, precisely so the browser cannot report which addresses are registered; a non-200 here
131
+ // now means a malformed request or a server fault, and saying "unknown account" would both be
132
+ // wrong and re-open the oracle in the UI text.
133
+ if (!saltRes.ok) throw new Error(`sign-in is unavailable right now (${saltRes.status})`);
134
+ const { salt } = (await saltRes.json()) as { salt: string; iterations: number };
135
+
136
+ const authHash = await deriveAuthHash(password, hexToBytes(salt));
137
+
138
+ const res = await fetch("/api/auth/password/login", {
139
+ method: "POST",
140
+ headers: { "content-type": "application/json" },
141
+ body: JSON.stringify({ email, authHash }),
142
+ });
143
+ if (!res.ok) throw new Error(`login failed: ${res.status}`);
144
+ const data = (await res.json()) as {
145
+ accountId: string;
146
+ vaultId: string | null;
147
+ r2Key: string | null;
148
+ wrappedPrivateKey: string;
149
+ kdfParams: { salt: string; iterations: number };
150
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
151
+ };
152
+
153
+ const kek = await deriveKekFromPassword(password, hexToBytes(data.kdfParams.salt));
154
+ const privateKey = await unwrapPrivateKey(base64ToBytes(data.wrappedPrivateKey), kek);
155
+ const dek = data.ownerEnvelope
156
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
157
+ : null;
158
+
159
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: (data as { rotationPending?: boolean }).rotationPending ?? false };
160
+ }
161
+
162
+ // Browser-side orchestration for passkey (WebAuthn + PRF) signup/login. Mirrors
163
+ // signupPassword/loginPassword above: all crypto stays in ./crypto, this file only wires it to
164
+ // startRegistration/startAuthentication and an adopter's own passkey-auth endpoints. The server
165
+ // never sees the PRF secret, a KEK, or a DEK.
166
+
167
+ // The server sends the PRF extension's `eval.first` as a base64url STRING (see webauthn.ts) —
168
+ // startRegistration/startAuthentication spread `extensions` straight into
169
+ // navigator.credentials.{create,get}() without decoding custom extensions, so we must convert
170
+ // it back into a BufferSource ourselves before calling them.
171
+ function preparePrfExtension<T extends { extensions?: unknown }>(optionsJSON: T): T {
172
+ const ext = optionsJSON.extensions as { prf?: { eval?: { first?: string } } } | undefined;
173
+ if (!ext?.prf?.eval?.first) return optionsJSON;
174
+ return {
175
+ ...optionsJSON,
176
+ extensions: { ...ext, prf: { eval: { first: base64URLStringToBuffer(ext.prf.eval.first) } } },
177
+ } as T;
178
+ }
179
+
180
+ function extractPrfSecret(clientExtensionResults: unknown): Uint8Array | null {
181
+ const first = (clientExtensionResults as { prf?: { results?: { first?: ArrayBuffer } } } | undefined)?.prf?.results?.first;
182
+ return first ? new Uint8Array(first) : null;
183
+ }
184
+
185
+ export async function signupPasskey(
186
+ email: string,
187
+ displayName: string
188
+ ): Promise<{ accountId: string; vaultId: string }> {
189
+ const optionsRes = await fetch("/api/auth/passkey/register/options", {
190
+ method: "POST",
191
+ headers: { "content-type": "application/json" },
192
+ body: JSON.stringify({ email, displayName }),
193
+ });
194
+ if (!optionsRes.ok) throw await failed(optionsRes, "register options failed");
195
+ const optionsJSON = (await optionsRes.json()) as PublicKeyCredentialCreationOptionsJSON;
196
+ const prfSaltB64 = (optionsJSON.extensions as { prf?: { eval?: { first?: string } } } | undefined)?.prf?.eval?.first;
197
+ if (!prfSaltB64) throw new Error("registration options missing the PRF extension");
198
+
199
+ const attestationResponse = await startRegistration({ optionsJSON: preparePrfExtension(optionsJSON) });
200
+
201
+ let prfSecret = extractPrfSecret(attestationResponse.clientExtensionResults);
202
+ if (!prfSecret) {
203
+ // Some authenticators don't evaluate PRF during create() — fall back to a local get()
204
+ // ceremony against the credential we just made, purely to read the PRF secret. This is not
205
+ // sent to the server; the create() attestation above is what gets verified server-side.
206
+ const fallbackOptions: PublicKeyCredentialRequestOptionsJSON = {
207
+ challenge: bytesToBase64(rand(16)),
208
+ allowCredentials: [{ id: attestationResponse.id, type: "public-key", transports: attestationResponse.response.transports }],
209
+ userVerification: "preferred",
210
+ extensions: { prf: { eval: { first: base64URLStringToBuffer(prfSaltB64) } } } as PublicKeyCredentialRequestOptionsJSON["extensions"],
211
+ };
212
+ const fallbackAssertion = await startAuthentication({ optionsJSON: fallbackOptions });
213
+ prfSecret = extractPrfSecret(fallbackAssertion.clientExtensionResults);
214
+ if (!prfSecret) throw new Error("authenticator does not support the PRF extension");
215
+ }
216
+
217
+ const kek = await kekFromPrfSecret(prfSecret);
218
+ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
219
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
220
+
221
+ const dek = await generateDEK();
222
+ const vaultBlob = await encryptVaultV2({ clients: {} }, dek);
223
+ const ownerEnvelope = await wrapDEKForPublicKey(dek, publicKeyJwk);
224
+ const { orgPublicKeyJwk } = await getOrgKey();
225
+ const orgEnvelope = await wrapDEKForPublicKey(dek, orgPublicKeyJwk);
226
+
227
+ // No recovery credential at signup; minted on demand from the Account menu.
228
+ const verifyRes = await fetch("/api/auth/passkey/register/verify", {
229
+ method: "POST",
230
+ headers: { "content-type": "application/json" },
231
+ body: JSON.stringify({
232
+ attestationResponse,
233
+ publicKeyJwk,
234
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
235
+ vaultBlob: bytesToBase64(vaultBlob),
236
+ ownerEnvelope: {
237
+ wrappedDEK: bytesToBase64(ownerEnvelope.wrappedDEK),
238
+ ephemeralPublicKeyJwk: ownerEnvelope.ephemeralPublicKeyJwk,
239
+ },
240
+ orgEnvelope: {
241
+ wrappedDEK: bytesToBase64(orgEnvelope.wrappedDEK),
242
+ ephemeralPublicKeyJwk: orgEnvelope.ephemeralPublicKeyJwk,
243
+ },
244
+ prfSaltHex: bytesToHex(new Uint8Array(base64URLStringToBuffer(prfSaltB64))),
245
+ }),
246
+ });
247
+ if (!verifyRes.ok) throw await failed(verifyRes, "register verify failed");
248
+ const { accountId, vaultId } = (await verifyRes.json()) as { accountId: string; vaultId: string };
249
+ return { accountId, vaultId };
250
+ }
251
+
252
+ export async function loginPasskey(
253
+ email: string
254
+ ): Promise<{ accountId: string; vaultId: string | null; r2Key: string | null; privateKey: CryptoKey; dek: CryptoKey | null; rotationPending: boolean }> {
255
+ const optionsRes = await fetch("/api/auth/passkey/login/options", {
256
+ method: "POST",
257
+ headers: { "content-type": "application/json" },
258
+ body: JSON.stringify({ email }),
259
+ });
260
+ if (!optionsRes.ok) throw new Error(`login options failed: ${optionsRes.status}`);
261
+ const optionsJSON = (await optionsRes.json()) as PublicKeyCredentialRequestOptionsJSON;
262
+
263
+ const authenticationResponse = await startAuthentication({ optionsJSON: preparePrfExtension(optionsJSON) });
264
+ const prfSecret = extractPrfSecret(authenticationResponse.clientExtensionResults);
265
+ if (!prfSecret) throw new Error("authenticator did not return a PRF secret");
266
+
267
+ const verifyRes = await fetch("/api/auth/passkey/login/verify", {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify({ authenticationResponse }),
271
+ });
272
+ if (!verifyRes.ok) throw new Error(`login verify failed: ${verifyRes.status}`);
273
+ const data = (await verifyRes.json()) as {
274
+ accountId: string;
275
+ vaultId: string | null;
276
+ r2Key: string | null;
277
+ wrappedPrivateKey: string;
278
+ prfSalt: string;
279
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
280
+ };
281
+
282
+ const kek = await kekFromPrfSecret(prfSecret);
283
+ const privateKey = await unwrapPrivateKey(base64ToBytes(data.wrappedPrivateKey), kek);
284
+ const dek = data.ownerEnvelope
285
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
286
+ : null;
287
+
288
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: (data as { rotationPending?: boolean }).rotationPending ?? false };
289
+ }
290
+
291
+ // Google login bootstrap. After the OAuth callback redirects to /?google=1 the session is
292
+ // already set; this fetches the server-unwrapped private key + owner envelope and recovers the DEK
293
+ // client-side. Returns the same shape as loginPassword so it flows straight into an adopter's own
294
+ // account-entry step. Unlike password/passkey, the private key is handed over by the server
295
+ // (server-custody) rather than unwrapped from a client-held secret.
296
+ export async function bootstrapGoogleSession(): Promise<{
297
+ accountId: string;
298
+ vaultId: string | null;
299
+ r2Key: string | null;
300
+ privateKey: CryptoKey;
301
+ dek: CryptoKey | null;
302
+ rotationPending: boolean;
303
+ }> {
304
+ const res = await fetch("/api/auth/google/session", { cache: "no-store" });
305
+ if (!res.ok) throw new Error(`google session failed: ${res.status}`);
306
+ const data = (await res.json()) as {
307
+ accountId: string;
308
+ vaultId: string | null;
309
+ r2Key: string | null;
310
+ rotationPending: boolean;
311
+ privateKeyPkcs8: string;
312
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
313
+ };
314
+ const privateKey = await importPrivateKeyPkcs8(base64ToBytes(data.privateKeyPkcs8));
315
+ const dek = data.ownerEnvelope
316
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
317
+ : null;
318
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: data.rotationPending };
319
+ }
320
+
321
+ // Plain-refresh resume (password/passkey). Session-gated read of the vault location + owner DEK
322
+ // envelope; the client already holds the account private key (non-extractable, from IndexedDB) and
323
+ // unwraps the DEK locally. Returns null when there's no valid session (401) so the caller can fall
324
+ // back to the lock screen. Does NOT return the wrapped private key — the key never leaves the device.
325
+ export async function resumeSession(): Promise<{
326
+ accountId: string;
327
+ vaultId: string | null;
328
+ r2Key: string | null;
329
+ rotationPending: boolean;
330
+ providerKind: "clinician" | "support" | null;
331
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
332
+ } | null> {
333
+ const res = await fetch("/api/auth/session/resume", { cache: "no-store" });
334
+ if (res.status === 401) return null;
335
+ if (!res.ok) throw await failed(res, "resume failed");
336
+ return res.json();
337
+ }
338
+
339
+ // Finish adding Google to the signed-in account. The OAuth popup already set the signed
340
+ // link cookie (bound to this account + the verified sub); here we hand the server the in-memory
341
+ // private key so it can wrap it under the server KEK. The sub is trusted from the cookie, not us.
342
+ export async function addGoogleMethod(privateKey: CryptoKey, currentPassword?: string, email?: string): Promise<void> {
343
+ const currentAuthHash = await currentAuthHashFor(email, currentPassword);
344
+ const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey("pkcs8", privateKey));
345
+ const res = await fetch("/api/account/methods/google", {
346
+ method: "POST",
347
+ headers: { "content-type": "application/json" },
348
+ body: JSON.stringify({ privateKeyPkcs8: bytesToBase64(pkcs8), ...(currentAuthHash ? { currentAuthHash } : {}) }),
349
+ });
350
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `add google failed: ${res.status}`);
351
+ }
352
+
353
+ export async function getMyAccount(): Promise<{ id: string; email: string | null; emailConfirmed: boolean; displayName: string; providerKind: "clinician" | "support" | null; unitSystem: "metric" | "imperial" | null }> {
354
+ const res = await fetch("/api/account", { cache: "no-store" });
355
+ if (!res.ok) throw await failed(res, "account fetch failed");
356
+ return res.json();
357
+ }
358
+
359
+ // Account settings. Change email/display name; add/remove a login method (password/passkey).
360
+ // Adding a method re-wraps the account's in-memory private key under the new method's KEK — same
361
+ // zero-knowledge model as signup; the server never sees the key.
362
+
363
+ /**
364
+ * The step-up proof, derived the same way the login path derives its authHash.
365
+ *
366
+ * Shared by all three add-a-method calls. Adding a passkey or a Google identity used to need only a
367
+ * session cookie, which meant a captured cookie could mint a credential that outlived it; the server
368
+ * now challenges those paths too, so all three send the same proof.
369
+ *
370
+ * Returns undefined when there is nothing to prove — an account with no password cannot be challenged,
371
+ * and the server treats that case as allowed-and-notified rather than refusing it.
372
+ */
373
+ export async function currentAuthHashFor(email?: string, currentPassword?: string): Promise<string | undefined> {
374
+ if (!currentPassword || !email) return undefined;
375
+ const saltRes = await fetch(`/api/auth/password/salt?email=${encodeURIComponent(email)}`);
376
+ if (!saltRes.ok) throw new Error(`could not verify your current password (${saltRes.status})`);
377
+ const { salt: currentSalt } = (await saltRes.json()) as { salt: string };
378
+ return deriveAuthHash(currentPassword, hexToBytes(currentSalt));
379
+ }
380
+
381
+ export async function addPasskeyMethod(privateKey: CryptoKey, currentPassword?: string, email?: string): Promise<void> {
382
+ const currentAuthHash = await currentAuthHashFor(email, currentPassword);
383
+ const optionsRes = await fetch("/api/account/methods/passkey/options", { method: "POST" });
384
+ if (!optionsRes.ok) throw await failed(optionsRes, "passkey options failed");
385
+ const optionsJSON = (await optionsRes.json()) as PublicKeyCredentialCreationOptionsJSON;
386
+ const prfSaltB64 = (optionsJSON.extensions as { prf?: { eval?: { first?: string } } } | undefined)?.prf?.eval?.first;
387
+ if (!prfSaltB64) throw new Error("registration options missing the PRF extension");
388
+
389
+ const attestationResponse = await startRegistration({ optionsJSON: preparePrfExtension(optionsJSON) });
390
+ let prfSecret = extractPrfSecret(attestationResponse.clientExtensionResults);
391
+ if (!prfSecret) {
392
+ // Some authenticators don't evaluate PRF during create() — read it via a local get() (not sent).
393
+ const fallbackAssertion = await startAuthentication({
394
+ optionsJSON: {
395
+ challenge: bytesToBase64(rand(16)),
396
+ allowCredentials: [{ id: attestationResponse.id, type: "public-key", transports: attestationResponse.response.transports }],
397
+ userVerification: "preferred",
398
+ extensions: { prf: { eval: { first: base64URLStringToBuffer(prfSaltB64) } } } as PublicKeyCredentialRequestOptionsJSON["extensions"],
399
+ },
400
+ });
401
+ prfSecret = extractPrfSecret(fallbackAssertion.clientExtensionResults);
402
+ if (!prfSecret) throw new Error("authenticator does not support the PRF extension");
403
+ }
404
+
405
+ const kek = await kekFromPrfSecret(prfSecret);
406
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
407
+ const verifyRes = await fetch("/api/account/methods/passkey/verify", {
408
+ method: "POST",
409
+ headers: { "content-type": "application/json" },
410
+ body: JSON.stringify({
411
+ attestationResponse,
412
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
413
+ prfSaltHex: bytesToHex(new Uint8Array(base64URLStringToBuffer(prfSaltB64))),
414
+ ...(currentAuthHash ? { currentAuthHash } : {}),
415
+ }),
416
+ });
417
+ if (verifyRes.status === 401) {
418
+ const { error } = (await verifyRes.json().catch(() => ({ error: "" }))) as { error?: string };
419
+ throw new Error(error || "enter your current password to add a passkey");
420
+ }
421
+ if (!verifyRes.ok) throw await failed(verifyRes, "add passkey failed");
422
+ }
package/auth-grants.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { wrapDEKForPublicKey } from "./crypto";
2
+ import { bytesToBase64, failed } from "./auth-client";
3
+
4
+ // Provider escrow (patient side). A logged-in patient grants a provider access by wrapping
5
+ // their in-memory DEK to the provider's public key client-side and posting the opaque envelope; the
6
+ // server never sees a plaintext DEK. Revoke deletes the envelope + marks the link revoked.
7
+
8
+ export interface ProviderLinkView {
9
+ linkId: string;
10
+ providerAccountId: string;
11
+ displayName: string;
12
+ kind: "clinician" | "support";
13
+ status: "invited" | "active" | "revoked";
14
+ expiresAt: string | null;
15
+ publicKeyJwk?: JsonWebKey | null; // present only for a pending (invited) support request
16
+ }
17
+
18
+ export async function listMyProviders(): Promise<ProviderLinkView[]> {
19
+ const res = await fetch("/api/providers", { cache: "no-store" });
20
+ if (!res.ok) throw await failed(res, "list providers failed");
21
+ return ((await res.json()) as { providers: ProviderLinkView[] }).providers;
22
+ }
23
+
24
+ // Resolve a provider by email to their public key; null if no such provider (uniform 404).
25
+ export async function lookupProvider(
26
+ email: string
27
+ ): Promise<{ providerAccountId: string; displayName: string; providerKind: string; publicKeyJwk: JsonWebKey } | null> {
28
+ const res = await fetch(`/api/providers/lookup?email=${encodeURIComponent(email)}`);
29
+ if (res.status === 404) return null;
30
+ if (!res.ok) throw await failed(res, "lookup failed");
31
+ return res.json();
32
+ }
33
+
34
+ // Grant a looked-up provider access to the caller's vault. `dek` is the owner's in-memory DEK.
35
+ export async function grantProvider(
36
+ dek: CryptoKey,
37
+ provider: { providerAccountId: string; publicKeyJwk: JsonWebKey }
38
+ ): Promise<void> {
39
+ const env = await wrapDEKForPublicKey(dek, provider.publicKeyJwk);
40
+ const res = await fetch("/api/providers/grant", {
41
+ method: "POST",
42
+ headers: { "content-type": "application/json" },
43
+ body: JSON.stringify({
44
+ providerAccountId: provider.providerAccountId,
45
+ wrappedDEK: bytesToBase64(env.wrappedDEK),
46
+ ephemeralPublicKeyJwk: env.ephemeralPublicKeyJwk,
47
+ }),
48
+ });
49
+ if (!res.ok) throw await failed(res, "grant failed");
50
+ }
51
+
52
+ export async function revokeProvider(linkId: string): Promise<void> {
53
+ const res = await fetch(`/api/providers/${encodeURIComponent(linkId)}`, { method: "DELETE" });
54
+ if (!res.ok) throw await failed(res, "revoke failed");
55
+ }