@tinytars/vault 0.1.17 → 0.1.19
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 +69 -39
- package/adapters/conformance.ts +9 -9
- package/adapters/d1/audit.ts +8 -5
- package/adapters/d1/index.ts +5 -5
- package/adapters/d1/providers.ts +15 -11
- package/adapters/memory.ts +6 -6
- package/auth-client.ts +4 -3
- package/auth-grants.ts +3 -2
- package/auth-recovery.ts +6 -6
- package/auth-support.ts +22 -22
- package/break-glass.ts +4 -4
- package/envelope-access.ts +1 -1
- package/package.json +1 -1
- package/stores.ts +11 -10
- package/vault-session.ts +5 -5
package/README.md
CHANGED
|
@@ -7,46 +7,76 @@ A storage operator who can read what it stores hasn't encrypted the data — it'
|
|
|
7
7
|
and most hand-rolled "encrypted vault" designs end up exactly there, one convenience shortcut at a
|
|
8
8
|
time. This package is the alternative already built: a shared key-derivation primitive, an
|
|
9
9
|
authenticated envelope format with multi-principal access via wrapped data-encryption keys, and
|
|
10
|
-
storage-agnostic contracts for the account/vault/grant data model those envelopes sit on top of
|
|
10
|
+
storage-agnostic contracts for the account/vault/grant data model those envelopes sit on top of —
|
|
11
|
+
domain-neutral by design, not health-specific code that happened to get open-sourced.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
revocable provider access; published because the primitives don't have anything health-specific
|
|
14
|
-
in them. Maintained by the [Tiny Tars Foundation](https://tinytars.foundation), a 501(c)(3).
|
|
13
|
+
Maintained by the [Tiny Tars Foundation](https://tinytars.foundation), a 501(c)(3).
|
|
15
14
|
|
|
16
15
|
## Why
|
|
17
16
|
|
|
18
|
-
###
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`break-glass.ts`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
17
|
+
### The HIPAA-adjacent deployment's vault
|
|
18
|
+
|
|
19
|
+
This package was extracted from a health-records app built against HIPAA-adjacent constraints,
|
|
20
|
+
and that origin is worth stating plainly: it's a real, fully worked use case, not a footnote.
|
|
21
|
+
The fit is exact: the vault **owner** is the person whose record it is; a **provider** link is a
|
|
22
|
+
treating professional's standing access to that record; a **support** link is a colleague's
|
|
23
|
+
time-boxed access, handled by `break-glass.ts`'s grant/check/revoke lifecycle so a temporary
|
|
24
|
+
exception doesn't quietly become a permanent one.
|
|
25
|
+
|
|
26
|
+
And the fit isn't just structural — four of the package's primitives map onto four things a
|
|
27
|
+
HIPAA-adjacent deployment specifically needs:
|
|
28
|
+
|
|
29
|
+
#### Satisfies the FTC Health Breach Notification Rule
|
|
30
|
+
|
|
31
|
+
The audit log in `adapters/d1/audit.ts` is shaped to satisfy the Rule's disclosure-logging
|
|
32
|
+
requirement directly — who accessed whose record and why, so a breach can be scoped to affected
|
|
33
|
+
individuals.
|
|
34
|
+
|
|
35
|
+
#### Zero-knowledge storage
|
|
36
|
+
|
|
37
|
+
`crypto.ts` gives that app a zero-knowledge vault — the storage operator holds ciphertext and
|
|
38
|
+
never a key or usable plaintext, which matters when the payload is a medical record.
|
|
39
|
+
|
|
40
|
+
#### Consent-based sharing
|
|
41
|
+
|
|
42
|
+
`envelope-access.ts` gives it consent-based sharing — access is a per-principal, revocable
|
|
43
|
+
wrapped-key grant, not a shared secret or a role flag, which is what "the record's owner controls
|
|
44
|
+
who sees it" actually requires at the implementation level.
|
|
45
|
+
|
|
46
|
+
#### Time-boxed emergency access
|
|
47
|
+
|
|
48
|
+
`break-glass.ts` gives it the piece hand-rolled HIPAA-adjacent systems get wrong most often: a
|
|
49
|
+
time-boxed grant with the TTL clamp, self-expiry, and audit trail built in.
|
|
50
|
+
|
|
51
|
+
None of this makes the package itself HIPAA-compliant — it's a primitive an adopter builds
|
|
52
|
+
compliant handling on top of, not a compliance product in its own right — but the shape it ships
|
|
53
|
+
is exactly the shape that adopter needs, not something assembled from unrelated parts after the
|
|
54
|
+
fact.
|
|
55
|
+
|
|
56
|
+
### Beyond health
|
|
57
|
+
|
|
58
|
+
The same three primitives — an encrypted resource only its owner can read by default, a revocable
|
|
59
|
+
per-principal grant, and a time-boxed version of that grant — solve an identical problem anywhere
|
|
60
|
+
one party owns sensitive data and needs to hand a second party temporary or standing access to it,
|
|
61
|
+
under a record of who saw what and when. None of `stores.ts`'s contracts, `crypto.ts`'s envelope
|
|
62
|
+
format, or `break-glass.ts`'s lifecycle reference anything about the payload's shape or domain.
|
|
63
|
+
Concretely, beyond the healthcare case above:
|
|
64
|
+
|
|
65
|
+
- **Outside counsel** reviewing a client's encrypted case files, access granted for the matter's
|
|
66
|
+
duration and revoked when it closes.
|
|
67
|
+
- **An accountant** getting temporary access to a household's financial records at tax time,
|
|
68
|
+
through the same time-boxed grant `break-glass.ts` gives a treating professional.
|
|
69
|
+
- **A corporate IT admin** granted standing access to an employee's HR file for the audit trail
|
|
70
|
+
it creates, or temporary access during an offboarding review.
|
|
71
|
+
- **A SaaS support agent** getting time-boxed access to a customer's account data to debug a
|
|
72
|
+
ticket, auto-expiring instead of depending on someone remembering to revoke it.
|
|
43
73
|
|
|
44
74
|
Claims like these are only worth as much as the threat model backing them. **Read
|
|
45
75
|
`THREAT_MODEL.md` before you adopt this** — it documents what's in scope, what isn't, and two
|
|
46
76
|
deliberate design tradeoffs (extractable keys, no forward secrecy on revoke) that look like bugs
|
|
47
|
-
if you haven't read them first.
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
if you haven't read them first. It's written in the same domain-neutral vocabulary as the rest of
|
|
78
|
+
this package — no health-specific language anywhere in it. That's not an oversight; it's the same
|
|
79
|
+
evidence the argument above rests on, stated a second way.
|
|
50
80
|
|
|
51
81
|
## What it does
|
|
52
82
|
|
|
@@ -170,7 +200,7 @@ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
|
|
|
170
200
|
|
|
171
201
|
// Encrypt a payload (any JSON-serializable value) under a fresh, random DEK.
|
|
172
202
|
const dek = await generateDEK();
|
|
173
|
-
const envelope = await encryptVaultV2({ note: "
|
|
203
|
+
const envelope = await encryptVaultV2({ note: "owner-controlled data" }, dek);
|
|
174
204
|
|
|
175
205
|
// Grant this account access by wrapping the DEK for its public key.
|
|
176
206
|
const wrapped = await wrapDEKForPublicKey(dek, publicKeyJwk);
|
|
@@ -179,7 +209,7 @@ const wrapped = await wrapDEKForPublicKey(dek, publicKeyJwk);
|
|
|
179
209
|
const recoveredDek = await unwrapDEKWithPrivateKey(wrapped.wrappedDEK, wrapped.ephemeralPublicKeyJwk, privateKey);
|
|
180
210
|
const plaintext = await decryptVaultV2(envelope, recoveredDek);
|
|
181
211
|
|
|
182
|
-
console.log(plaintext); // { note: "
|
|
212
|
+
console.log(plaintext); // { note: "owner-controlled data" }
|
|
183
213
|
```
|
|
184
214
|
|
|
185
215
|
This package ships TypeScript source directly (no compiled `dist/`) — the subpath imports above
|
|
@@ -203,7 +233,7 @@ import type { Envelope, ProviderLink, VaultRow } from "@tinytars/vault/stores";
|
|
|
203
233
|
|
|
204
234
|
const envelopes = new Map<string, Envelope>(); // key: `${vaultId}:${principalAccountId}`
|
|
205
235
|
const vaults = new Map<string, VaultRow>();
|
|
206
|
-
const providerLinks = new Map<string, ProviderLink>(); // key: `${
|
|
236
|
+
const providerLinks = new Map<string, ProviderLink>(); // key: `${ownerAccountId}:${providerAccountId}`
|
|
207
237
|
|
|
208
238
|
const envelopeSource: EnvelopeAccessSource = {
|
|
209
239
|
async getEnvelopeRow(vaultId, principalAccountId) {
|
|
@@ -215,8 +245,8 @@ const envelopeSource: EnvelopeAccessSource = {
|
|
|
215
245
|
};
|
|
216
246
|
|
|
217
247
|
const providerLinkSource: ProviderLinkSource = {
|
|
218
|
-
async getActive(
|
|
219
|
-
return providerLinks.get(`${
|
|
248
|
+
async getActive(ownerAccountId, providerAccountId) {
|
|
249
|
+
return providerLinks.get(`${ownerAccountId}:${providerAccountId}`) ?? null;
|
|
220
250
|
},
|
|
221
251
|
};
|
|
222
252
|
|
|
@@ -267,8 +297,8 @@ is entirely your own auth middleware's job. See `THREAT_MODEL.md`'s "trust bound
|
|
|
267
297
|
| `adapters/conformance.ts` | Shared vitest contract suites for each `stores.ts` interface, run against every adapter above so "storage-agnostic" is proven, not asserted |
|
|
268
298
|
| `auth-client.ts` | Password/passkey/Google signup, login, session resume, account-settings method management — the browser-side orchestration wiring `crypto.ts` to a specific `/api/auth/*`/`/api/account/*` API. Reference client, not a portable primitive |
|
|
269
299
|
| `auth-recovery.ts` | Recovery-code issuance/redemption (owner and provider-issued), DEK rotation, access-event log fetch — same reference-client caveat as `auth-client.ts` |
|
|
270
|
-
| `auth-support.ts` | Audited support-agent access:
|
|
271
|
-
| `auth-grants.ts` | Provider
|
|
300
|
+
| `auth-support.ts` | Audited support-agent access: owner approves a pending request, support enters via an audited endpoint; support→provider roster access |
|
|
301
|
+
| `auth-grants.ts` | Provider grant CRUD from the owner side: lookup, grant, revoke |
|
|
272
302
|
| `org-recovery.ts` | Backfills the org-recovery envelope for accounts that predate or missed it at signup — best-effort, never blocks an unlock |
|
|
273
303
|
| `vault-session.ts` | `VaultEntry`/`VaultSession` types plus `openVault()` — the decrypt-and-open-session step every unlock path shares |
|
|
274
304
|
| `base64.ts` | Byte ↔ base64 codec used throughout the client layer |
|
package/adapters/conformance.ts
CHANGED
|
@@ -164,14 +164,14 @@ export function runProviderLinkStoreConformance(label: string, factory: () => Pr
|
|
|
164
164
|
describe(`ProviderLinkStore conformance (${label})`, () => {
|
|
165
165
|
it("creates a link, defaulting status to invited", async () => {
|
|
166
166
|
const store = await factory();
|
|
167
|
-
const link = await store.create({
|
|
167
|
+
const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "primary", grantedBy: "p1" });
|
|
168
168
|
expect(link.status).toBe("invited");
|
|
169
169
|
expect(await store.get(link.id)).toEqual(link);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
172
|
it("getActive is null for an invited (non-active) link", async () => {
|
|
173
173
|
const store = await factory();
|
|
174
|
-
const link = await store.create({
|
|
174
|
+
const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "primary", grantedBy: "p1" });
|
|
175
175
|
expect(await store.getActive("p1", "d1")).toBeNull();
|
|
176
176
|
await store.updateStatus(link.id, "active");
|
|
177
177
|
expect((await store.getActive("p1", "d1"))?.id).toBe(link.id);
|
|
@@ -179,14 +179,14 @@ export function runProviderLinkStoreConformance(label: string, factory: () => Pr
|
|
|
179
179
|
|
|
180
180
|
it("getActive is null once expiresAt is in the past", async () => {
|
|
181
181
|
const store = await factory();
|
|
182
|
-
const link = await store.create({
|
|
182
|
+
const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1", status: "active", expiresAt: "2000-01-01T00:00:00.000Z" });
|
|
183
183
|
expect(link.status).toBe("active");
|
|
184
184
|
expect(await store.getActive("p1", "d1")).toBeNull();
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
it("grantSupport activates and time-boxes a link", async () => {
|
|
188
188
|
const store = await factory();
|
|
189
|
-
const link = await store.create({
|
|
189
|
+
const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1" });
|
|
190
190
|
const expiresAt = new Date(Date.now() + 3600_000).toISOString();
|
|
191
191
|
await store.grantSupport(link.id, { expiresAt, consentRef: "consent-1" });
|
|
192
192
|
const after = await store.get(link.id);
|
|
@@ -194,12 +194,12 @@ export function runProviderLinkStoreConformance(label: string, factory: () => Pr
|
|
|
194
194
|
expect(after?.expiresAt).toBe(expiresAt);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
-
it("
|
|
197
|
+
it("listForOwner / listForProvider scope correctly", async () => {
|
|
198
198
|
const store = await factory();
|
|
199
|
-
await store.create({
|
|
200
|
-
await store.create({
|
|
201
|
-
await store.create({
|
|
202
|
-
expect(await store.
|
|
199
|
+
await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "primary", grantedBy: "p1" });
|
|
200
|
+
await store.create({ ownerAccountId: "p1", providerAccountId: "d2", role: "primary", grantedBy: "p1" });
|
|
201
|
+
await store.create({ ownerAccountId: "p2", providerAccountId: "d1", role: "primary", grantedBy: "p2" });
|
|
202
|
+
expect(await store.listForOwner("p1")).toHaveLength(2);
|
|
203
203
|
expect(await store.listForProvider("d1")).toHaveLength(2);
|
|
204
204
|
});
|
|
205
205
|
});
|
package/adapters/d1/audit.ts
CHANGED
|
@@ -2,12 +2,15 @@ import type { D1Database } from "./types";
|
|
|
2
2
|
import type { AccessEvent } from "../../stores";
|
|
3
3
|
export type { AccessEvent };
|
|
4
4
|
|
|
5
|
-
// AuditStore's two members only —
|
|
6
|
-
// bookkeeping are app-specific concerns that stay in the app's
|
|
7
|
-
// own docstring on AuditStore).
|
|
5
|
+
// AuditStore's two members only — consent-scoped vault-access events. Lifecycle/CRM events and
|
|
6
|
+
// raw-object ownership bookkeeping are app-specific concerns that stay in the app's
|
|
7
|
+
// identity-audit.ts (see stores.ts's own docstring on AuditStore).
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// A generic actor/subject/action audit log for consent-scoped vault access — e.g., satisfies FTC
|
|
10
|
+
// Health Breach Notification Rule PHI-disclosure logging for healthcare deployments, or analogous
|
|
11
|
+
// breach-notification and access-audit obligations in other regulated domains. Records WHO
|
|
12
|
+
// (actor) accessed WHOSE (subject) vault and WHY (action + consent_ref), so a breach can be
|
|
13
|
+
// scoped to affected individuals. NO PHI.
|
|
11
14
|
interface AccessEventRow {
|
|
12
15
|
id: string;
|
|
13
16
|
actor_account_id: string;
|
package/adapters/d1/index.ts
CHANGED
|
@@ -157,14 +157,14 @@ export class D1ProviderLinkStore implements ProviderLinkStore {
|
|
|
157
157
|
get(id: string) {
|
|
158
158
|
return providers.getProviderLink(this.db, id);
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
return providers.
|
|
160
|
+
listForOwner(ownerAccountId: string) {
|
|
161
|
+
return providers.listProvidersForOwner(this.db, ownerAccountId);
|
|
162
162
|
}
|
|
163
163
|
listForProvider(providerAccountId: string) {
|
|
164
|
-
return providers.
|
|
164
|
+
return providers.listOwnersForProvider(this.db, providerAccountId);
|
|
165
165
|
}
|
|
166
|
-
getActive(
|
|
167
|
-
return providers.getActiveProviderLink(this.db,
|
|
166
|
+
getActive(ownerAccountId: string, providerAccountId: string) {
|
|
167
|
+
return providers.getActiveProviderLink(this.db, ownerAccountId, providerAccountId);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
package/adapters/d1/providers.ts
CHANGED
|
@@ -13,10 +13,14 @@ interface ProviderLinkRow {
|
|
|
13
13
|
granted_at: string;
|
|
14
14
|
expires_at: string | null;
|
|
15
15
|
}
|
|
16
|
+
// The `patient_account_id` column name is a legacy leftover from before this package's TS-facing
|
|
17
|
+
// vocabulary was genericized to "owner" — renaming a live D1 column is a schema migration against
|
|
18
|
+
// real production data, which that vocabulary cleanup had no reason to force. This mapping function
|
|
19
|
+
// is the boundary that absorbs the difference; the TS-facing shape has never said "patient".
|
|
16
20
|
function mapProviderLink(r: ProviderLinkRow): ProviderLink {
|
|
17
21
|
return {
|
|
18
22
|
id: r.id,
|
|
19
|
-
|
|
23
|
+
ownerAccountId: r.patient_account_id,
|
|
20
24
|
providerAccountId: r.provider_account_id,
|
|
21
25
|
role: r.role,
|
|
22
26
|
status: r.status,
|
|
@@ -30,7 +34,7 @@ function mapProviderLink(r: ProviderLinkRow): ProviderLink {
|
|
|
30
34
|
export async function createProviderLink(
|
|
31
35
|
db: D1Database,
|
|
32
36
|
l: {
|
|
33
|
-
|
|
37
|
+
ownerAccountId: string;
|
|
34
38
|
providerAccountId: string;
|
|
35
39
|
role: ProviderKind;
|
|
36
40
|
status?: LinkStatus;
|
|
@@ -49,11 +53,11 @@ export async function createProviderLink(
|
|
|
49
53
|
.prepare(
|
|
50
54
|
"INSERT INTO provider_links (id, patient_account_id, provider_account_id, role, status, consent_ref, granted_by, granted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
51
55
|
)
|
|
52
|
-
.bind(id, l.
|
|
56
|
+
.bind(id, l.ownerAccountId, l.providerAccountId, l.role, status, consentRef, l.grantedBy, grantedAt, expiresAt)
|
|
53
57
|
.run();
|
|
54
58
|
return {
|
|
55
59
|
id,
|
|
56
|
-
|
|
60
|
+
ownerAccountId: l.ownerAccountId,
|
|
57
61
|
providerAccountId: l.providerAccountId,
|
|
58
62
|
role: l.role,
|
|
59
63
|
status,
|
|
@@ -68,7 +72,7 @@ export async function updateProviderLinkStatus(db: D1Database, id: string, statu
|
|
|
68
72
|
await db.prepare("UPDATE provider_links SET status = ? WHERE id = ?").bind(status, id).run();
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
//
|
|
75
|
+
// An owner approving a support request: flip the link active, stamp its time-box + consent.
|
|
72
76
|
export async function grantSupportLink(
|
|
73
77
|
db: D1Database,
|
|
74
78
|
id: string,
|
|
@@ -85,15 +89,15 @@ export async function getProviderLink(db: D1Database, id: string): Promise<Provi
|
|
|
85
89
|
return row ? mapProviderLink(row) : null;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
export async function
|
|
92
|
+
export async function listProvidersForOwner(db: D1Database, ownerAccountId: string): Promise<ProviderLink[]> {
|
|
89
93
|
const { results } = await db
|
|
90
94
|
.prepare("SELECT * FROM provider_links WHERE patient_account_id = ?")
|
|
91
|
-
.bind(
|
|
95
|
+
.bind(ownerAccountId)
|
|
92
96
|
.all<ProviderLinkRow>();
|
|
93
97
|
return results.map(mapProviderLink);
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
export async function
|
|
100
|
+
export async function listOwnersForProvider(db: D1Database, providerAccountId: string): Promise<ProviderLink[]> {
|
|
97
101
|
const { results } = await db
|
|
98
102
|
.prepare("SELECT * FROM provider_links WHERE provider_account_id = ?")
|
|
99
103
|
.bind(providerAccountId)
|
|
@@ -103,15 +107,15 @@ export async function listPatientsForProvider(db: D1Database, providerAccountId:
|
|
|
103
107
|
|
|
104
108
|
export async function getActiveProviderLink(
|
|
105
109
|
db: D1Database,
|
|
106
|
-
|
|
110
|
+
ownerAccountId: string,
|
|
107
111
|
providerAccountId: string
|
|
108
112
|
): Promise<ProviderLink | null> {
|
|
109
113
|
const link = await db
|
|
110
114
|
.prepare("SELECT * FROM provider_links WHERE patient_account_id = ? AND provider_account_id = ?")
|
|
111
|
-
.bind(
|
|
115
|
+
.bind(ownerAccountId, providerAccountId)
|
|
112
116
|
.first<ProviderLinkRow>();
|
|
113
117
|
if (!link || link.status !== "active") return null;
|
|
114
|
-
// `expires_at` is set on time-boxed support grants and null for
|
|
118
|
+
// `expires_at` is set on time-boxed support grants and null for primary links.
|
|
115
119
|
if (link.expires_at && Date.parse(link.expires_at) <= Date.now()) return null;
|
|
116
120
|
return mapProviderLink(link);
|
|
117
121
|
}
|
package/adapters/memory.ts
CHANGED
|
@@ -266,10 +266,10 @@ export class MemoryEnvelopeStore implements EnvelopeStore {
|
|
|
266
266
|
export class MemoryProviderLinkStore implements ProviderLinkStore {
|
|
267
267
|
private links = new Map<string, ProviderLink>();
|
|
268
268
|
|
|
269
|
-
async create(l: {
|
|
269
|
+
async create(l: { ownerAccountId: string; providerAccountId: string; role: ProviderKind; status?: LinkStatus; consentRef?: string | null; grantedBy: string; expiresAt?: string | null; id?: string }): Promise<ProviderLink> {
|
|
270
270
|
const link: ProviderLink = {
|
|
271
271
|
id: l.id ?? crypto.randomUUID(),
|
|
272
|
-
|
|
272
|
+
ownerAccountId: l.ownerAccountId,
|
|
273
273
|
providerAccountId: l.providerAccountId,
|
|
274
274
|
role: l.role,
|
|
275
275
|
status: l.status ?? "invited",
|
|
@@ -300,17 +300,17 @@ export class MemoryProviderLinkStore implements ProviderLinkStore {
|
|
|
300
300
|
return l ? { ...l } : null;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
async
|
|
304
|
-
return [...this.links.values()].filter((l) => l.
|
|
303
|
+
async listForOwner(ownerAccountId: string): Promise<ProviderLink[]> {
|
|
304
|
+
return [...this.links.values()].filter((l) => l.ownerAccountId === ownerAccountId).map((l) => ({ ...l }));
|
|
305
305
|
}
|
|
306
306
|
|
|
307
307
|
async listForProvider(providerAccountId: string): Promise<ProviderLink[]> {
|
|
308
308
|
return [...this.links.values()].filter((l) => l.providerAccountId === providerAccountId).map((l) => ({ ...l }));
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
async getActive(
|
|
311
|
+
async getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null> {
|
|
312
312
|
const link = [...this.links.values()].find(
|
|
313
|
-
(l) => l.
|
|
313
|
+
(l) => l.ownerAccountId === ownerAccountId && l.providerAccountId === providerAccountId
|
|
314
314
|
);
|
|
315
315
|
if (!link || link.status !== "active") return null;
|
|
316
316
|
if (link.expiresAt && Date.parse(link.expiresAt) <= Date.now()) return null;
|
package/auth-client.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
PublicKeyCredentialRequestOptionsJSON,
|
|
21
21
|
} from "@simplewebauthn/browser";
|
|
22
22
|
import { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
|
|
23
|
+
import type { ProviderKind } from "./stores";
|
|
23
24
|
export { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
|
|
24
25
|
|
|
25
26
|
export const KDF_ITERATIONS = 200_000;
|
|
@@ -42,7 +43,7 @@ export function hexToBytes(hex: string): Uint8Array {
|
|
|
42
43
|
*
|
|
43
44
|
* Nearly every branch below threw `${what} failed: ${res.status}`, discarding a message the
|
|
44
45
|
* endpoint had already composed for exactly this moment ("that recovery code has expired", "this
|
|
45
|
-
*
|
|
46
|
+
* provider already has access", "no access to this vault") and showing an owner a bare number
|
|
46
47
|
* instead. These are the paths a person is on during the worst day they will have with this app, and
|
|
47
48
|
* a status code tells them nothing about whether to retry, wait, or ask someone.
|
|
48
49
|
*
|
|
@@ -327,7 +328,7 @@ export async function resumeSession(): Promise<{
|
|
|
327
328
|
vaultId: string | null;
|
|
328
329
|
r2Key: string | null;
|
|
329
330
|
rotationPending: boolean;
|
|
330
|
-
providerKind:
|
|
331
|
+
providerKind: ProviderKind | null;
|
|
331
332
|
ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
|
|
332
333
|
} | null> {
|
|
333
334
|
const res = await fetch("/api/auth/session/resume", { cache: "no-store" });
|
|
@@ -350,7 +351,7 @@ export async function addGoogleMethod(privateKey: CryptoKey, currentPassword?: s
|
|
|
350
351
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `add google failed: ${res.status}`);
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
export async function getMyAccount(): Promise<{ id: string; email: string | null; emailConfirmed: boolean; displayName: string; providerKind:
|
|
354
|
+
export async function getMyAccount(): Promise<{ id: string; email: string | null; emailConfirmed: boolean; displayName: string; providerKind: ProviderKind | null; unitSystem: "metric" | "imperial" | null }> {
|
|
354
355
|
const res = await fetch("/api/account", { cache: "no-store" });
|
|
355
356
|
if (!res.ok) throw await failed(res, "account fetch failed");
|
|
356
357
|
return res.json();
|
package/auth-grants.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { wrapDEKForPublicKey } from "./crypto";
|
|
2
2
|
import { bytesToBase64, failed } from "./auth-client";
|
|
3
|
+
import type { ProviderKind } from "./stores";
|
|
3
4
|
|
|
4
|
-
// Provider escrow (
|
|
5
|
+
// Provider escrow (owner side). A logged-in owner grants a provider access by wrapping
|
|
5
6
|
// their in-memory DEK to the provider's public key client-side and posting the opaque envelope; the
|
|
6
7
|
// server never sees a plaintext DEK. Revoke deletes the envelope + marks the link revoked.
|
|
7
8
|
|
|
@@ -9,7 +10,7 @@ export interface ProviderLinkView {
|
|
|
9
10
|
linkId: string;
|
|
10
11
|
providerAccountId: string;
|
|
11
12
|
displayName: string;
|
|
12
|
-
kind:
|
|
13
|
+
kind: ProviderKind;
|
|
13
14
|
status: "invited" | "active" | "revoked";
|
|
14
15
|
expiresAt: string | null;
|
|
15
16
|
publicKeyJwk?: JsonWebKey | null; // present only for a pending (invited) support request
|
package/auth-recovery.ts
CHANGED
|
@@ -162,7 +162,7 @@ export async function recoverAccount(
|
|
|
162
162
|
// Regenerate the recovery code (owner session): re-wrap the in-memory private key under a fresh code +
|
|
163
163
|
// store the verifier. Returns the new code to display once.
|
|
164
164
|
// ── Provider-issued recovery ──────────────────────────────────────────────────
|
|
165
|
-
// Apple's recovery-contact model: a
|
|
165
|
+
// Apple's recovery-contact model: a provider who already holds the owner's DEK re-wraps it under a
|
|
166
166
|
// one-time code and READS THE CODE TO THEM. It is never emailed — see RECOVERY.md I2; the server would
|
|
167
167
|
// have to be given the code, and it already holds the wrapped DEK.
|
|
168
168
|
|
|
@@ -191,11 +191,11 @@ export function detectRecoveryKind(raw: string): "code" | "grant" {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/**
|
|
194
|
-
*
|
|
194
|
+
* Provider side. Unwraps the owner's DEK with the provider's own key — which is what provider
|
|
195
195
|
* access already is — and re-wraps it under the code. Returns the code to display once.
|
|
196
196
|
*/
|
|
197
197
|
export async function issueRecoveryCode(
|
|
198
|
-
|
|
198
|
+
ownerAccountId: string,
|
|
199
199
|
envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey },
|
|
200
200
|
providerKey: CryptoKey,
|
|
201
201
|
): Promise<{ code: string; expiresAt: string }> {
|
|
@@ -207,7 +207,7 @@ export async function issueRecoveryCode(
|
|
|
207
207
|
method: "POST",
|
|
208
208
|
headers: { "content-type": "application/json" },
|
|
209
209
|
body: JSON.stringify({
|
|
210
|
-
|
|
210
|
+
ownerAccountId,
|
|
211
211
|
wrappedDek: bytesToBase64(await wrapDEKWithKek(dek, await deriveKekFromPassword(normalized, salt))),
|
|
212
212
|
kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
|
|
213
213
|
codeAuthHash: await deriveAuthHash(normalized, salt),
|
|
@@ -218,12 +218,12 @@ export async function issueRecoveryCode(
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
|
-
*
|
|
221
|
+
* Owner side. Two calls against the same endpoint: the first proves the code and returns the wrapped
|
|
222
222
|
* DEK, the second installs a brand-new keypair locked under the new password. The code is proved on
|
|
223
223
|
* both — the second is not authorised by the first having happened.
|
|
224
224
|
*
|
|
225
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
|
|
226
|
+
* old private key is exactly what the owner no longer has. That is why the server clears the other
|
|
227
227
|
* credentials: they wrap a key nothing references any more.
|
|
228
228
|
*/
|
|
229
229
|
export async function redeemRecoveryCode(
|
package/auth-support.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { wrapDEKForPublicKey } from "./crypto";
|
|
2
2
|
import { bytesToBase64, failed } from "./auth-client";
|
|
3
3
|
|
|
4
|
-
// Support consented-access. A
|
|
4
|
+
// Support consented-access. A vault owner approves a pending support request by wrapping their
|
|
5
5
|
// in-memory DEK to the support agent's public key (time-boxed); support enters via an audited endpoint.
|
|
6
6
|
|
|
7
|
-
//
|
|
7
|
+
// Owner side — approve a pending support request (linkId + the agent's publicKeyJwk from GET /api/providers).
|
|
8
8
|
export async function approveSupport(linkId: string, dek: CryptoKey, publicKeyJwk: JsonWebKey, ttlHours: number): Promise<void> {
|
|
9
9
|
const env = await wrapDEKForPublicKey(dek, publicKeyJwk);
|
|
10
10
|
const res = await fetch("/api/support/approve", {
|
|
@@ -16,30 +16,30 @@ export async function approveSupport(linkId: string, dek: CryptoKey, publicKeyJw
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// Support side.
|
|
19
|
-
export interface
|
|
20
|
-
|
|
19
|
+
export interface SupportOwner {
|
|
20
|
+
ownerAccountId: string;
|
|
21
21
|
displayName: string;
|
|
22
22
|
expiresAt: string | null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export async function requestSupportAccess(
|
|
25
|
+
export async function requestSupportAccess(ownerEmail: string): Promise<void> {
|
|
26
26
|
const res = await fetch("/api/support/request", {
|
|
27
27
|
method: "POST",
|
|
28
28
|
headers: { "content-type": "application/json" },
|
|
29
|
-
body: JSON.stringify({
|
|
29
|
+
body: JSON.stringify({ ownerEmail }),
|
|
30
30
|
});
|
|
31
31
|
if (!res.ok) throw await failed(res, "request failed");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export async function
|
|
35
|
-
const res = await fetch("/api/support/
|
|
36
|
-
if (!res.ok) throw await failed(res, "support
|
|
37
|
-
return ((await res.json()) as {
|
|
34
|
+
export async function listSupportOwners(): Promise<SupportOwner[]> {
|
|
35
|
+
const res = await fetch("/api/support/owners", { cache: "no-store" });
|
|
36
|
+
if (!res.ok) throw await failed(res, "support owners failed");
|
|
37
|
+
return ((await res.json()) as { owners: SupportOwner[] }).owners;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// Enter
|
|
41
|
-
export async function
|
|
42
|
-
|
|
40
|
+
// Enter an owner's vault (audited server-side); returns the envelope for client-side DEK unwrap.
|
|
41
|
+
export async function enterSupportOwner(ownerAccountId: string): Promise<{
|
|
42
|
+
ownerAccountId: string;
|
|
43
43
|
displayName: string;
|
|
44
44
|
email: string | null;
|
|
45
45
|
vaultId: string;
|
|
@@ -49,15 +49,15 @@ export async function enterSupportPatient(patientAccountId: string): Promise<{
|
|
|
49
49
|
const res = await fetch("/api/support/access", {
|
|
50
50
|
method: "POST",
|
|
51
51
|
headers: { "content-type": "application/json" },
|
|
52
|
-
body: JSON.stringify({
|
|
52
|
+
body: JSON.stringify({ ownerAccountId }),
|
|
53
53
|
});
|
|
54
54
|
if (!res.ok) throw await failed(res, "support access failed");
|
|
55
55
|
return res.json();
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// Support→provider roster access. A support agent can request access to a
|
|
59
|
-
// /api/support/request, which classifies by the target's kind). The
|
|
60
|
-
// no DEK), then support sees
|
|
58
|
+
// Support→provider roster access. A support agent can request access to a primary provider too (same
|
|
59
|
+
// /api/support/request, which classifies by the target's kind). The primary provider approves (metadata
|
|
60
|
+
// only, no DEK), then support sees their roster and can open the owners who separately consented.
|
|
61
61
|
|
|
62
62
|
export interface SupportProvider {
|
|
63
63
|
linkId: string;
|
|
@@ -67,8 +67,8 @@ export interface SupportProvider {
|
|
|
67
67
|
expiresAt: string | null;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export interface
|
|
71
|
-
|
|
70
|
+
export interface SupportRosterOwner {
|
|
71
|
+
ownerAccountId: string;
|
|
72
72
|
displayName: string;
|
|
73
73
|
email: string | null;
|
|
74
74
|
openable: boolean;
|
|
@@ -80,7 +80,7 @@ export interface SupportRequest {
|
|
|
80
80
|
targetAccountId: string;
|
|
81
81
|
displayName: string;
|
|
82
82
|
email: string | null;
|
|
83
|
-
kind: "
|
|
83
|
+
kind: "owner" | "provider";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// Provider side — approve a pending support roster request (no DEK; a provider owns no vault).
|
|
@@ -99,10 +99,10 @@ export async function listSupportProviders(): Promise<SupportProvider[]> {
|
|
|
99
99
|
return ((await res.json()) as { providers: SupportProvider[] }).providers;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export async function getProviderRoster(providerId: string): Promise<
|
|
102
|
+
export async function getProviderRoster(providerId: string): Promise<SupportRosterOwner[]> {
|
|
103
103
|
const res = await fetch(`/api/support/provider-roster?providerId=${encodeURIComponent(providerId)}`, { cache: "no-store" });
|
|
104
104
|
if (!res.ok) throw await failed(res, "provider roster failed");
|
|
105
|
-
return ((await res.json()) as { roster:
|
|
105
|
+
return ((await res.json()) as { roster: SupportRosterOwner[] }).roster;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export async function listSupportRequests(): Promise<SupportRequest[]> {
|
package/break-glass.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface BreakGlassGrantStores {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
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 (
|
|
29
|
+
* requested TTL, stamps a consent ref, optionally writes an envelope (owner approvals only — a
|
|
30
30
|
* provider approving a roster request owns nothing encrypted), flips the link active, and audits.
|
|
31
31
|
*/
|
|
32
32
|
export async function grantBreakGlass(
|
|
@@ -43,7 +43,7 @@ export async function grantBreakGlass(
|
|
|
43
43
|
}
|
|
44
44
|
): Promise<BreakGlassGrantResult> {
|
|
45
45
|
const link = await stores.links.get(opts.linkId);
|
|
46
|
-
if (!link || link.
|
|
46
|
+
if (!link || link.ownerAccountId !== opts.approverAccountId || link.role !== "support" || link.status !== "invited") {
|
|
47
47
|
return { ok: false, error: "no_pending_link" };
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -124,10 +124,10 @@ export interface BreakGlassRevokeStores {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
|
-
* Ends a link early — either side may call this (the
|
|
127
|
+
* Ends a link early — either side may call this (the owner revoking, or the provider dropping it).
|
|
128
128
|
* Deletes the provider's envelope (if any) so no new read can unwrap the DEK, and marks the link
|
|
129
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 (
|
|
130
|
+
* omitted for link kinds that don't carry a disclosure-audit obligation (primary links).
|
|
131
131
|
*/
|
|
132
132
|
export async function revokeBreakGlass(
|
|
133
133
|
stores: BreakGlassRevokeStores,
|
package/envelope-access.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface EnvelopeAccessSource {
|
|
|
8
8
|
getVault(vaultId: string): Promise<VaultRow | null>;
|
|
9
9
|
}
|
|
10
10
|
export interface ProviderLinkSource {
|
|
11
|
-
getActive(
|
|
11
|
+
getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/vault",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Runtime-agnostic key derivation, authenticated envelope encryption, and storage-agnostic access-control contracts for per-user encrypted data.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/stores.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
|
|
8
8
|
export type AuthMethod = "passkey" | "google" | "password" | "recovery";
|
|
9
|
-
export type ProviderKind = "
|
|
9
|
+
export type ProviderKind = "primary" | "support";
|
|
10
10
|
export type LinkStatus = "invited" | "active" | "revoked";
|
|
11
11
|
export type UnitSystem = "metric" | "imperial";
|
|
12
12
|
|
|
@@ -74,18 +74,18 @@ export interface EnvelopeInput {
|
|
|
74
74
|
|
|
75
75
|
export interface ProviderLink {
|
|
76
76
|
id: string;
|
|
77
|
-
|
|
77
|
+
ownerAccountId: string;
|
|
78
78
|
providerAccountId: string;
|
|
79
79
|
role: ProviderKind;
|
|
80
80
|
status: LinkStatus;
|
|
81
81
|
consentRef: string | null;
|
|
82
82
|
grantedBy: string;
|
|
83
83
|
grantedAt: string;
|
|
84
|
-
/** Set on time-boxed support grants; null for
|
|
84
|
+
/** Set on time-boxed support grants; null for primary links. */
|
|
85
85
|
expiresAt: string | null;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** A
|
|
88
|
+
/** A consent-scoped access audit-log entry — who touched whose vault, and why. */
|
|
89
89
|
export interface AccessEvent {
|
|
90
90
|
id: string;
|
|
91
91
|
actorAccountId: string;
|
|
@@ -159,7 +159,7 @@ export interface EnvelopeStore {
|
|
|
159
159
|
|
|
160
160
|
export interface ProviderLinkStore {
|
|
161
161
|
create(l: {
|
|
162
|
-
|
|
162
|
+
ownerAccountId: string;
|
|
163
163
|
providerAccountId: string;
|
|
164
164
|
role: ProviderKind;
|
|
165
165
|
status?: LinkStatus;
|
|
@@ -171,15 +171,16 @@ export interface ProviderLinkStore {
|
|
|
171
171
|
updateStatus(id: string, status: LinkStatus): Promise<void>;
|
|
172
172
|
grantSupport(id: string, opts: { expiresAt: string | null; consentRef?: string | null }): Promise<void>;
|
|
173
173
|
get(id: string): Promise<ProviderLink | null>;
|
|
174
|
-
|
|
174
|
+
listForOwner(ownerAccountId: string): Promise<ProviderLink[]>;
|
|
175
175
|
listForProvider(providerAccountId: string): Promise<ProviderLink[]>;
|
|
176
|
-
/** The active, unexpired link between this
|
|
177
|
-
getActive(
|
|
176
|
+
/** The active, unexpired link between this owner and provider, or null. */
|
|
177
|
+
getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
|
-
* The
|
|
182
|
-
* ownership bookkeeping are app-specific concerns that don't belong in a portable
|
|
181
|
+
* The consent-scoped access-event half of an adopter's audit trail only. Lifecycle/CRM events and
|
|
182
|
+
* raw-object ownership bookkeeping are app-specific concerns that don't belong in a portable
|
|
183
|
+
* security package.
|
|
183
184
|
*/
|
|
184
185
|
export interface AuditStore {
|
|
185
186
|
insertAccessEvent(e: { actorAccountId: string; subjectAccountId: string; vaultId?: string | null; action: string; consentRef?: string | null; meta?: unknown; id?: string }): Promise<AccessEvent>;
|
package/vault-session.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { decryptVaultV2 } from "./crypto";
|
|
|
6
6
|
* agree by coincidence.
|
|
7
7
|
*/
|
|
8
8
|
export interface VaultEntry {
|
|
9
|
-
|
|
9
|
+
ownerAccountId: string;
|
|
10
10
|
displayName: string;
|
|
11
11
|
email: string | null;
|
|
12
12
|
r2Key: string;
|
|
@@ -23,7 +23,7 @@ export interface VaultSession {
|
|
|
23
23
|
* re-wrap it when adding a login method. Null in a provider or support session.
|
|
24
24
|
*/
|
|
25
25
|
readonly ownerKey: CryptoKey | null;
|
|
26
|
-
/** A provider account's private key, which unwraps each
|
|
26
|
+
/** A provider account's private key, which unwraps each owner's envelope. Null for an owner. */
|
|
27
27
|
readonly providerKey: CryptoKey | null;
|
|
28
28
|
/** True only when a vault is genuinely open — derived, never tracked separately. */
|
|
29
29
|
readonly isOpen: boolean;
|
|
@@ -37,8 +37,8 @@ export interface VaultSession {
|
|
|
37
37
|
/**
|
|
38
38
|
* Closes the open vault. Clears the data key and the id together.
|
|
39
39
|
*
|
|
40
|
-
* Does NOT clear the provider key: a provider who leaves one
|
|
41
|
-
* needs their own key to open the next. That asymmetry was already the behaviour of
|
|
40
|
+
* Does NOT clear the provider key: a provider who leaves one owner's vault is still signed in and
|
|
41
|
+
* still needs their own key to open the next. That asymmetry was already the behaviour of
|
|
42
42
|
* `backToRoster()`; stating it here is what stops it being re-derived incorrectly later.
|
|
43
43
|
*/
|
|
44
44
|
close(): void;
|
|
@@ -48,7 +48,7 @@ export interface VaultSession {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Decrypts a vault blob and opens the session on it in one step, shared by every path that unlocks
|
|
51
|
-
* a vault — the signed-in owner's own, and a provider's drill-in to
|
|
51
|
+
* a vault — the signed-in owner's own, and a provider's drill-in to an owner's. `fetchBlob` stays a
|
|
52
52
|
* caller-supplied thunk because fetching it (the R2 route, the ETag it must remember for later saves)
|
|
53
53
|
* is app-local, not frame-generic.
|
|
54
54
|
*/
|