@tinytars/vault 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/adapters/d1/accounts.ts +1 -1
- package/adapters/d1/audit.ts +1 -1
- package/adapters/d1/credentials.ts +2 -2
- package/adapters/d1/providers.ts +1 -1
- package/adapters/r2.ts +4 -5
- package/auth-client.ts +422 -0
- package/auth-grants.ts +55 -0
- package/auth-recovery.ts +360 -0
- package/auth-support.ts +118 -0
- package/base64.ts +15 -0
- package/break-glass.ts +5 -6
- package/crypto.ts +20 -19
- package/kdf.ts +12 -20
- package/key-store.ts +4 -3
- package/org-recovery.ts +19 -0
- package/package.json +13 -3
- package/stores.ts +3 -5
- package/vault-session.ts +59 -0
- package/vault-sink.ts +25 -27
package/org-recovery.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { VaultSession } from "./vault-session";
|
|
2
|
+
import { wrapDEKForPublicKey } from "./crypto";
|
|
3
|
+
import { bytesToB64 } from "./base64";
|
|
4
|
+
import { getVaultPrincipals, putRecoveryEnvelope } from "./auth-recovery";
|
|
5
|
+
|
|
6
|
+
// Backfill the org-recovery envelope for accounts that predate it (or missed it at signup).
|
|
7
|
+
// Best-effort: it must never block or fail an unlock.
|
|
8
|
+
export async function ensureOrgRecoveryEnvelope(session: VaultSession): Promise<void> {
|
|
9
|
+
if (!session.dek) return;
|
|
10
|
+
try {
|
|
11
|
+
const principals = await getVaultPrincipals();
|
|
12
|
+
if (principals.orgRecoveryRevokedAt !== null) return;
|
|
13
|
+
if (principals.envelopePrincipalIds.includes(principals.orgAccountId)) return;
|
|
14
|
+
const e = await wrapDEKForPublicKey(session.dek, principals.orgPublicKeyJwk);
|
|
15
|
+
await putRecoveryEnvelope({ wrappedDEK: bytesToB64(e.wrappedDEK), ephemeralPublicKeyJwk: e.ephemeralPublicKeyJwk });
|
|
16
|
+
} catch {
|
|
17
|
+
/* best-effort — never block or fail an unlock */
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/vault",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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",
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
"./envelope-access": "./envelope-access.ts",
|
|
36
36
|
"./break-glass": "./break-glass.ts",
|
|
37
37
|
"./blob-store": "./blob-store.ts",
|
|
38
|
+
"./auth-client": "./auth-client.ts",
|
|
39
|
+
"./auth-recovery": "./auth-recovery.ts",
|
|
40
|
+
"./auth-support": "./auth-support.ts",
|
|
41
|
+
"./auth-grants": "./auth-grants.ts",
|
|
42
|
+
"./org-recovery": "./org-recovery.ts",
|
|
43
|
+
"./vault-session": "./vault-session.ts",
|
|
44
|
+
"./base64": "./base64.ts",
|
|
38
45
|
"./adapters/d1": "./adapters/d1/index.ts",
|
|
39
46
|
"./adapters/r2": "./adapters/r2.ts",
|
|
40
47
|
"./adapters/memory": "./adapters/memory.ts",
|
|
@@ -45,8 +52,11 @@
|
|
|
45
52
|
"test": "vitest run",
|
|
46
53
|
"typecheck": "tsc --noEmit"
|
|
47
54
|
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@simplewebauthn/browser": "^13.3.0"
|
|
57
|
+
},
|
|
48
58
|
"devDependencies": {
|
|
49
|
-
"
|
|
50
|
-
"
|
|
59
|
+
"typescript": "~6.0.2",
|
|
60
|
+
"vitest": "^4.1.8"
|
|
51
61
|
}
|
|
52
62
|
}
|
package/stores.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// Storage-agnostic contracts for the identity/vault/access-control data this package's crypto
|
|
2
2
|
// operates over. No Cloudflare or D1 dependency here by design (see README.md) — an adopter wires
|
|
3
|
-
// these to whatever database they run
|
|
4
|
-
// these contracts, not the definition of them
|
|
5
|
-
//
|
|
6
|
-
// An adopter's own route handlers should re-export the types below rather than redeclaring them,
|
|
7
|
-
// so there is exactly one definition of each shape.
|
|
3
|
+
// these to whatever database they run. `adapters/d1/` in this package is one implementation of
|
|
4
|
+
// these contracts, not the definition of them; an adopter's own server code should import the
|
|
5
|
+
// types below rather than redeclaring them, so there is exactly one definition of each shape.
|
|
8
6
|
|
|
9
7
|
export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
|
|
10
8
|
export type AuthMethod = "passkey" | "google" | "password" | "recovery";
|
package/vault-session.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { decryptVaultV2 } from "./crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A vault this session may open on someone else's behalf, and the envelope that opens it. The shape
|
|
5
|
+
* the provider roster and the support console both hand to the host — one drill-in, not two that
|
|
6
|
+
* agree by coincidence.
|
|
7
|
+
*/
|
|
8
|
+
export interface VaultEntry {
|
|
9
|
+
patientAccountId: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
email: string | null;
|
|
12
|
+
r2Key: string;
|
|
13
|
+
envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VaultSession {
|
|
17
|
+
/** The current vault's data key, or null when no vault is open. */
|
|
18
|
+
readonly dek: CryptoKey | null;
|
|
19
|
+
/** The current vault's R2 id (slug of its r2_key), or null. */
|
|
20
|
+
readonly r2Id: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* The signed-in owner's account private key, retained for the session so Account settings can
|
|
23
|
+
* re-wrap it when adding a login method. Null in a provider or support session.
|
|
24
|
+
*/
|
|
25
|
+
readonly ownerKey: CryptoKey | null;
|
|
26
|
+
/** A provider account's private key, which unwraps each patient's envelope. Null for an owner. */
|
|
27
|
+
readonly providerKey: CryptoKey | null;
|
|
28
|
+
/** True only when a vault is genuinely open — derived, never tracked separately. */
|
|
29
|
+
readonly isOpen: boolean;
|
|
30
|
+
|
|
31
|
+
/** Opens a vault: the id and the key that decrypts it, together or not at all. */
|
|
32
|
+
open(r2Id: string, dek: CryptoKey): void;
|
|
33
|
+
/** Retains the owner's account key for this session. */
|
|
34
|
+
setOwnerKey(key: CryptoKey | null): void;
|
|
35
|
+
/** Retains a provider's account key for this session. */
|
|
36
|
+
setProviderKey(key: CryptoKey | null): void;
|
|
37
|
+
/**
|
|
38
|
+
* Closes the open vault. Clears the data key and the id together.
|
|
39
|
+
*
|
|
40
|
+
* Does NOT clear the provider key: a provider who leaves one patient is still signed in and still
|
|
41
|
+
* needs their own key to open the next. That asymmetry was already the behaviour of
|
|
42
|
+
* `backToRoster()`; stating it here is what stops it being re-derived incorrectly later.
|
|
43
|
+
*/
|
|
44
|
+
close(): void;
|
|
45
|
+
/** Ends the whole session — every key, including the provider's. */
|
|
46
|
+
signOut(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
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 a patient's. `fetchBlob` stays a
|
|
52
|
+
* caller-supplied thunk because fetching it (the R2 route, the ETag it must remember for later saves)
|
|
53
|
+
* is app-local, not frame-generic.
|
|
54
|
+
*/
|
|
55
|
+
export async function openVault<V>(session: VaultSession, id: string, dek: CryptoKey, fetchBlob: (id: string) => Promise<Uint8Array>): Promise<V> {
|
|
56
|
+
const vault = await decryptVaultV2<V>(await fetchBlob(id), dek);
|
|
57
|
+
session.open(id, dek);
|
|
58
|
+
return vault;
|
|
59
|
+
}
|
package/vault-sink.ts
CHANGED
|
@@ -10,13 +10,12 @@ export interface VaultSink {
|
|
|
10
10
|
/**
|
|
11
11
|
* The version of each vault blob this browser context last saw.
|
|
12
12
|
*
|
|
13
|
-
* Deliberately held HERE rather than threaded through callers.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* request is built makes every path correct by construction instead of by remembering.
|
|
13
|
+
* Deliberately held HERE rather than threaded through callers. `saveVaultV2` can have several call
|
|
14
|
+
* sites in an adopting app (a queued edit path plus one-off writes — key rotation, a background
|
|
15
|
+
* regen, an import, a raw upload), and not all of them necessarily go through the same queue. Since
|
|
16
|
+
* the precondition is REQUIRED on the browser path, a call site that forgot to pass an etag would not
|
|
17
|
+
* degrade silently — it would 428, i.e. a user unable to save their own data. Keeping the token where
|
|
18
|
+
* the request is built makes every path correct by construction instead of by remembering.
|
|
20
19
|
*
|
|
21
20
|
* One entry per vault id, which is exactly the domain: a browser context has one current view of a
|
|
22
21
|
* given blob.
|
|
@@ -49,10 +48,10 @@ export class VaultConflictError extends Error {
|
|
|
49
48
|
/**
|
|
50
49
|
* One hook, so every save path reports a conflict — not just the queued one.
|
|
51
50
|
*
|
|
52
|
-
* Most
|
|
53
|
-
* background
|
|
54
|
-
* would be one chance to miss it
|
|
55
|
-
*
|
|
51
|
+
* Most of `saveVaultV2`'s call sites in a typical adopting app are direct `await`s outside any save
|
|
52
|
+
* queue (key rotation, a background regen, an import, a raw upload). Wiring the conflict state
|
|
53
|
+
* through each would be one chance per site to miss it, and a missed one is an unhandled rejection on
|
|
54
|
+
* a user's data. Every save funnels through this sink, so this is the one place that sees them all.
|
|
56
55
|
* The error still throws afterwards, so existing per-path error handling is unchanged.
|
|
57
56
|
*/
|
|
58
57
|
let onConflict: ((e: VaultConflictError) => void) | null = null;
|
|
@@ -60,9 +59,9 @@ export function setVaultConflictHandler(fn: ((e: VaultConflictError) => void) |
|
|
|
60
59
|
onConflict = fn;
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
// Dev-only sink: POSTs the blob to a local dev-server middleware
|
|
64
|
-
//
|
|
65
|
-
//
|
|
62
|
+
// Dev-only sink: POSTs the blob to a local dev-server middleware that writes it to disk. Absent
|
|
63
|
+
// from the deployed build — there is no such endpoint in production. r2Sink below is the second
|
|
64
|
+
// implementation of this interface, for remote/mobile save.
|
|
66
65
|
export const localSink: VaultSink = {
|
|
67
66
|
async put(id, blob) {
|
|
68
67
|
const res = await fetch(`/__save-vault?id=${encodeURIComponent(id)}`, {
|
|
@@ -76,21 +75,20 @@ export const localSink: VaultSink = {
|
|
|
76
75
|
},
|
|
77
76
|
};
|
|
78
77
|
|
|
79
|
-
// Remote sink: PUTs the encrypted blob to an R2-backed
|
|
80
|
-
// `/api/vault/{id}` only exists in the deployed build; the route
|
|
81
|
-
// session cookie
|
|
82
|
-
// The
|
|
78
|
+
// Remote sink: PUTs the encrypted blob to an R2-backed server route.
|
|
79
|
+
// `/api/vault/{id}` only exists in the deployed build; the route is expected to authenticate the
|
|
80
|
+
// caller via whatever session cookie the adopter's auth layer sets — same-origin fetch sends it
|
|
81
|
+
// automatically. The route stores opaque ciphertext — same as localSink, never plaintext or a key.
|
|
83
82
|
/**
|
|
84
83
|
* One write at a time per vault, so the app never conflicts with ITSELF.
|
|
85
84
|
*
|
|
86
|
-
*
|
|
87
|
-
* and `await` directly — so a user edit and a
|
|
88
|
-
* reads the version token when it builds its request, so the second would send
|
|
89
|
-
* already superseded, and the guard would correctly report a conflict against a
|
|
90
|
-
*
|
|
91
|
-
* the conflict panel intercepting pointer events.
|
|
85
|
+
* A queued edit path is one likely caller, but most adopting apps also have one-off writes that
|
|
86
|
+
* bypass any queue and `await` directly — so a user edit and a background regen could end up in
|
|
87
|
+
* flight together. Each reads the version token when it builds its request, so the second would send
|
|
88
|
+
* one the first had already superseded, and the guard would correctly report a conflict against a
|
|
89
|
+
* caller that is only racing itself.
|
|
92
90
|
*
|
|
93
|
-
* Serializing HERE rather than
|
|
91
|
+
* Serializing HERE rather than upstream is deliberate: this is the only place every write passes
|
|
94
92
|
* through, and the version token lives here too — the token must be read after the previous write has
|
|
95
93
|
* settled, which is precisely what a chain guarantees.
|
|
96
94
|
*/
|
|
@@ -139,8 +137,8 @@ async function putConditional(id: string, blob: Uint8Array): Promise<void> {
|
|
|
139
137
|
// wrangler-pages-dev e2e harness) persists to R2 and never references /__save-vault.
|
|
140
138
|
export const vaultSink: VaultSink = import.meta.env.DEV ? localSink : r2Sink;
|
|
141
139
|
|
|
142
|
-
// Encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's
|
|
143
|
-
//
|
|
140
|
+
// Encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's random
|
|
141
|
+
// data key, unwrapped at login from the caller's key envelope; it stays in memory.
|
|
144
142
|
export async function saveVaultV2<T = Record<string, unknown>>(vault: T, id: string, dek: CryptoKey, sink: VaultSink): Promise<void> {
|
|
145
143
|
const blob = await encryptVaultV2(vault, dek);
|
|
146
144
|
await sink.put(id, blob);
|