@tinytars/vault 0.1.14 → 0.1.15
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/adapters/d1/accounts.ts +2 -2
- package/adapters/d1/audit.ts +1 -1
- package/adapters/d1/credentials.ts +3 -3
- package/adapters/d1/providers.ts +1 -1
- package/adapters/d1/vault.ts +3 -3
- package/adapters/r2.ts +4 -4
- package/break-glass.ts +6 -5
- package/crypto.ts +13 -11
- package/kdf.ts +13 -12
- package/key-store.ts +1 -1
- package/package.json +1 -1
- package/stores.ts +4 -5
- package/vault-sink.ts +18 -18
package/adapters/d1/accounts.ts
CHANGED
|
@@ -70,7 +70,7 @@ export async function getAccountByEmail(db: D1Database, email: string): Promise<
|
|
|
70
70
|
/**
|
|
71
71
|
* The instant before which this account's session cookies are no longer accepted, or null.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
73
|
+
* Read on every authenticated request, by the adopter's own session-check middleware. One indexed lookup by primary
|
|
74
74
|
* key is what buys revocability: the cookie is self-contained, so without a server-side fact to
|
|
75
75
|
* check against, nothing short of rotating SESSION_SECRET for the entire deployment can invalidate one.
|
|
76
76
|
*/
|
|
@@ -149,7 +149,7 @@ export async function tombstoneAccount(db: D1Database, accountId: string, at: st
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
152
|
+
* Stamps when the account's email address changed.
|
|
153
153
|
*
|
|
154
154
|
* Read by the recovery routes, which refuse to issue a grant while the address is still new: a stolen
|
|
155
155
|
* cookie that repoints the mailbox should not be able to convert that into a recovery code minutes
|
package/adapters/d1/audit.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type { AccessEvent };
|
|
|
6
6
|
// bookkeeping are app-specific concerns that stay in the app's identity-audit.ts (see stores.ts's
|
|
7
7
|
// own docstring on AuditStore).
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// FTC-HBNR (§I) PHI-access/disclosure audit log. Records WHO (actor) accessed WHOSE (subject)
|
|
10
10
|
// vault and WHY (action + consent_ref), so a breach can be scoped to affected individuals. NO PHI.
|
|
11
11
|
interface AccessEventRow {
|
|
12
12
|
id: string;
|
|
@@ -99,8 +99,8 @@ export async function getCredential(db: D1Database, accountId: string, method: A
|
|
|
99
99
|
return row ? mapCredential(row) : null;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
// "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
|
|
102
|
+
// The account's key-bearing methods (password/passkey/recovery), for the account-settings screen and
|
|
103
|
+
// the "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
|
|
104
104
|
// row independently wraps the same private key); identities lacks a recovery row.
|
|
105
105
|
export async function listCredentials(db: D1Database, accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]> {
|
|
106
106
|
const { results } = await db
|
|
@@ -118,7 +118,7 @@ export async function deleteIdentity(db: D1Database, accountId: string, method:
|
|
|
118
118
|
await db.prepare("DELETE FROM identities WHERE account_id = ? AND method = ?").bind(accountId, method).run();
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
//
|
|
121
|
+
// Bump the passkey authenticator's signature counter after a successful login
|
|
122
122
|
// (replay-attack detection). Merges into the existing kdf_params rather than a raw column
|
|
123
123
|
// update so the wrapped key row stays a single INSERT OR REPLACE-shaped record.
|
|
124
124
|
export async function updatePasskeyCounter(db: D1Database, accountId: string, counter: number): Promise<void> {
|
package/adapters/d1/providers.ts
CHANGED
|
@@ -68,7 +68,7 @@ export async function updateProviderLinkStatus(db: D1Database, id: string, statu
|
|
|
68
68
|
await db.prepare("UPDATE provider_links SET status = ? WHERE id = ?").bind(status, id).run();
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
//
|
|
71
|
+
// An owner approving a support request: flip the link active, stamp its time-box + consent.
|
|
72
72
|
export async function grantSupportLink(
|
|
73
73
|
db: D1Database,
|
|
74
74
|
id: string,
|
package/adapters/d1/vault.ts
CHANGED
|
@@ -53,7 +53,7 @@ export async function getVault(db: D1Database, vaultId: string): Promise<VaultRo
|
|
|
53
53
|
return row ? mapVault(row) : null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/** The vault whose in-flight rotation has reserved this key. See `commitRotation`. */
|
|
57
57
|
export async function getVaultByStagingR2Key(db: D1Database, r2Key: string): Promise<VaultRow | null> {
|
|
58
58
|
const row = await db.prepare("SELECT * FROM vaults WHERE rotation_staging_r2_key = ?").bind(r2Key).first<VaultRowRaw>();
|
|
59
59
|
return row ? mapVault(row) : null;
|
|
@@ -106,7 +106,7 @@ export async function putEnvelope(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
109
|
+
* Swap a vault's whole envelope set atomically.
|
|
110
110
|
*
|
|
111
111
|
* The rotation route used to delete every envelope in a loop and then build the replacements one at a
|
|
112
112
|
* time, decoding each `wrappedDEK` as it went. Two ways that ended in an unrecoverable vault: a
|
|
@@ -139,7 +139,7 @@ export async function replaceEnvelopes(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
*
|
|
142
|
+
* The whole re-key, committed as one D1 batch: swap the envelope set AND repoint the vault at
|
|
143
143
|
* the freshly-written object, in one implicit transaction.
|
|
144
144
|
*
|
|
145
145
|
* The atomicity is the point. The rotation used to re-encrypt IN PLACE — new DEK into the same r2
|
package/adapters/r2.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { BlobStore, BlobConditional, StoredBlob } from "../blob-store";
|
|
2
2
|
|
|
3
3
|
// Minimal structural type for the R2 binding — no @cloudflare/workers-types dependency, and
|
|
4
|
-
// trivially mockable in tests.
|
|
5
|
-
//
|
|
4
|
+
// trivially mockable in tests. An adopter's own Pages Function route should import R2Bucket/
|
|
5
|
+
// R2BlobStore from here instead of declaring its own copy.
|
|
6
6
|
export interface R2ObjectBody {
|
|
7
7
|
body: ReadableStream;
|
|
8
|
-
/**
|
|
8
|
+
/** The version token. Handed to the browser on GET and sent back as If-Match on PUT. */
|
|
9
9
|
etag: string;
|
|
10
10
|
}
|
|
11
|
-
/** A precondition on a write.
|
|
11
|
+
/** A precondition on a write. Verify this against real `workerd` in an adopter's own test suite — see ARCHITECTURE.md's "Adapters" section. */
|
|
12
12
|
export interface R2Conditional {
|
|
13
13
|
etagMatches?: string;
|
|
14
14
|
etagDoesNotMatch?: string;
|
package/break-glass.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern. It
|
|
2
|
+
// exists because this package's first adopter had several routes that each hardcoded their own TTL
|
|
3
|
+
// clamp, consent-string prefix, and audit shape — one for granting with an envelope, one for
|
|
4
|
+
// granting metadata-only access, one for checking/expiring, one for revoking. Centralizing the
|
|
5
|
+
// pattern here means every one of those routes shares the same TTL clamp, consent format, and audit
|
|
6
|
+
// trail instead of drifting independently.
|
|
6
7
|
|
|
7
8
|
import type { AuditStore, EnvelopeStore, ProviderLink, ProviderLinkStore } from "./stores";
|
|
8
9
|
|
package/crypto.ts
CHANGED
|
@@ -14,10 +14,11 @@ const deriveKey = (passphrase: string, salt: Uint8Array) => deriveAesKey(subtle,
|
|
|
14
14
|
|
|
15
15
|
// Deterministic, non-reversible bearer derived from the passphrase: base64url(SHA-256).
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// This is a generator for a stable, non-reversible token an adopter can use to gate a script or an
|
|
18
|
+
// allowlisted bearer-token integration off the same passphrase used for the interactive login path,
|
|
19
|
+
// without storing the passphrase itself anywhere. It is not session auth — an interactive route
|
|
20
|
+
// should be protected by a real session cookie/token, not a static bearer derived here. Keep this
|
|
21
|
+
// only for a script-facing bearer allowlist you still rely on; drop it once you don't.
|
|
21
22
|
export async function deriveBearerToken(passphrase: string): Promise<string> {
|
|
22
23
|
const digest = new Uint8Array(await subtle.digest("SHA-256", toAB(enc.encode(passphrase))));
|
|
23
24
|
let bin = "";
|
|
@@ -67,7 +68,7 @@ export async function decryptVault<T = Record<string, unknown>>(blob: Uint8Array
|
|
|
67
68
|
return JSON.parse(dec.decode(plaintext)) as T;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
// ──
|
|
71
|
+
// ── Envelope encryption (v2) ──────────────────────────────────────────────────
|
|
71
72
|
// v1 above (passphrase → PBKDF2 → AES key → blob) is untouched. v2 splits the key:
|
|
72
73
|
// a random per-vault DEK encrypts the blob, and the DEK is wrapped per principal
|
|
73
74
|
// (owner, provider, support) to their ECDH public key. The passphrase path is gone;
|
|
@@ -138,17 +139,18 @@ export async function unwrapPrivateKey(blob: Uint8Array, kek: CryptoKey): Promis
|
|
|
138
139
|
} catch {
|
|
139
140
|
throw new Error("cannot unwrap private key (wrong KEK or corrupt blob)");
|
|
140
141
|
}
|
|
141
|
-
// Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate
|
|
142
|
-
// recovery code
|
|
142
|
+
// Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate a
|
|
143
|
+
// recovery code, both of which wrap this same key under a new KEK. Same rationale/posture as the
|
|
143
144
|
// DEK returned by unwrapDEKWithPrivateKey (also extractable for re-granting). The key stays in-memory
|
|
144
145
|
// only, same trust boundary as the session's DEK.
|
|
145
146
|
return subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// Import a raw PKCS8 ECDH private key (extractable, so it can be re-wrapped to add a login method —
|
|
149
|
-
// same trust boundary as unwrapPrivateKey's output).
|
|
150
|
-
//
|
|
151
|
-
//
|
|
150
|
+
// same trust boundary as unwrapPrivateKey's output). A server-custody login path (e.g. SSO, where
|
|
151
|
+
// the server itself bootstraps the session) moves the plaintext key over the wire instead of a
|
|
152
|
+
// KEK-wrapped blob, so both server (re-wrap under the server KEK) and client (recover the DEK)
|
|
153
|
+
// import it here rather than unwrapping.
|
|
152
154
|
export async function importPrivateKeyPkcs8(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|
153
155
|
return subtle.importKey("pkcs8", toAB(pkcs8), EC_PARAMS, true, ["deriveKey", "deriveBits"]);
|
|
154
156
|
}
|
|
@@ -204,7 +206,7 @@ export async function unwrapDEKWithPrivateKey(
|
|
|
204
206
|
return subtle.importKey("raw", raw, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
205
207
|
}
|
|
206
208
|
|
|
207
|
-
//
|
|
209
|
+
// The DEK wrapped under a KEK, for a provider-issued recovery grant.
|
|
208
210
|
//
|
|
209
211
|
// The raw-key twin of wrapPrivateKey/unwrapPrivateKey above: same AES-GCM, same iv‖ct blob shape, same
|
|
210
212
|
// reasoning. It exists because a recovery grant hands the DEK to a one-time code rather than to a
|
package/kdf.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
// The passphrase→key derivation shared by every passphrase-encrypted blob
|
|
1
|
+
// The passphrase→key derivation shared by every passphrase-encrypted blob that builds on this
|
|
2
|
+
// package.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// This derivation is deliberately factored out on its own, separate from any particular envelope
|
|
5
|
+
// format: PBKDF2-SHA256, 200_000 iterations, AES-GCM-256, a 16-byte salt and a 12-byte IV. An
|
|
6
|
+
// adopter building a second, unrelated encrypted-blob format alongside this package's own HD1
|
|
7
|
+
// envelope (`crypto.ts`) should reuse this file rather than reimplementing the derivation —
|
|
8
|
+
// reimplementing it independently is exactly how a later decision to raise the iteration count
|
|
9
|
+
// gets taken in one copy and silently not the other.
|
|
9
10
|
//
|
|
10
|
-
// WHAT IS DELIBERATELY NOT SHARED: the envelope.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// separation prevents for free. If you are here to "finish the
|
|
14
|
-
// is the bug this comment exists to stop.
|
|
11
|
+
// WHAT IS DELIBERATELY NOT SHARED: the envelope. HD1 keeps its own magic bytes, its own version
|
|
12
|
+
// handling and its own framing in its own module — a sibling format should do the same in its own.
|
|
13
|
+
// A unified envelope would make a foreign blob a syntactically valid input to this package's vault
|
|
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.
|
|
15
16
|
//
|
|
16
17
|
// RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. One caller is
|
|
17
18
|
// Node-only (`node:crypto`'s webcrypto), the others run in the browser and in Workers, so the
|
package/key-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Persist the account private key across a browser refresh so a valid session 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/vault",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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
|
@@ -1,11 +1,10 @@
|
|
|
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
|
-
//
|
|
5
|
-
// not the definition of them.
|
|
3
|
+
// these to whatever database they run; `adapters/d1/` in this package is one implementation of
|
|
4
|
+
// these contracts, not the definition of them.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
9
8
|
|
|
10
9
|
export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
|
|
11
10
|
export type AuthMethod = "passkey" | "google" | "password" | "recovery";
|
package/vault-sink.ts
CHANGED
|
@@ -8,13 +8,14 @@ export interface VaultSink {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
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
|
-
* degrade — it would 428, i.e.
|
|
13
|
+
* Deliberately held HERE rather than threaded through callers. A real adopter typically has several
|
|
14
|
+
* independent call sites that all persist through `saveVaultV2` — a queued-edit path plus things
|
|
15
|
+
* like key rotation, background regeneration, imports, raw uploads and onboarding — and usually only
|
|
16
|
+
* one of them goes through a save queue. Since the precondition is REQUIRED on the browser path, a
|
|
17
|
+
* call site that forgot to pass an etag would not degrade — it would 428, i.e. an owner unable to
|
|
18
|
+
* save their own record. Keeping the token where the
|
|
18
19
|
* request is built makes every path correct by construction instead of by remembering.
|
|
19
20
|
*
|
|
20
21
|
* One entry per vault id, which is exactly the domain: a browser context has one current view of a
|
|
@@ -48,10 +49,10 @@ export class VaultConflictError extends Error {
|
|
|
48
49
|
/**
|
|
49
50
|
* One hook, so every save path reports a conflict — not just the queued one.
|
|
50
51
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Most real `saveVaultV2` call sites are direct `await`s outside any save queue (key rotation,
|
|
53
|
+
* background regeneration, import, raw upload, onboarding). Wiring the conflict state through each
|
|
54
|
+
* would be one chance to miss it per call site, and a missed one is an unhandled rejection on a
|
|
55
|
+
* record someone owns. Every save funnels through this sink, so this is the one place that sees them all.
|
|
55
56
|
* The error still throws afterwards, so existing per-path error handling is unchanged.
|
|
56
57
|
*/
|
|
57
58
|
let onConflict: ((e: VaultConflictError) => void) | null = null;
|
|
@@ -59,10 +60,9 @@ export function setVaultConflictHandler(fn: ((e: VaultConflictError) => void) |
|
|
|
59
60
|
onConflict = fn;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
// Dev-only sink: POSTs the blob to
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// second implementation of this interface for remote/mobile save.
|
|
63
|
+
// Dev-only sink: POSTs the blob to a local dev-server middleware, which writes it to disk.
|
|
64
|
+
// Absent from the deployed build — there is no such endpoint in production. The R2 sink below is
|
|
65
|
+
// the second implementation of this interface, for a real remote/mobile save.
|
|
66
66
|
export const localSink: VaultSink = {
|
|
67
67
|
async put(id, blob) {
|
|
68
68
|
const res = await fetch(`/__save-vault?id=${encodeURIComponent(id)}`, {
|
|
@@ -76,9 +76,9 @@ export const localSink: VaultSink = {
|
|
|
76
76
|
},
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
// Remote sink: PUTs the encrypted blob to
|
|
80
|
-
// `/api/vault/{id}` only exists in the deployed build; the guard
|
|
81
|
-
// cookie (owner/granted-provider envelope check) — same-origin fetch sends it automatically.
|
|
79
|
+
// Remote sink: PUTs the encrypted blob to an R2-backed Pages Function.
|
|
80
|
+
// `/api/vault/{id}` only exists in the deployed build; the route guard checks the adopter's own
|
|
81
|
+
// session cookie (owner/granted-provider envelope check) — same-origin fetch sends it automatically.
|
|
82
82
|
// The Function stores opaque ciphertext — same as localSink, never plaintext or a key.
|
|
83
83
|
/**
|
|
84
84
|
* One write at a time per vault, so the app never conflicts with ITSELF.
|
|
@@ -139,7 +139,7 @@ async function putConditional(id: string, blob: Uint8Array): Promise<void> {
|
|
|
139
139
|
// wrangler-pages-dev e2e harness) persists to R2 and never references /__save-vault.
|
|
140
140
|
export const vaultSink: VaultSink = import.meta.env.DEV ? localSink : r2Sink;
|
|
141
141
|
|
|
142
|
-
//
|
|
142
|
+
// Encrypt the vault under its DEK (HD1 v2 envelope) and persist. The DEK is the vault's
|
|
143
143
|
// random data key, unwrapped at login from the caller's key envelope; it stays in memory.
|
|
144
144
|
export async function saveVaultV2<T = Record<string, unknown>>(vault: T, id: string, dek: CryptoKey, sink: VaultSink): Promise<void> {
|
|
145
145
|
const blob = await encryptVaultV2(vault, dek);
|