@tinytars/vault 0.1.16 → 0.1.18
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 +53 -39
- package/adapters/conformance.ts +9 -9
- package/adapters/d1/accounts.ts +1 -1
- package/adapters/d1/audit.ts +8 -5
- package/adapters/d1/credentials.ts +2 -2
- package/adapters/d1/index.ts +5 -5
- package/adapters/d1/providers.ts +14 -10
- package/adapters/memory.ts +6 -6
- package/adapters/r2.ts +4 -5
- package/auth-client.ts +423 -0
- package/auth-grants.ts +56 -0
- package/auth-recovery.ts +360 -0
- package/auth-support.ts +118 -0
- package/base64.ts +15 -0
- package/break-glass.ts +9 -10
- package/crypto.ts +20 -19
- package/envelope-access.ts +1 -1
- package/kdf.ts +12 -20
- package/key-store.ts +4 -3
- package/org-recovery.ts +19 -0
- package/package.json +6 -3
- package/stores.ts +14 -15
- package/vault-session.ts +59 -0
- package/vault-sink.ts +25 -27
package/kdf.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
// The passphrase→key derivation shared by every passphrase-encrypted blob
|
|
2
|
-
// package.
|
|
1
|
+
// The passphrase→key derivation shared by every passphrase-encrypted blob this package produces.
|
|
3
2
|
//
|
|
4
|
-
// This
|
|
5
|
-
// format
|
|
6
|
-
//
|
|
7
|
-
// envelope
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// This module derives a key from a passphrase and salt and stops there — it does not define an
|
|
4
|
+
// envelope format, version byte, or framing. A caller with its own envelope needs (see `crypto.ts`
|
|
5
|
+
// for this package's own HD1 format) keeps its magic bytes, version handling, and framing in its own
|
|
6
|
+
// module. A unified envelope would make one format's blob a syntactically valid input to another
|
|
7
|
+
// format's reader, which is a confusion this separation prevents for free. Sharing only the
|
|
8
|
+
// derivation, never the envelope, is the design, not an oversight to "fix" by merging.
|
|
10
9
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// reader, which is a confusion this separation prevents for free. If you are here to "finish the
|
|
15
|
-
// job" by merging formats — that is the bug this comment exists to stop.
|
|
16
|
-
//
|
|
17
|
-
// RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. One caller is
|
|
18
|
-
// Node-only (`node:crypto`'s webcrypto), the others run in the browser and in Workers, so the
|
|
19
|
-
// `SubtleCrypto` is passed in rather than reached for. That also makes the parameters testable
|
|
20
|
-
// without a runtime shim.
|
|
10
|
+
// RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. Callers run
|
|
11
|
+
// in Node, the browser, and Workers alike, so the `SubtleCrypto` instance is passed in rather than
|
|
12
|
+
// reached for globally. That also makes the parameters testable without a runtime shim.
|
|
21
13
|
|
|
22
14
|
import { toArrayBuffer } from "./bytes";
|
|
23
15
|
|
|
@@ -56,8 +48,8 @@ export async function deriveAesKey(
|
|
|
56
48
|
}
|
|
57
49
|
|
|
58
50
|
/**
|
|
59
|
-
* PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key —
|
|
60
|
-
* server-verifiable auth hash. Separate entry point rather than a flag on `deriveAesKey`, because a
|
|
51
|
+
* PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key — this package's own
|
|
52
|
+
* server-verifiable auth hash (see `crypto.ts`'s `deriveAuthHash`). Separate entry point rather than a flag on `deriveAesKey`, because a
|
|
61
53
|
* function that sometimes returns exportable bytes and sometimes an unexportable key is exactly the
|
|
62
54
|
* kind of thing a reviewer has to read twice.
|
|
63
55
|
*/
|
package/key-store.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Persist the account private key across a browser refresh so a valid session doesn't force
|
|
1
|
+
// Persist the account private key across a browser refresh so a valid session cookie doesn't force
|
|
2
2
|
// re-auth. The key is stored as a NON-EXTRACTABLE CryptoKey (structured-cloned into IndexedDB): it can
|
|
3
3
|
// still unwrap the vault DEK (ECDH deriveKey) on the next load, but its raw bytes can't be read back
|
|
4
4
|
// out, so an XSS payload can't exfiltrate it (it could still USE it while the page is open — inherent
|
|
5
|
-
// to any "stay signed in"). Password/passkey only;
|
|
5
|
+
// to any "stay signed in"). Password/passkey only; a federated-login method re-bootstraps from
|
|
6
|
+
// server-custody material instead.
|
|
6
7
|
|
|
7
|
-
const DB_NAME = "
|
|
8
|
+
const DB_NAME = "security-session";
|
|
8
9
|
const STORE = "keys";
|
|
9
10
|
const KEY_ID = "account-private-key";
|
|
10
11
|
const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" } as const;
|
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.18",
|
|
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",
|
|
@@ -52,8 +52,11 @@
|
|
|
52
52
|
"test": "vitest run",
|
|
53
53
|
"typecheck": "tsc --noEmit"
|
|
54
54
|
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@simplewebauthn/browser": "^13.3.0"
|
|
57
|
+
},
|
|
55
58
|
"devDependencies": {
|
|
56
|
-
"
|
|
57
|
-
"
|
|
59
|
+
"typescript": "~6.0.2",
|
|
60
|
+
"vitest": "^4.1.8"
|
|
58
61
|
}
|
|
59
62
|
}
|
package/stores.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
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";
|
|
11
|
-
export type ProviderKind = "
|
|
9
|
+
export type ProviderKind = "primary" | "support";
|
|
12
10
|
export type LinkStatus = "invited" | "active" | "revoked";
|
|
13
11
|
export type UnitSystem = "metric" | "imperial";
|
|
14
12
|
|
|
@@ -76,18 +74,18 @@ export interface EnvelopeInput {
|
|
|
76
74
|
|
|
77
75
|
export interface ProviderLink {
|
|
78
76
|
id: string;
|
|
79
|
-
|
|
77
|
+
ownerAccountId: string;
|
|
80
78
|
providerAccountId: string;
|
|
81
79
|
role: ProviderKind;
|
|
82
80
|
status: LinkStatus;
|
|
83
81
|
consentRef: string | null;
|
|
84
82
|
grantedBy: string;
|
|
85
83
|
grantedAt: string;
|
|
86
|
-
/** Set on time-boxed support grants; null for
|
|
84
|
+
/** Set on time-boxed support grants; null for primary links. */
|
|
87
85
|
expiresAt: string | null;
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
/** A
|
|
88
|
+
/** A consent-scoped access audit-log entry — who touched whose vault, and why. */
|
|
91
89
|
export interface AccessEvent {
|
|
92
90
|
id: string;
|
|
93
91
|
actorAccountId: string;
|
|
@@ -161,7 +159,7 @@ export interface EnvelopeStore {
|
|
|
161
159
|
|
|
162
160
|
export interface ProviderLinkStore {
|
|
163
161
|
create(l: {
|
|
164
|
-
|
|
162
|
+
ownerAccountId: string;
|
|
165
163
|
providerAccountId: string;
|
|
166
164
|
role: ProviderKind;
|
|
167
165
|
status?: LinkStatus;
|
|
@@ -173,15 +171,16 @@ export interface ProviderLinkStore {
|
|
|
173
171
|
updateStatus(id: string, status: LinkStatus): Promise<void>;
|
|
174
172
|
grantSupport(id: string, opts: { expiresAt: string | null; consentRef?: string | null }): Promise<void>;
|
|
175
173
|
get(id: string): Promise<ProviderLink | null>;
|
|
176
|
-
|
|
174
|
+
listForOwner(ownerAccountId: string): Promise<ProviderLink[]>;
|
|
177
175
|
listForProvider(providerAccountId: string): Promise<ProviderLink[]>;
|
|
178
|
-
/** The active, unexpired link between this
|
|
179
|
-
getActive(
|
|
176
|
+
/** The active, unexpired link between this owner and provider, or null. */
|
|
177
|
+
getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
|
|
180
178
|
}
|
|
181
179
|
|
|
182
180
|
/**
|
|
183
|
-
* The
|
|
184
|
-
* 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.
|
|
185
184
|
*/
|
|
186
185
|
export interface AuditStore {
|
|
187
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
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
|
+
ownerAccountId: 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 owner'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 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
|
+
* `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 an owner'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);
|