@tinytars/vault 0.1.4
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/LICENSE +21 -0
- package/README.md +271 -0
- package/adapters/conformance.ts +224 -0
- package/adapters/d1/accounts.ts +160 -0
- package/adapters/d1/audit.ts +58 -0
- package/adapters/d1/credentials.ts +154 -0
- package/adapters/d1/index.ts +179 -0
- package/adapters/d1/providers.ts +117 -0
- package/adapters/d1/types.ts +22 -0
- package/adapters/d1/vault.ts +209 -0
- package/adapters/memory.ts +342 -0
- package/adapters/pages-http.ts +13 -0
- package/adapters/r2.ts +50 -0
- package/blob-store.ts +27 -0
- package/break-glass.ts +158 -0
- package/bytes.ts +18 -0
- package/crypto.ts +276 -0
- package/env.d.ts +9 -0
- package/envelope-access.ts +44 -0
- package/kdf.ts +73 -0
- package/key-store.ts +67 -0
- package/package.json +52 -0
- package/stores.ts +190 -0
- package/vault-sink.ts +147 -0
package/break-glass.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
import type { AuditStore, EnvelopeStore, ProviderLink, ProviderLinkStore } from "./stores";
|
|
8
|
+
|
|
9
|
+
export interface BreakGlassPolicy {
|
|
10
|
+
defaultTtlHours: number;
|
|
11
|
+
maxTtlHours: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clampTtlHours(requested: number | undefined, policy: BreakGlassPolicy): number {
|
|
15
|
+
return typeof requested === "number" && requested > 0 ? Math.min(requested, policy.maxTtlHours) : policy.defaultTtlHours;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BreakGlassGrantResult = { ok: true; expiresAt: string } | { ok: false; error: "no_pending_link" };
|
|
19
|
+
|
|
20
|
+
export interface BreakGlassGrantStores {
|
|
21
|
+
links: Pick<ProviderLinkStore, "get" | "grantSupport">;
|
|
22
|
+
audit: Pick<AuditStore, "insertAccessEvent">;
|
|
23
|
+
/** Only required when a grant call passes `envelope`. */
|
|
24
|
+
envelopes?: Pick<EnvelopeStore, "putEnvelope">;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Approves a pending support-role link: validates it's the approver's own pending request, clamps the
|
|
29
|
+
* requested TTL, stamps a consent ref, optionally writes an envelope (patient approvals only — a
|
|
30
|
+
* provider approving a roster request owns nothing encrypted), flips the link active, and audits.
|
|
31
|
+
*/
|
|
32
|
+
export async function grantBreakGlass(
|
|
33
|
+
stores: BreakGlassGrantStores,
|
|
34
|
+
opts: {
|
|
35
|
+
linkId: string;
|
|
36
|
+
approverAccountId: string;
|
|
37
|
+
requestedTtlHours: number | undefined;
|
|
38
|
+
policy: BreakGlassPolicy;
|
|
39
|
+
consentPrefix: string;
|
|
40
|
+
auditAction: string;
|
|
41
|
+
envelope?: { vaultId: string; wrappedDek: Uint8Array; ephemeralPublicKeyJwk: unknown };
|
|
42
|
+
buildAuditMeta: (expiresAt: string, link: ProviderLink) => unknown;
|
|
43
|
+
}
|
|
44
|
+
): Promise<BreakGlassGrantResult> {
|
|
45
|
+
const link = await stores.links.get(opts.linkId);
|
|
46
|
+
if (!link || link.patientAccountId !== opts.approverAccountId || link.role !== "support" || link.status !== "invited") {
|
|
47
|
+
return { ok: false, error: "no_pending_link" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ttlHours = clampTtlHours(opts.requestedTtlHours, opts.policy);
|
|
51
|
+
const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000).toISOString();
|
|
52
|
+
const consentRef = `${opts.consentPrefix}:${new Date().toISOString()}`;
|
|
53
|
+
|
|
54
|
+
if (opts.envelope) {
|
|
55
|
+
if (!stores.envelopes) throw new Error("grantBreakGlass: envelope requested but no envelope store supplied");
|
|
56
|
+
await stores.envelopes.putEnvelope({
|
|
57
|
+
vaultId: opts.envelope.vaultId,
|
|
58
|
+
principalAccountId: link.providerAccountId,
|
|
59
|
+
wrappedDek: opts.envelope.wrappedDek,
|
|
60
|
+
ephemeralPublicKeyJwk: opts.envelope.ephemeralPublicKeyJwk,
|
|
61
|
+
createdBy: opts.approverAccountId,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
await stores.links.grantSupport(link.id, { expiresAt, consentRef });
|
|
65
|
+
await stores.audit.insertAccessEvent({
|
|
66
|
+
actorAccountId: opts.approverAccountId,
|
|
67
|
+
subjectAccountId: opts.approverAccountId,
|
|
68
|
+
vaultId: opts.envelope?.vaultId ?? null,
|
|
69
|
+
action: opts.auditAction,
|
|
70
|
+
consentRef,
|
|
71
|
+
meta: opts.buildAuditMeta(expiresAt, link),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { ok: true, expiresAt };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type BreakGlassCheckResult = { ok: true } | { ok: false; error: "expired" };
|
|
78
|
+
|
|
79
|
+
export interface BreakGlassCheckStores {
|
|
80
|
+
links: Pick<ProviderLinkStore, "updateStatus">;
|
|
81
|
+
audit: Pick<AuditStore, "insertAccessEvent">;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks whether an active support-role link has passed its TTL. Past expiry, self-revokes the link
|
|
86
|
+
* and audits `expiredAuditAction`; `onExpire` is the caller's chance to clean up whatever the grant
|
|
87
|
+
* attached (an envelope, for the vault-access case — nothing, for a metadata-only grant).
|
|
88
|
+
*/
|
|
89
|
+
export async function checkBreakGlass(
|
|
90
|
+
stores: BreakGlassCheckStores,
|
|
91
|
+
opts: {
|
|
92
|
+
link: ProviderLink;
|
|
93
|
+
actorAccountId: string;
|
|
94
|
+
subjectAccountId: string;
|
|
95
|
+
vaultId: string | null;
|
|
96
|
+
now?: number;
|
|
97
|
+
expiredAuditAction: string;
|
|
98
|
+
onExpire?: () => Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
): Promise<BreakGlassCheckResult> {
|
|
101
|
+
const now = opts.now ?? Date.now();
|
|
102
|
+
if (!opts.link.expiresAt || new Date(opts.link.expiresAt).getTime() >= now) {
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opts.onExpire) await opts.onExpire();
|
|
107
|
+
await stores.links.updateStatus(opts.link.id, "revoked");
|
|
108
|
+
await stores.audit.insertAccessEvent({
|
|
109
|
+
actorAccountId: opts.actorAccountId,
|
|
110
|
+
subjectAccountId: opts.subjectAccountId,
|
|
111
|
+
vaultId: opts.vaultId,
|
|
112
|
+
action: opts.expiredAuditAction,
|
|
113
|
+
consentRef: opts.link.consentRef,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { ok: false, error: "expired" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface BreakGlassRevokeStores {
|
|
120
|
+
links: Pick<ProviderLinkStore, "updateStatus">;
|
|
121
|
+
audit: Pick<AuditStore, "insertAccessEvent">;
|
|
122
|
+
/** Only required when the link being revoked has a vault (i.e. is not metadata-only). */
|
|
123
|
+
envelopes?: Pick<EnvelopeStore, "deleteEnvelope">;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ends a link early — either side may call this (the patient revoking, or the provider dropping it).
|
|
128
|
+
* Deletes the provider's envelope (if any) so no new read can unwrap the DEK, and marks the link
|
|
129
|
+
* revoked. Idempotent: a second call re-deletes (no-op) and re-marks revoked (no-op); `auditAction` is
|
|
130
|
+
* omitted for link kinds that don't carry a disclosure-audit obligation (clinician links).
|
|
131
|
+
*/
|
|
132
|
+
export async function revokeBreakGlass(
|
|
133
|
+
stores: BreakGlassRevokeStores,
|
|
134
|
+
opts: {
|
|
135
|
+
linkId: string;
|
|
136
|
+
actorAccountId: string;
|
|
137
|
+
subjectAccountId: string;
|
|
138
|
+
providerAccountId: string;
|
|
139
|
+
vaultId: string | null;
|
|
140
|
+
auditAction?: string;
|
|
141
|
+
auditMeta?: unknown;
|
|
142
|
+
}
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
if (opts.vaultId) {
|
|
145
|
+
if (!stores.envelopes) throw new Error("revokeBreakGlass: vaultId given but no envelope store supplied");
|
|
146
|
+
await stores.envelopes.deleteEnvelope(opts.vaultId, opts.providerAccountId);
|
|
147
|
+
}
|
|
148
|
+
await stores.links.updateStatus(opts.linkId, "revoked");
|
|
149
|
+
if (opts.auditAction) {
|
|
150
|
+
await stores.audit.insertAccessEvent({
|
|
151
|
+
actorAccountId: opts.actorAccountId,
|
|
152
|
+
subjectAccountId: opts.subjectAccountId,
|
|
153
|
+
vaultId: opts.vaultId,
|
|
154
|
+
action: opts.auditAction,
|
|
155
|
+
meta: opts.auditMeta,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
package/bytes.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule for handing a `Uint8Array` to WebCrypto.
|
|
3
|
+
*
|
|
4
|
+
* `subtle.*` takes a `BufferSource`, and a `Uint8Array` is only interchangeable with its underlying
|
|
5
|
+
* `ArrayBuffer` when it spans the whole thing. A view produced by `slice()` on a larger buffer, or by
|
|
6
|
+
* `subarray()`, carries a non-zero `byteOffset` — passing `view.buffer` there silently encrypts or
|
|
7
|
+
* hashes the WRONG BYTES rather than failing. Every call site that used to write
|
|
8
|
+
* `as unknown as ArrayBuffer` was one refactor away from that.
|
|
9
|
+
*
|
|
10
|
+
* The whole-buffer case returns the live buffer rather than a copy: the result is consumed
|
|
11
|
+
* synchronously by the `subtle` call it is written for, so there is no window in which aliasing is
|
|
12
|
+
* observable, and the copy would be on every vault read.
|
|
13
|
+
*/
|
|
14
|
+
export function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
|
15
|
+
return view.byteOffset === 0 && view.byteLength === view.buffer.byteLength
|
|
16
|
+
? (view.buffer as ArrayBuffer)
|
|
17
|
+
: (view.slice().buffer as ArrayBuffer);
|
|
18
|
+
}
|
package/crypto.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { toArrayBuffer as toAB } from "./bytes";
|
|
2
|
+
import { deriveAesKey, deriveBits, IV_LEN, SALT_LEN } from "./kdf";
|
|
3
|
+
|
|
4
|
+
// HD1 is this app's own envelope and stays here. Only the derivation is shared with the QBO vault's
|
|
5
|
+
// EB1 — see the header of @tinytars/vault/kdf for why the two formats must NOT be merged.
|
|
6
|
+
const MAGIC = new Uint8Array([0x48, 0x44, 0x31]); // "HD1"
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
|
|
9
|
+
const subtle = (globalThis.crypto as Crypto).subtle;
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
const dec = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
const deriveKey = (passphrase: string, salt: Uint8Array) => deriveAesKey(subtle, passphrase, salt);
|
|
14
|
+
|
|
15
|
+
// Deterministic, non-reversible bearer derived from the passphrase: base64url(SHA-256).
|
|
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 survives — scripts/chat-allowlist.ts
|
|
20
|
+
// prints the VAULT_TOKEN value, which vault/[id].ts:73,155 checks and scripts/vault-sync.ts sends.
|
|
21
|
+
export async function deriveBearerToken(passphrase: string): Promise<string> {
|
|
22
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", toAB(enc.encode(passphrase))));
|
|
23
|
+
let bin = "";
|
|
24
|
+
for (const b of digest) bin += String.fromCharCode(b);
|
|
25
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function encryptVault<T = Record<string, unknown>>(data: T, passphrase: string): Promise<Uint8Array> {
|
|
29
|
+
const salt = globalThis.crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
30
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
31
|
+
const key = await deriveKey(passphrase, salt);
|
|
32
|
+
const plaintext = enc.encode(JSON.stringify(data));
|
|
33
|
+
const ciphertext = new Uint8Array(
|
|
34
|
+
await subtle.encrypt({ name: "AES-GCM", iv: toAB(iv) }, key, toAB(plaintext)),
|
|
35
|
+
);
|
|
36
|
+
const out = new Uint8Array(MAGIC.length + 1 + SALT_LEN + IV_LEN + ciphertext.length);
|
|
37
|
+
let o = 0;
|
|
38
|
+
out.set(MAGIC, o); o += MAGIC.length;
|
|
39
|
+
out[o++] = VERSION;
|
|
40
|
+
out.set(salt, o); o += SALT_LEN;
|
|
41
|
+
out.set(iv, o); o += IV_LEN;
|
|
42
|
+
out.set(ciphertext, o);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function decryptVault<T = Record<string, unknown>>(blob: Uint8Array, passphrase: string): Promise<T> {
|
|
47
|
+
if (blob.length < MAGIC.length + 1 + SALT_LEN + IV_LEN) {
|
|
48
|
+
throw new Error("blob too short");
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < MAGIC.length; i++) {
|
|
51
|
+
if (blob[i] !== MAGIC[i]) throw new Error("not an HD1 blob");
|
|
52
|
+
}
|
|
53
|
+
let o = MAGIC.length;
|
|
54
|
+
const version = blob[o++];
|
|
55
|
+
if (version === VERSION_V2) throw new Error("v2 envelope blob: open it with decryptVaultV2 and a DEK");
|
|
56
|
+
if (version !== VERSION) throw new Error(`unsupported version ${version}`);
|
|
57
|
+
const salt = blob.slice(o, o + SALT_LEN); o += SALT_LEN;
|
|
58
|
+
const iv = blob.slice(o, o + IV_LEN); o += IV_LEN;
|
|
59
|
+
const ciphertext = blob.slice(o);
|
|
60
|
+
const key = await deriveKey(passphrase, salt);
|
|
61
|
+
let plaintext: ArrayBuffer;
|
|
62
|
+
try {
|
|
63
|
+
plaintext = await subtle.decrypt({ name: "AES-GCM", iv: toAB(iv) }, key, toAB(ciphertext));
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error("wrong passphrase or corrupt blob");
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(dec.decode(plaintext)) as T;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── W44 envelope encryption (v2) ─────────────────────────────────────────────
|
|
71
|
+
// v1 above (passphrase → PBKDF2 → AES key → blob) is untouched. v2 splits the key:
|
|
72
|
+
// a random per-vault DEK encrypts the blob, and the DEK is wrapped per principal
|
|
73
|
+
// (owner, provider, support) to their ECDH public key. The passphrase path is gone;
|
|
74
|
+
// a v2 blob is opened only with its DEK. This is what makes "grant access" a
|
|
75
|
+
// re-wrap of the DEK rather than a shared secret.
|
|
76
|
+
|
|
77
|
+
const VERSION_V2 = 2;
|
|
78
|
+
const KEYID_LEN = 16; // opaque per-vault key id, reserved for future re-key tracking
|
|
79
|
+
const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" } as const;
|
|
80
|
+
|
|
81
|
+
export interface AccountKeypair {
|
|
82
|
+
publicKeyJwk: JsonWebKey;
|
|
83
|
+
privateKey: CryptoKey; // extractable ECDH private — wrap it immediately, then drop the handle
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DEKEnvelope {
|
|
87
|
+
wrappedDEK: Uint8Array; // iv(12) + AES-GCM(sharedKey, raw DEK)
|
|
88
|
+
ephemeralPublicKeyJwk: JsonWebKey;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function generateAccountKeypair(): Promise<AccountKeypair> {
|
|
92
|
+
const pair = await subtle.generateKey(EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
93
|
+
const publicKeyJwk = await subtle.exportKey("jwk", pair.publicKey);
|
|
94
|
+
return { publicKeyJwk, privateKey: pair.privateKey };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A KEK wraps the account's private key. Password → PBKDF2 (same params as v1);
|
|
98
|
+
// passkey → the WebAuthn PRF secret imported directly as an AES-GCM key.
|
|
99
|
+
export async function deriveKekFromPassword(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
100
|
+
return deriveKey(password, salt);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function kekFromPrfSecret(prfBytes: Uint8Array): Promise<CryptoKey> {
|
|
104
|
+
return subtle.importKey("raw", toAB(prfBytes), "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Server-verifiable password auth signal, in a DISTINCT KDF domain from the KEK (salt||"|auth"),
|
|
108
|
+
// so it can be sent to the server without leaking the key-wrapping secret. The server stores
|
|
109
|
+
// SHA-256(authHash) and compares — it never sees the password or the KEK. base64url of 256 bits.
|
|
110
|
+
export async function deriveAuthHash(password: string, salt: Uint8Array): Promise<string> {
|
|
111
|
+
const suffix = enc.encode("|auth");
|
|
112
|
+
const domainSalt = new Uint8Array(salt.length + suffix.length);
|
|
113
|
+
domainSalt.set(salt, 0);
|
|
114
|
+
domainSalt.set(suffix, salt.length);
|
|
115
|
+
const bits = await deriveBits(subtle, password, domainSalt);
|
|
116
|
+
let bin = "";
|
|
117
|
+
for (const b of bits) bin += String.fromCharCode(b);
|
|
118
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Wrap/unwrap the account private key under a KEK: AES-GCM over its PKCS8 bytes. Blob = iv(12)+ct.
|
|
122
|
+
export async function wrapPrivateKey(privateKey: CryptoKey, kek: CryptoKey): Promise<Uint8Array> {
|
|
123
|
+
const pkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", privateKey));
|
|
124
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
125
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: toAB(iv) }, kek, toAB(pkcs8)));
|
|
126
|
+
const out = new Uint8Array(IV_LEN + ct.length);
|
|
127
|
+
out.set(iv, 0);
|
|
128
|
+
out.set(ct, IV_LEN);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function unwrapPrivateKey(blob: Uint8Array, kek: CryptoKey): Promise<CryptoKey> {
|
|
133
|
+
const iv = blob.slice(0, IV_LEN);
|
|
134
|
+
const ct = blob.slice(IV_LEN);
|
|
135
|
+
let pkcs8: ArrayBuffer;
|
|
136
|
+
try {
|
|
137
|
+
pkcs8 = await subtle.decrypt({ name: "AES-GCM", iv: toAB(iv) }, kek, toAB(ct));
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error("cannot unwrap private key (wrong KEK or corrupt blob)");
|
|
140
|
+
}
|
|
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
|
|
143
|
+
// DEK returned by unwrapDEKWithPrivateKey (also extractable for re-granting). The key stays in-memory
|
|
144
|
+
// only, same trust boundary as the session's DEK.
|
|
145
|
+
return subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 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.
|
|
152
|
+
export async function importPrivateKeyPkcs8(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|
153
|
+
return subtle.importKey("pkcs8", toAB(pkcs8), EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function generateDEK(): Promise<CryptoKey> {
|
|
157
|
+
return subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ECDH-ES: wrap the DEK to a recipient public key via an ephemeral ECDH → AES-GCM key.
|
|
161
|
+
// (A256GCM over the raw DEK; equivalent to JWE ECDH-ES+A256GCM — simpler and less
|
|
162
|
+
// error-prone here than AES-KW, which the plan named illustratively.)
|
|
163
|
+
export async function wrapDEKForPublicKey(dek: CryptoKey, recipientPublicKeyJwk: JsonWebKey): Promise<DEKEnvelope> {
|
|
164
|
+
const recipientPub = await subtle.importKey("jwk", recipientPublicKeyJwk, EC_PARAMS, false, []);
|
|
165
|
+
const eph = await subtle.generateKey(EC_PARAMS, true, ["deriveKey"]);
|
|
166
|
+
const shared = await subtle.deriveKey(
|
|
167
|
+
{ name: "ECDH", public: recipientPub },
|
|
168
|
+
eph.privateKey,
|
|
169
|
+
{ name: "AES-GCM", length: 256 },
|
|
170
|
+
false,
|
|
171
|
+
["encrypt"],
|
|
172
|
+
);
|
|
173
|
+
const rawDek = new Uint8Array(await subtle.exportKey("raw", dek));
|
|
174
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
175
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: toAB(iv) }, shared, toAB(rawDek)));
|
|
176
|
+
const wrappedDEK = new Uint8Array(IV_LEN + ct.length);
|
|
177
|
+
wrappedDEK.set(iv, 0);
|
|
178
|
+
wrappedDEK.set(ct, IV_LEN);
|
|
179
|
+
return { wrappedDEK, ephemeralPublicKeyJwk: await subtle.exportKey("jwk", eph.publicKey) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function unwrapDEKWithPrivateKey(
|
|
183
|
+
wrappedDEK: Uint8Array,
|
|
184
|
+
ephemeralPublicKeyJwk: JsonWebKey,
|
|
185
|
+
privateKey: CryptoKey,
|
|
186
|
+
): Promise<CryptoKey> {
|
|
187
|
+
const ephPub = await subtle.importKey("jwk", ephemeralPublicKeyJwk, EC_PARAMS, false, []);
|
|
188
|
+
const shared = await subtle.deriveKey(
|
|
189
|
+
{ name: "ECDH", public: ephPub },
|
|
190
|
+
privateKey,
|
|
191
|
+
{ name: "AES-GCM", length: 256 },
|
|
192
|
+
false,
|
|
193
|
+
["decrypt"],
|
|
194
|
+
);
|
|
195
|
+
const iv = wrappedDEK.slice(0, IV_LEN);
|
|
196
|
+
const ct = wrappedDEK.slice(IV_LEN);
|
|
197
|
+
let raw: ArrayBuffer;
|
|
198
|
+
try {
|
|
199
|
+
raw = await subtle.decrypt({ name: "AES-GCM", iv: toAB(iv) }, shared, toAB(ct));
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("cannot unwrap DEK (wrong key or corrupt envelope)");
|
|
202
|
+
}
|
|
203
|
+
// extractable so an authorized holder can re-wrap it to grant another principal
|
|
204
|
+
return subtle.importKey("raw", raw, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// W73 — the DEK wrapped under a KEK, for a provider-issued recovery grant.
|
|
208
|
+
//
|
|
209
|
+
// The raw-key twin of wrapPrivateKey/unwrapPrivateKey above: same AES-GCM, same iv‖ct blob shape, same
|
|
210
|
+
// reasoning. It exists because a recovery grant hands the DEK to a one-time code rather than to a
|
|
211
|
+
// principal's public key — the ECDH envelope path needs a keypair, and a code read down a phone line
|
|
212
|
+
// is not one.
|
|
213
|
+
//
|
|
214
|
+
// The DEK is `extractable` by design (see unwrapDEKWithPrivateKey below), which is what makes
|
|
215
|
+
// exportKey("raw") here possible at all.
|
|
216
|
+
export async function wrapDEKWithKek(dek: CryptoKey, kek: CryptoKey): Promise<Uint8Array> {
|
|
217
|
+
const raw = new Uint8Array(await subtle.exportKey("raw", dek));
|
|
218
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
219
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: toAB(iv) }, kek, toAB(raw)));
|
|
220
|
+
const out = new Uint8Array(IV_LEN + ct.length);
|
|
221
|
+
out.set(iv, 0);
|
|
222
|
+
out.set(ct, IV_LEN);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function unwrapDEKWithKek(blob: Uint8Array, kek: CryptoKey): Promise<CryptoKey> {
|
|
227
|
+
const iv = blob.slice(0, IV_LEN);
|
|
228
|
+
const ct = blob.slice(IV_LEN);
|
|
229
|
+
let raw: ArrayBuffer;
|
|
230
|
+
try {
|
|
231
|
+
raw = await subtle.decrypt({ name: "AES-GCM", iv: toAB(iv) }, kek, toAB(ct));
|
|
232
|
+
} catch {
|
|
233
|
+
// AES-GCM's tag is what tells a holder of this blob that a guessed code was right, which is why the
|
|
234
|
+
// blob is deleted the moment its grant is consumed — see migrations/0009.
|
|
235
|
+
throw new Error("cannot unwrap DEK (wrong recovery code or corrupt grant)");
|
|
236
|
+
}
|
|
237
|
+
// Extractable for the same reason as every other DEK here: the recovering client immediately re-wraps
|
|
238
|
+
// it to the new account key it just generated.
|
|
239
|
+
return subtle.importKey("raw", raw, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// HD1 v2 blob: magic(3) + version=2(1) + vaultKeyId(16) + iv(12) + AES-GCM(DEK, JSON).
|
|
243
|
+
// Header = 32 B, equal to v1, so the Functions' MIN_BYTES=32 + magic checks stay valid.
|
|
244
|
+
export async function encryptVaultV2<T = Record<string, unknown>>(data: T, dek: CryptoKey): Promise<Uint8Array> {
|
|
245
|
+
const vaultKeyId = globalThis.crypto.getRandomValues(new Uint8Array(KEYID_LEN));
|
|
246
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
247
|
+
const plaintext = enc.encode(JSON.stringify(data));
|
|
248
|
+
const ciphertext = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: toAB(iv) }, dek, toAB(plaintext)));
|
|
249
|
+
const out = new Uint8Array(MAGIC.length + 1 + KEYID_LEN + IV_LEN + ciphertext.length);
|
|
250
|
+
let o = 0;
|
|
251
|
+
out.set(MAGIC, o); o += MAGIC.length;
|
|
252
|
+
out[o++] = VERSION_V2;
|
|
253
|
+
out.set(vaultKeyId, o); o += KEYID_LEN;
|
|
254
|
+
out.set(iv, o); o += IV_LEN;
|
|
255
|
+
out.set(ciphertext, o);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function decryptVaultV2<T = Record<string, unknown>>(blob: Uint8Array, dek: CryptoKey): Promise<T> {
|
|
260
|
+
const header = MAGIC.length + 1 + KEYID_LEN + IV_LEN;
|
|
261
|
+
if (blob.length < header) throw new Error("blob too short");
|
|
262
|
+
for (let i = 0; i < MAGIC.length; i++) if (blob[i] !== MAGIC[i]) throw new Error("not an HD1 blob");
|
|
263
|
+
let o = MAGIC.length;
|
|
264
|
+
const version = blob[o++];
|
|
265
|
+
if (version !== VERSION_V2) throw new Error(`expected HD1 v2, got version ${version}`);
|
|
266
|
+
o += KEYID_LEN; // vaultKeyId — reserved/opaque
|
|
267
|
+
const iv = blob.slice(o, o + IV_LEN); o += IV_LEN;
|
|
268
|
+
const ciphertext = blob.slice(o);
|
|
269
|
+
let plaintext: ArrayBuffer;
|
|
270
|
+
try {
|
|
271
|
+
plaintext = await subtle.decrypt({ name: "AES-GCM", iv: toAB(iv) }, dek, toAB(ciphertext));
|
|
272
|
+
} catch {
|
|
273
|
+
throw new Error("wrong DEK or corrupt blob");
|
|
274
|
+
}
|
|
275
|
+
return JSON.parse(dec.decode(plaintext)) as T;
|
|
276
|
+
}
|
package/env.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// vault-sink.ts's dev/prod switch reads import.meta.env.DEV, a Vite convention this package's
|
|
2
|
+
// consumers (bundler-based, per README.md) provide at build time. Declared locally rather than
|
|
3
|
+
// depending on the `vite` package just for its ambient types.
|
|
4
|
+
interface ImportMetaEnv {
|
|
5
|
+
readonly DEV: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface ImportMeta {
|
|
8
|
+
readonly env: ImportMetaEnv;
|
|
9
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Envelope, ProviderLink, VaultRow } from "./stores";
|
|
2
|
+
|
|
3
|
+
// Narrow, structural slices of EnvelopeStore/ProviderLinkStore — only what the policy below reads.
|
|
4
|
+
// Any conforming store satisfies these without change; an adopter with no "org recovery account"
|
|
5
|
+
// concept can call this with orgAccountId set to a value nothing will ever match, or skip it.
|
|
6
|
+
export interface EnvelopeAccessSource {
|
|
7
|
+
getEnvelopeRow(vaultId: string, principalAccountId: string): Promise<Envelope | null>;
|
|
8
|
+
getVault(vaultId: string): Promise<VaultRow | null>;
|
|
9
|
+
}
|
|
10
|
+
export interface ProviderLinkSource {
|
|
11
|
+
getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The envelope a principal may actually use, or null.
|
|
16
|
+
*
|
|
17
|
+
* This check has to live here, composed once, rather than duplicated per route — that is exactly
|
|
18
|
+
* how it was bypassed before: expiry was enforced lazily and only inside individual routes, so a
|
|
19
|
+
* grantee who never called the one endpoint that self-revokes a lapsed grant could keep reading PHI
|
|
20
|
+
* indefinitely. The grant's own expiry was real; nothing on the read path consulted it.
|
|
21
|
+
*
|
|
22
|
+
* The owner needs no link (they have no provider-link row about themselves), and neither does the
|
|
23
|
+
* org-recovery principal, whose envelope the owner can revoke outright — recorded on the vault, not
|
|
24
|
+
* as a link.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveEnvelopeAccess(
|
|
27
|
+
envelopes: EnvelopeAccessSource,
|
|
28
|
+
providers: ProviderLinkSource,
|
|
29
|
+
vaultId: string,
|
|
30
|
+
principalAccountId: string,
|
|
31
|
+
orgAccountId: string
|
|
32
|
+
): Promise<Envelope | null> {
|
|
33
|
+
const envelope = await envelopes.getEnvelopeRow(vaultId, principalAccountId);
|
|
34
|
+
if (!envelope) return null;
|
|
35
|
+
|
|
36
|
+
const vault = await envelopes.getVault(vaultId);
|
|
37
|
+
if (!vault) return null;
|
|
38
|
+
if (vault.ownerAccountId === principalAccountId) return envelope;
|
|
39
|
+
if (principalAccountId === orgAccountId) return vault.orgRecoveryRevokedAt ? null : envelope;
|
|
40
|
+
|
|
41
|
+
const link = await providers.getActive(vault.ownerAccountId, principalAccountId);
|
|
42
|
+
if (!link) return null;
|
|
43
|
+
return envelope;
|
|
44
|
+
}
|
package/kdf.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The passphrase→key derivation shared by every passphrase-encrypted blob in this monorepo.
|
|
2
|
+
//
|
|
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.
|
|
9
|
+
//
|
|
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.
|
|
15
|
+
//
|
|
16
|
+
// RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. One caller is
|
|
17
|
+
// Node-only (`node:crypto`'s webcrypto), the others run in the browser and in Workers, so the
|
|
18
|
+
// `SubtleCrypto` is passed in rather than reached for. That also makes the parameters testable
|
|
19
|
+
// without a runtime shim.
|
|
20
|
+
|
|
21
|
+
import { toArrayBuffer } from "./bytes";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* OWASP's floor for PBKDF2-SHA256 at the time this was set. Raising it is a compatibility decision,
|
|
25
|
+
* not a tuning knob: existing blobs carry no iteration count in their header, so both formats would
|
|
26
|
+
* have to grow a version byte that records it before this number can move.
|
|
27
|
+
*/
|
|
28
|
+
export const PBKDF2_ITERATIONS = 200_000;
|
|
29
|
+
export const SALT_LEN = 16;
|
|
30
|
+
/** AES-GCM standard nonce length. Not 16 — a non-96-bit IV is re-hashed by GHASH and buys nothing. */
|
|
31
|
+
export const IV_LEN = 12;
|
|
32
|
+
|
|
33
|
+
const enc = new TextEncoder();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* PBKDF2-SHA256 over `passphrase` and `salt` → a non-extractable AES-GCM-256 key.
|
|
37
|
+
*
|
|
38
|
+
* Non-extractable is the point: the caller gets something it can encrypt and decrypt with and cannot
|
|
39
|
+
* export, so a derived key cannot escape the module that derived it.
|
|
40
|
+
*/
|
|
41
|
+
export async function deriveAesKey(
|
|
42
|
+
subtle: SubtleCrypto,
|
|
43
|
+
passphrase: string,
|
|
44
|
+
salt: Uint8Array,
|
|
45
|
+
usages: KeyUsage[] = ["encrypt", "decrypt"],
|
|
46
|
+
): Promise<CryptoKey> {
|
|
47
|
+
const baseKey = await subtle.importKey("raw", toArrayBuffer(enc.encode(passphrase)), "PBKDF2", false, ["deriveKey"]);
|
|
48
|
+
return subtle.deriveKey(
|
|
49
|
+
{ name: "PBKDF2", salt: toArrayBuffer(salt), iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
|
|
50
|
+
baseKey,
|
|
51
|
+
{ name: "AES-GCM", length: 256 },
|
|
52
|
+
false,
|
|
53
|
+
usages,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key — health-dash's
|
|
59
|
+
* server-verifiable auth hash. Separate entry point rather than a flag on `deriveAesKey`, because a
|
|
60
|
+
* function that sometimes returns exportable bytes and sometimes an unexportable key is exactly the
|
|
61
|
+
* kind of thing a reviewer has to read twice.
|
|
62
|
+
*/
|
|
63
|
+
export async function deriveBits(
|
|
64
|
+
subtle: SubtleCrypto,
|
|
65
|
+
passphrase: string,
|
|
66
|
+
salt: Uint8Array,
|
|
67
|
+
length = 256,
|
|
68
|
+
): Promise<Uint8Array> {
|
|
69
|
+
const baseKey = await subtle.importKey("raw", toArrayBuffer(enc.encode(passphrase)), "PBKDF2", false, ["deriveBits"]);
|
|
70
|
+
return new Uint8Array(
|
|
71
|
+
await subtle.deriveBits({ name: "PBKDF2", salt: toArrayBuffer(salt), iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, baseKey, length),
|
|
72
|
+
);
|
|
73
|
+
}
|
package/key-store.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// W49 — persist the account private key across a browser refresh so a valid hd_session doesn't force
|
|
2
|
+
// re-auth. The key is stored as a NON-EXTRACTABLE CryptoKey (structured-cloned into IndexedDB): it can
|
|
3
|
+
// still unwrap the vault DEK (ECDH deriveKey) on the next load, but its raw bytes can't be read back
|
|
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; Google re-bootstraps from server-custody material.
|
|
6
|
+
|
|
7
|
+
const DB_NAME = "hd-session";
|
|
8
|
+
const STORE = "keys";
|
|
9
|
+
const KEY_ID = "account-private-key";
|
|
10
|
+
const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" } as const;
|
|
11
|
+
|
|
12
|
+
function openDb(): Promise<IDBDatabase> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
15
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
16
|
+
req.onsuccess = () => resolve(req.result);
|
|
17
|
+
req.onerror = () => reject(req.error);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Store a non-extractable copy of the (currently extractable, in-memory) account private key. Silent
|
|
22
|
+
// no-op if IndexedDB is unavailable (private-browsing / disabled) — persistence is best-effort; the
|
|
23
|
+
// session still works for the current page, the user just re-auths on the next refresh.
|
|
24
|
+
export async function putAccountKey(extractableKey: CryptoKey): Promise<void> {
|
|
25
|
+
const subtle = (globalThis.crypto as Crypto).subtle;
|
|
26
|
+
const pkcs8 = await subtle.exportKey("pkcs8", extractableKey);
|
|
27
|
+
const nonExtractable = await subtle.importKey("pkcs8", pkcs8, EC_PARAMS, false, ["deriveKey", "deriveBits"]);
|
|
28
|
+
const db = await openDb();
|
|
29
|
+
try {
|
|
30
|
+
await new Promise<void>((resolve, reject) => {
|
|
31
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
32
|
+
tx.objectStore(STORE).put(nonExtractable, KEY_ID);
|
|
33
|
+
tx.oncomplete = () => resolve();
|
|
34
|
+
tx.onerror = () => reject(tx.error);
|
|
35
|
+
});
|
|
36
|
+
} finally {
|
|
37
|
+
db.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function getAccountKey(): Promise<CryptoKey | null> {
|
|
42
|
+
const db = await openDb();
|
|
43
|
+
try {
|
|
44
|
+
return await new Promise<CryptoKey | null>((resolve, reject) => {
|
|
45
|
+
const tx = db.transaction(STORE, "readonly");
|
|
46
|
+
const req = tx.objectStore(STORE).get(KEY_ID);
|
|
47
|
+
req.onsuccess = () => resolve((req.result as CryptoKey | undefined) ?? null);
|
|
48
|
+
req.onerror = () => reject(req.error);
|
|
49
|
+
});
|
|
50
|
+
} finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function clearAccountKey(): Promise<void> {
|
|
56
|
+
const db = await openDb();
|
|
57
|
+
try {
|
|
58
|
+
await new Promise<void>((resolve, reject) => {
|
|
59
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
60
|
+
tx.objectStore(STORE).delete(KEY_ID);
|
|
61
|
+
tx.oncomplete = () => resolve();
|
|
62
|
+
tx.onerror = () => reject(tx.error);
|
|
63
|
+
});
|
|
64
|
+
} finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|