diff --git a/docs/design/012-vault-retirement.md b/docs/design/012-vault-retirement.md new file mode 100644 index 0000000..5814f30 --- /dev/null +++ b/docs/design/012-vault-retirement.md @@ -0,0 +1,54 @@ +# 012: Retire notme/vault — cloister is the canonical credential vault + +**Status:** Accepted +**Date:** 2026-06-25 +**Bead:** notme-9af5dd (closes), supersedes notme-6bbec6 +**Relates to:** cloister ADR-0010 (the 2026-05-09 vault lift), cloister ADR-0030 §A3 (3-tier KEK scoping) + +## Decision + +Delete `vault/` from the notme repo. cloister owns and deploys the credential +vault going forward; notme retains no copy. + +## Why + +The 2026-05-09 lift (cloister ADR-0010) was deliberately **copy-not-move** so the +two copies could diverge during a tolerance window. That window is over — the +copies have diverged and notme's copy is dead weight: + +- **notme does not use it.** No `VAULT` binding in `worker/wrangler.toml`; nothing + in `worker/` or `proxy/` imports `@notme/vault` or fetches a vault URL. The + package is `"private": true` (never published) with no deploy target in + `Taskfile.yml`. notme's own threat model already declares it moved: + `worker/src/__tests__/threat-model.test.ts` — `contract.vault.aad-binding` is an + `it.todo` reading "moved-to-cloister … covered by cloister/vault". +- **cloister is canonical and ahead.** cloister deploys the vault via its + hypervisor (`cluster.capnp` holds `VAULT_STORE` + `VAULT_KEK_SOURCE`) and has + gained 3-tier per-tenant KEK scoping (`cloister/vault/src/kek-scope.ts`, ADR-0030 + §A3) that notme never had. That feature is exactly what notme-6bbec6 asked for — + so 6bbec6 is resolved by consuming cloister's vault, not by re-implementing here. +- **No license blocker.** notme is Apache-2.0; cloister's vault is AGPL-3.0. notme + consumes it as a separately-deployed network service (not a linked library), so + no copyleft obligation attaches to notme's own code. (`vault/NOTICE` further + records that the single author has already exercised relicensing in both + directions; there is no third-party-contribution entanglement.) + +## What was removed + +- `vault/` (the whole package: `src/`, tests, README, NOTICE, wrangler example). +- `"vault"` from `pnpm-workspace.yaml`. +- `../vault/src/**` include/exclude globs from `worker/tsconfig.json`. +- `../vault/src/__tests__/**` glob from `worker/vitest.config.ts`. + +## Recovery / archive + +The last commit in which `vault/` existed in notme is **`1ccd3b0`**. If notme ever +needs to self-host a vault Worker again, the notme-unique entrypoint +(`vault/src/worker.ts` + `vault/wrangler.toml.example` — never round-tripped to +cloister, per the old `vault/NOTICE`) is recoverable with: + +``` +git show 1ccd3b0:vault/src/worker.ts +``` + +For any new vault work, use **cloister/vault**. diff --git a/package.json b/package.json index 15b707f..f25cdf3 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ }, "pnpm": { "overrides": { - "undici@<6.24.0": "^6.24.0", - "ws@<8.20.1": "^8.20.1" + "ws@<8.20.1": "^8.20.1", + "undici@<6.27.0": "^6.27.0" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a29645..ba64536 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - undici@<6.24.0: ^6.24.0 ws@<8.20.1: ^8.20.1 + undici@<6.27.0: ^6.27.0 importers: @@ -47,8 +47,6 @@ importers: packages/contract: {} - vault: {} - worker: dependencies: '@notme/contract': @@ -1489,8 +1487,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@6.25.0: - resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} undici@7.24.4: @@ -1666,7 +1664,7 @@ snapshots: '@actions/http-client@2.2.3': dependencies: tunnel: 0.0.6 - undici: 6.25.0 + undici: 6.27.0 '@actions/io@1.1.3': {} @@ -2810,7 +2808,7 @@ snapshots: undici-types@7.24.6: {} - undici@6.25.0: {} + undici@6.27.0: {} undici@7.24.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5a2b9fa..2ae934e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,4 @@ packages: - "action" - "worker" - - "vault" - "packages/*" diff --git a/vault/NOTICE b/vault/NOTICE deleted file mode 100644 index bb99e67..0000000 --- a/vault/NOTICE +++ /dev/null @@ -1,33 +0,0 @@ -notme/vault/ -============ - -This directory contains a sealed-credential vault that round-tripped -between the `notme` and `cloister` projects: - - - **Originally**: developed in `notme/vault/` under Apache License 2.0. - - **Lifted out**: copied into `cloister/vault/` on 2026-05-09 and - re-licensed under AGPL-3.0-or-later to match the rest of cloister - (per cloister ADR-0010). See `cloister/vault/NOTICE` for that side - of the trip. - - **Hardened in cloister**: gained F1 per-caller token-bucket budget - + concurrency cap, F3/F4 KEK-rejected-promise + credential-payload - DoS gates, and a pluggable KEK source (env / file / keychain / - helper-binary). Two new files: `src/kek-source.ts` and - `src/rate-bucket.ts`. - - **Re-incorporated**: copied back here on 2026-05-17 and - re-licensed under Apache License 2.0. The sole author retained - the right to relicense own work; no third-party contributions - existed in the cloister copy (verified via `git log --since= - "2026-05-09" -- vault/`). - -The notme-specific Cloudflare Worker entry point (`src/worker.ts`, -`src/__tests__/worker.test.ts`, `wrangler.toml.example`) is unique -to this side and was NOT round-tripped. - -Per Apache License 2.0 §4(c), each source file carries an SPDX -header identifying it as Apache-2.0 and a one-line provenance note -documenting the round-trip. - -Original `cloister/vault/NOTICE` is preserved upstream for the -historical AGPL period. No additional third-party attributions -apply. diff --git a/vault/README.md b/vault/README.md deleted file mode 100644 index cf86985..0000000 --- a/vault/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# vault - -Zero-secret credential vault Worker (HKDF + AES-GCM envelope encryption) gated by notme identity. - -> **🚧 Migration in progress — copy-not-move (lift dated 2026-05-09):** -> -> A copy of this code has been lifted into [`cloister/vault/`](https://github.com/agentic-research/cloister/tree/main/vault) per **cloister ADR-0010** (`cloister/docs/adr/0010-vault-and-bundle-clusters.md`). The lift is **copy-not-move** — both copies coexist for the divergence-tolerance window: -> -> - **`cloister/vault/`** — license **AGPL-3.0-or-later** (cloister's license). SPDX headers per file. NOTICE preserves Apache-2.0 attribution per §4(c). This is the canonical home going forward. -> - **`notme/vault/` (this directory)** — license **Apache-2.0** (notme's license, as of 2026-05-09 relicense). Stays here for the divergence-tolerance window. Tracked under bead [`notme-9af5dd`](https://github.com/agentic-research/notme) (P3, DEFERRED — "Remove notme/vault/ once cloister's copy is proven; no urgency"). -> -> The two copies **may diverge** over the window (each repo's tests + reviews stay in their own ecosystem). Don't sync changes manually — pick one as your editing target and let the other catch up via deliberate forward-porting. - -## license posture during the migration - -| Copy | License | Why | -|---|---|---| -| `notme/vault/` (this) | Apache-2.0 (matches notme repo) | Permissive; ecosystem reuse | -| `cloister/vault/` | AGPL-3.0-or-later (matches cloister repo) | Copyleft for the substrate | - -Apache-2.0 → AGPL relicensing is **legally permitted** (Apache 2.0 §4 allows redistribution under different licenses provided notice is preserved). cloister's `vault/NOTICE` discharges that obligation. The relicensing is **one-way**: code that originated in cloister/vault under AGPL cannot be back-ported here without a separate negotiation. - -If you're integrating against the vault and want a long-term target, prefer **cloister/vault**. notme/vault is for legacy notme-specific use only and will be removed when `notme-9af5dd` closes. - -## what this is - -`notme-vault` — a separate Cloudflare Worker (`name = "notme-vault"` per `wrangler.toml.example`). Brokers third-party credentials (e.g. Anthropic API keys, GitHub PATs) for tools running under a notme bridge cert. Tools authenticate to the vault with their bridge cert; the vault attaches the upstream credential and forwards. The credential never enters the tool's process memory. - -## entry points - -- **`src/worker.ts`** — Worker fetch handler + `CredentialVault` Durable Object. -- **`src/vault.ts`** — Pure vault helpers (proxy req shaping, scope check, validation). -- **`src/crypto.ts`** — Encryption / decryption helpers (HKDF + AES-GCM envelope). -- **`src/handler.ts`** — Per-route logic. -- **`src/kek-source.ts`** — URL-driven KEK resolver (`env://`, `file://`, `keychain://`, `http(s)://`). -- **`src/rate-bucket.ts`** — Per-caller token-bucket math (pure functions over `BucketState`). -- **`src/__tests__/`** — vitest suite (vault, security, adversarial, encryption, kek-source, rate-bucket, worker, worker-do). - -## KEK source - -The vault DO derives its AES-GCM KEK from a secret resolved via a URL spec in `VAULT_KEK_SOURCE`. Schemes accepted by the current dispatcher (`buildKekSource()` in `src/kek-source.ts`): - -| Scheme | Use when | Needs | -|---|---|---| -| `env://NAME` | You're fine with a plaintext workerd binding (CI, dev). | nothing | -| `file:///path` | The secret lives on disk and you've set up a workerd disk service. | `KEK_DISK` binding | -| `keychain://name` | macOS Keychain (cloister's local-dev posture). | `KEK_HELPER` sidecar | -| `http(s)://host/...` | Any HTTP backend (use sparingly — secret in transit). | `KEK_HELPER` sidecar | - -Workerd is a sandboxed V8 isolate — no `fs`, no `child_process`. `keychain://` and `http(s)://` go through a separate Node sidecar (`scripts/kek-helper.mjs` in cloister) bound as `KEK_HELPER`. See **cloister ADR-0019** for the helper-binary design rationale and the supply-chain analysis (why we don't shell out to `/usr/bin/security` from a worker). - -> **Deferred — helper-backed schemes not yet wired:** `secret-tool://` (Linux libsecret), `op://` (1Password), `apple-password://` (macOS Passwords app), `keyring://` (generic cross-platform) all need wiring through `buildKekSource()`'s `HelperKekSource` dispatcher. Tracked as a follow-up to `rosary-54ad76` (see the bead linked from PR #22). Until that lands, configuring these schemes throws at runtime. - -Legacy `VAULT_KEK_SECRET` is supported but **deprecated** — set `VAULT_KEK_SOURCE=env://VAULT_KEK_SECRET` (or another scheme) instead. The DO emits a one-time `console.warn` on first derive if the legacy path is in use. - -## rate budget - -Every authenticated request charges a per-caller token bucket inside the DO (`consumeBudget(sub, costClass)`). Configured in `src/rate-bucket.ts`: - -- Capacity: 100 tokens per caller -- Refill: 10 tokens/sec -- Cost per request: `read` = 1, `write` = 3, `proxy` = 5 -- Max in-flight (burst cap): 16 - -Over-budget callers get **HTTP 429** with a `Retry-After` header derived from the bucket's refill rate. Bucket state lives in DO memory (single-writer per DO) — if the DO is evicted, callers get a full bucket on their next request, the same outcome a long-idle caller would see. Cloister's `dos-friend` pilot (`cloister-211b68`, finding F1) is the load-bearing reason this exists; see that bead for the threat model. - -## related - -- **cloister ADR-0010** — `cloister/docs/adr/0010-vault-and-bundle-clusters.md` — Vault as scoped slices, bundles as the unit of trust, clusters as the unit of identity. The architectural rationale for the lift. -- **bead `cloister-9ad9eb`** (P1) — "Lift notme/vault → cloister/vault (copy-not-move; notme keeps its copy for now)". The lift execution. -- **bead `notme-9af5dd`** (P3) — "DEFERRED: Remove notme/vault/ once cloister's copy is proven (no urgency)". The eventual removal. -- [`../README.md`](../README.md) — top-level repo overview (notme is Apache-2.0 as of 2026-05-09). -- [`../docs/design/007-secretless-local-proxy.md`](../docs/design/007-secretless-local-proxy.md) — local-proxy design (vault is the edge counterpart pattern). diff --git a/vault/package.json b/vault/package.json deleted file mode 100644 index a88f758..0000000 --- a/vault/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@notme/vault", - "version": "0.2.0", - "private": true, - "description": "Sealed credential vault — AES-GCM envelope encryption, pluggable KEK source, per-caller rate budget. See NOTICE for the cloister round-trip.", - "type": "module", - "main": "./src/vault.ts", - "exports": { - ".": "./src/vault.ts", - "./crypto": "./src/crypto.ts", - "./handler": "./src/handler.ts", - "./kek-source": "./src/kek-source.ts", - "./rate-bucket": "./src/rate-bucket.ts" - }, - "files": [ - "src", - "NOTICE", - "README.md", - "wrangler.toml.example" - ] -} diff --git a/vault/src/__tests__/encryption.test.ts b/vault/src/__tests__/encryption.test.ts deleted file mode 100644 index e89554e..0000000 --- a/vault/src/__tests__/encryption.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 notme contributors -// Origin: hardened in cloister (AGPL-3.0) by sole author, re-incorporated under Apache-2.0 on 2026-05-17; see NOTICE. - -/** - * encryption.test.ts — TDD tests for envelope encryption. - * - * Credential header values must be AES-GCM encrypted before storage. - * KEK (key encryption key) from Worker secret. Per-credential DEK - * (data encryption key) generated and wrapped alongside ciphertext. - * - * Threat model: CF holds disk encryption keys. App-level encryption - * means a CF infrastructure compromise still can't read credentials. - */ - -import { describe, expect, it, beforeAll } from "vitest"; - -async function getCrypto() { - return import("../crypto"); -} - -// ── Key derivation ───────────────────────────────────────────────────────── - -describe("deriveKEK", () => { - it("derives a 256-bit AES-GCM key from a secret string", async () => { - const { deriveKEK } = await getCrypto(); - const kek = await deriveKEK("my-worker-secret-abc123"); - - expect(kek.type).toBe("secret"); - expect(kek.algorithm).toMatchObject({ name: "AES-GCM" }); - expect(kek.usages).toContain("wrapKey"); - expect(kek.usages).toContain("unwrapKey"); - }); - - it("same secret produces same KEK (deterministic via encrypt/decrypt)", async () => { - const { deriveKEK, encrypt, decrypt } = await getCrypto(); - const k1 = await deriveKEK("same-secret"); - const k2 = await deriveKEK("same-secret"); - - // Encrypt with k1, decrypt with k2 — proves they're the same key - const headers = { proof: "deterministic" }; - const sealed = await encrypt(headers, k1); - const opened = await decrypt(sealed, k2); - expect(opened).toEqual(headers); - }); - - it("different secrets produce different KEKs", async () => { - const { deriveKEK, encrypt, decrypt } = await getCrypto(); - const k1 = await deriveKEK("secret-a"); - const k2 = await deriveKEK("secret-b"); - - // Encrypt with k1, fail to decrypt with k2 - const sealed = await encrypt({ key: "val" }, k1); - await expect(decrypt(sealed, k2)).rejects.toThrow(); - }); -}); - -// ── Encrypt / decrypt roundtrip ──────────────────────────────────────────── - -describe("encrypt + decrypt", () => { - let kek: CryptoKey; - - beforeAll(async () => { - const { deriveKEK } = await getCrypto(); - kek = await deriveKEK("test-kek-secret"); - }); - - it("roundtrips a credential headers object", async () => { - const { encrypt, decrypt } = await getCrypto(); - - const headers = { apiKey: "sk-live-abc123", Authorization: "Bearer gh_token" }; - const sealed = await encrypt(headers, kek); - const opened = await decrypt(sealed, kek); - - expect(opened).toEqual(headers); - }); - - it("produces different ciphertext each time (unique IV)", async () => { - const { encrypt } = await getCrypto(); - - const headers = { apiKey: "same-value" }; - const s1 = await encrypt(headers, kek); - const s2 = await encrypt(headers, kek); - - // Sealed blobs must differ (different IV + different DEK) - expect(s1).not.toEqual(s2); - }); - - it("fails to decrypt with wrong KEK", async () => { - const { deriveKEK, encrypt, decrypt } = await getCrypto(); - - const rightKek = await deriveKEK("right-secret"); - const wrongKek = await deriveKEK("wrong-secret"); - - const headers = { apiKey: "secret" }; - const sealed = await encrypt(headers, rightKek); - - await expect(decrypt(sealed, wrongKek)).rejects.toThrow(); - }); - - it("fails to decrypt tampered ciphertext", async () => { - const { encrypt, decrypt } = await getCrypto(); - - const headers = { apiKey: "secret" }; - const sealed = await encrypt(headers, kek); - - // Tamper with ciphertext - const tampered = { ...sealed, ciphertext: sealed.ciphertext.slice(0, -4) + "AAAA" }; - await expect(decrypt(tampered, kek)).rejects.toThrow(); - }); - - it("sealed blob contains no plaintext credential values", async () => { - const { encrypt } = await getCrypto(); - - const secret = "sk-live-SUPER-SECRET-KEY-12345"; - const headers = { apiKey: secret }; - const sealed = await encrypt(headers, kek); - - // Stringify the entire sealed object — the secret must not appear - const json = JSON.stringify(sealed); - expect(json).not.toContain(secret); - expect(json).not.toContain("SUPER-SECRET"); - }); - - it("handles empty headers", async () => { - const { encrypt, decrypt } = await getCrypto(); - - const sealed = await encrypt({}, kek); - const opened = await decrypt(sealed, kek); - expect(opened).toEqual({}); - }); - - it("handles headers with special characters", async () => { - const { encrypt, decrypt } = await getCrypto(); - - const headers = { - key: 'value with "quotes" and \\ backslashes', - unicode: "emoji: \u{1F680} and \u{1F389}", - }; - const sealed = await encrypt(headers, kek); - const opened = await decrypt(sealed, kek); - expect(opened).toEqual(headers); - }); -}); - -// ── Sealed blob structure ────────────────────────────────────────────────── - -describe("sealed blob structure", () => { - it("contains wrappedDek, iv, ciphertext — all base64url", async () => { - const { deriveKEK, encrypt } = await getCrypto(); - const kek = await deriveKEK("test"); - const sealed = await encrypt({ key: "val" }, kek); - - expect(typeof sealed.wrappedDek).toBe("string"); - expect(typeof sealed.iv).toBe("string"); - expect(typeof sealed.ciphertext).toBe("string"); - - // All should be base64url (no +, /, or =) - for (const field of [sealed.wrappedDek, sealed.iv, sealed.ciphertext]) { - expect(field).toMatch(/^[A-Za-z0-9_-]+$/); - } - }); -}); diff --git a/vault/src/__tests__/kek-source.test.ts b/vault/src/__tests__/kek-source.test.ts deleted file mode 100644 index 044937b..0000000 --- a/vault/src/__tests__/kek-source.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 notme contributors -// Origin: developed in cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-17; see NOTICE. - -/** - * kek-source.test.ts — unit tests for the pluggable KEK source. - * - * These tests exercise the URL-driven resolver in isolation, with - * fake env shapes and stub Fetcher bindings. The actual macOS Keychain - * round-trip is gated behind a separate end-to-end test (run manually - * via the dogfood smoke described in ADR-0014). The kek-helper sidecar - * is tested over a real HTTP server in - * `test/vault/kek-helper-smoke.test.ts`. - */ - -import { describe, expect, it } from "vitest"; - -import { buildKekSource, type KekSourceEnv } from "../kek-source"; - -// ── env:// ────────────────────────────────────────────────────────────────── - -describe("env:// KEK source", () => { - it("resolves the value of the named env binding", async () => { - const env: KekSourceEnv = { MY_KEK: "super-secret-32-bytes-pretend" }; - const src = buildKekSource("env://MY_KEK", env); - await expect(src.resolve()).resolves.toBe("super-secret-32-bytes-pretend"); - }); - - it("throws when the env binding is unset", async () => { - const env: KekSourceEnv = {}; - const src = buildKekSource("env://NOPE", env); - await expect(src.resolve()).rejects.toThrow(/env:\/\/NOPE is unset or empty/); - }); - - it("throws when the env binding is the empty string", async () => { - const env: KekSourceEnv = { EMPTY: "" }; - const src = buildKekSource("env://EMPTY", env); - await expect(src.resolve()).rejects.toThrow(/unset or empty/); - }); - - it("rejects invalid var names at construction (no shell-like chars)", () => { - const env: KekSourceEnv = {}; - expect(() => buildKekSource("env://", env)).toThrow(); - expect(() => buildKekSource("env://has spaces", env)).toThrow(); - expect(() => buildKekSource("env://semi;colon", env)).toThrow(); - expect(() => buildKekSource("env://dotted.name", env)).toThrow(); - }); -}); - -// ── file:// ───────────────────────────────────────────────────────────────── - -describe("file:// KEK source", () => { - function fakeDisk(handler: (path: string) => Response): Fetcher { - return { - fetch: async (input: RequestInfo) => { - const url = typeof input === "string" ? input : input.url; - const path = new URL(url).pathname; - return handler(path); - }, - } as unknown as Fetcher; - } - - it("GETs the path from the KEK_DISK service binding", async () => { - let seen = ""; - const env: KekSourceEnv = { - KEK_DISK: fakeDisk((path) => { - seen = path; - return new Response("file-kek-bytes\n"); - }), - }; - const src = buildKekSource("file:///etc/cloister/kek.bin", env); - await expect(src.resolve()).resolves.toBe("file-kek-bytes"); - expect(seen).toBe("/etc/cloister/kek.bin"); - }); - - it("strips trailing newlines (single)", async () => { - const env: KekSourceEnv = { - KEK_DISK: fakeDisk(() => new Response("abc\n")), - }; - const src = buildKekSource("file:///x", env); - await expect(src.resolve()).resolves.toBe("abc"); - }); - - it("strips trailing newlines (multiple + CRLF)", async () => { - const env: KekSourceEnv = { - KEK_DISK: fakeDisk(() => new Response("abc\r\n\n\r\n")), - }; - const src = buildKekSource("file:///x", env); - await expect(src.resolve()).resolves.toBe("abc"); - }); - - it("throws if KEK_DISK is unbound", async () => { - const env: KekSourceEnv = {}; - const src = buildKekSource("file:///x", env); - await expect(src.resolve()).rejects.toThrow(/KEK_DISK service binding/); - }); - - it("throws on non-2xx disk response", async () => { - const env: KekSourceEnv = { - KEK_DISK: fakeDisk(() => new Response("nope", { status: 404 })), - }; - const src = buildKekSource("file:///missing.bin", env); - await expect(src.resolve()).rejects.toThrow(/status 404/); - }); - - it("throws on empty disk response", async () => { - const env: KekSourceEnv = { - KEK_DISK: fakeDisk(() => new Response("\n\n")), - }; - const src = buildKekSource("file:///empty", env); - await expect(src.resolve()).rejects.toThrow(/empty bytes/); - }); - - it("rejects file:// with a non-empty host", () => { - expect(() => buildKekSource("file://somehost/path", {})).toThrow(/host must be empty/); - }); - - it("rejects file:// with no path", () => { - expect(() => buildKekSource("file:///", {})).toThrow(/path must be non-empty/); - }); -}); - -// ── keychain:// + http(s):// via KEK_HELPER ───────────────────────────────── - -describe("keychain:// + http(s):// KEK source (via KEK_HELPER)", () => { - function fakeHelper(handler: (url: string) => Response): Fetcher { - return { - fetch: async (input: RequestInfo) => { - const url = typeof input === "string" ? input : input.url; - return handler(url); - }, - } as unknown as Fetcher; - } - - it("issues GET /resolve?url= against KEK_HELPER and returns body", async () => { - let seenUrl = ""; - const env: KekSourceEnv = { - KEK_HELPER: fakeHelper((url) => { - seenUrl = url; - return new Response("keychain-bytes-here"); - }), - }; - const src = buildKekSource("keychain://com.cloister/kek", env); - await expect(src.resolve()).resolves.toBe("keychain-bytes-here"); - expect(seenUrl).toContain("/resolve?url="); - expect(decodeURIComponent(seenUrl)).toContain("keychain://com.cloister/kek"); - }); - - it("strips trailing newlines from helper response", async () => { - const env: KekSourceEnv = { - KEK_HELPER: fakeHelper(() => new Response("hex\n")), - }; - const src = buildKekSource("keychain://x", env); - await expect(src.resolve()).resolves.toBe("hex"); - }); - - it("throws if KEK_HELPER is unbound", async () => { - const src = buildKekSource("keychain://x", {}); - await expect(src.resolve()).rejects.toThrow(/KEK_HELPER service binding/); - }); - - it("throws on non-2xx helper response and does NOT leak the spec", async () => { - const env: KekSourceEnv = { - KEK_HELPER: fakeHelper(() => new Response("not found", { status: 404 })), - }; - const src = buildKekSource("keychain://secret-service-name", env); - await expect(src.resolve()).rejects.toThrow(/keychain:\/\/ lookup returned 404/); - // The error must NOT contain the service name (potential info leak). - try { - await src.resolve(); - } catch (err) { - expect((err as Error).message).not.toContain("secret-service-name"); - } - }); - - it("throws on empty helper body", async () => { - const env: KekSourceEnv = { - KEK_HELPER: fakeHelper(() => new Response("")), - }; - const src = buildKekSource("keychain://x", env); - await expect(src.resolve()).rejects.toThrow(/returned empty body/); - }); - - it("supports http:// helper URLs (treated as helper-backed)", async () => { - const env: KekSourceEnv = { - KEK_HELPER: fakeHelper(() => new Response("http-backed-kek")), - }; - const src = buildKekSource("http://my-helper.local/kek", env); - await expect(src.resolve()).resolves.toBe("http-backed-kek"); - }); -}); - -// ── Construction errors ──────────────────────────────────────────────────── - -describe("buildKekSource — construction errors", () => { - it("rejects empty spec", () => { - expect(() => buildKekSource("", {})).toThrow(); - }); - - it("rejects unknown scheme", () => { - expect(() => buildKekSource("dpapi://stuff", {})).toThrow(/unsupported URL scheme/); - }); - - it("rejects a spec missing a scheme entirely", () => { - expect(() => buildKekSource("just-some-string", {})).toThrow(/unsupported URL scheme/); - }); -}); diff --git a/vault/src/__tests__/rate-bucket.test.ts b/vault/src/__tests__/rate-bucket.test.ts deleted file mode 100644 index d530a01..0000000 --- a/vault/src/__tests__/rate-bucket.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 notme contributors -// Origin: developed in cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-17; see NOTICE. - -/** - * rate-bucket.test.ts — unit tests for the per-caller token-bucket - * (cloister-211b68 / dos-friend F1). The math is pure functions over a - * BucketState value, so we can exhaustively test the gate logic - * without going through the workerd RPC harness. - * - * Integration coverage (the DO's persistence + RPC dispatch shim) lives - * in test/vault-store.test.ts. Anything timing-sensitive or - * cardinality-sensitive belongs HERE, not there. - */ - -import { describe, expect, it } from "vitest"; -import { RATE_LIMITS, refillBucket, tryConsume } from "../rate-bucket"; - -describe("refillBucket", () => { - it("a never-seen subject starts with a full bucket", () => { - const r = refillBucket(null, 1_700_000_000_000); - expect(r.tokens).toBe(RATE_LIMITS.CAPACITY); - expect(r.lastRefillMs).toBe(1_700_000_000_000); - }); - - it("refills linearly between calls", () => { - const prev = { tokens: 0, lastRefillMs: 1_000 }; - // 500ms elapsed at 10 tokens/sec → 5 tokens. - const r = refillBucket(prev, 1_500); - expect(r.tokens).toBeCloseTo(5, 5); - expect(r.lastRefillMs).toBe(1_500); - }); - - it("clamps to CAPACITY (no over-refill)", () => { - const prev = { tokens: 50, lastRefillMs: 0 }; - // 60s elapsed at 10 tokens/sec = 600 tokens. Capped at CAPACITY (100). - const r = refillBucket(prev, 60_000); - expect(r.tokens).toBe(RATE_LIMITS.CAPACITY); - }); - - it("does not negative-elapse on clock skew (now < lastRefill)", () => { - const prev = { tokens: 50, lastRefillMs: 10_000 }; - // now is 1000ms BEFORE lastRefill — elapsedSec clamped to 0. - const r = refillBucket(prev, 9_000); - expect(r.tokens).toBe(50); - expect(r.lastRefillMs).toBe(9_000); - }); - - it("sub-token refill at sub-second resolution", () => { - const prev = { tokens: 0, lastRefillMs: 0 }; - // 100ms elapsed → 1 token. - const r1 = refillBucket(prev, 100); - expect(r1.tokens).toBeCloseTo(1, 5); - // 50ms more → 1.5 tokens total. - const r2 = refillBucket(r1, 150); - expect(r2.tokens).toBeCloseTo(1.5, 5); - }); -}); - -describe("tryConsume", () => { - it("accepts when tokens ≥ cost; subtracts cost", () => { - const state = { tokens: 5, lastRefillMs: 1000 }; - const r = tryConsume(state, 3); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.next.tokens).toBe(2); - expect(r.next.lastRefillMs).toBe(1000); - } - }); - - it("rejects when tokens < cost; returns retry-after", () => { - const state = { tokens: 1, lastRefillMs: 1000 }; - const r = tryConsume(state, 5); - expect(r.ok).toBe(false); - if (!r.ok) { - // Deficit 4 / 10 refill-per-sec = 0.4 → ceil → 1, clamped to min 1. - expect(r.retryAfterSec).toBe(1); - // State on reject preserves the refilled timestamp. - expect(r.next.lastRefillMs).toBe(1000); - expect(r.next.tokens).toBe(1); - } - }); - - it("retry-after scales with deficit", () => { - const state = { tokens: 0, lastRefillMs: 0 }; - // Deficit = cost = 100 (full bucket cost). 100/10 = 10s. - const r = tryConsume(state, 100); - expect(r.ok).toBe(false); - if (!r.ok) expect(r.retryAfterSec).toBe(10); - }); - - it("retry-after floors at 1s (never returns 0)", () => { - const state = { tokens: 0.5, lastRefillMs: 0 }; - // Deficit = 0.5 / 10 = 0.05 → ceil → 1, but clamped to ≥1. - const r = tryConsume(state, 1); - expect(r.ok).toBe(false); - if (!r.ok) expect(r.retryAfterSec).toBeGreaterThanOrEqual(1); - }); - - it("exact-tokens-equals-cost accepts and zeroes the bucket", () => { - const state = { tokens: 5, lastRefillMs: 1000 }; - const r = tryConsume(state, 5); - expect(r.ok).toBe(true); - if (r.ok) expect(r.next.tokens).toBe(0); - }); -}); - -describe("RATE_LIMITS — production limits sanity", () => { - it("read cost < write cost < proxy cost (relative weights make sense)", () => { - expect(RATE_LIMITS.COST.read).toBeLessThan(RATE_LIMITS.COST.write); - expect(RATE_LIMITS.COST.write).toBeLessThan(RATE_LIMITS.COST.proxy); - }); - - it("capacity supports realistic burst for each cost class", () => { - // 10× headroom over realistic single-burst patterns: - // reads ≤ 10/burst, writes ≤ 3/burst, proxies ≤ 2/burst. - expect(RATE_LIMITS.CAPACITY / RATE_LIMITS.COST.read).toBeGreaterThanOrEqual(100); - expect(RATE_LIMITS.CAPACITY / RATE_LIMITS.COST.write).toBeGreaterThanOrEqual(30); - expect(RATE_LIMITS.CAPACITY / RATE_LIMITS.COST.proxy).toBeGreaterThanOrEqual(20); - }); - - it("refill rate sustains realistic steady-state", () => { - // ≥1 read per refill-second steady-state. - expect(RATE_LIMITS.REFILL_PER_SEC / RATE_LIMITS.COST.read).toBeGreaterThanOrEqual(1); - }); - - it("MAX_INFLIGHT is bounded but allows realistic burst", () => { - expect(RATE_LIMITS.MAX_INFLIGHT).toBeGreaterThan(1); - expect(RATE_LIMITS.MAX_INFLIGHT).toBeLessThanOrEqual(64); - }); -}); - -describe("scenario: attacker tight-loop", () => { - it("100 cost-1 calls deplete a fresh bucket; 101st rejects", () => { - let state = refillBucket(null, 0); - let accepted = 0; - for (let i = 0; i < 101; i++) { - const r = tryConsume(state, 1); - if (r.ok) { - accepted++; - state = r.next; - } else { - state = r.next; - break; - } - } - expect(accepted).toBe(RATE_LIMITS.CAPACITY); // exactly CAPACITY accepted - }); - - it("after 100 rejections, bucket lastRefillMs advances (no time-freeze)", () => { - let state = { tokens: 0, lastRefillMs: 1000 }; - for (let t = 1100; t <= 2000; t += 100) { - state = refillBucket(state, t); - const r = tryConsume(state, 100); // demand more than tokens; always rejects - state = r.next; - } - // Final lastRefillMs reflects the latest refill, not the seed. - expect(state.lastRefillMs).toBe(2000); - }); -}); diff --git a/vault/src/__tests__/vault-adversarial.test.ts b/vault/src/__tests__/vault-adversarial.test.ts deleted file mode 100644 index 5956d64..0000000 --- a/vault/src/__tests__/vault-adversarial.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 notme contributors -// Origin: hardened in cloister (AGPL-3.0) by sole author, re-incorporated under Apache-2.0 on 2026-05-17; see NOTICE. - -/** - * vault-adversarial.test.ts — Red team tests. - * - * Each test simulates a specific attack vector. If any of these pass - * without the security check, we have a vulnerability. - */ - -import { describe, expect, it } from "vitest"; - -async function getVault() { - return import("../vault"); -} - -// ── Helper: build a stored credential with a known secret ────────────────── - -const SECRET_KEY = "sk-live-NEVER-SEE-THIS-abc123xyz789"; -const NVD_CRED = { - upstream: "https://services.nvd.nist.gov/rest/json/cves/2.0", - headers: { apiKey: SECRET_KEY }, - allowedSubs: ["repo:org/venturi:*"], -}; - -// ═══════════════════════════════════════════════════════════════════════════ -// ATTACK 1: Credential exfiltration via reflected headers -// Attacker's goal: get the vault to send the API key back in the response -// ═══════════════════════════════════════════════════════════════════════════ - -describe("ATTACK: credential exfiltration via headers", () => { - it("credential headers do NOT appear in sanitized response", async () => { - const { sanitizeResponse } = await getVault(); - - // Imagine upstream echoes back all request headers (some APIs do this in debug mode) - const upstream = new Response('{"debug": true}', { - headers: { - "Content-Type": "application/json", - "apiKey": SECRET_KEY, // upstream echoes back the key we sent - "X-Echo-Authorization": "Bearer " + SECRET_KEY, - "X-Debug-Headers": JSON.stringify({ apiKey: SECRET_KEY }), - }, - }); - - const safe = sanitizeResponse(upstream); - // None of the echoed credential headers should make it through - expect(safe.headers.get("apiKey")).toBeNull(); - expect(safe.headers.get("X-Echo-Authorization")).toBeNull(); - expect(safe.headers.get("X-Debug-Headers")).toBeNull(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// ATTACK 2: Scope escalation via glob injection -// Attacker's goal: bypass scope restrictions by crafting a sub that -// exploits regex/glob parsing bugs -// ═══════════════════════════════════════════════════════════════════════════ - -describe("ATTACK: scope escalation via glob injection", () => { - it("regex special chars in sub don't bypass matching", async () => { - const { checkAccess } = await getVault(); - - // Attacker tries to use regex metacharacters as their sub - expect(checkAccess(["repo:org/venturi:read"], "repo:org/venturi:read|repo:org/secret:admin")).toBe(false); - expect(checkAccess(["repo:org/venturi:*"], "repo:org/venturi:read\nrepo:org/secret:admin")).toBe(false); - expect(checkAccess(["repo:org/venturi:*"], "repo:org/(.*)")).toBe(false); - }); - - it("pattern with regex metacharacters is safe", async () => { - const { checkAccess } = await getVault(); - - // Attacker convinces admin to store a pattern that looks like regex - // The pattern should be treated as a literal glob, not regex - expect(checkAccess(["repo:org/(.*):*"], "repo:org/venturi:read")).toBe(false); - expect(checkAccess(["repo:org/[abc]:*"], "repo:org/a:read")).toBe(false); - }); - - it("null bytes in sub are rejected outright", async () => { - const { checkAccess } = await getVault(); - - // Null bytes in subs are always rejected — they're control characters - // used in injection attacks (C-style string truncation) - expect(checkAccess(["repo:org/venturi:*"], "repo:org/venturi:read\0repo:org/secret:admin")).toBe(false); - expect(checkAccess(["repo:org/venturi:read"], "repo:org/venturi:read\0")).toBe(false); - expect(checkAccess(["*"], "anything\0else")).toBe(false); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// ATTACK 3: Service name injection / path traversal -// Attacker's goal: access a different service's credentials by crafting -// the service name parameter -// ═══════════════════════════════════════════════════════════════════════════ - -describe("ATTACK: service name injection", () => { - it("rejects path traversal attempts", async () => { - const { validateServiceName } = await getVault(); - - const attacks = [ - "../admin", - "..%2fadmin", - "nvd/../admin", - "nvd/../../etc/passwd", - "..", - ".", - "./nvd", - "nvd/.", - "%2e%2e/admin", - "....//admin", - ]; - - for (const attack of attacks) { - expect(validateServiceName(attack)).toBe(false); - } - }); - - it("rejects URL-encoded attacks", async () => { - const { validateServiceName } = await getVault(); - - expect(validateServiceName("nvd%00admin")).toBe(false); // null byte - expect(validateServiceName("nvd\nadmin")).toBe(false); // newline - expect(validateServiceName("nvd\tadmin")).toBe(false); // tab - expect(validateServiceName("nvd admin")).toBe(false); // space - }); - - it("rejects control characters", async () => { - const { validateServiceName } = await getVault(); - - for (let i = 0; i < 32; i++) { - const name = "nvd" + String.fromCharCode(i) + "admin"; - expect(validateServiceName(name)).toBe(false); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// ATTACK 4: SSRF via upstream URL manipulation -// Attacker's goal: make the vault fetch from an internal service -// (metadata endpoint, localhost, private network) -// ═══════════════════════════════════════════════════════════════════════════ - -describe("ATTACK: SSRF via upstream URL", () => { - it("blocks cloud metadata endpoints", async () => { - const { validateUpstreamUrl } = await getVault(); - - const metadataUrls = [ - "https://169.254.169.254/latest/meta-data/", - "https://169.254.169.254/latest/api/token", - "https://metadata.google.internal/computeMetadata/v1/", - "https://169.254.169.254/metadata/instance", - ]; - - for (const url of metadataUrls) { - expect(validateUpstreamUrl(url)).toBe(false); - } - }); - - it("blocks localhost with various representations", async () => { - const { validateUpstreamUrl } = await getVault(); - - const localhostVariants = [ - "https://localhost/steal", - "https://127.0.0.1/steal", - "https://[::1]/steal", - "https://0.0.0.0/steal", - "https://127.1/steal", // shorthand - "https://127.0.0.255/steal", // still loopback - ]; - - for (const url of localhostVariants) { - expect(validateUpstreamUrl(url)).toBe(false); - } - }); - - it("blocks private RFC1918 ranges", async () => { - const { validateUpstreamUrl } = await getVault(); - - const privateIps = [ - "https://10.0.0.1/api", - "https://10.255.255.255/api", - "https://172.16.0.1/api", - "https://172.31.255.255/api", - "https://192.168.0.1/api", - "https://192.168.255.255/api", - ]; - - for (const url of privateIps) { - expect(validateUpstreamUrl(url)).toBe(false); - } - }); - - it("blocks non-HTTPS (downgrade attack)", async () => { - const { validateUpstreamUrl } = await getVault(); - - expect(validateUpstreamUrl("http://api.example.com/data")).toBe(false); - expect(validateUpstreamUrl("ftp://api.example.com/data")).toBe(false); - expect(validateUpstreamUrl("javascript:alert(1)")).toBe(false); - expect(validateUpstreamUrl("data:text/html,