@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/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tinytars/vault",
3
+ "version": "0.1.4",
4
+ "description": "Runtime-agnostic key derivation, authenticated envelope encryption, and storage-agnostic access-control contracts for per-user encrypted data.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/tinytars/vault"
10
+ },
11
+ "homepage": "https://github.com/tinytars/vault",
12
+ "bugs": "https://github.com/tinytars/vault/issues",
13
+ "keywords": [
14
+ "encryption",
15
+ "webcrypto",
16
+ "envelope-encryption",
17
+ "access-control",
18
+ "key-derivation"
19
+ ],
20
+ "engines": {
21
+ "node": ">=19"
22
+ },
23
+ "files": [
24
+ "*.ts",
25
+ "adapters/**/*.ts",
26
+ "!tests"
27
+ ],
28
+ "exports": {
29
+ "./crypto": "./crypto.ts",
30
+ "./kdf": "./kdf.ts",
31
+ "./bytes": "./bytes.ts",
32
+ "./key-store": "./key-store.ts",
33
+ "./vault-sink": "./vault-sink.ts",
34
+ "./stores": "./stores.ts",
35
+ "./envelope-access": "./envelope-access.ts",
36
+ "./break-glass": "./break-glass.ts",
37
+ "./blob-store": "./blob-store.ts",
38
+ "./adapters/d1": "./adapters/d1/index.ts",
39
+ "./adapters/r2": "./adapters/r2.ts",
40
+ "./adapters/memory": "./adapters/memory.ts",
41
+ "./adapters/pages-http": "./adapters/pages-http.ts",
42
+ "./adapters/conformance": "./adapters/conformance.ts"
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "typecheck": "tsc --noEmit"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^4.1.8",
50
+ "typescript": "~6.0.2"
51
+ }
52
+ }
package/stores.ts ADDED
@@ -0,0 +1,190 @@
1
+ // Storage-agnostic contracts for the identity/vault/access-control data this package's crypto
2
+ // operates over. No Cloudflare or D1 dependency here by design (see README.md) — an adopter wires
3
+ // these to whatever database they run; health-dash-web's D1 adapter lives at
4
+ // apps/health-dash-web/functions/_lib/d1-identity-store.ts and is one implementation of these,
5
+ // not the definition of them.
6
+ //
7
+ // health-dash-web's own functions/_lib/identity-*.ts modules re-export the types below rather than
8
+ // redeclaring them, so there is exactly one definition of each shape.
9
+
10
+ export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
11
+ export type AuthMethod = "passkey" | "google" | "password" | "recovery";
12
+ export type ProviderKind = "clinician" | "support";
13
+ export type LinkStatus = "invited" | "active" | "revoked";
14
+ export type UnitSystem = "metric" | "imperial";
15
+
16
+ export interface Account {
17
+ id: string;
18
+ email: string | null;
19
+ emailConfirmed: boolean;
20
+ displayName: string;
21
+ lifecycleStage: LifecycleStage;
22
+ providerKind: ProviderKind | null;
23
+ unitSystem: UnitSystem | null;
24
+ createdAt: string;
25
+ /** Set when the account was erased; every personal field is null from that moment. */
26
+ deletedAt?: string | null;
27
+ /** When the email address was last changed. Null means never. */
28
+ emailChangedAt?: string | null;
29
+ }
30
+
31
+ export interface Identity {
32
+ id: string;
33
+ accountId: string;
34
+ method: AuthMethod;
35
+ providerSubject: string | null;
36
+ credentialId: string | null;
37
+ createdAt: string;
38
+ }
39
+ export interface Credential {
40
+ accountId: string;
41
+ method: AuthMethod;
42
+ wrappedPrivateKey: Uint8Array;
43
+ kdfParams: unknown;
44
+ createdAt: string;
45
+ }
46
+ export interface PublicKey {
47
+ accountId: string;
48
+ publicKeyJwk: unknown;
49
+ createdAt: string;
50
+ }
51
+
52
+ export interface VaultRow {
53
+ vaultId: string;
54
+ ownerAccountId: string;
55
+ r2Key: string;
56
+ hd1Version: number;
57
+ /** Set on support-grant expiry; the owner's next login re-keys and clears it. */
58
+ rotationPending: boolean;
59
+ /** Set when the owner revokes the org-recovery envelope. */
60
+ orgRecoveryRevokedAt: string | null;
61
+ /** The object an in-flight re-key is writing to, before the pointer swap. */
62
+ rotationStagingR2Key: string | null;
63
+ }
64
+ export interface Envelope {
65
+ vaultId: string;
66
+ principalAccountId: string;
67
+ wrappedDek: Uint8Array;
68
+ ephemeralPublicKeyJwk: unknown;
69
+ createdBy: string;
70
+ createdAt: string;
71
+ }
72
+ export interface EnvelopeInput {
73
+ principalAccountId: string;
74
+ wrappedDek: Uint8Array;
75
+ ephemeralPublicKeyJwk: unknown;
76
+ }
77
+
78
+ export interface ProviderLink {
79
+ id: string;
80
+ patientAccountId: string;
81
+ providerAccountId: string;
82
+ role: ProviderKind;
83
+ status: LinkStatus;
84
+ consentRef: string | null;
85
+ grantedBy: string;
86
+ grantedAt: string;
87
+ /** Set on time-boxed support grants; null for clinician links. */
88
+ expiresAt: string | null;
89
+ }
90
+
91
+ /** A PHI-access audit-log entry — who touched whose vault, and why. */
92
+ export interface AccessEvent {
93
+ id: string;
94
+ actorAccountId: string;
95
+ subjectAccountId: string;
96
+ vaultId: string | null;
97
+ action: string;
98
+ consentRef: string | null;
99
+ meta: unknown;
100
+ createdAt: string;
101
+ }
102
+
103
+ export interface AccountStore {
104
+ create(a: {
105
+ id: string;
106
+ displayName: string;
107
+ email?: string | null;
108
+ lifecycleStage?: LifecycleStage;
109
+ providerKind?: ProviderKind | null;
110
+ }): Promise<Account>;
111
+ get(id: string): Promise<Account | null>;
112
+ getByEmail(email: string): Promise<Account | null>;
113
+ sessionsValidFrom(accountId: string): Promise<number | null>;
114
+ revokeSessions(accountId: string): Promise<void>;
115
+ setEmailConfirmed(id: string, confirmed: boolean): Promise<void>;
116
+ setLifecycleStage(id: string, stage: LifecycleStage): Promise<void>;
117
+ updateProfile(id: string, updates: { email?: string | null; displayName?: string; unitSystem?: UnitSystem | null }): Promise<void>;
118
+ tombstone(accountId: string, at: string): Promise<void>;
119
+ markEmailChanged(accountId: string, at: string): Promise<void>;
120
+ }
121
+
122
+ /** Auth-method linkage (`Identity`) plus the key material it points at (`Credential`, `PublicKey`). */
123
+ export interface CredentialStore {
124
+ addIdentity(i: { accountId: string; method: AuthMethod; providerSubject?: string | null; credentialId?: string | null; id?: string }): Promise<Identity>;
125
+ getIdentityByProviderSubject(method: AuthMethod, subject: string): Promise<Identity | null>;
126
+ getIdentityByCredentialId(credentialId: string): Promise<Identity | null>;
127
+ listIdentities(accountId: string): Promise<Identity[]>;
128
+ deleteIdentity(accountId: string, method: AuthMethod): Promise<void>;
129
+ putCredential(c: { accountId: string; method: AuthMethod; wrappedPrivateKey: Uint8Array; kdfParams: unknown }): Promise<void>;
130
+ getCredential(accountId: string, method: AuthMethod): Promise<Credential | null>;
131
+ listCredentials(accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]>;
132
+ deleteCredential(accountId: string, method: AuthMethod): Promise<void>;
133
+ updatePasskeyCounter(accountId: string, counter: number): Promise<void>;
134
+ putPublicKey(p: { accountId: string; publicKeyJwk: unknown }): Promise<void>;
135
+ getPublicKey(accountId: string): Promise<PublicKey | null>;
136
+ }
137
+
138
+ /**
139
+ * Vault + envelope storage only — no access policy. `getEnvelopeRow` answers "does a row exist",
140
+ * not "may this principal open the vault"; see `resolveEnvelopeAccess` in ./envelope-access for
141
+ * the policy that composes this with `ProviderLinkStore`.
142
+ */
143
+ export interface EnvelopeStore {
144
+ createVault(v: { vaultId: string; ownerAccountId: string; r2Key: string; hd1Version: number }): Promise<VaultRow>;
145
+ getVault(vaultId: string): Promise<VaultRow | null>;
146
+ getVaultByR2Key(r2Key: string): Promise<VaultRow | null>;
147
+ getVaultByStagingR2Key(r2Key: string): Promise<VaultRow | null>;
148
+ listVaultsForOwner(ownerAccountId: string): Promise<VaultRow[]>;
149
+ setRotationPending(vaultId: string, pending: boolean): Promise<void>;
150
+ setRotationStaging(vaultId: string, r2Key: string | null): Promise<void>;
151
+ setOrgRecoveryRevoked(vaultId: string, at: string | null): Promise<void>;
152
+ putEnvelope(e: { vaultId: string; principalAccountId: string; wrappedDek: Uint8Array; ephemeralPublicKeyJwk: unknown; createdBy: string }): Promise<void>;
153
+ /** Atomically swaps a vault's whole envelope set. */
154
+ replaceEnvelopes(vaultId: string, envelopes: EnvelopeInput[], createdBy: string): Promise<void>;
155
+ /** Atomically swaps the envelope set AND repoints the vault at a freshly-written object. */
156
+ commitRotation(vaultId: string, newR2Key: string, envelopes: EnvelopeInput[], createdBy: string): Promise<void>;
157
+ getEnvelopeRow(vaultId: string, principalAccountId: string): Promise<Envelope | null>;
158
+ listEnvelopesForVault(vaultId: string): Promise<Envelope[]>;
159
+ listEnvelopesForPrincipal(principalAccountId: string): Promise<Envelope[]>;
160
+ deleteEnvelope(vaultId: string, principalAccountId: string): Promise<void>;
161
+ }
162
+
163
+ export interface ProviderLinkStore {
164
+ create(l: {
165
+ patientAccountId: string;
166
+ providerAccountId: string;
167
+ role: ProviderKind;
168
+ status?: LinkStatus;
169
+ consentRef?: string | null;
170
+ grantedBy: string;
171
+ expiresAt?: string | null;
172
+ id?: string;
173
+ }): Promise<ProviderLink>;
174
+ updateStatus(id: string, status: LinkStatus): Promise<void>;
175
+ grantSupport(id: string, opts: { expiresAt: string | null; consentRef?: string | null }): Promise<void>;
176
+ get(id: string): Promise<ProviderLink | null>;
177
+ listForPatient(patientAccountId: string): Promise<ProviderLink[]>;
178
+ listForProvider(providerAccountId: string): Promise<ProviderLink[]>;
179
+ /** The active, unexpired link between this patient and provider, or null. */
180
+ getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
181
+ }
182
+
183
+ /**
184
+ * The PHI-access half of an adopter's audit trail only. Lifecycle/CRM events and raw-object
185
+ * ownership bookkeeping are app-specific concerns that don't belong in a portable security package.
186
+ */
187
+ export interface AuditStore {
188
+ insertAccessEvent(e: { actorAccountId: string; subjectAccountId: string; vaultId?: string | null; action: string; consentRef?: string | null; meta?: unknown; id?: string }): Promise<AccessEvent>;
189
+ listAccessEventsForSubject(subjectAccountId: string): Promise<AccessEvent[]>;
190
+ }
package/vault-sink.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { encryptVaultV2 } from "./crypto";
2
+
3
+ // A VaultSink persists an already-encrypted vault blob under a vault id. The
4
+ // encryption boundary stays in the browser (saveVaultV2 encrypts); a sink only
5
+ // stores opaque ciphertext, never plaintext or a key.
6
+ export interface VaultSink {
7
+ put(id: string, blob: Uint8Array): Promise<void>;
8
+ }
9
+
10
+ /**
11
+ * W70 — the version of each vault blob this browser context last saw.
12
+ *
13
+ * Deliberately held HERE rather than threaded through callers. `saveVaultV2` has SEVEN call sites in
14
+ * App.svelte (the queued edit path plus key rotation, leaf-regen persist, Finding refresh, report
15
+ * import, raw upload and onboarding), and only one of them goes through the save queue. Since the
16
+ * precondition is now REQUIRED on the browser path, a call site that forgot to pass an etag would not
17
+ * degrade — it would 428, i.e. a patient unable to save their own record. Keeping the token where the
18
+ * request is built makes every path correct by construction instead of by remembering.
19
+ *
20
+ * One entry per vault id, which is exactly the domain: a browser context has one current view of a
21
+ * given blob.
22
+ */
23
+ const etags = new Map<string, string>();
24
+
25
+ /** Record the version seen on a GET (or cleared, when a vault is closed). */
26
+ export function rememberVaultEtag(id: string, etag: string | null): void {
27
+ if (etag) etags.set(id, etag);
28
+ else etags.delete(id);
29
+ }
30
+
31
+ export function knownVaultEtag(id: string): string | null {
32
+ return etags.get(id) ?? null;
33
+ }
34
+
35
+ /**
36
+ * The save was refused because the blob moved since this context read it.
37
+ *
38
+ * Typed, not a string match: the caller must be able to tell "someone else edited this record" from
39
+ * "the network is down", because the honest response to each is completely different.
40
+ */
41
+ export class VaultConflictError extends Error {
42
+ constructor(readonly serverEtag: string | null) {
43
+ super("This record was changed in another tab or on another device.");
44
+ this.name = "VaultConflictError";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * One hook, so every save path reports a conflict — not just the queued one.
50
+ *
51
+ * Six of `saveVaultV2`'s seven call sites are direct `await`s outside the save queue (key rotation,
52
+ * leaf-regen persist, Finding refresh, report import, raw upload, onboarding). Wiring the conflict
53
+ * state through each would be six chances to miss one, and a missed one is an unhandled rejection on a
54
+ * health record. Every save funnels through this sink, so this is the one place that sees them all.
55
+ * The error still throws afterwards, so existing per-path error handling is unchanged.
56
+ */
57
+ let onConflict: ((e: VaultConflictError) => void) | null = null;
58
+ export function setVaultConflictHandler(fn: ((e: VaultConflictError) => void) | null): void {
59
+ onConflict = fn;
60
+ }
61
+
62
+ // Dev-only sink: POSTs the blob to the Vite dev-server middleware, which writes
63
+ // public/data-{id}.enc to disk (see vite.config.ts). Absent from the deployed
64
+ // build — there is no such endpoint in production. The R2 sink (W6) is the
65
+ // second implementation of this interface for remote/mobile save.
66
+ export const localSink: VaultSink = {
67
+ async put(id, blob) {
68
+ const res = await fetch(`/__save-vault?id=${encodeURIComponent(id)}`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/octet-stream" },
71
+ body: blob as BodyInit,
72
+ });
73
+ if (!res.ok) {
74
+ throw new Error(`save failed (${res.status}): ${(await res.text()) || res.statusText}`);
75
+ }
76
+ },
77
+ };
78
+
79
+ // Remote sink: PUTs the encrypted blob to the R2-backed Pages Function (W6, re-gated W44).
80
+ // `/api/vault/{id}` only exists in the deployed build; the guard now accepts the hd_session
81
+ // cookie (owner/granted-provider envelope check) — same-origin fetch sends it automatically.
82
+ // The Function stores opaque ciphertext — same as localSink, never plaintext or a key.
83
+ /**
84
+ * One write at a time per vault, so the app never conflicts with ITSELF.
85
+ *
86
+ * `vaultSave` serializes the queued edit path, but six of `saveVaultV2`'s seven call sites bypass it
87
+ * and `await` directly — so a user edit and a leaf-regen persist could be in flight together. Each
88
+ * reads the version token when it builds its request, so the second would send one the first had
89
+ * already superseded, and the guard would correctly report a conflict against a tab that is only
90
+ * racing itself. CI found exactly that: five specs that save and then trigger a regen went red with
91
+ * the conflict panel intercepting pointer events.
92
+ *
93
+ * Serializing HERE rather than in vaultSave is deliberate: this is the only place every write passes
94
+ * through, and the version token lives here too — the token must be read after the previous write has
95
+ * settled, which is precisely what a chain guarantees.
96
+ */
97
+ const writeChains = new Map<string, Promise<void>>();
98
+
99
+ export const r2Sink: VaultSink = {
100
+ put(id, blob) {
101
+ const prev = writeChains.get(id) ?? Promise.resolve();
102
+ // The `.catch` is on the READ, not the store: one failed write must not poison every later one,
103
+ // while the failure still reaches its own caller through the promise returned below. Catching on
104
+ // both sides would be redundant — and a redundant guard is one no test can distinguish, which is
105
+ // how it was caught here.
106
+ const next = prev.catch(() => {}).then(() => putConditional(id, blob));
107
+ writeChains.set(id, next);
108
+ return next;
109
+ },
110
+ };
111
+
112
+ async function putConditional(id: string, blob: Uint8Array): Promise<void> {
113
+ {
114
+ const known = etags.get(id);
115
+ const headers: Record<string, string> = { "Content-Type": "application/octet-stream" };
116
+ // Either "replace exactly this version" or "create, and refuse if one already exists". Never
117
+ // neither: the Function 428s an unconditional browser write, by design.
118
+ if (known) headers["If-Match"] = known;
119
+ else headers["If-None-Match"] = "*";
120
+
121
+ const res = await fetch(`/api/vault/${encodeURIComponent(id)}`, { method: "PUT", headers, body: blob as BodyInit });
122
+
123
+ if (res.status === 412) {
124
+ // Adopt nothing yet — the caller decides. The server hands back the current version so a
125
+ // resolution costs one round trip rather than two.
126
+ const conflict = new VaultConflictError(res.headers.get("ETag"));
127
+ onConflict?.(conflict);
128
+ throw conflict;
129
+ }
130
+ if (!res.ok) {
131
+ throw new Error(`save failed (${res.status}): ${(await res.text()) || res.statusText}`);
132
+ }
133
+ const newEtag = res.headers.get("ETag");
134
+ if (newEtag) etags.set(id, newEtag);
135
+ }
136
+ }
137
+
138
+ // Build-time selection: dev (vite) writes to disk via localSink; the deployed bundle (and the
139
+ // wrangler-pages-dev e2e harness) persists to R2 and never references /__save-vault.
140
+ export const vaultSink: VaultSink = import.meta.env.DEV ? localSink : r2Sink;
141
+
142
+ // W44 — encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's
143
+ // random data key, unwrapped at login from the caller's key envelope; it stays in memory.
144
+ export async function saveVaultV2<T = Record<string, unknown>>(vault: T, id: string, dek: CryptoKey, sink: VaultSink): Promise<void> {
145
+ const blob = await encryptVaultV2(vault, dek);
146
+ await sink.put(id, blob);
147
+ }