@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 +24 -0
- package/adapters/d1/accounts.ts +1 -1
- package/adapters/d1/audit.ts +1 -1
- package/adapters/d1/credentials.ts +2 -2
- package/adapters/d1/providers.ts +1 -1
- package/adapters/r2.ts +4 -5
- package/auth-client.ts +422 -0
- package/auth-grants.ts +55 -0
- package/auth-recovery.ts +360 -0
- package/auth-support.ts +118 -0
- package/base64.ts +15 -0
- package/break-glass.ts +5 -6
- package/crypto.ts +20 -19
- package/kdf.ts +12 -20
- package/key-store.ts +4 -3
- package/org-recovery.ts +19 -0
- package/package.json +13 -3
- package/stores.ts +3 -5
- package/vault-session.ts +59 -0
- package/vault-sink.ts +25 -27
package/auth-recovery.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateAccountKeypair,
|
|
3
|
+
deriveKekFromPassword,
|
|
4
|
+
deriveAuthHash,
|
|
5
|
+
wrapPrivateKey,
|
|
6
|
+
unwrapPrivateKey,
|
|
7
|
+
wrapDEKWithKek,
|
|
8
|
+
unwrapDEKWithKek,
|
|
9
|
+
wrapDEKForPublicKey,
|
|
10
|
+
unwrapDEKWithPrivateKey,
|
|
11
|
+
} from "./crypto";
|
|
12
|
+
import { bytesToBase64, base64ToBytes, bytesToHex, hexToBytes, rand, failed, KDF_ITERATIONS, currentAuthHashFor } from "./auth-client";
|
|
13
|
+
|
|
14
|
+
// Not a security boundary (the recovery KEK is still PBKDF2-derived from this code) —
|
|
15
|
+
// just a printable code the user can write down. Excludes ambiguous chars (0/O, 1/I).
|
|
16
|
+
const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
17
|
+
function randomRecoveryCode(): string {
|
|
18
|
+
return Array.from(rand(20), (b) => RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length]).join("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Account settings: change email/display name; add/remove a login method (password/passkey).
|
|
22
|
+
// Adding a method re-wraps the account's in-memory private key under the new method's KEK — same
|
|
23
|
+
// zero-knowledge model as signup; the server never sees the key.
|
|
24
|
+
|
|
25
|
+
export interface LoginMethod {
|
|
26
|
+
method: "password" | "passkey" | "google" | "recovery";
|
|
27
|
+
createdAt: string;
|
|
28
|
+
isRecovery: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listMethods(): Promise<LoginMethod[]> {
|
|
32
|
+
const res = await fetch("/api/account/methods", { cache: "no-store" });
|
|
33
|
+
if (!res.ok) throw await failed(res, "list methods failed");
|
|
34
|
+
return ((await res.json()) as { methods: LoginMethod[] }).methods;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function updateProfile(updates: { email?: string; displayName?: string; unitSystem?: "metric" | "imperial" }): Promise<void> {
|
|
38
|
+
const res = await fetch("/api/account", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(updates) });
|
|
39
|
+
if (!res.ok) throw await failed(res, "profile update failed");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function removeMethod(method: "password" | "passkey" | "google"): Promise<void> {
|
|
43
|
+
const res = await fetch("/api/account/methods", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ method }) });
|
|
44
|
+
if (!res.ok) throw await failed(res, "remove method failed");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sets or replaces the account password.
|
|
49
|
+
*
|
|
50
|
+
* `currentPassword` is the step-up proof, and is REQUIRED by the server whenever a password
|
|
51
|
+
* already exists. It is derived against the account's CURRENT salt, fetched by email, because an
|
|
52
|
+
* authHash is only meaningful paired with the salt it was derived from; deriving the proof against
|
|
53
|
+
* the new salt would produce a value that matches nothing.
|
|
54
|
+
*
|
|
55
|
+
* Undefined is correct for an account setting its first password — a passkey-only or Google-only
|
|
56
|
+
* account has nothing to prove and nothing is being destroyed.
|
|
57
|
+
*/
|
|
58
|
+
export async function addPasswordMethod(
|
|
59
|
+
privateKey: CryptoKey,
|
|
60
|
+
newPassword: string,
|
|
61
|
+
currentPassword?: string,
|
|
62
|
+
email?: string,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const currentAuthHash = await currentAuthHashFor(email, currentPassword);
|
|
65
|
+
|
|
66
|
+
const salt = rand(16);
|
|
67
|
+
const kek = await deriveKekFromPassword(newPassword, salt);
|
|
68
|
+
const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
|
|
69
|
+
const authHash = await deriveAuthHash(newPassword, salt);
|
|
70
|
+
const res = await fetch("/api/account/methods", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "content-type": "application/json" },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
method: "password",
|
|
75
|
+
wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
|
|
76
|
+
kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
|
|
77
|
+
authHash,
|
|
78
|
+
...(currentAuthHash ? { currentAuthHash } : {}),
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
if (res.status === 401) {
|
|
82
|
+
const { error } = (await res.json().catch(() => ({ error: "" }))) as { error?: string };
|
|
83
|
+
throw new Error(error || "enter your current password to set a new one");
|
|
84
|
+
}
|
|
85
|
+
if (!res.ok) throw await failed(res, "add password failed");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Recover with a recovery code (mirrors loginPassword, keyed on the recovery credential).
|
|
89
|
+
// Returns the same shape as loginPassword so it flows straight into the app's enter-account path.
|
|
90
|
+
export async function recoverAccount(
|
|
91
|
+
email: string,
|
|
92
|
+
recoveryCode: string,
|
|
93
|
+
/**
|
|
94
|
+
* The replacement password, set as part of redeeming the code.
|
|
95
|
+
*
|
|
96
|
+
* Without this the flow was a trap: redeeming signed you in but left the forgotten password in place,
|
|
97
|
+
* so the next sign-in put you right back where you started. It cannot be a follow-up call to
|
|
98
|
+
* `/api/account/methods` either — the account still holds its old password credential, so the
|
|
99
|
+
* add-a-method step-up would demand the very password being recovered.
|
|
100
|
+
*
|
|
101
|
+
* So the code is redeemed TWICE against the same endpoint: once to obtain the wrapped key (which is
|
|
102
|
+
* the only way to get it — the server releases it on proof, never on request), and once to install
|
|
103
|
+
* the re-wrapped key. The second call re-proves the recovery code, so the replacement is authorised
|
|
104
|
+
* by the code rather than by the session the first call happened to mint.
|
|
105
|
+
*/
|
|
106
|
+
newPassword?: string,
|
|
107
|
+
): Promise<{ accountId: string; vaultId: string | null; r2Key: string | null; privateKey: CryptoKey; dek: CryptoKey | null; rotationPending: boolean }> {
|
|
108
|
+
const saltRes = await fetch(`/api/auth/recovery/salt?email=${encodeURIComponent(email)}`);
|
|
109
|
+
// Same as the password path above: a decoy is returned for an address with no recovery credential,
|
|
110
|
+
// so a non-200 is a fault, not an answer about the account.
|
|
111
|
+
if (!saltRes.ok) throw new Error(`recovery is unavailable right now (${saltRes.status})`);
|
|
112
|
+
const { salt } = (await saltRes.json()) as { salt: string; iterations: number };
|
|
113
|
+
|
|
114
|
+
const recoveryAuthHash = await deriveAuthHash(recoveryCode, hexToBytes(salt));
|
|
115
|
+
const res = await fetch("/api/auth/recovery/login", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ email, recoveryAuthHash }),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) throw new Error(`recovery failed: ${res.status}`);
|
|
121
|
+
const data = (await res.json()) as {
|
|
122
|
+
accountId: string;
|
|
123
|
+
vaultId: string | null;
|
|
124
|
+
r2Key: string | null;
|
|
125
|
+
wrappedPrivateKey: string;
|
|
126
|
+
kdfParams: { salt: string; iterations: number };
|
|
127
|
+
ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const kek = await deriveKekFromPassword(recoveryCode, hexToBytes(data.kdfParams.salt));
|
|
131
|
+
const privateKey = await unwrapPrivateKey(base64ToBytes(data.wrappedPrivateKey), kek);
|
|
132
|
+
const dek = data.ownerEnvelope
|
|
133
|
+
? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
if (newPassword) {
|
|
137
|
+
// The SAME private key, re-wrapped under a KEK derived from the new password. It is not a new
|
|
138
|
+
// keypair, which is why the account's passkey and Google credentials keep working afterwards.
|
|
139
|
+
const newSalt = rand(16);
|
|
140
|
+
const newKek = await deriveKekFromPassword(newPassword, newSalt);
|
|
141
|
+
const install = await fetch("/api/auth/recovery/login", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
email,
|
|
146
|
+
recoveryAuthHash,
|
|
147
|
+
newCredential: {
|
|
148
|
+
wrappedPrivateKey: bytesToBase64(await wrapPrivateKey(privateKey, newKek)),
|
|
149
|
+
kdfParams: { salt: bytesToHex(newSalt), iterations: KDF_ITERATIONS },
|
|
150
|
+
authHash: await deriveAuthHash(newPassword, newSalt),
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
// Deliberately loud rather than silent: the caller is already signed in at this point, so a
|
|
155
|
+
// swallowed failure would leave the user believing they had set a password they had not.
|
|
156
|
+
if (!install.ok) throw new Error(`your code was accepted but the new password could not be saved (${install.status}) — try setting it from Account settings`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: (data as { rotationPending?: boolean }).rotationPending ?? false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Regenerate the recovery code (owner session): re-wrap the in-memory private key under a fresh code +
|
|
163
|
+
// store the verifier. Returns the new code to display once.
|
|
164
|
+
// ── Provider-issued recovery ──────────────────────────────────────────────────
|
|
165
|
+
// Apple's recovery-contact model: a clinician who already holds the patient's DEK re-wraps it under a
|
|
166
|
+
// one-time code and READS THE CODE TO THEM. It is never emailed — see RECOVERY.md I2; the server would
|
|
167
|
+
// have to be given the code, and it already holds the wrapped DEK.
|
|
168
|
+
|
|
169
|
+
// Crockford base32, minus the characters that are misheard or mistyped (I/L/O/U). 12 characters ≈ 60
|
|
170
|
+
// bits, which is only safe because the server caps attempts and expires the grant in an hour — a code
|
|
171
|
+
// short enough to read down a phone line cannot also resist an unbounded oracle.
|
|
172
|
+
const GRANT_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
173
|
+
export function randomGrantCode(): string {
|
|
174
|
+
const raw = Array.from(rand(12), (b) => GRANT_ALPHABET[b % GRANT_ALPHABET.length]).join("");
|
|
175
|
+
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Strips the display grouping so "a7k2-9qmf-3xpb" and "A7K29QMF3XPB" are the same code. */
|
|
179
|
+
export function normalizeGrantCode(input: string): string {
|
|
180
|
+
return input.replace(/[^0-9A-Za-z]/g, "").toUpperCase();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Which ladder rung a pasted/typed string belongs to, by shape alone. `randomRecoveryCode`
|
|
185
|
+
* always emits 20 raw characters; `randomGrantCode` always emits 12 (displayed grouped). No
|
|
186
|
+
* collision is possible by construction, so the stripped length alone is enough to route —
|
|
187
|
+
* this never has to look at what the string actually contains.
|
|
188
|
+
*/
|
|
189
|
+
export function detectRecoveryKind(raw: string): "code" | "grant" {
|
|
190
|
+
return normalizeGrantCode(raw).length === 12 ? "grant" : "code";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Clinician side. Unwraps the patient's DEK with the clinician's own key — which is what provider
|
|
195
|
+
* access already is — and re-wraps it under the code. Returns the code to display once.
|
|
196
|
+
*/
|
|
197
|
+
export async function issueRecoveryCode(
|
|
198
|
+
patientAccountId: string,
|
|
199
|
+
envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey },
|
|
200
|
+
providerKey: CryptoKey,
|
|
201
|
+
): Promise<{ code: string; expiresAt: string }> {
|
|
202
|
+
const code = randomGrantCode();
|
|
203
|
+
const normalized = normalizeGrantCode(code);
|
|
204
|
+
const dek = await unwrapDEKWithPrivateKey(base64ToBytes(envelope.wrappedDEK), envelope.ephemeralPublicKeyJwk, providerKey);
|
|
205
|
+
const salt = rand(16);
|
|
206
|
+
const res = await fetch("/api/recovery/grant", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "content-type": "application/json" },
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
patientAccountId,
|
|
211
|
+
wrappedDek: bytesToBase64(await wrapDEKWithKek(dek, await deriveKekFromPassword(normalized, salt))),
|
|
212
|
+
kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
|
|
213
|
+
codeAuthHash: await deriveAuthHash(normalized, salt),
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `could not issue a recovery code (${res.status})`);
|
|
217
|
+
return { code, expiresAt: (await res.json()).expiresAt };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Patient side. Two calls against the same endpoint: the first proves the code and returns the wrapped
|
|
222
|
+
* DEK, the second installs a brand-new keypair locked under the new password. The code is proved on
|
|
223
|
+
* both — the second is not authorised by the first having happened.
|
|
224
|
+
*
|
|
225
|
+
* Unlike `recoverAccount`, this MINTS A NEW KEYPAIR rather than re-wrapping the old one, because the
|
|
226
|
+
* old private key is exactly what the patient no longer has. That is why the server clears the other
|
|
227
|
+
* credentials: they wrap a key nothing references any more.
|
|
228
|
+
*/
|
|
229
|
+
export async function redeemRecoveryCode(
|
|
230
|
+
email: string,
|
|
231
|
+
code: string,
|
|
232
|
+
newPassword: string,
|
|
233
|
+
): Promise<{ accountId: string; vaultId: string | null; r2Key: string | null; privateKey: CryptoKey; dek: CryptoKey; rotationPending: boolean }> {
|
|
234
|
+
const normalized = normalizeGrantCode(code);
|
|
235
|
+
const saltRes = await fetch(`/api/auth/recovery/grant-salt?email=${encodeURIComponent(email)}`);
|
|
236
|
+
if (!saltRes.ok) throw new Error(`recovery is unavailable right now (${saltRes.status})`);
|
|
237
|
+
const { salt } = (await saltRes.json()) as { salt: string };
|
|
238
|
+
const codeAuthHash = await deriveAuthHash(normalized, hexToBytes(salt));
|
|
239
|
+
|
|
240
|
+
const first = await fetch("/api/auth/recovery/grant-redeem", {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "content-type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ email, codeAuthHash }),
|
|
244
|
+
});
|
|
245
|
+
if (!first.ok) throw new Error((await first.json().catch(() => ({}))).error || "that code is not valid, or it has expired");
|
|
246
|
+
const { wrappedDek } = (await first.json()) as { wrappedDek: string };
|
|
247
|
+
const dek = await unwrapDEKWithKek(base64ToBytes(wrappedDek), await deriveKekFromPassword(normalized, hexToBytes(salt)));
|
|
248
|
+
|
|
249
|
+
const fresh = await generateAccountKeypair();
|
|
250
|
+
const pwSalt = rand(16);
|
|
251
|
+
const envelope = await wrapDEKForPublicKey(dek, fresh.publicKeyJwk);
|
|
252
|
+
const second = await fetch("/api/auth/recovery/grant-redeem", {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: { "content-type": "application/json" },
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
email,
|
|
257
|
+
codeAuthHash,
|
|
258
|
+
newIdentity: {
|
|
259
|
+
publicKeyJwk: fresh.publicKeyJwk,
|
|
260
|
+
wrappedPrivateKey: bytesToBase64(await wrapPrivateKey(fresh.privateKey, await deriveKekFromPassword(newPassword, pwSalt))),
|
|
261
|
+
kdfParams: { salt: bytesToHex(pwSalt), iterations: KDF_ITERATIONS },
|
|
262
|
+
authHash: await deriveAuthHash(newPassword, pwSalt),
|
|
263
|
+
envelope: { wrappedDEK: bytesToBase64(envelope.wrappedDEK), ephemeralPublicKeyJwk: envelope.ephemeralPublicKeyJwk },
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
if (!second.ok) throw new Error((await second.json().catch(() => ({}))).error || `could not finish recovery (${second.status})`);
|
|
268
|
+
const data = (await second.json()) as { accountId: string; vaultId: string | null; r2Key: string | null; rotationPending: boolean };
|
|
269
|
+
return { ...data, privateKey: fresh.privateKey, dek };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// DEK rotation. Fetch the re-wrap targets (owner + org + active providers), then commit the
|
|
273
|
+
// new envelope set after the client re-encrypts the vault under a fresh DEK.
|
|
274
|
+
export interface VaultPrincipals {
|
|
275
|
+
selfAccountId: string;
|
|
276
|
+
orgAccountId: string;
|
|
277
|
+
selfPublicKeyJwk: JsonWebKey;
|
|
278
|
+
orgPublicKeyJwk: JsonWebKey;
|
|
279
|
+
providers: { accountId: string; publicKeyJwk: JsonWebKey }[];
|
|
280
|
+
envelopePrincipalIds: string[];
|
|
281
|
+
orgRecoveryRevokedAt: string | null;
|
|
282
|
+
vaultId: string;
|
|
283
|
+
}
|
|
284
|
+
export async function getVaultPrincipals(): Promise<VaultPrincipals> {
|
|
285
|
+
const res = await fetch("/api/vault/principals", { cache: "no-store" });
|
|
286
|
+
if (!res.ok) throw await failed(res, "principals failed");
|
|
287
|
+
return res.json();
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Reserve the object the re-key will write to. The server mints the id; the caller PUTs the
|
|
291
|
+
* blob re-encrypted under the new DEK there, then calls `rotateVault` to make the swap real.
|
|
292
|
+
*/
|
|
293
|
+
export async function stageVaultRotation(vaultId: string): Promise<string> {
|
|
294
|
+
const res = await fetch("/api/vault/rotate", {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "content-type": "application/json" },
|
|
297
|
+
body: JSON.stringify({ phase: "stage", vaultId }),
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok) throw await failed(res, "stage rotation failed");
|
|
300
|
+
return (await res.json()).newVaultId as string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function rotateVault(args: {
|
|
304
|
+
vaultId: string;
|
|
305
|
+
newVaultId: string;
|
|
306
|
+
envelopes: { principalAccountId: string; wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey }[];
|
|
307
|
+
}): Promise<void> {
|
|
308
|
+
const res = await fetch("/api/vault/rotate", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "content-type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ phase: "commit", ...args }),
|
|
312
|
+
});
|
|
313
|
+
if (!res.ok) throw await failed(res, "rotate failed");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function regenerateRecoveryCode(privateKey: CryptoKey): Promise<string> {
|
|
317
|
+
const recoveryCode = randomRecoveryCode();
|
|
318
|
+
const salt = rand(16);
|
|
319
|
+
const kek = await deriveKekFromPassword(recoveryCode, salt);
|
|
320
|
+
const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
|
|
321
|
+
const recoveryAuthHash = await deriveAuthHash(recoveryCode, salt);
|
|
322
|
+
const res = await fetch("/api/account/recovery", {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: { "content-type": "application/json" },
|
|
325
|
+
body: JSON.stringify({ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey), kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS }, recoveryAuthHash }),
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok) throw await failed(res, "regenerate recovery failed");
|
|
328
|
+
return recoveryCode;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function putRecoveryEnvelope(e: { wrappedDEK: string; ephemeralPublicKeyJwk: unknown }): Promise<{ status: string }> {
|
|
332
|
+
const res = await fetch("/api/vault/recovery-envelope", {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: { "content-type": "application/json" },
|
|
335
|
+
body: JSON.stringify(e),
|
|
336
|
+
});
|
|
337
|
+
if (!res.ok) throw await failed(res, "put recovery envelope failed");
|
|
338
|
+
return res.json();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function revokeRecoveryEnvelope(): Promise<{ status: string; revokedAt: string }> {
|
|
342
|
+
const res = await fetch("/api/vault/recovery-envelope", { method: "DELETE" });
|
|
343
|
+
if (!res.ok) throw await failed(res, "revoke recovery envelope failed");
|
|
344
|
+
return res.json();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface AccessEventRow {
|
|
348
|
+
id: string;
|
|
349
|
+
action: string;
|
|
350
|
+
actorAccountId: string;
|
|
351
|
+
vaultId: string | null;
|
|
352
|
+
consentRef: string | null;
|
|
353
|
+
meta: unknown;
|
|
354
|
+
createdAt: string;
|
|
355
|
+
}
|
|
356
|
+
export async function getAccessEvents(): Promise<{ events: AccessEventRow[] }> {
|
|
357
|
+
const res = await fetch("/api/account/access-events", { cache: "no-store" });
|
|
358
|
+
if (!res.ok) throw await failed(res, "access events failed");
|
|
359
|
+
return res.json();
|
|
360
|
+
}
|
package/auth-support.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { wrapDEKForPublicKey } from "./crypto";
|
|
2
|
+
import { bytesToBase64, failed } from "./auth-client";
|
|
3
|
+
|
|
4
|
+
// Support consented-access. A patient approves a pending support request by wrapping their
|
|
5
|
+
// in-memory DEK to the support agent's public key (time-boxed); support enters via an audited endpoint.
|
|
6
|
+
|
|
7
|
+
// Patient side — approve a pending support request (linkId + the agent's publicKeyJwk from GET /api/providers).
|
|
8
|
+
export async function approveSupport(linkId: string, dek: CryptoKey, publicKeyJwk: JsonWebKey, ttlHours: number): Promise<void> {
|
|
9
|
+
const env = await wrapDEKForPublicKey(dek, publicKeyJwk);
|
|
10
|
+
const res = await fetch("/api/support/approve", {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "content-type": "application/json" },
|
|
13
|
+
body: JSON.stringify({ linkId, wrappedDEK: bytesToBase64(env.wrappedDEK), ephemeralPublicKeyJwk: env.ephemeralPublicKeyJwk, ttlHours }),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) throw await failed(res, "approve failed");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Support side.
|
|
19
|
+
export interface SupportPatient {
|
|
20
|
+
patientAccountId: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
expiresAt: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function requestSupportAccess(patientEmail: string): Promise<void> {
|
|
26
|
+
const res = await fetch("/api/support/request", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ patientEmail }),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) throw await failed(res, "request failed");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listSupportPatients(): Promise<SupportPatient[]> {
|
|
35
|
+
const res = await fetch("/api/support/patients", { cache: "no-store" });
|
|
36
|
+
if (!res.ok) throw await failed(res, "support patients failed");
|
|
37
|
+
return ((await res.json()) as { patients: SupportPatient[] }).patients;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Enter a patient (audited server-side); returns the envelope for client-side DEK unwrap.
|
|
41
|
+
export async function enterSupportPatient(patientAccountId: string): Promise<{
|
|
42
|
+
patientAccountId: string;
|
|
43
|
+
displayName: string;
|
|
44
|
+
email: string | null;
|
|
45
|
+
vaultId: string;
|
|
46
|
+
r2Key: string;
|
|
47
|
+
envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey };
|
|
48
|
+
}> {
|
|
49
|
+
const res = await fetch("/api/support/access", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ patientAccountId }),
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) throw await failed(res, "support access failed");
|
|
55
|
+
return res.json();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Support→provider roster access. A support agent can request access to a clinician too (same
|
|
59
|
+
// /api/support/request, which classifies by the target's kind). The clinician approves (metadata only,
|
|
60
|
+
// no DEK), then support sees the clinician's roster and can open the patients who separately consented.
|
|
61
|
+
|
|
62
|
+
export interface SupportProvider {
|
|
63
|
+
linkId: string;
|
|
64
|
+
providerAccountId: string;
|
|
65
|
+
displayName: string;
|
|
66
|
+
email: string | null;
|
|
67
|
+
expiresAt: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SupportRosterPatient {
|
|
71
|
+
patientAccountId: string;
|
|
72
|
+
displayName: string;
|
|
73
|
+
email: string | null;
|
|
74
|
+
openable: boolean;
|
|
75
|
+
pending: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SupportRequest {
|
|
79
|
+
linkId: string;
|
|
80
|
+
targetAccountId: string;
|
|
81
|
+
displayName: string;
|
|
82
|
+
email: string | null;
|
|
83
|
+
kind: "patient" | "provider";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Provider side — approve a pending support roster request (no DEK; a provider owns no vault).
|
|
87
|
+
export async function approveSupportAsProvider(linkId: string, ttlHours: number): Promise<void> {
|
|
88
|
+
const res = await fetch("/api/providers/approve-support", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
body: JSON.stringify({ linkId, ttlHours }),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) throw await failed(res, "approve failed");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function listSupportProviders(): Promise<SupportProvider[]> {
|
|
97
|
+
const res = await fetch("/api/support/providers", { cache: "no-store" });
|
|
98
|
+
if (!res.ok) throw await failed(res, "support providers failed");
|
|
99
|
+
return ((await res.json()) as { providers: SupportProvider[] }).providers;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getProviderRoster(providerId: string): Promise<SupportRosterPatient[]> {
|
|
103
|
+
const res = await fetch(`/api/support/provider-roster?providerId=${encodeURIComponent(providerId)}`, { cache: "no-store" });
|
|
104
|
+
if (!res.ok) throw await failed(res, "provider roster failed");
|
|
105
|
+
return ((await res.json()) as { roster: SupportRosterPatient[] }).roster;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function listSupportRequests(): Promise<SupportRequest[]> {
|
|
109
|
+
const res = await fetch("/api/support/requests", { cache: "no-store" });
|
|
110
|
+
if (!res.ok) throw await failed(res, "support requests failed");
|
|
111
|
+
return ((await res.json()) as { requests: SupportRequest[] }).requests;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Cancel a pending request the support agent made (support is the provider side of the link).
|
|
115
|
+
export async function cancelSupportRequest(linkId: string): Promise<void> {
|
|
116
|
+
const res = await fetch(`/api/providers/${encodeURIComponent(linkId)}`, { method: "DELETE" });
|
|
117
|
+
if (!res.ok) throw await failed(res, "cancel failed");
|
|
118
|
+
}
|
package/base64.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Byte <-> base64. Pure, no reactive state — used throughout this package's client layer wherever
|
|
2
|
+
// key material or an envelope needs to cross a JSON boundary.
|
|
3
|
+
|
|
4
|
+
export function b64ToBytes(b64: string): Uint8Array {
|
|
5
|
+
const bin = atob(b64);
|
|
6
|
+
const out = new Uint8Array(bin.length);
|
|
7
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function bytesToB64(bytes: Uint8Array): string {
|
|
12
|
+
let bin = "";
|
|
13
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
14
|
+
return btoa(bin);
|
|
15
|
+
}
|
package/break-glass.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// trail instead of drifting independently.
|
|
1
|
+
// The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern, factored
|
|
2
|
+
// out of routes that would otherwise each hardcode their own TTL clamp, consent-string prefix, and
|
|
3
|
+
// audit shape: a grant-with-envelope route, a grant-metadata-only route, a check/expire route, and a
|
|
4
|
+
// revoke route. Parameterized TTL rather than a fixed default, so an adopter with different
|
|
5
|
+
// access-window needs isn't stuck with ours.
|
|
7
6
|
|
|
8
7
|
import type { AuditStore, EnvelopeStore, ProviderLink, ProviderLinkStore } from "./stores";
|
|
9
8
|
|
package/crypto.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { toArrayBuffer as toAB } from "./bytes";
|
|
2
2
|
import { deriveAesKey, deriveBits, IV_LEN, SALT_LEN } from "./kdf";
|
|
3
3
|
|
|
4
|
-
// HD1 is this
|
|
5
|
-
//
|
|
4
|
+
// HD1 is this package's own envelope and stays here. Only the derivation is shared with a sibling
|
|
5
|
+
// encrypted-blob format elsewhere — see kdf.ts's header for why the two formats must NOT be merged.
|
|
6
6
|
const MAGIC = new Uint8Array([0x48, 0x44, 0x31]); // "HD1"
|
|
7
7
|
const VERSION = 1;
|
|
8
8
|
|
|
@@ -12,18 +12,22 @@ const dec = new TextDecoder();
|
|
|
12
12
|
|
|
13
13
|
const deriveKey = (passphrase: string, salt: Uint8Array) => deriveAesKey(subtle, passphrase, salt);
|
|
14
14
|
|
|
15
|
+
function bytesToBase64Url(bytes: Uint8Array): string {
|
|
16
|
+
let bin = "";
|
|
17
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
18
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
// Deterministic, non-reversible bearer derived from the passphrase: base64url(SHA-256).
|
|
16
22
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
23
|
+
// A stable, non-reversible token an adopter can hand to a script or an allowlisted bearer-token
|
|
24
|
+
// integration gated off the same passphrase used for the interactive login path, without storing
|
|
25
|
+
// the passphrase itself anywhere. Not session auth — an interactive route should be protected by a
|
|
26
|
+
// real session cookie/token, not a static bearer derived here. Keep this only for a script-facing
|
|
27
|
+
// bearer allowlist you still rely on; drop it once you don't.
|
|
22
28
|
export async function deriveBearerToken(passphrase: string): Promise<string> {
|
|
23
29
|
const digest = new Uint8Array(await subtle.digest("SHA-256", toAB(enc.encode(passphrase))));
|
|
24
|
-
|
|
25
|
-
for (const b of digest) bin += String.fromCharCode(b);
|
|
26
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
30
|
+
return bytesToBase64Url(digest);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export async function encryptVault<T = Record<string, unknown>>(data: T, passphrase: string): Promise<Uint8Array> {
|
|
@@ -114,9 +118,7 @@ export async function deriveAuthHash(password: string, salt: Uint8Array): Promis
|
|
|
114
118
|
domainSalt.set(salt, 0);
|
|
115
119
|
domainSalt.set(suffix, salt.length);
|
|
116
120
|
const bits = await deriveBits(subtle, password, domainSalt);
|
|
117
|
-
|
|
118
|
-
for (const b of bits) bin += String.fromCharCode(b);
|
|
119
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
121
|
+
return bytesToBase64Url(bits);
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
// Wrap/unwrap the account private key under a KEK: AES-GCM over its PKCS8 bytes. Blob = iv(12)+ct.
|
|
@@ -139,18 +141,17 @@ export async function unwrapPrivateKey(blob: Uint8Array, kek: CryptoKey): Promis
|
|
|
139
141
|
} catch {
|
|
140
142
|
throw new Error("cannot unwrap private key (wrong KEK or corrupt blob)");
|
|
141
143
|
}
|
|
142
|
-
// Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate
|
|
143
|
-
// recovery code,
|
|
144
|
+
// Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate the
|
|
145
|
+
// recovery code, which wraps this same key under a new KEK. Same rationale/posture as the
|
|
144
146
|
// DEK returned by unwrapDEKWithPrivateKey (also extractable for re-granting). The key stays in-memory
|
|
145
147
|
// only, same trust boundary as the session's DEK.
|
|
146
148
|
return subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
// Import a raw PKCS8 ECDH private key (extractable, so it can be re-wrapped to add a login method —
|
|
150
|
-
// same trust boundary as unwrapPrivateKey's output). A
|
|
151
|
-
// the server
|
|
152
|
-
//
|
|
153
|
-
// import it here rather than unwrapping.
|
|
152
|
+
// same trust boundary as unwrapPrivateKey's output). A federated-login path that moves the plaintext key
|
|
153
|
+
// over the wire (server-custody) needs both server (re-wrap under the server KEK) and client (recover
|
|
154
|
+
// the DEK) to import it here instead of unwrapping a KEK-wrapped blob.
|
|
154
155
|
export async function importPrivateKeyPkcs8(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|
155
156
|
return subtle.importKey("pkcs8", toAB(pkcs8), EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
156
157
|
}
|
package/kdf.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
// The passphrase→key derivation shared by every passphrase-encrypted blob
|
|
2
|
-
// package.
|
|
1
|
+
// The passphrase→key derivation shared by every passphrase-encrypted blob this package produces.
|
|
3
2
|
//
|
|
4
|
-
// This
|
|
5
|
-
// format
|
|
6
|
-
//
|
|
7
|
-
// envelope
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// This module derives a key from a passphrase and salt and stops there — it does not define an
|
|
4
|
+
// envelope format, version byte, or framing. A caller with its own envelope needs (see `crypto.ts`
|
|
5
|
+
// for this package's own HD1 format) keeps its magic bytes, version handling, and framing in its own
|
|
6
|
+
// module. A unified envelope would make one format's blob a syntactically valid input to another
|
|
7
|
+
// format's reader, which is a confusion this separation prevents for free. Sharing only the
|
|
8
|
+
// derivation, never the envelope, is the design, not an oversight to "fix" by merging.
|
|
10
9
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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.
|
|
16
|
-
//
|
|
17
|
-
// RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. One caller is
|
|
18
|
-
// Node-only (`node:crypto`'s webcrypto), the others run in the browser and in Workers, so the
|
|
19
|
-
// `SubtleCrypto` is passed in rather than reached for. That also makes the parameters testable
|
|
20
|
-
// without a runtime shim.
|
|
10
|
+
// RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. Callers run
|
|
11
|
+
// in Node, the browser, and Workers alike, so the `SubtleCrypto` instance is passed in rather than
|
|
12
|
+
// reached for globally. That also makes the parameters testable without a runtime shim.
|
|
21
13
|
|
|
22
14
|
import { toArrayBuffer } from "./bytes";
|
|
23
15
|
|
|
@@ -56,8 +48,8 @@ export async function deriveAesKey(
|
|
|
56
48
|
}
|
|
57
49
|
|
|
58
50
|
/**
|
|
59
|
-
* PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key —
|
|
60
|
-
* server-verifiable auth hash. Separate entry point rather than a flag on `deriveAesKey`, because a
|
|
51
|
+
* PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key — this package's own
|
|
52
|
+
* server-verifiable auth hash (see `crypto.ts`'s `deriveAuthHash`). Separate entry point rather than a flag on `deriveAesKey`, because a
|
|
61
53
|
* function that sometimes returns exportable bytes and sometimes an unexportable key is exactly the
|
|
62
54
|
* kind of thing a reviewer has to read twice.
|
|
63
55
|
*/
|
package/key-store.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Persist the account private key across a browser refresh so a valid session doesn't force
|
|
1
|
+
// Persist the account private key across a browser refresh so a valid session cookie 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
|
|
5
|
-
// to any "stay signed in"). Password/passkey only;
|
|
5
|
+
// to any "stay signed in"). Password/passkey only; a federated-login method re-bootstraps from
|
|
6
|
+
// server-custody material instead.
|
|
6
7
|
|
|
7
|
-
const DB_NAME = "
|
|
8
|
+
const DB_NAME = "security-session";
|
|
8
9
|
const STORE = "keys";
|
|
9
10
|
const KEY_ID = "account-private-key";
|
|
10
11
|
const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" } as const;
|