From ac1ccbb159d9ccb0e2f603c252ae5f8d5aae51b0 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:13:54 +0000 Subject: [PATCH 1/3] Encrypt credential submissions with per-request JWE keys --- .env.example | 7 +- .github/workflows/check.yml | 25 ++ README.md | 88 ++++--- app/credentials/collect/collection-page.tsx | 23 +- bun.lock | 192 ++++++++++++++ lib/collection-handler.ts | 48 +++- lib/collection-keys.ts | 82 ++++++ lib/credential-envelope.ts | 43 ++++ lib/private-key-protector.ts | 52 ++++ lib/request-store.ts | 38 ++- lib/runtime.ts | 3 +- lib/vault-client.ts | 2 +- package.json | 1 + scripts/create-request.ts | 12 +- tests/collection-handler.test.ts | 262 +++++++++++++++++--- tests/collection-keys.test.ts | 82 ++++++ tests/collection-page.test.tsx | 81 +++++- tests/setup.ts | 3 + 18 files changed, 927 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 bun.lock create mode 100644 lib/collection-keys.ts create mode 100644 lib/credential-envelope.ts create mode 100644 lib/private-key-protector.ts create mode 100644 tests/collection-keys.test.ts diff --git a/.env.example b/.env.example index 547d400..da6bef1 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ MOCK_VAULT=true KERNEL_API_KEY= # KERNEL_API_BASE_URL=https://api.onkernel.com -# Local links are written to .data/credential-requests.json. +# Required in BOTH mock and real mode. Server-only; never use a NEXT_PUBLIC_ variable. +# Generate 32 random bytes as base64 once (see README); use the same key for CLI and server. +# Keep outside the request store. Production should use KMS/HSM-backed key wrapping. +COLLECTION_KEY_WRAPPING_KEY= + +# Local links and wrapped per-request private keys are written to .data/credential-requests.json. # Replace FileCollectionRequestStore with your database in production. COLLECTION_REQUEST_TTL_MINUTES=15 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..e8436f5 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,25 @@ +name: Check + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run test + - run: bun run build + env: + NEXT_TELEMETRY_DISABLED: 1 diff --git a/README.md b/README.md index ee7dd27..dbf0b68 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,90 @@ -# Customer-hosted credential collection +# Customer-hosted encrypted credential collection -A runnable Next.js example for collecting credentials with [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react). The browser renders Kernel's unstyled credential form while a same-origin backend keeps the Kernel API key private and maps one-time bearer capabilities to vault items. +A runnable Next.js example using [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react). The existing `CredentialForm.onSubmit` boundary encrypts the complete `CredentialSubmission` before sending it to the same-origin backend. Only that backend's collection handler decrypts it, validates it, and forwards plaintext through the existing Kernel item PATCH. No Kernel API or `@onkernel/vault-react` changes are required. ## Run the mock flow -Requires Bun and Node.js 20.9 or newer. +Requires Bun and Node.js 20.9 or newer. Web Crypto requires HTTPS in browsers; localhost works for development. ```sh cp .env.example .env.local -bun install +chmod 600 .env.local +bun install --frozen-lockfile +# Run once: writes a fresh wrapping key directly to the ignored env file, not stdout. +bun -e 'const fs = require("node:fs"); const crypto = require("node:crypto"); const path = ".env.local"; fs.writeFileSync(path, fs.readFileSync(path, "utf8").replace(/^COLLECTION_KEY_WRAPPING_KEY=$/m, "COLLECTION_KEY_WRAPPING_KEY=" + crypto.randomBytes(32).toString("base64")));' bun run dev ``` -In a second terminal, create a 15-minute collection link: +In a second terminal: ```sh bun run create-request ``` -Open the printed URL. Mock mode exercises the complete browser/backend flow without calling Kernel. Do not enter real credentials in mock mode. +Open the printed 15-minute collection link. Mock mode exercises browser encryption, backend decryption, and consumption without calling Kernel. Do not enter real credentials in mock mode. The mock vault is in-memory and resets on restart; collection requests survive in `.data/credential-requests.json`. + +The CLI and server must use the same `COLLECTION_KEY_WRAPPING_KEY`: a server-only, base64-encoded 32-byte random key. There is no insecure default, including in mock mode. Restart the server after changing environment variables. Changing this key makes existing requests undecryptable; generate new links or implement key rotation with historical wrapping-key IDs. Old request files without encryption keys are not migrated; generate fresh links. ## Connect a Kernel vault item 1. Set `MOCK_VAULT=false` and `KERNEL_API_KEY` in `.env.local`. 2. Create a credential item in a Kernel vault. -3. Generate a link for that item: +3. Generate a link: ```sh bun run create-request -- ``` -The command retrieves the item's immutable ID, generates 32 random bytes, stores only the SHA-256 token digest, and prints the raw token once in this form: +The command retrieves the immutable item ID, creates a fresh P-256 keypair and key ID, wraps the private key, and persists the request. It also generates 32 random bearer bytes, stores only their SHA-256 digest, and prints the raw token once: ```text https://app.example.com/credentials/collect#token=<43-character-base64url-token> ``` -The fragment is not sent in page requests or referrers. The page sends it only in an `Authorization: Bearer ` header to the same-origin `/api/credential-requests/current` endpoint. +Treat the printed URL as a credential. The fragment is not sent in page requests or referrers. The page sends the token only in an `Authorization: Bearer ` header to `/api/credential-requests/current` and clears the fragment after confirmed completion. -## How it is organized +## Encryption flow -- `app/credentials/collect` reads and clears the fragment capability and renders `CredentialForm`. -- `app/api/credential-requests/current` serves safe item data and accepts versioned field edits. -- `lib/collection-handler.ts` validates the bearer, origin, target, item ID, version, field names, value sizes, and body size. -- `lib/vault-client.ts` contains real and mock vault adapters. Only the real adapter receives `KERNEL_API_KEY`. -- `lib/request-store.ts` defines the durable `CollectionRequestStore` boundary. -- `scripts/create-request.ts` creates a short-lived link without persisting the raw token. +1. **Create:** `scripts/create-request.ts` generates a separate keypair for every collection request. `PrivateKeyProtector` wraps its PKCS#8 private key before `CollectionRequestStore` saves anything. No plaintext private JWK/PEM is persisted. +2. **Render:** the bearer-authorized GET returns the safe item projection plus `{ keyId, requestId, itemId, version, publicKey }`. The explicit public-key projection includes only `kty`, `crv`, `x`, and `y`; neither wrapped nor unwrapped private-key material is returned. +3. **Encrypt:** `lib/credential-envelope.ts` uses `jose` and browser Web Crypto to produce compact JWE with **ECDH-ES+A256KW / A256GCM**, using P-256. JOSE manages the ephemeral sender key, content-encryption key, key wrapping, nonce, and authentication tag. The entire submission, including non-sensitive edits and null clears, is encrypted. The PATCH body is only the compact JWE, with `Content-Type: application/jose`. +4. **Validate and forward:** `lib/collection-handler.ts` checks the bearer, expiry, revocation, consumption, and exact origin. It retrieves the mapped item's immutable ID, unwraps and decrypts inside the handler path, and validates the authenticated key ID, request ID, item ID, and version. The protected-header version must equal the plaintext submission version and the current item version. Existing field-name/type/required/size validation remains. The SDK PATCH still carries plaintext fields, `expected_item_id`, and the rendered version. +5. **Complete:** after a confirmed valid Kernel response (or an unchanged submission), the store marks the request consumed and clears its wrapped private key in the same file replacement. PATCH returns only a completion marker, not echoed field values. Subsequent reads/writes return `consumed`. -Successful submissions mark the mapping consumed. Kernel writes include both the rendered version and immutable item ID, so a stale form or a deleted-and-recreated key cannot redirect a write. +The server pins both JWE algorithms; the client cannot select a weaker algorithm. The protected header is authenticated as JWE additional authenticated data, not encrypted: IDs, version, algorithm identifiers, and the ephemeral public key are visible. The JWE authenticates ciphertext integrity, **not sender identity**; authorization still depends on the bearer and origin checks. Anyone with the public key can construct ciphertext, but cannot submit without a valid bearer. -## Use a production store +Limits: 192 KiB streamed HTTP envelope, 128 KiB decrypted UTF-8 JSON, 2 KiB encoded protected header, 32 edited fields, and 16 KiB UTF-8 per value. Compression is not supported. Plaintext JSON PATCHes are rejected. -`FileCollectionRequestStore` is intentionally local-only. Its atomic file replacement prevents torn files, but it does not coordinate multiple processes and most serverless filesystems are ephemeral. +## Guarantees and non-guarantees -Before deployment, implement `CollectionRequestStore` with your database and replace the construction in `lib/runtime.ts`. Persist: +**Protected:** passive capture of the browser-to-collection-endpoint request body by a CDN, load balancer, request logger, generic middleware/APM, or other relay sees ciphertext, not entered values. This assumes those components do not control the served JavaScript/public key or inspect the decrypting process. HTTPS, `no-store`, `no-referrer`, and same-origin enforcement remain necessary. -```ts -{ - id, - tokenDigest, - vaultId, - itemKey, - itemId, - expiresAt, - consumedAt, - revokedAt, -} -``` +**Not end-to-end encryption from the application:** the customer-hosted frontend sees entered values. The decrypt-and-forward backend briefly handles plaintext, as does Kernel through the existing API. A compromised frontend, XSS, session replay, malicious key substitution, in-process instrumentation of the handler, or backend compromise can recover values. Code with access to both the wrapping service/key and stored keys can decrypt captured envelopes. This does not protect the backend-to-Kernel plaintext request from instrumentation on that leg; use TLS and exclude it from capture too. + +**Not all response data is secret:** the existing safe GET projection intentionally includes displayable non-sensitive saved values (for example, email), field definitions, and status. Those responses are not application-layer encrypted. Sensitive saved values are never returned. Bearer headers, URL fragments, request metadata, and ciphertext lengths also remain outside this confidentiality guarantee. One-time consumption prevents later writes, not disclosure after key compromise; this is not forward secrecy. + +No secrets, private keys, plaintext, envelopes, or upstream bodies/errors are logged by the handler. SDK logging is explicitly disabled. Configure platform, proxy, and APM capture separately: do not record authorization headers, collection URLs/fragments, request bodies, DOM input/session replay, handler locals, or outbound Kernel bodies/errors. Generic middleware that only observes HTTP bodies is distinct from an APM agent inspecting handler memory. -Add any signed-in application user ID needed by your authorization model. Verify that user in addition to the bearer on every read and write. Make consumption and revocation atomic in the database. +## Failure and retry behavior -## Deployment checklist +- Validation failures, conflicts, and upstream failures do **not** consume the request or delete its wrapped key. A same-version retry can succeed if Kernel did not commit. The SDK disables automatic retries. +- If Kernel commits but its response is lost, or consumption persistence fails after the commit, retrying the original envelope sees the advanced version and returns `stale` without another write. The key remains available for authorized reconciliation until explicitly cleared. A changed version alone does not prove this submission succeeded: another writer may have updated the item. +- A lost browser response after durable consumption returns `consumed` on retry. No second write occurs. The example serializes concurrent PATCHes for a token within one handler instance. +- There is no distributed transaction with Kernel and no automatic reconciliation workflow here. In production, persist an in-flight operation/version and outcome, investigate uncertain outcomes before retiring their keys, and use a durable per-request claim shared by all replicas. Never claim exactly-once delivery or automatically consume an uncertain result. -1. Set `APP_ORIGIN` to the exact HTTPS origin serving the page. -2. Set `MOCK_VAULT=false` and provide `KERNEL_API_KEY` only to the server runtime. -3. Replace the file store with a durable database implementation. -4. Deliver links through a channel suitable for password-reset links and keep expiry short. -5. Exclude URLs, fragments, authorization headers, request bodies, credentials, and upstream errors from logs, analytics, traces, and session replay. +## Storage and production guidance -The customer page and backend receive raw entered values. They are trusted application code, not an isolated Kernel collector. Keep the collection route free of third-party scripts and preserve the response's `no-store` and `no-referrer` policies. +`LocalPrivateKeyProtector` uses `jose` direct-key **AES-256-GCM** JWE wrapping under the environment key. Its authenticated header binds the private key to the request, immutable item, and key ID. Keep the environment key separate from the data store; do not commit it, expose it in a `NEXT_PUBLIC_` variable, or include it in browser builds/backups with the request file. A file copy alone cannot decrypt private keys without the wrapping key. + +`FileCollectionRequestStore` is **local-development-only**. Mutations are serialized within one store instance, and atomic replacement prevents torn files. Neither coordinates separate instances/processes. The CLI and server share that file; avoid creating requests concurrently with submissions. Multiple replicas, cross-process writers, and ephemeral/serverless storage are not supported. In-handler serialization is not a distributed lock. + +Before deploying: + +1. Replace `PrivateKeyProtector` with a KMS/HSM-backed wrapping service or equivalent, with scoped IAM, audit controls, key rotation/versioning, and separation from stored ciphertext. The decrypting handler remains trusted; wrapping is not protection against that handler's compromise. +2. Replace `CollectionRequestStore` with durable transactional storage and distributed claims. Persist the existing request mapping/lifecycle fields plus `key: { keyId, publicKey, wrappedPrivateKey }`. Atomically record consumption and remove wrapped material; retain recoverable state for uncertain writes. +3. Sweep expired/revoked requests and reconcile uncertain outcomes before applying the retention policy. Clearing the current wrapped key does not securely erase old files, backups, snapshots, or memory. Byte buffers are cleared where practical, but JavaScript strings, parsed submissions, and CryptoKeys cannot be reliably zeroized. +4. Use an exact HTTPS `APP_ORIGIN`, keep the collection route free of third-party scripts, apply CSP and rate limits, and exclude sensitive input/output from logs and telemetry. Store `KERNEL_API_KEY` only on the server. +5. Deliver links through a channel suitable for password-reset links and keep expiry short. Add signed-in user checks to both reads and writes if required by your authorization model. The fragment bearer alone is the example's capability. ## Checks @@ -88,6 +94,8 @@ bun run test bun run build ``` +Tests exercise the actual form-to-handler flow, ciphertext-only browser bodies, exact decrypted SDK payloads, key/envelope/binding tampering, plaintext and size rejection, lifecycle/origin checks, wrapped storage/cleanup, concurrent submissions, and uncertain outcomes. + ## License MIT diff --git a/app/credentials/collect/collection-page.tsx b/app/credentials/collect/collection-page.tsx index 4290d30..0f88eb1 100644 --- a/app/credentials/collect/collection-page.tsx +++ b/app/credentials/collect/collection-page.tsx @@ -9,6 +9,7 @@ import { type CredentialItem, } from '@onkernel/vault-react'; import { readCollectionToken } from '@/lib/collection-token'; +import { encryptSubmission, type CollectionEncryption } from '@/lib/credential-envelope'; const endpoint = '/api/credential-requests/current'; const errorCodes = new Set([ @@ -43,7 +44,10 @@ export function CollectionPage() { } function RequestForm({ token }: { token: string }) { - const [item, setItem] = useState(); + const [collection, setCollection] = useState<{ + item: CredentialItem; + encryption: CollectionEncryption; + }>(); const [failure, setFailure] = useState(); const authorization = `Bearer ${token}`; @@ -57,8 +61,12 @@ function RequestForm({ token }: { token: string }) { }) .then(async (response) => { if (!response.ok) throw new CredentialFormError(await responseError(response, 'unavailable')); - const safe = safeCredentialItem(await response.json()); - if (!controller.signal.aborted) setItem(safe); + const data = await response.json(); + const item = safeCredentialItem(data.item); + if (data.encryption.itemId !== item.id || data.encryption.version !== item.version) { + throw new CredentialFormError('unavailable'); + } + if (!controller.signal.aborted) setCollection({ item, encryption: data.encryption }); }) .catch((error: unknown) => { if (!controller.signal.aborted) { @@ -75,20 +83,21 @@ function RequestForm({ token }: { token: string }) { }; return {messages[failure] ?? 'collection unavailable. request a new link.'}; } - if (!item) return

loading credential fields…

; + if (!collection) return

loading credential fields…

; return ( { + const envelope = await encryptSubmission(submission, collection.encryption); const response = await fetch(endpoint, { method: 'PATCH', credentials: 'omit', cache: 'no-store', - headers: { Authorization: authorization, 'Content-Type': 'application/json' }, - body: JSON.stringify(submission), + headers: { Authorization: authorization, 'Content-Type': 'application/jose' }, + body: envelope, }); if (!response.ok) { throw new CredentialFormError(await responseError(response, 'unavailable')); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f8c11f9 --- /dev/null +++ b/bun.lock @@ -0,0 +1,192 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "vault-react-example", + "dependencies": { + "@onkernel/sdk": "^0.110.0", + "@onkernel/vault-react": "^0.1.0", + "jose": "^6.2.12", + "next": "^16.3.5", + "react": "^19.2.3", + "react-dom": "^19.2.3", + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.14.5", + "@types/bun": "latest", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.5" } }, "sha512-B05ID9DhSwLs6mlm1fzlkAtTIvB3duCvjJjfr19LBrlTK7VZtRjDqoTRIVv13GuYuNdhByu8LSZgThwV3Rkj7g=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + + "@next/env": ["@next/env@16.3.5", "", {}, "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA=="], + + "@onkernel/sdk": ["@onkernel/sdk@0.110.0", "", {}, "sha512-lUMXEcp8FsQkcVV8AzGU+dkrA9f5ucaaYMIDA6Y2N9oHCtcBENiUKFJpxZTA/+Oc9by8Zj+3H10qajCsWUgDiw=="], + + "@onkernel/vault-react": ["@onkernel/vault-react@0.1.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-XogSCQ3GhtU9Q0TeIFt+pdfKOQVRZHLwKE6Uvj1YvH5CLwaF2q45KgM4jnMmUvrOXamq/of0xh3ECPPtOF/xgA=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@22.20.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="], + + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "next": ["next@16.3.5", "", { "dependencies": { "@next/env": "16.3.5", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.5", "@next/swc-darwin-x64": "16.3.5", "@next/swc-linux-arm64-gnu": "16.3.5", "@next/swc-linux-arm64-musl": "16.3.5", "@next/swc-linux-x64-gnu": "16.3.5", "@next/swc-linux-x64-musl": "16.3.5", "@next/swc-win32-arm64-msvc": "16.3.5", "@next/swc-win32-x64-msvc": "16.3.5", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "@happy-dom/global-registrator/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "@types/ws/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "buffer-image-size/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "bun-types/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "happy-dom/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "@types/ws/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "buffer-image-size/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "happy-dom/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/lib/collection-handler.ts b/lib/collection-handler.ts index 5fa2767..8e234b8 100644 --- a/lib/collection-handler.ts +++ b/lib/collection-handler.ts @@ -1,6 +1,9 @@ import { safeCredentialItem, type CredentialSubmission } from '@onkernel/vault-react/data'; import type { CollectionRequestStore } from './request-store'; import { collectionTokenDigest } from './server-token'; +import { decryptSubmission, keyBinding } from './collection-keys'; +import { MAX_ENVELOPE_BYTES } from './credential-envelope'; +import type { PrivateKeyProtector } from './private-key-protector'; import { VaultConflictError, VaultInvalidRequestError, @@ -16,7 +19,7 @@ function json(body: unknown, status = 200) { }); } -async function readJson(request: Request): Promise { +async function readEnvelope(request: Request): Promise { const reader = request.body?.getReader(); if (!reader) throw new Error('invalid request'); const chunks: Uint8Array[] = []; @@ -26,7 +29,7 @@ async function readJson(request: Request): Promise { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; - if (size > 128 * 1024) { + if (size > MAX_ENVELOPE_BYTES) { await reader.cancel(); throw new Error('invalid request'); } @@ -41,48 +44,69 @@ async function readJson(request: Request): Promise { body.set(chunk, offset); offset += chunk.byteLength; } - return JSON.parse(new TextDecoder().decode(body)); + return new TextDecoder('utf-8', { fatal: true }).decode(body); } export function createCollectionHandler({ appOrigin, store, vault, + protector, now = Date.now, }: { appOrigin: string; store: CollectionRequestStore; vault: CredentialVaultClient; + protector: PrivateKeyProtector; now?: () => number; }) { const trustedOrigin = new URL(appOrigin).origin; + // Local-only serialization. Production needs a durable, distributed request claim. + const submitting = new Set(); return async (request: Request): Promise => { + let claimedDigest: string | undefined; try { if (!['GET', 'PATCH'].includes(request.method)) return json({ code: 'invalid' }, 405); const bearer = bearerPattern.exec(request.headers.get('Authorization') ?? '')?.[1]; if (!bearer) return json({ code: 'unavailable' }, 401); const digest = collectionTokenDigest(bearer); + if (request.method === 'PATCH') { + if (request.headers.get('Origin') !== trustedOrigin) return json({ code: 'unavailable' }, 403); + if (submitting.has(digest)) return json({ code: 'unavailable' }, 409); + submitting.add(digest); + claimedDigest = digest; + } const mapping = await store.findByTokenDigest(digest); if (!mapping) return json({ code: 'unavailable' }, 404); if (mapping.revokedAt !== null) return json({ code: 'unavailable' }, 410); if (mapping.consumedAt !== null) return json({ code: 'consumed' }, 410); if (mapping.expiresAt <= now()) return json({ code: 'expired' }, 410); - if (request.method === 'PATCH' && request.headers.get('Origin') !== trustedOrigin) { - return json({ code: 'unavailable' }, 403); - } const item = safeCredentialItem(await vault.retrieve(mapping)); if (item.id !== mapping.itemId) return json({ code: 'unavailable' }, 404); - if (request.method === 'GET') return json(item); - if (!request.headers.get('Content-Type')?.startsWith('application/json')) { + if (request.method === 'GET') { + const { x, y } = mapping.key.publicKey; + return json({ + item, + encryption: { + ...keyBinding(mapping), + version: item.version, + publicKey: { kty: 'EC', crv: 'P-256', x, y }, + }, + }); + } + if (request.headers.get('Content-Type')?.split(';')[0].trim() !== 'application/jose') { return json({ code: 'invalid' }, 400); } let body: unknown; + let boundVersion: number; try { - body = await readJson(request); + const decrypted = await decryptSubmission(await readEnvelope(request), mapping, protector); + body = decrypted.submission; + boundVersion = decrypted.version; } catch { return json({ code: 'invalid' }, 400); } @@ -91,6 +115,7 @@ export function createCollectionHandler({ } const input = body as Record; if ( + input.version !== boundVersion || !Number.isSafeInteger(input.version) || Number(input.version) < 1 || !input.fields || @@ -131,8 +156,9 @@ export function createCollectionHandler({ try { const updated = safeCredentialItem(await vault.update(mapping, submission)); + if (updated.id !== mapping.itemId) throw new Error('unexpected vault item'); await store.markConsumed(digest, now()); - return json(updated); + return json({ saved: true }); } catch (error) { if (error instanceof VaultConflictError) return json({ code: 'stale' }, 409); if (error instanceof VaultInvalidRequestError) return json({ code: 'invalid' }, 400); @@ -140,6 +166,8 @@ export function createCollectionHandler({ } } catch { return json({ code: 'unavailable' }, 502); + } finally { + if (claimedDigest) submitting.delete(claimedDigest); } }; } diff --git a/lib/collection-keys.ts b/lib/collection-keys.ts new file mode 100644 index 0000000..8671a96 --- /dev/null +++ b/lib/collection-keys.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; +import { + compactDecrypt, decodeProtectedHeader, exportJWK, exportPKCS8, generateKeyPair, importPKCS8, + type JWK, +} from 'jose'; +import { + CONTENT_ALGORITHM, ENVELOPE_TYPE, KEY_ALGORITHM, MAX_ENVELOPE_BYTES, MAX_PLAINTEXT_BYTES, + type KeyBinding, +} from './credential-envelope'; +import type { PrivateKeyProtector } from './private-key-protector'; +import type { CollectionRequest } from './request-store'; + +export type CollectionKey = { + keyId: string; + publicKey: JWK; + wrappedPrivateKey: string | null; +}; + +export function keyBinding(request: CollectionRequest): KeyBinding { + return { keyId: request.key.keyId, requestId: request.id, itemId: request.itemId }; +} + +export async function createCollectionKey( + requestId: string, + itemId: string, + protector: PrivateKeyProtector, +): Promise { + const keyId = randomUUID(); + const { publicKey, privateKey } = await generateKeyPair(KEY_ALGORITHM, { + crv: 'P-256', extractable: true, + }); + const privateBytes = new TextEncoder().encode(await exportPKCS8(privateKey)); + try { + return { + keyId, + publicKey: await exportJWK(publicKey), + wrappedPrivateKey: await protector.wrap(privateBytes, { keyId, requestId, itemId }), + }; + } finally { + privateBytes.fill(0); + } +} + +export async function decryptSubmission( + envelope: string, + request: CollectionRequest, + protector: PrivateKeyProtector, +): Promise<{ submission: unknown; version: number }> { + if ( + envelope.length > MAX_ENVELOPE_BYTES || + !/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){4}$/.test(envelope) || + envelope.indexOf('.') > 2048 || !request.key.wrappedPrivateKey + ) throw new Error('invalid envelope'); + + const header = decodeProtectedHeader(envelope); + if ( + header.alg !== KEY_ALGORITHM || header.enc !== CONTENT_ALGORITHM || + header.typ !== ENVELOPE_TYPE || header.kid !== request.key.keyId || + header.request_id !== request.id || header.item_id !== request.itemId || + !Number.isSafeInteger(header.version) || Number(header.version) < 1 + ) throw new Error('invalid envelope'); + + const privateBytes = await protector.unwrap(request.key.wrappedPrivateKey, keyBinding(request)); + try { + const key = await importPKCS8(new TextDecoder().decode(privateBytes), KEY_ALGORITHM); + const { plaintext } = await compactDecrypt(envelope, key, { + keyManagementAlgorithms: [KEY_ALGORITHM], + contentEncryptionAlgorithms: [CONTENT_ALGORITHM], + }); + try { + if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) throw new Error('invalid submission'); + return { + submission: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext)), + version: Number(header.version), + }; + } finally { + plaintext.fill(0); + } + } finally { + privateBytes.fill(0); + } +} diff --git a/lib/credential-envelope.ts b/lib/credential-envelope.ts new file mode 100644 index 0000000..2577a23 --- /dev/null +++ b/lib/credential-envelope.ts @@ -0,0 +1,43 @@ +import { CompactEncrypt, importJWK, type JWK } from 'jose'; +import type { CredentialSubmission } from '@onkernel/vault-react/data'; + +export const KEY_ALGORITHM = 'ECDH-ES+A256KW'; +export const CONTENT_ALGORITHM = 'A256GCM'; +export const ENVELOPE_TYPE = 'credential-submission+jwe'; +export const MAX_PLAINTEXT_BYTES = 128 * 1024; +export const MAX_ENVELOPE_BYTES = 192 * 1024; + +export type KeyBinding = { + keyId: string; + requestId: string; + itemId: string; +}; + +export type CollectionEncryption = KeyBinding & { + version: number; + publicKey: JWK; +}; + +export async function encryptSubmission( + submission: CredentialSubmission, + encryption: CollectionEncryption, +): Promise { + const plaintext = new TextEncoder().encode(JSON.stringify(submission)); + if (plaintext.byteLength > MAX_PLAINTEXT_BYTES || submission.version !== encryption.version) { + throw new Error('invalid submission'); + } + const key = await importJWK(encryption.publicKey, KEY_ALGORITHM); + const envelope = await new CompactEncrypt(plaintext) + .setProtectedHeader({ + alg: KEY_ALGORITHM, + enc: CONTENT_ALGORITHM, + typ: ENVELOPE_TYPE, + kid: encryption.keyId, + request_id: encryption.requestId, + item_id: encryption.itemId, + version: encryption.version, + }) + .encrypt(key); + if (envelope.length > MAX_ENVELOPE_BYTES) throw new Error('invalid submission'); + return envelope; +} diff --git a/lib/private-key-protector.ts b/lib/private-key-protector.ts new file mode 100644 index 0000000..24df869 --- /dev/null +++ b/lib/private-key-protector.ts @@ -0,0 +1,52 @@ +import { CompactEncrypt, compactDecrypt } from 'jose'; +import type { KeyBinding } from './credential-envelope'; + +/** Replace this boundary with a KMS/HSM-backed implementation in production. */ +export interface PrivateKeyProtector { + wrap(privateKey: Uint8Array, binding: KeyBinding): Promise; + unwrap(wrappedKey: string, binding: KeyBinding): Promise; +} + +const type = 'collection-private-key+jwe'; + +export class LocalPrivateKeyProtector implements PrivateKeyProtector { + private readonly key: Uint8Array; + + constructor(encodedKey: string) { + const key = Buffer.from(encodedKey, 'base64'); + if (key.length !== 32 || key.toString('base64') !== encodedKey) { + throw new Error('COLLECTION_KEY_WRAPPING_KEY must be 32 random bytes encoded as base64'); + } + this.key = key; + } + + async wrap(privateKey: Uint8Array, binding: KeyBinding) { + return new CompactEncrypt(privateKey) + .setProtectedHeader({ + alg: 'dir', enc: 'A256GCM', typ: type, + kid: binding.keyId, request_id: binding.requestId, item_id: binding.itemId, + }) + .encrypt(this.key); + } + + async unwrap(wrappedKey: string, binding: KeyBinding) { + const { plaintext, protectedHeader } = await compactDecrypt(wrappedKey, this.key, { + keyManagementAlgorithms: ['dir'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + if ( + protectedHeader.typ !== type || protectedHeader.kid !== binding.keyId || + protectedHeader.request_id !== binding.requestId || protectedHeader.item_id !== binding.itemId + ) { + plaintext.fill(0); + throw new Error('invalid wrapped key'); + } + return plaintext; + } +} + +export function localPrivateKeyProtector() { + const key = process.env.COLLECTION_KEY_WRAPPING_KEY; + if (!key) throw new Error('COLLECTION_KEY_WRAPPING_KEY is required'); + return new LocalPrivateKeyProtector(key); +} diff --git a/lib/request-store.ts b/lib/request-store.ts index d6e473e..beea830 100644 --- a/lib/request-store.ts +++ b/lib/request-store.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; +import type { CollectionKey } from './collection-keys'; export type CollectionRequest = { id: string; @@ -7,6 +8,7 @@ export type CollectionRequest = { vaultId: string; itemKey: string; itemId: string; + key: CollectionKey; expiresAt: number; consumedAt: number | null; revokedAt: number | null; @@ -22,6 +24,8 @@ type StoreData = { requests: CollectionRequest[] }; /** Local development only. Implement CollectionRequestStore with your database in production. */ export class FileCollectionRequestStore implements CollectionRequestStore { + private pendingMutation = Promise.resolve(); + constructor(private readonly path: string) {} async findByTokenDigest(tokenDigest: string) { @@ -29,20 +33,32 @@ export class FileCollectionRequestStore implements CollectionRequestStore { } async save(request: CollectionRequest) { - const data = await this.read(); - if (data.requests.some(({ id, tokenDigest }) => id === request.id || tokenDigest === request.tokenDigest)) { - throw new Error('collection request already exists'); - } - data.requests.push(request); - await this.write(data); + return this.mutate((data) => { + if (data.requests.some(({ id, tokenDigest }) => id === request.id || tokenDigest === request.tokenDigest)) { + throw new Error('collection request already exists'); + } + data.requests.push(request); + }); } async markConsumed(tokenDigest: string, consumedAt: number) { - const data = await this.read(); - const request = data.requests.find((candidate) => candidate.tokenDigest === tokenDigest); - if (!request) throw new Error('collection request not found'); - request.consumedAt ??= consumedAt; - await this.write(data); + return this.mutate((data) => { + const request = data.requests.find((candidate) => candidate.tokenDigest === tokenDigest); + if (!request) throw new Error('collection request not found'); + request.consumedAt ??= consumedAt; + request.key.wrappedPrivateKey = null; + }); + } + + private mutate(update: (data: StoreData) => void) { + const mutation = this.pendingMutation.then(async () => { + const data = await this.read(); + update(data); + await this.write(data); + }); + // A failed write must not block later reconciliation attempts. + this.pendingMutation = mutation.catch(() => {}); + return mutation; } private async read(): Promise { diff --git a/lib/runtime.ts b/lib/runtime.ts index 1652638..505fb5f 100644 --- a/lib/runtime.ts +++ b/lib/runtime.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { createCollectionHandler } from './collection-handler'; import { FileCollectionRequestStore } from './request-store'; +import { localPrivateKeyProtector } from './private-key-protector'; import { KernelCredentialVaultClient, MockCredentialVaultClient } from './vault-client'; let handler: ReturnType | undefined; @@ -16,7 +17,7 @@ export function getCollectionHandler() { required('KERNEL_API_KEY'), process.env.KERNEL_API_BASE_URL, ); - handler = createCollectionHandler({ appOrigin, store, vault }); + handler = createCollectionHandler({ appOrigin, store, vault, protector: localPrivateKeyProtector() }); return handler; } diff --git a/lib/vault-client.ts b/lib/vault-client.ts index 17523ac..b393ba8 100644 --- a/lib/vault-client.ts +++ b/lib/vault-client.ts @@ -23,7 +23,7 @@ export class KernelCredentialVaultClient implements CredentialVaultClient { } as const; constructor(apiKey: string, baseURL?: string) { - this.kernel = new Kernel({ apiKey, maxRetries: 0, ...(baseURL ? { baseURL } : {}) }); + this.kernel = new Kernel({ apiKey, maxRetries: 0, logLevel: 'off', ...(baseURL ? { baseURL } : {}) }); } async retrieve(request: CollectionRequest) { diff --git a/package.json b/package.json index b995e03..b2a9885 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@onkernel/sdk": "^0.110.0", "@onkernel/vault-react": "^0.1.0", + "jose": "^6.2.12", "next": "^16.3.5", "react": "^19.2.3", "react-dom": "^19.2.3" diff --git a/scripts/create-request.ts b/scripts/create-request.ts index 99f63bc..2609f83 100644 --- a/scripts/create-request.ts +++ b/scripts/create-request.ts @@ -3,6 +3,8 @@ import { join } from 'node:path'; import Kernel from '@onkernel/sdk'; import { FileCollectionRequestStore } from '../lib/request-store'; import { collectionTokenDigest, createCollectionToken } from '../lib/server-token'; +import { createCollectionKey } from '../lib/collection-keys'; +import { localPrivateKeyProtector } from '../lib/private-key-protector'; const mock = process.env.MOCK_VAULT === 'true'; const [vaultArg, itemArg] = process.argv.slice(2); @@ -11,6 +13,7 @@ if (!mock && (!vaultArg || !itemArg)) { } const appOrigin = new URL(required('APP_ORIGIN')).origin; +const protector = localPrivateKeyProtector(); const ttlMinutes = Number(process.env.COLLECTION_REQUEST_TTL_MINUTES ?? '15'); if (!Number.isSafeInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > 24 * 60) { throw new Error('COLLECTION_REQUEST_TTL_MINUTES must be an integer from 1 to 1440'); @@ -24,9 +27,12 @@ if (!mock) { const kernel = new Kernel({ apiKey: required('KERNEL_API_KEY'), maxRetries: 0, + logLevel: 'off', ...(process.env.KERNEL_API_BASE_URL ? { baseURL: process.env.KERNEL_API_BASE_URL } : {}), }); - const item = await kernel.vaults.items.retrieve(itemKey, { id_or_name: vaultId }); + const item = await kernel.vaults.items.retrieve(itemKey, { id_or_name: vaultId }).catch(() => { + throw new Error('unable to retrieve the credential item'); + }); if (item.type !== 'credential') throw new Error('the selected vault item is not a credential'); itemId = item.id; } @@ -35,12 +41,14 @@ const token = createCollectionToken(); const store = new FileCollectionRequestStore( join(process.cwd(), '.data', 'credential-requests.json'), ); +const id = randomUUID(); await store.save({ - id: randomUUID(), + id, tokenDigest: collectionTokenDigest(token), vaultId, itemKey, itemId, + key: await createCollectionKey(id, itemId, protector), expiresAt: Date.now() + ttlMinutes * 60_000, consumedAt: null, revokedAt: null, diff --git a/tests/collection-handler.test.ts b/tests/collection-handler.test.ts index d9f6e58..bf91b3d 100644 --- a/tests/collection-handler.test.ts +++ b/tests/collection-handler.test.ts @@ -1,9 +1,14 @@ import { expect, mock, test } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { CompactEncrypt, importJWK } from 'jose'; +import { createCollectionKey, type CollectionKey } from '../lib/collection-keys'; +import { CONTENT_ALGORITHM, ENVELOPE_TYPE, KEY_ALGORITHM, MAX_ENVELOPE_BYTES, MAX_PLAINTEXT_BYTES } from '../lib/credential-envelope'; +import { LocalPrivateKeyProtector } from '../lib/private-key-protector'; import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react/data'; import { createCollectionHandler } from '../lib/collection-handler'; import type { CollectionRequest, CollectionRequestStore } from '../lib/request-store'; import { collectionTokenDigest } from '../lib/server-token'; -import { VaultConflictError, type CredentialVaultClient } from '../lib/vault-client'; +import { KernelCredentialVaultClient, VaultConflictError, VaultInvalidRequestError, type CredentialVaultClient } from '../lib/vault-client'; const origin = 'https://app.example.com'; const token = 'a'.repeat(43); @@ -36,16 +41,21 @@ function credentialItem(): CredentialItem { } class MemoryStore implements CollectionRequestStore { - request: CollectionRequest | null = { - id: 'request-1', - tokenDigest: digest, - vaultId: 'server-vault', - itemKey: 'server-key', - itemId: 'vi_example', - expiresAt: now + 60_000, - consumedAt: null, - revokedAt: null, - }; + request: CollectionRequest | null; + + constructor(key: CollectionKey) { + this.request = { + key, + id: 'request-1', + tokenDigest: digest, + vaultId: 'server-vault', + itemKey: 'server-key', + itemId: 'vi_example', + expiresAt: now + 60_000, + consumedAt: null, + revokedAt: null, + }; + } async findByTokenDigest(candidate: string) { return candidate === digest ? this.request : null; @@ -58,11 +68,14 @@ class MemoryStore implements CollectionRequestStore { async markConsumed(candidate: string, consumedAt: number) { if (!this.request || candidate !== digest) throw new Error('not found'); this.request.consumedAt = consumedAt; + this.request.key.wrappedPrivateKey = null; } } -function setup() { - const store = new MemoryStore(); +async function setup() { + const protector = new LocalPrivateKeyProtector(randomBytes(32).toString('base64')); + const key = await createCollectionKey('request-1', 'vi_example', protector); + const store = new MemoryStore(key); const retrieve = mock(async (_request: CollectionRequest): Promise => credentialItem()); const update = mock( async (_request: CollectionRequest, _submission: CredentialSubmission): Promise => ({ @@ -71,8 +84,18 @@ function setup() { }), ); const vault: CredentialVaultClient = { retrieve, update }; - const handler = createCollectionHandler({ appOrigin: origin, store, vault, now: () => now }); - return { handler, store, retrieve, update }; + const handler = createCollectionHandler({ appOrigin: origin, store, vault, protector, now: () => now }); + const publicKey = await importJWK(key.publicKey, KEY_ALGORITHM); + const encrypt = async (body: unknown, overrides: Record = {}) => { + const version = (body as { version?: number })?.version ?? 7; + return new CompactEncrypt(new TextEncoder().encode(JSON.stringify(body))) + .setProtectedHeader({ + alg: KEY_ALGORITHM, enc: CONTENT_ALGORITHM, typ: ENVELOPE_TYPE, + kid: key.keyId, request_id: 'request-1', item_id: 'vi_example', version, + ...overrides, + }).encrypt(publicKey); + }; + return { handler, store, retrieve, update, encrypt, protector }; } const get = (bearer = authorization) => @@ -83,12 +106,12 @@ const get = (bearer = authorization) => const patch = (body: unknown, requestOrigin = origin, bearer = authorization) => new Request(`${origin}/api/credential-requests/current`, { method: 'PATCH', - headers: { Authorization: bearer, Origin: requestOrigin, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { Authorization: bearer, Origin: requestOrigin, 'Content-Type': 'application/jose' }, + body: typeof body === 'string' ? body : JSON.stringify(body), }); test('only a valid fragment bearer can authorize a request', async () => { - const { handler, retrieve } = setup(); + const { handler, retrieve } = await setup(); for (const request of [get(''), get('Bearer request_123'), get(`Bearer ${'a'.repeat(42)}`)]) { expect((await handler(request)).status).toBe(401); } @@ -96,16 +119,20 @@ test('only a valid fragment bearer can authorize a request', async () => { }); test('returns only the safe item projection', async () => { - const { handler } = setup(); + const { handler } = await setup(); const response = await handler(get()); expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(response.headers.get('Referrer-Policy')).toBe('no-referrer'); - expect(await response.json()).toEqual(credentialItem()); + const body = await response.json(); + expect(body.item).toEqual(credentialItem()); + expect(body.encryption).toMatchObject({ requestId: 'request-1', itemId: 'vi_example', version: 7 }); + expect(Object.keys(body.encryption).sort()).toEqual(['itemId', 'keyId', 'publicKey', 'requestId', 'version']); + expect(Object.keys(body.encryption.publicKey).sort()).toEqual(['crv', 'kty', 'x', 'y']); }); test('rejects expired, consumed, revoked, and replaced targets', async () => { - const { handler, store, retrieve } = setup(); + const { handler, store, retrieve } = await setup(); store.request!.expiresAt = now; expect((await handler(get())).status).toBe(410); store.request!.expiresAt = now + 60_000; @@ -120,7 +147,7 @@ test('rejects expired, consumed, revoked, and replaced targets', async () => { }); test('rejects cross-origin writes before loading the item', async () => { - const { handler, retrieve } = setup(); + const { handler, retrieve } = await setup(); const response = await handler( patch({ version: 7, fields: {} }, 'https://attacker.example.com'), ); @@ -129,9 +156,9 @@ test('rejects cross-origin writes before loading the item', async () => { }); test('writes the mapped target with rendered version and consumes the request', async () => { - const { handler, store, update } = setup(); + const { handler, store, update, encrypt } = await setup(); const response = await handler( - patch({ version: 7, fields: { password: { value: ' exact ' } } }), + patch(await encrypt({ version: 7, fields: { password: { value: ' exact ' } } })), ); expect(response.status).toBe(200); expect(update).toHaveBeenCalledTimes(1); @@ -145,38 +172,211 @@ test('writes the mapped target with rendered version and consumes the request', fields: { password: { value: ' exact ' } }, }); expect(store.request?.consumedAt).toBe(now); + expect(store.request?.key.wrappedPrivateKey).toBeNull(); expect(await (await handler(get())).json()).toEqual({ code: 'consumed' }); + expect((await handler(patch(await encrypt({ version: 7, fields: {} })))).status).toBe(410); + expect(update).toHaveBeenCalledTimes(1); }); test('unchanged completion skips the vault write and consumes the request', async () => { - const { handler, store, update } = setup(); - expect(await (await handler(patch({ version: 7, fields: {} }))).json()).toEqual({ unchanged: true }); + const { handler, store, update, encrypt } = await setup(); + expect(await (await handler(patch(await encrypt({ version: 7, fields: {} })))).json()).toEqual({ unchanged: true }); expect(update).not.toHaveBeenCalled(); expect(store.request?.consumedAt).toBe(now); }); test('stale writes return a conflict without consuming the request', async () => { - const { handler, store, update } = setup(); - expect((await handler(patch({ version: 6, fields: { note: { value: 'edit' } } }))).status).toBe(409); + const { handler, store, update, encrypt } = await setup(); + expect((await handler(patch(await encrypt({ version: 6, fields: { note: { value: 'edit' } } })))).status).toBe(409); expect(update).not.toHaveBeenCalled(); update.mockImplementationOnce(async () => { throw new VaultConflictError(); }); - expect((await handler(patch({ version: 7, fields: { note: { value: 'edit' } } }))).status).toBe(409); + expect((await handler(patch(await encrypt({ version: 7, fields: { note: { value: 'edit' } } })))).status).toBe(409); expect(store.request?.consumedAt).toBeNull(); }); test('rejects TOTP, unknown fields, required clearing, oversized bodies, and malformed values', async () => { - const { handler, update } = setup(); + const { handler, update, encrypt } = await setup(); for (const body of [ { version: 7, fields: { otp: { value: 'seed' } } }, { version: 7, fields: { unknown: { value: 'edit' } } }, { version: 7, fields: { password: { value: null } } }, { version: 7, fields: { note: { value: '' } } }, + { version: 7, fields: { note: { value: 42 } } }, + { version: 7, fields: { note: { value: 'edit', extra: true } } }, + { version: 7, fields: { note: { value: 'é'.repeat(8193) } } }, + { version: 7, fields: [] }, + null, { version: 7, fields: {}, vaultId: 'attacker-vault' }, { version: 7, fields: { note: { value: 'x'.repeat(128 * 1024) } } }, ]) { - expect((await handler(patch(body))).status).toBe(400); + expect((await handler(patch(await encrypt(body)))).status).toBe(400); + } + expect(update).not.toHaveBeenCalled(); +}); + +test('rejects plaintext, malformed JWE, wrong keys, and mismatched authenticated bindings', async () => { + const { handler, update, encrypt, store } = await setup(); + const other = await setup(); + const body = { version: 7, fields: { note: { value: 'synthetic edit' } } }; + const wrappedKey = store.request!.key.wrappedPrivateKey; + const envelopes = [ + JSON.stringify(body), 'not-a-jwe', await other.encrypt(body), + await other.encrypt(body, { kid: store.request!.key.keyId }), + await encrypt(body, { kid: 'wrong-key' }), + await encrypt(body, { request_id: 'other-request' }), + await encrypt(body, { item_id: 'other-item' }), + await encrypt(body, { version: 8 }), + await encrypt(body, { version: '7' }), + await encrypt(body, { typ: 'other+jwe' }), + await encrypt(body, { enc: 'A128GCM' }), + ]; + for (const envelope of envelopes) { + const response = await handler(patch(envelope)); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: 'invalid' }); + } + expect(update).not.toHaveBeenCalled(); + expect(store.request!.consumedAt).toBeNull(); + expect(store.request!.key.wrappedPrivateKey).toBe(wrappedKey); +}); + +test('rejects tampering with every JWE segment, including protected metadata', async () => { + const { handler, update, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); + for (let index = 0; index < 5; index++) { + const parts = envelope.split('.'); + parts[index] = (parts[index][0] === 'A' ? 'B' : 'A') + parts[index].slice(1); + expect((await handler(patch(parts.join('.')))).status).toBe(400); + } + const parts = envelope.split('.'); + const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString()); + header.version = 8; + parts[0] = Buffer.from(JSON.stringify(header)).toString('base64url'); + expect((await handler(patch(parts.join('.')))).status).toBe(400); + expect(update).not.toHaveBeenCalled(); +}); + +test('bounds encrypted and decrypted bodies independently and rejects oversized streams', async () => { + const { handler, update, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'x'.repeat(MAX_PLAINTEXT_BYTES) } } }); + expect(envelope.length).toBeLessThan(MAX_ENVELOPE_BYTES); + expect((await handler(patch(envelope))).status).toBe(400); + const request = patch(''); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_ENVELOPE_BYTES)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }); + expect((await handler(new Request(request, { body: stream }))).status).toBe(400); + expect(update).not.toHaveBeenCalled(); +}); + +test('preserves encrypted PATCH expiry, revocation, consumption, identity, and origin checks', async () => { + const { handler, update, retrieve, store, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); + for (const state of ['expiresAt', 'revokedAt', 'consumedAt'] as const) { + const original = store.request![state]; + store.request![state] = now; + expect((await handler(patch(envelope))).status).toBe(410); + if (state === 'expiresAt') store.request!.expiresAt = original!; + else store.request![state] = original; + } + for (const requestOrigin of ['', 'null', 'https://attacker.example.com']) { + expect((await handler(patch(envelope, requestOrigin))).status).toBe(403); } + expect(retrieve).not.toHaveBeenCalled(); + retrieve.mockImplementationOnce(async () => ({ ...credentialItem(), id: 'replacement' })); + expect((await handler(patch(envelope))).status).toBe(404); + const wrongContentType = patch(envelope); + wrongContentType.headers.set('Content-Type', 'application/json'); + expect((await handler(wrongContentType)).status).toBe(400); expect(update).not.toHaveBeenCalled(); }); + +test('retains the key after upstream failures and allows a safe retry', async () => { + const { handler, update, store, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); + const wrappedKey = store.request!.key.wrappedPrivateKey; + for (const [error, status] of [[new VaultInvalidRequestError(), 400], [new Error('upstream details'), 502]] as const) { + update.mockImplementationOnce(async () => { throw error; }); + const response = await handler(patch(envelope)); + expect(response.status).toBe(status); + expect(await response.text()).not.toContain('upstream details'); + expect(store.request!.consumedAt).toBeNull(); + expect(store.request!.key.wrappedPrivateKey).toBe(wrappedKey); + } + expect((await handler(patch(envelope))).status).toBe(200); + expect(store.request!.key.wrappedPrivateKey).toBeNull(); +}); + +test('an uncertain committed write retains the key and version checking prevents replay', async () => { + const { handler, update, retrieve, store, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); + update.mockImplementationOnce(async () => { + retrieve.mockImplementation(async () => ({ ...credentialItem(), version: 8 })); + throw new Error('response lost'); + }); + expect((await handler(patch(envelope))).status).toBe(502); + expect((await handler(patch(envelope))).status).toBe(409); + expect(update).toHaveBeenCalledTimes(1); + expect(store.request!.consumedAt).toBeNull(); + expect(store.request!.key.wrappedPrivateKey).not.toBeNull(); +}); + +test('a failed consumption write retains key material for reconciliation', async () => { + const { handler, update, retrieve, store, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); + store.markConsumed = async () => { throw new Error('storage unavailable'); }; + update.mockImplementationOnce(async () => { + retrieve.mockImplementation(async () => ({ ...credentialItem(), version: 8 })); + return { ...credentialItem(), version: 8 }; + }); + expect((await handler(patch(envelope))).status).toBe(502); + expect((await handler(patch(envelope))).status).toBe(409); + expect(update).toHaveBeenCalledTimes(1); + expect(store.request!.consumedAt).toBeNull(); + expect(store.request!.key.wrappedPrivateKey).not.toBeNull(); +}); + +test('serializes local concurrent submissions and prevents writes after consumption', async () => { + const { handler, update, encrypt } = await setup(); + const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); + const responses = await Promise.all([handler(patch(envelope)), handler(patch(envelope))]); + expect(responses.map(({ status }) => status).sort()).toEqual([200, 409]); + expect((await handler(patch(envelope))).status).toBe(410); + expect(update).toHaveBeenCalledTimes(1); +}); + +test('accepts optional clearing and preserves the complete submission exactly', async () => { + const { handler, update, encrypt } = await setup(); + const body = { version: 7, fields: { password: { value: ' é 🔒 ' }, note: { value: null } } }; + expect((await handler(patch(await encrypt(body)))).status).toBe(200); + expect(update.mock.calls[0][1]).toEqual(body); +}); + +test('decrypts into the existing plaintext Kernel SDK PATCH with immutable identity and version', async () => { + const { store, protector, encrypt } = await setup(); + const originalFetch = globalThis.fetch; + const upstream = mock(async (_input: string | URL | Request, _init?: RequestInit) => Response.json(credentialItem())); + globalThis.fetch = Object.assign(upstream, { preconnect() {} }); + try { + const vault = new KernelCredentialVaultClient(randomBytes(32).toString('hex'), 'https://kernel.example.com'); + const handler = createCollectionHandler({ appOrigin: origin, store, protector, vault, now: () => now }); + const body = { version: 7, fields: { password: { value: ' synthetic password ' }, note: { value: null } } }; + expect((await handler(patch(await encrypt(body)))).status).toBe(200); + expect(upstream).toHaveBeenCalledTimes(2); + const [url, init] = upstream.mock.calls[1]; + expect(String(url)).toBe('https://kernel.example.com/vaults/server-vault/items/server-key'); + expect(init?.method).toBe('PATCH'); + expect(JSON.parse(String(init?.body))).toEqual({ + type: 'credential', expected_item_id: 'vi_example', version: 7, spec: { fields: body.fields }, + }); + expect(store.request!.key.wrappedPrivateKey).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/collection-keys.test.ts b/tests/collection-keys.test.ts new file mode 100644 index 0000000..4dbbf13 --- /dev/null +++ b/tests/collection-keys.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createCollectionKey, decryptSubmission, keyBinding } from '../lib/collection-keys'; +import { encryptSubmission, MAX_PLAINTEXT_BYTES } from '../lib/credential-envelope'; +import { LocalPrivateKeyProtector } from '../lib/private-key-protector'; +import { FileCollectionRequestStore, type CollectionRequest } from '../lib/request-store'; + +const protector = () => new LocalPrivateKeyProtector(randomBytes(32).toString('base64')); + +test('creates independent keys and persists only wrapped private material, clearing it on consumption', async () => { + const directory = await mkdtemp(join(tmpdir(), 'collection-keys-')); + try { + const wrapping = protector(); + const key = await createCollectionKey('request-1', 'item-1', wrapping); + const other = await createCollectionKey('request-2', 'item-1', wrapping); + expect(key.keyId).not.toBe(other.keyId); + expect(key.publicKey).not.toEqual(other.publicKey); + expect(key.publicKey.d).toBeUndefined(); + const request: CollectionRequest = { + id: 'request-1', itemId: 'item-1', itemKey: 'item', vaultId: 'vault', tokenDigest: 'digest', + expiresAt: Date.now() + 60_000, revokedAt: null, consumedAt: null, key, + }; + const path = join(directory, 'requests.json'); + const store = new FileCollectionRequestStore(path); + await store.save(request); + const contents = await readFile(path, 'utf8'); + expect(contents).not.toContain('PRIVATE KEY'); + expect(contents).not.toContain('"d":'); + const unwrapped = await wrapping.unwrap(key.wrappedPrivateKey!, keyBinding(request)); + expect(contents).not.toContain(new TextDecoder().decode(unwrapped)); + unwrapped.fill(0); + + const body = { version: 1, fields: { password: { value: 'synthetic value' } } }; + const envelope = await encryptSubmission(body, { ...keyBinding(request), version: 1, publicKey: key.publicKey }); + const restored = await new FileCollectionRequestStore(path).findByTokenDigest('digest'); + expect((await decryptSubmission(envelope, restored!, wrapping)).submission).toEqual(body); + await expect(store.markConsumed('missing', Date.now())).rejects.toThrow(); + await store.save({ ...request, id: 'request-2', tokenDigest: 'other-digest', key: other }); + await Promise.all([ + store.markConsumed('digest', Date.now()), + store.markConsumed('other-digest', Date.now()), + ]); + expect((await store.findByTokenDigest('other-digest'))!.key.wrappedPrivateKey).toBeNull(); + const consumed = await store.findByTokenDigest('digest'); + expect(consumed!.key.wrappedPrivateKey).toBeNull(); + expect(await readFile(path, 'utf8')).not.toContain(key.wrappedPrivateKey!); + await expect(decryptSubmission(envelope, consumed!, wrapping)).rejects.toThrow(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('wrapping rejects a wrong wrapping key, tampering, or a transplanted request/item/key binding', async () => { + const wrapping = protector(); + const key = await createCollectionKey('request-1', 'item-1', wrapping); + const binding = { requestId: 'request-1', itemId: 'item-1', keyId: key.keyId }; + await expect(protector().unwrap(key.wrappedPrivateKey!, binding)).rejects.toThrow(); + for (const field of ['requestId', 'itemId', 'keyId'] as const) { + await expect(wrapping.unwrap(key.wrappedPrivateKey!, { ...binding, [field]: 'other' })).rejects.toThrow(); + } + const parts = key.wrappedPrivateKey!.split('.'); + parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1); + await expect(wrapping.unwrap(parts.join('.'), binding)).rejects.toThrow(); +}); + +test('requires a correctly encoded 32-byte wrapping key', () => { + for (const value of ['', 'not-base64', randomBytes(16).toString('base64'), randomBytes(33).toString('base64')]) { + expect(() => new LocalPrivateKeyProtector(value)).toThrow('32 random bytes'); + } +}); + +test('browser encryption rejects oversized plaintext and inconsistent rendered versions', async () => { + const key = await createCollectionKey('request-1', 'item-1', protector()); + const encryption = { requestId: 'request-1', itemId: 'item-1', keyId: key.keyId, publicKey: key.publicKey, version: 1 }; + await expect(encryptSubmission({ version: 2, fields: {} }, encryption)).rejects.toThrow(); + await expect(encryptSubmission({ + version: 1, fields: { password: { value: 'x'.repeat(MAX_PLAINTEXT_BYTES) } }, + }, encryption)).rejects.toThrow(); +}); diff --git a/tests/collection-page.test.tsx b/tests/collection-page.test.tsx index 2e2bf9e..e74b79f 100644 --- a/tests/collection-page.test.tsx +++ b/tests/collection-page.test.tsx @@ -1,6 +1,15 @@ import './setup'; import { afterEach, beforeEach, expect, mock, test } from 'bun:test'; -import type { CredentialItem } from '@onkernel/vault-react'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react'; +import { createCollectionHandler } from '../lib/collection-handler'; +import { createCollectionKey } from '../lib/collection-keys'; +import { LocalPrivateKeyProtector } from '../lib/private-key-protector'; +import { FileCollectionRequestStore, type CollectionRequest } from '../lib/request-store'; +import { collectionTokenDigest } from '../lib/server-token'; const { act } = await import('react'); const { createRoot } = await import('react-dom/client'); @@ -9,6 +18,7 @@ const token = 'a'.repeat(43); let container: HTMLDivElement; let root: ReturnType; let originalFetch: typeof fetch; +let directory: string; function item(): CredentialItem { return { @@ -16,14 +26,21 @@ function item(): CredentialItem { type: 'credential', version: 1, spec: { - fields: [{ name: 'password', type: 'password', required: true, sensitive: true }], + fields: [ + { name: 'password', type: 'password', required: true, sensitive: true }, + { name: 'note', type: 'text', required: false, sensitive: false }, + ], + }, + state: { + status: 'pending_collection', + fields: { password: { has_value: false }, note: { has_value: false } }, }, - state: { status: 'pending_collection', fields: { password: { has_value: false } } }, }; } -beforeEach(() => { +beforeEach(async () => { originalFetch = globalThis.fetch; + directory = await mkdtemp(join(tmpdir(), 'collection-page-')); window.location.href = `https://app.example.com/credentials/collect#token=${token}`; container = document.createElement('div'); document.body.append(container); @@ -34,39 +51,77 @@ afterEach(async () => { await act(async () => root.unmount()); container.remove(); globalThis.fetch = originalFetch; + await rm(directory, { recursive: true, force: true }); }); -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); -test('keeps the capability in the fragment and same-origin authorization header', async () => { +test('encrypts the complete browser submission and forwards exact values through the handler', async () => { + const origin = 'https://app.example.com'; + const protector = new LocalPrivateKeyProtector(randomBytes(32).toString('base64')); + const store = new FileCollectionRequestStore(join(directory, 'requests.json')); + const mapping: CollectionRequest = { + id: 'request-1', tokenDigest: collectionTokenDigest(token), vaultId: 'vault', + itemKey: 'credential', itemId: item().id, + key: await createCollectionKey('request-1', item().id, protector), + expiresAt: Date.now() + 60_000, consumedAt: null, revokedAt: null, + }; + await store.save(mapping); + const update = mock(async (_mapping: CollectionRequest, _submission: CredentialSubmission) => ({ ...item(), version: 2 })); + const handler = createCollectionHandler({ + appOrigin: origin, store, protector, vault: { retrieve: async () => item(), update }, + }); const fetcher = Object.assign( - mock(async (_input: string | URL | Request, _init?: RequestInit) => Response.json(item())), + mock(async (input: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (init?.method === 'PATCH') headers.set('Origin', origin); + return handler(new Request(new URL(String(input), origin), { ...init, headers })); + }), { preconnect() {} }, ); globalThis.fetch = fetcher; - await act(async () => root.render()); await act(async () => { - await settle(); - await settle(); + root.render(); }); + for (let i = 0; i < 100 && !container.querySelector('form'); i++) { + await act(settle); + } expect(fetcher.mock.calls[0][0]).toBe('/api/credential-requests/current'); const read = fetcher.mock.calls[0][1]; expect(read?.credentials).toBe('omit'); + expect(read?.cache).toBe('no-store'); expect(new Headers(read?.headers).get('Authorization')).toBe(`Bearer ${token}`); expect(String(fetcher.mock.calls[0][0])).not.toContain(token); - container.querySelector('input[name="password"]')!.value = 'replacement'; + const password = ' synthetic password é 🔒 '; + const note = 'synthetic non-sensitive note'; + container.querySelector('input[name="password"]')!.value = password; + container.querySelector('input[name="note"]')!.value = note; await act(async () => { container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - await settle(); }); + for (let i = 0; i < 100 && window.location.hash; i++) { + await act(settle); + } const submission = fetcher.mock.calls[1]; expect(submission[0]).toBe('/api/credential-requests/current'); expect(submission[1]?.method).toBe('PATCH'); + expect(submission[1]?.credentials).toBe('omit'); + expect(submission[1]?.cache).toBe('no-store'); expect(new Headers(submission[1]?.headers).get('Authorization')).toBe(`Bearer ${token}`); - expect(String(submission[1]?.body)).not.toContain(token); + expect(new Headers(submission[1]?.headers).get('Content-Type')).toBe('application/jose'); + const body = String(submission[1]?.body); + expect(body.split('.')).toHaveLength(5); + for (const value of [token, password, note, 'password', 'fields']) expect(body).not.toContain(value); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0][1]).toEqual({ + version: 1, fields: { password: { value: password }, note: { value: note } }, + }); + const consumed = await store.findByTokenDigest(mapping.tokenDigest); + expect(consumed?.consumedAt).not.toBeNull(); + expect(consumed?.key.wrappedPrivateKey).toBeNull(); expect(window.location.pathname).toBe('/credentials/collect'); expect(window.location.hash).toBe(''); }); diff --git a/tests/setup.ts b/tests/setup.ts index cf1271b..6c23452 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,4 +1,7 @@ import { GlobalRegistrator } from '@happy-dom/global-registrator'; +const http = { Request, Response, Headers, fetch, AbortController, AbortSignal }; GlobalRegistrator.register({ url: 'https://app.example.com' }); +// Keep server HTTP semantics; Happy DOM strips the Origin header as a browser would. +Object.assign(globalThis, http); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; From 2eaeba1a8362d76e7d6686e74ae8496476f3a555 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:55:55 +0000 Subject: [PATCH 2/3] Encrypt values to the vault's Kernel key instead of per-request keys --- .env.example | 7 +- README.md | 97 ++++--- app/credentials/collect/collection-page.tsx | 36 ++- app/credentials/collect/page.tsx | 2 +- bun.lock | 4 +- lib/collection-handler.ts | 71 ++--- lib/collection-keys.ts | 82 ------ lib/credential-envelope.ts | 43 --- lib/encrypted-submission.ts | 34 +++ lib/private-key-protector.ts | 52 ---- lib/request-store.ts | 38 +-- lib/runtime.ts | 3 +- lib/vault-client.ts | 52 +++- package.json | 2 +- scripts/create-request.ts | 12 +- tests/collection-handler.test.ts | 293 ++++---------------- tests/collection-keys.test.ts | 82 ------ tests/collection-page.test.tsx | 94 ++----- tests/encrypted-submission.test.ts | 82 ++++++ 19 files changed, 358 insertions(+), 728 deletions(-) delete mode 100644 lib/collection-keys.ts delete mode 100644 lib/credential-envelope.ts create mode 100644 lib/encrypted-submission.ts delete mode 100644 lib/private-key-protector.ts delete mode 100644 tests/collection-keys.test.ts create mode 100644 tests/encrypted-submission.test.ts diff --git a/.env.example b/.env.example index da6bef1..547d400 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,6 @@ MOCK_VAULT=true KERNEL_API_KEY= # KERNEL_API_BASE_URL=https://api.onkernel.com -# Required in BOTH mock and real mode. Server-only; never use a NEXT_PUBLIC_ variable. -# Generate 32 random bytes as base64 once (see README); use the same key for CLI and server. -# Keep outside the request store. Production should use KMS/HSM-backed key wrapping. -COLLECTION_KEY_WRAPPING_KEY= - -# Local links and wrapped per-request private keys are written to .data/credential-requests.json. +# Local links are written to .data/credential-requests.json. # Replace FileCollectionRequestStore with your database in production. COLLECTION_REQUEST_TTL_MINUTES=15 diff --git a/README.md b/README.md index dbf0b68..51ff21a 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,103 @@ -# Customer-hosted encrypted credential collection +# Customer-hosted credential collection -A runnable Next.js example using [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react). The existing `CredentialForm.onSubmit` boundary encrypts the complete `CredentialSubmission` before sending it to the same-origin backend. Only that backend's collection handler decrypts it, validates it, and forwards plaintext through the existing Kernel item PATCH. No Kernel API or `@onkernel/vault-react` changes are required. +A runnable Next.js example for collecting credentials with [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react). The browser renders Kernel's unstyled credential form while a same-origin backend keeps the Kernel API key private and maps one-time bearer capabilities to vault items. + +Entered values are encrypted in the browser to the vault's public key, sent to this backend still encrypted, and forwarded to Kernel as `encrypted_value`. Only Kernel can decrypt them, so plaintext never appears in this app's backend, proxies, or request logs. See [Encrypted values](#encrypted-values). ## Run the mock flow -Requires Bun and Node.js 20.9 or newer. Web Crypto requires HTTPS in browsers; localhost works for development. +Requires Bun and Node.js 20.9 or newer. ```sh cp .env.example .env.local -chmod 600 .env.local -bun install --frozen-lockfile -# Run once: writes a fresh wrapping key directly to the ignored env file, not stdout. -bun -e 'const fs = require("node:fs"); const crypto = require("node:crypto"); const path = ".env.local"; fs.writeFileSync(path, fs.readFileSync(path, "utf8").replace(/^COLLECTION_KEY_WRAPPING_KEY=$/m, "COLLECTION_KEY_WRAPPING_KEY=" + crypto.randomBytes(32).toString("base64")));' +bun install bun run dev ``` -In a second terminal: +In a second terminal, create a 15-minute collection link: ```sh bun run create-request ``` -Open the printed 15-minute collection link. Mock mode exercises browser encryption, backend decryption, and consumption without calling Kernel. Do not enter real credentials in mock mode. The mock vault is in-memory and resets on restart; collection requests survive in `.data/credential-requests.json`. - -The CLI and server must use the same `COLLECTION_KEY_WRAPPING_KEY`: a server-only, base64-encoded 32-byte random key. There is no insecure default, including in mock mode. Restart the server after changing environment variables. Changing this key makes existing requests undecryptable; generate new links or implement key rotation with historical wrapping-key IDs. Old request files without encryption keys are not migrated; generate fresh links. +Open the printed URL. Mock mode exercises the complete browser/backend flow without calling Kernel. Do not enter real credentials in mock mode. ## Connect a Kernel vault item 1. Set `MOCK_VAULT=false` and `KERNEL_API_KEY` in `.env.local`. 2. Create a credential item in a Kernel vault. -3. Generate a link: +3. Generate a link for that item: ```sh bun run create-request -- ``` -The command retrieves the immutable item ID, creates a fresh P-256 keypair and key ID, wraps the private key, and persists the request. It also generates 32 random bearer bytes, stores only their SHA-256 digest, and prints the raw token once: +The command retrieves the item's immutable ID, generates 32 random bytes, stores only the SHA-256 token digest, and prints the raw token once in this form: ```text https://app.example.com/credentials/collect#token=<43-character-base64url-token> ``` -Treat the printed URL as a credential. The fragment is not sent in page requests or referrers. The page sends the token only in an `Authorization: Bearer ` header to `/api/credential-requests/current` and clears the fragment after confirmed completion. +The fragment is not sent in page requests or referrers. The page sends it only in an `Authorization: Bearer ` header to the same-origin `/api/credential-requests/current` endpoint. + +## How it is organized -## Encryption flow +- `app/credentials/collect` reads and clears the fragment capability, renders `CredentialForm`, and encrypts the submission before sending it. +- `app/api/credential-requests/current` serves safe item data with the vault's public key and accepts versioned, encrypted field edits. +- `lib/encrypted-submission.ts` turns a `CredentialForm` submission into `encrypted_value` fields with [`jose`](https://github.com/panva/jose). +- `lib/collection-handler.ts` validates the bearer, origin, target, item ID, version, field names, ciphertext shape, and body size. +- `lib/vault-client.ts` contains real and mock vault adapters. Only the real adapter receives `KERNEL_API_KEY`. The mock adapter generates its own key pair and decrypts like Kernel does. +- `lib/request-store.ts` defines the durable `CollectionRequestStore` boundary. +- `scripts/create-request.ts` creates a short-lived link without persisting the raw token. -1. **Create:** `scripts/create-request.ts` generates a separate keypair for every collection request. `PrivateKeyProtector` wraps its PKCS#8 private key before `CollectionRequestStore` saves anything. No plaintext private JWK/PEM is persisted. -2. **Render:** the bearer-authorized GET returns the safe item projection plus `{ keyId, requestId, itemId, version, publicKey }`. The explicit public-key projection includes only `kty`, `crv`, `x`, and `y`; neither wrapped nor unwrapped private-key material is returned. -3. **Encrypt:** `lib/credential-envelope.ts` uses `jose` and browser Web Crypto to produce compact JWE with **ECDH-ES+A256KW / A256GCM**, using P-256. JOSE manages the ephemeral sender key, content-encryption key, key wrapping, nonce, and authentication tag. The entire submission, including non-sensitive edits and null clears, is encrypted. The PATCH body is only the compact JWE, with `Content-Type: application/jose`. -4. **Validate and forward:** `lib/collection-handler.ts` checks the bearer, expiry, revocation, consumption, and exact origin. It retrieves the mapped item's immutable ID, unwraps and decrypts inside the handler path, and validates the authenticated key ID, request ID, item ID, and version. The protected-header version must equal the plaintext submission version and the current item version. Existing field-name/type/required/size validation remains. The SDK PATCH still carries plaintext fields, `expected_item_id`, and the rendered version. -5. **Complete:** after a confirmed valid Kernel response (or an unchanged submission), the store marks the request consumed and clears its wrapped private key in the same file replacement. PATCH returns only a completion marker, not echoed field values. Subsequent reads/writes return `consumed`. +Successful submissions mark the mapping consumed. Kernel writes include both the rendered version and immutable item ID, so a stale form or a deleted-and-recreated key cannot redirect a write. -The server pins both JWE algorithms; the client cannot select a weaker algorithm. The protected header is authenticated as JWE additional authenticated data, not encrypted: IDs, version, algorithm identifiers, and the ephemeral public key are visible. The JWE authenticates ciphertext integrity, **not sender identity**; authorization still depends on the bearer and origin checks. Anyone with the public key can construct ciphertext, but cannot submit without a valid bearer. +## Encrypted values -Limits: 192 KiB streamed HTTP envelope, 128 KiB decrypted UTF-8 JSON, 2 KiB encoded protected header, 32 edited fields, and 16 KiB UTF-8 per value. Compression is not supported. Plaintext JSON PATCHes are rejected. +1. `GET /api/credential-requests/current` returns `{ item, encryption_key }`. The backend gets the key from `kernel.vaults.retrieveEncryptionKey(vaultId)` (`GET /vaults/{id_or_name}/encryption_key`). Each vault has its own P-256 key; it is public and stable for the vault's lifetime. +2. On submit, the page encrypts every entered value as a compact JWE (`alg: ECDH-ES`, `enc: A256GCM`, the vault's `kid` in the header). Clearing an optional field stays `{ "value": null }`. +3. The backend validates the request and passes each field through unchanged: -## Guarantees and non-guarantees +```json +{ "version": 3, "fields": { "password": { "encrypted_value": "eyJhbGciOiJFQ0RILUVTIi..." } } } +``` -**Protected:** passive capture of the browser-to-collection-endpoint request body by a CDN, load balancer, request logger, generic middleware/APM, or other relay sees ciphertext, not entered values. This assumes those components do not control the served JavaScript/public key or inspect the decrypting process. HTTPS, `no-store`, `no-referrer`, and same-origin enforcement remain necessary. +4. `kernel.vaults.items.update` sends the ciphertext to Kernel, which decrypts and validates it exactly like `value`. -**Not end-to-end encryption from the application:** the customer-hosted frontend sees entered values. The decrypt-and-forward backend briefly handles plaintext, as does Kernel through the existing API. A compromised frontend, XSS, session replay, malicious key substitution, in-process instrumentation of the handler, or backend compromise can recover values. Code with access to both the wrapping service/key and stored keys can decrypt captured envelopes. This does not protect the backend-to-Kernel plaintext request from instrumentation on that leg; use TLS and exclude it from capture too. +The backend rejects plaintext `value` strings, so a misconfigured page cannot silently fall back to sending plaintext. Kernel rejects ciphertext encrypted for a different vault with `400 encryption_key_mismatch`, which this example reports as `invalid`. -**Not all response data is secret:** the existing safe GET projection intentionally includes displayable non-sensitive saved values (for example, email), field definitions, and status. Those responses are not application-layer encrypted. Sensitive saved values are never returned. Bearer headers, URL fragments, request metadata, and ciphertext lengths also remain outside this confidentiality guarantee. One-time consumption prevents later writes, not disclosure after key compromise; this is not forward secrecy. +Requires `@onkernel/sdk` 0.122.0 or newer. If your backend already holds plaintext (for example, a server-to-server integration), send `value` instead; `encrypted_value` is only for values encrypted in a browser you control. -No secrets, private keys, plaintext, envelopes, or upstream bodies/errors are logged by the handler. SDK logging is explicitly disabled. Configure platform, proxy, and APM capture separately: do not record authorization headers, collection URLs/fragments, request bodies, DOM input/session replay, handler locals, or outbound Kernel bodies/errors. Generic middleware that only observes HTTP bodies is distinct from an APM agent inspecting handler memory. +## Use a production store -## Failure and retry behavior +`FileCollectionRequestStore` is intentionally local-only. Its atomic file replacement prevents torn files, but it does not coordinate multiple processes and most serverless filesystems are ephemeral. -- Validation failures, conflicts, and upstream failures do **not** consume the request or delete its wrapped key. A same-version retry can succeed if Kernel did not commit. The SDK disables automatic retries. -- If Kernel commits but its response is lost, or consumption persistence fails after the commit, retrying the original envelope sees the advanced version and returns `stale` without another write. The key remains available for authorized reconciliation until explicitly cleared. A changed version alone does not prove this submission succeeded: another writer may have updated the item. -- A lost browser response after durable consumption returns `consumed` on retry. No second write occurs. The example serializes concurrent PATCHes for a token within one handler instance. -- There is no distributed transaction with Kernel and no automatic reconciliation workflow here. In production, persist an in-flight operation/version and outcome, investigate uncertain outcomes before retiring their keys, and use a durable per-request claim shared by all replicas. Never claim exactly-once delivery or automatically consume an uncertain result. +Before deployment, implement `CollectionRequestStore` with your database and replace the construction in `lib/runtime.ts`. Persist: -## Storage and production guidance +```ts +{ + id, + tokenDigest, + vaultId, + itemKey, + itemId, + expiresAt, + consumedAt, + revokedAt, +} +``` -`LocalPrivateKeyProtector` uses `jose` direct-key **AES-256-GCM** JWE wrapping under the environment key. Its authenticated header binds the private key to the request, immutable item, and key ID. Keep the environment key separate from the data store; do not commit it, expose it in a `NEXT_PUBLIC_` variable, or include it in browser builds/backups with the request file. A file copy alone cannot decrypt private keys without the wrapping key. +Add any signed-in application user ID needed by your authorization model. Verify that user in addition to the bearer on every read and write. Make consumption and revocation atomic in the database. -`FileCollectionRequestStore` is **local-development-only**. Mutations are serialized within one store instance, and atomic replacement prevents torn files. Neither coordinates separate instances/processes. The CLI and server share that file; avoid creating requests concurrently with submissions. Multiple replicas, cross-process writers, and ephemeral/serverless storage are not supported. In-handler serialization is not a distributed lock. +## Deployment checklist -Before deploying: +1. Set `APP_ORIGIN` to the exact HTTPS origin serving the page. +2. Set `MOCK_VAULT=false` and provide `KERNEL_API_KEY` only to the server runtime. +3. Replace the file store with a durable database implementation. +4. Deliver links through a channel suitable for password-reset links and keep expiry short. +5. Exclude URLs, fragments, authorization headers, request bodies, credentials, and upstream errors from logs, analytics, traces, and session replay. -1. Replace `PrivateKeyProtector` with a KMS/HSM-backed wrapping service or equivalent, with scoped IAM, audit controls, key rotation/versioning, and separation from stored ciphertext. The decrypting handler remains trusted; wrapping is not protection against that handler's compromise. -2. Replace `CollectionRequestStore` with durable transactional storage and distributed claims. Persist the existing request mapping/lifecycle fields plus `key: { keyId, publicKey, wrappedPrivateKey }`. Atomically record consumption and remove wrapped material; retain recoverable state for uncertain writes. -3. Sweep expired/revoked requests and reconcile uncertain outcomes before applying the retention policy. Clearing the current wrapped key does not securely erase old files, backups, snapshots, or memory. Byte buffers are cleared where practical, but JavaScript strings, parsed submissions, and CryptoKeys cannot be reliably zeroized. -4. Use an exact HTTPS `APP_ORIGIN`, keep the collection route free of third-party scripts, apply CSP and rate limits, and exclude sensitive input/output from logs and telemetry. Store `KERNEL_API_KEY` only on the server. -5. Deliver links through a channel suitable for password-reset links and keep expiry short. Add signed-in user checks to both reads and writes if required by your authorization model. The fragment bearer alone is the example's capability. +The backend receives only ciphertext, but the page's JavaScript sees raw entered values before encrypting them. It is trusted application code, not an isolated Kernel collector. A compromised page or a script injected into it can read values before encryption. Keep the collection route free of third-party scripts, serve it over HTTPS (WebCrypto requires a secure context), and preserve the response's `no-store` and `no-referrer` policies. ## Checks @@ -94,8 +107,6 @@ bun run test bun run build ``` -Tests exercise the actual form-to-handler flow, ciphertext-only browser bodies, exact decrypted SDK payloads, key/envelope/binding tampering, plaintext and size rejection, lifecycle/origin checks, wrapped storage/cleanup, concurrent submissions, and uncertain outcomes. - ## License MIT diff --git a/app/credentials/collect/collection-page.tsx b/app/credentials/collect/collection-page.tsx index 0f88eb1..de74582 100644 --- a/app/credentials/collect/collection-page.tsx +++ b/app/credentials/collect/collection-page.tsx @@ -9,7 +9,7 @@ import { type CredentialItem, } from '@onkernel/vault-react'; import { readCollectionToken } from '@/lib/collection-token'; -import { encryptSubmission, type CollectionEncryption } from '@/lib/credential-envelope'; +import { encryptSubmission, type VaultEncryptionKey } from '@/lib/encrypted-submission'; const endpoint = '/api/credential-requests/current'; const errorCodes = new Set([ @@ -44,10 +44,7 @@ export function CollectionPage() { } function RequestForm({ token }: { token: string }) { - const [collection, setCollection] = useState<{ - item: CredentialItem; - encryption: CollectionEncryption; - }>(); + const [collection, setCollection] = useState<{ item: CredentialItem; key: VaultEncryptionKey }>(); const [failure, setFailure] = useState(); const authorization = `Bearer ${token}`; @@ -61,12 +58,10 @@ function RequestForm({ token }: { token: string }) { }) .then(async (response) => { if (!response.ok) throw new CredentialFormError(await responseError(response, 'unavailable')); - const data = await response.json(); - const item = safeCredentialItem(data.item); - if (data.encryption.itemId !== item.id || data.encryption.version !== item.version) { - throw new CredentialFormError('unavailable'); - } - if (!controller.signal.aborted) setCollection({ item, encryption: data.encryption }); + const body = await response.json(); + const item = safeCredentialItem(body.item); + const key = vaultEncryptionKey(body.encryption_key); + if (!controller.signal.aborted) setCollection({ item, key }); }) .catch((error: unknown) => { if (!controller.signal.aborted) { @@ -91,13 +86,12 @@ function RequestForm({ token }: { token: string }) { item={collection.item} submitLabel="Save credentials" onSubmit={async (submission) => { - const envelope = await encryptSubmission(submission, collection.encryption); const response = await fetch(endpoint, { method: 'PATCH', credentials: 'omit', cache: 'no-store', - headers: { Authorization: authorization, 'Content-Type': 'application/jose' }, - body: envelope, + headers: { Authorization: authorization, 'Content-Type': 'application/json' }, + body: JSON.stringify(await encryptSubmission(submission, collection.key)), }); if (!response.ok) { throw new CredentialFormError(await responseError(response, 'unavailable')); @@ -108,6 +102,20 @@ function RequestForm({ token }: { token: string }) { ); } +function vaultEncryptionKey(value: unknown): VaultEncryptionKey { + const key = value as VaultEncryptionKey | undefined; + if ( + typeof key?.kid !== 'string' || + key.jwk?.kty !== 'EC' || + key.jwk.crv !== 'P-256' || + typeof key.jwk.x !== 'string' || + typeof key.jwk.y !== 'string' + ) { + throw new CredentialFormError('unavailable'); + } + return { kid: key.kid, jwk: { kty: 'EC', crv: 'P-256', x: key.jwk.x, y: key.jwk.y } }; +} + function Message({ children }: { children: React.ReactNode }) { return

{children}

; } diff --git a/app/credentials/collect/page.tsx b/app/credentials/collect/page.tsx index b12882e..9008619 100644 --- a/app/credentials/collect/page.tsx +++ b/app/credentials/collect/page.tsx @@ -6,7 +6,7 @@ export default function Page() {

Secure credential request

Connect your account

-

Your credentials are sent directly to the application that requested them.

+

Your credentials are encrypted in this page. Only Kernel can decrypt them.

diff --git a/bun.lock b/bun.lock index f8c11f9..d01a6c9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "vault-react-example", "dependencies": { - "@onkernel/sdk": "^0.110.0", + "@onkernel/sdk": "^0.122.0", "@onkernel/vault-react": "^0.1.0", "jose": "^6.2.12", "next": "^16.3.5", @@ -99,7 +99,7 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.110.0", "", {}, "sha512-lUMXEcp8FsQkcVV8AzGU+dkrA9f5ucaaYMIDA6Y2N9oHCtcBENiUKFJpxZTA/+Oc9by8Zj+3H10qajCsWUgDiw=="], + "@onkernel/sdk": ["@onkernel/sdk@0.122.0", "", {}, "sha512-cHjduH6Ssfs29Q/vetvvc4jACIDDTd/OVJ0p3frLXFJQ9mloLgKygyAilBrJBuXNtgeUsd9fhgXfVsTpDzhZGA=="], "@onkernel/vault-react": ["@onkernel/vault-react@0.1.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-XogSCQ3GhtU9Q0TeIFt+pdfKOQVRZHLwKE6Uvj1YvH5CLwaF2q45KgM4jnMmUvrOXamq/of0xh3ECPPtOF/xgA=="], diff --git a/lib/collection-handler.ts b/lib/collection-handler.ts index 8e234b8..4f77c33 100644 --- a/lib/collection-handler.ts +++ b/lib/collection-handler.ts @@ -1,9 +1,7 @@ -import { safeCredentialItem, type CredentialSubmission } from '@onkernel/vault-react/data'; +import { safeCredentialItem } from '@onkernel/vault-react/data'; +import type { EncryptedSubmission } from './encrypted-submission'; import type { CollectionRequestStore } from './request-store'; import { collectionTokenDigest } from './server-token'; -import { decryptSubmission, keyBinding } from './collection-keys'; -import { MAX_ENVELOPE_BYTES } from './credential-envelope'; -import type { PrivateKeyProtector } from './private-key-protector'; import { VaultConflictError, VaultInvalidRequestError, @@ -11,6 +9,9 @@ import { } from './vault-client'; const bearerPattern = /^Bearer ([A-Za-z0-9_-]{43})$/; +// Compact JWE: five base64url segments. Kernel rejects anything it cannot decrypt. +const compactJwePattern = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]*){4}$/; +const maxEncryptedValueLength = 24 * 1024; function json(body: unknown, status = 200) { return Response.json(body, { @@ -19,7 +20,7 @@ function json(body: unknown, status = 200) { }); } -async function readEnvelope(request: Request): Promise { +async function readJson(request: Request): Promise { const reader = request.body?.getReader(); if (!reader) throw new Error('invalid request'); const chunks: Uint8Array[] = []; @@ -29,7 +30,7 @@ async function readEnvelope(request: Request): Promise { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; - if (size > MAX_ENVELOPE_BYTES) { + if (size > 128 * 1024) { await reader.cancel(); throw new Error('invalid request'); } @@ -44,69 +45,51 @@ async function readEnvelope(request: Request): Promise { body.set(chunk, offset); offset += chunk.byteLength; } - return new TextDecoder('utf-8', { fatal: true }).decode(body); + return JSON.parse(new TextDecoder().decode(body)); } export function createCollectionHandler({ appOrigin, store, vault, - protector, now = Date.now, }: { appOrigin: string; store: CollectionRequestStore; vault: CredentialVaultClient; - protector: PrivateKeyProtector; now?: () => number; }) { const trustedOrigin = new URL(appOrigin).origin; - // Local-only serialization. Production needs a durable, distributed request claim. - const submitting = new Set(); return async (request: Request): Promise => { - let claimedDigest: string | undefined; try { if (!['GET', 'PATCH'].includes(request.method)) return json({ code: 'invalid' }, 405); const bearer = bearerPattern.exec(request.headers.get('Authorization') ?? '')?.[1]; if (!bearer) return json({ code: 'unavailable' }, 401); const digest = collectionTokenDigest(bearer); - if (request.method === 'PATCH') { - if (request.headers.get('Origin') !== trustedOrigin) return json({ code: 'unavailable' }, 403); - if (submitting.has(digest)) return json({ code: 'unavailable' }, 409); - submitting.add(digest); - claimedDigest = digest; - } const mapping = await store.findByTokenDigest(digest); if (!mapping) return json({ code: 'unavailable' }, 404); if (mapping.revokedAt !== null) return json({ code: 'unavailable' }, 410); if (mapping.consumedAt !== null) return json({ code: 'consumed' }, 410); if (mapping.expiresAt <= now()) return json({ code: 'expired' }, 410); + if (request.method === 'PATCH' && request.headers.get('Origin') !== trustedOrigin) { + return json({ code: 'unavailable' }, 403); + } const item = safeCredentialItem(await vault.retrieve(mapping)); if (item.id !== mapping.itemId) return json({ code: 'unavailable' }, 404); if (request.method === 'GET') { - const { x, y } = mapping.key.publicKey; - return json({ - item, - encryption: { - ...keyBinding(mapping), - version: item.version, - publicKey: { kty: 'EC', crv: 'P-256', x, y }, - }, - }); + const { kid, jwk } = await vault.encryptionKey(mapping); + return json({ item, encryption_key: { kid, jwk } }); } - if (request.headers.get('Content-Type')?.split(';')[0].trim() !== 'application/jose') { + if (!request.headers.get('Content-Type')?.startsWith('application/json')) { return json({ code: 'invalid' }, 400); } let body: unknown; - let boundVersion: number; try { - const decrypted = await decryptSubmission(await readEnvelope(request), mapping, protector); - body = decrypted.submission; - boundVersion = decrypted.version; + body = await readJson(request); } catch { return json({ code: 'invalid' }, 400); } @@ -115,7 +98,6 @@ export function createCollectionHandler({ } const input = body as Record; if ( - input.version !== boundVersion || !Number.isSafeInteger(input.version) || Number(input.version) < 1 || !input.fields || @@ -133,21 +115,19 @@ export function createCollectionHandler({ if (!field || field.type === 'totp' || !edit || typeof edit !== 'object' || Array.isArray(edit)) { return json({ code: 'invalid' }, 400); } + // Plaintext values are rejected: this backend only ever relays ciphertext or a clear. const replacement = edit as Record; - if ( - Object.keys(replacement).length !== 1 || - !Object.hasOwn(replacement, 'value') || - (replacement.value === null && field.required) || - (replacement.value !== null && - (typeof replacement.value !== 'string' || - replacement.value.length === 0 || - new TextEncoder().encode(replacement.value).length > 16 * 1024)) - ) { + const encrypted = replacement.encrypted_value; + const clear = Object.hasOwn(replacement, 'value') && replacement.value === null && !field.required; + const ciphertext = typeof encrypted === 'string' && + encrypted.length <= maxEncryptedValueLength && + compactJwePattern.test(encrypted); + if (Object.keys(replacement).length !== 1 || !(clear || ciphertext)) { return json({ code: 'invalid' }, 400); } } - const submission = input as CredentialSubmission; + const submission = input as EncryptedSubmission; if (submission.version !== item.version) return json({ code: 'stale' }, 409); if (!entries.length) { await store.markConsumed(digest, now()); @@ -156,9 +136,8 @@ export function createCollectionHandler({ try { const updated = safeCredentialItem(await vault.update(mapping, submission)); - if (updated.id !== mapping.itemId) throw new Error('unexpected vault item'); await store.markConsumed(digest, now()); - return json({ saved: true }); + return json(updated); } catch (error) { if (error instanceof VaultConflictError) return json({ code: 'stale' }, 409); if (error instanceof VaultInvalidRequestError) return json({ code: 'invalid' }, 400); @@ -166,8 +145,6 @@ export function createCollectionHandler({ } } catch { return json({ code: 'unavailable' }, 502); - } finally { - if (claimedDigest) submitting.delete(claimedDigest); } }; } diff --git a/lib/collection-keys.ts b/lib/collection-keys.ts deleted file mode 100644 index 8671a96..0000000 --- a/lib/collection-keys.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { - compactDecrypt, decodeProtectedHeader, exportJWK, exportPKCS8, generateKeyPair, importPKCS8, - type JWK, -} from 'jose'; -import { - CONTENT_ALGORITHM, ENVELOPE_TYPE, KEY_ALGORITHM, MAX_ENVELOPE_BYTES, MAX_PLAINTEXT_BYTES, - type KeyBinding, -} from './credential-envelope'; -import type { PrivateKeyProtector } from './private-key-protector'; -import type { CollectionRequest } from './request-store'; - -export type CollectionKey = { - keyId: string; - publicKey: JWK; - wrappedPrivateKey: string | null; -}; - -export function keyBinding(request: CollectionRequest): KeyBinding { - return { keyId: request.key.keyId, requestId: request.id, itemId: request.itemId }; -} - -export async function createCollectionKey( - requestId: string, - itemId: string, - protector: PrivateKeyProtector, -): Promise { - const keyId = randomUUID(); - const { publicKey, privateKey } = await generateKeyPair(KEY_ALGORITHM, { - crv: 'P-256', extractable: true, - }); - const privateBytes = new TextEncoder().encode(await exportPKCS8(privateKey)); - try { - return { - keyId, - publicKey: await exportJWK(publicKey), - wrappedPrivateKey: await protector.wrap(privateBytes, { keyId, requestId, itemId }), - }; - } finally { - privateBytes.fill(0); - } -} - -export async function decryptSubmission( - envelope: string, - request: CollectionRequest, - protector: PrivateKeyProtector, -): Promise<{ submission: unknown; version: number }> { - if ( - envelope.length > MAX_ENVELOPE_BYTES || - !/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){4}$/.test(envelope) || - envelope.indexOf('.') > 2048 || !request.key.wrappedPrivateKey - ) throw new Error('invalid envelope'); - - const header = decodeProtectedHeader(envelope); - if ( - header.alg !== KEY_ALGORITHM || header.enc !== CONTENT_ALGORITHM || - header.typ !== ENVELOPE_TYPE || header.kid !== request.key.keyId || - header.request_id !== request.id || header.item_id !== request.itemId || - !Number.isSafeInteger(header.version) || Number(header.version) < 1 - ) throw new Error('invalid envelope'); - - const privateBytes = await protector.unwrap(request.key.wrappedPrivateKey, keyBinding(request)); - try { - const key = await importPKCS8(new TextDecoder().decode(privateBytes), KEY_ALGORITHM); - const { plaintext } = await compactDecrypt(envelope, key, { - keyManagementAlgorithms: [KEY_ALGORITHM], - contentEncryptionAlgorithms: [CONTENT_ALGORITHM], - }); - try { - if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) throw new Error('invalid submission'); - return { - submission: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext)), - version: Number(header.version), - }; - } finally { - plaintext.fill(0); - } - } finally { - privateBytes.fill(0); - } -} diff --git a/lib/credential-envelope.ts b/lib/credential-envelope.ts deleted file mode 100644 index 2577a23..0000000 --- a/lib/credential-envelope.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CompactEncrypt, importJWK, type JWK } from 'jose'; -import type { CredentialSubmission } from '@onkernel/vault-react/data'; - -export const KEY_ALGORITHM = 'ECDH-ES+A256KW'; -export const CONTENT_ALGORITHM = 'A256GCM'; -export const ENVELOPE_TYPE = 'credential-submission+jwe'; -export const MAX_PLAINTEXT_BYTES = 128 * 1024; -export const MAX_ENVELOPE_BYTES = 192 * 1024; - -export type KeyBinding = { - keyId: string; - requestId: string; - itemId: string; -}; - -export type CollectionEncryption = KeyBinding & { - version: number; - publicKey: JWK; -}; - -export async function encryptSubmission( - submission: CredentialSubmission, - encryption: CollectionEncryption, -): Promise { - const plaintext = new TextEncoder().encode(JSON.stringify(submission)); - if (plaintext.byteLength > MAX_PLAINTEXT_BYTES || submission.version !== encryption.version) { - throw new Error('invalid submission'); - } - const key = await importJWK(encryption.publicKey, KEY_ALGORITHM); - const envelope = await new CompactEncrypt(plaintext) - .setProtectedHeader({ - alg: KEY_ALGORITHM, - enc: CONTENT_ALGORITHM, - typ: ENVELOPE_TYPE, - kid: encryption.keyId, - request_id: encryption.requestId, - item_id: encryption.itemId, - version: encryption.version, - }) - .encrypt(key); - if (envelope.length > MAX_ENVELOPE_BYTES) throw new Error('invalid submission'); - return envelope; -} diff --git a/lib/encrypted-submission.ts b/lib/encrypted-submission.ts new file mode 100644 index 0000000..61267be --- /dev/null +++ b/lib/encrypted-submission.ts @@ -0,0 +1,34 @@ +import { CompactEncrypt, importJWK } from 'jose'; +import type { CredentialSubmission } from '@onkernel/vault-react/data'; + +/** The vault's public key from `GET /vaults/{id_or_name}/encryption_key`. Not secret. */ +export type VaultEncryptionKey = { + kid: string; + jwk: { kty: 'EC'; crv: 'P-256'; x: string; y: string }; +}; + +/** Clears stay plaintext `null`; every entered value is ciphertext only Kernel can open. */ +export type EncryptedFieldEdit = { encrypted_value: string } | { value: null }; + +export type EncryptedSubmission = { + version: number; + fields: Record; +}; + +export async function encryptSubmission( + submission: CredentialSubmission, + key: VaultEncryptionKey, +): Promise { + const publicKey = await importJWK(key.jwk, 'ECDH-ES'); + const fields: Record = {}; + for (const [name, { value }] of Object.entries(submission.fields)) { + fields[name] = value === null + ? { value: null } + : { + encrypted_value: await new CompactEncrypt(new TextEncoder().encode(value)) + .setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM', kid: key.kid }) + .encrypt(publicKey), + }; + } + return { version: submission.version, fields }; +} diff --git a/lib/private-key-protector.ts b/lib/private-key-protector.ts deleted file mode 100644 index 24df869..0000000 --- a/lib/private-key-protector.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { CompactEncrypt, compactDecrypt } from 'jose'; -import type { KeyBinding } from './credential-envelope'; - -/** Replace this boundary with a KMS/HSM-backed implementation in production. */ -export interface PrivateKeyProtector { - wrap(privateKey: Uint8Array, binding: KeyBinding): Promise; - unwrap(wrappedKey: string, binding: KeyBinding): Promise; -} - -const type = 'collection-private-key+jwe'; - -export class LocalPrivateKeyProtector implements PrivateKeyProtector { - private readonly key: Uint8Array; - - constructor(encodedKey: string) { - const key = Buffer.from(encodedKey, 'base64'); - if (key.length !== 32 || key.toString('base64') !== encodedKey) { - throw new Error('COLLECTION_KEY_WRAPPING_KEY must be 32 random bytes encoded as base64'); - } - this.key = key; - } - - async wrap(privateKey: Uint8Array, binding: KeyBinding) { - return new CompactEncrypt(privateKey) - .setProtectedHeader({ - alg: 'dir', enc: 'A256GCM', typ: type, - kid: binding.keyId, request_id: binding.requestId, item_id: binding.itemId, - }) - .encrypt(this.key); - } - - async unwrap(wrappedKey: string, binding: KeyBinding) { - const { plaintext, protectedHeader } = await compactDecrypt(wrappedKey, this.key, { - keyManagementAlgorithms: ['dir'], - contentEncryptionAlgorithms: ['A256GCM'], - }); - if ( - protectedHeader.typ !== type || protectedHeader.kid !== binding.keyId || - protectedHeader.request_id !== binding.requestId || protectedHeader.item_id !== binding.itemId - ) { - plaintext.fill(0); - throw new Error('invalid wrapped key'); - } - return plaintext; - } -} - -export function localPrivateKeyProtector() { - const key = process.env.COLLECTION_KEY_WRAPPING_KEY; - if (!key) throw new Error('COLLECTION_KEY_WRAPPING_KEY is required'); - return new LocalPrivateKeyProtector(key); -} diff --git a/lib/request-store.ts b/lib/request-store.ts index beea830..d6e473e 100644 --- a/lib/request-store.ts +++ b/lib/request-store.ts @@ -1,6 +1,5 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; -import type { CollectionKey } from './collection-keys'; export type CollectionRequest = { id: string; @@ -8,7 +7,6 @@ export type CollectionRequest = { vaultId: string; itemKey: string; itemId: string; - key: CollectionKey; expiresAt: number; consumedAt: number | null; revokedAt: number | null; @@ -24,8 +22,6 @@ type StoreData = { requests: CollectionRequest[] }; /** Local development only. Implement CollectionRequestStore with your database in production. */ export class FileCollectionRequestStore implements CollectionRequestStore { - private pendingMutation = Promise.resolve(); - constructor(private readonly path: string) {} async findByTokenDigest(tokenDigest: string) { @@ -33,32 +29,20 @@ export class FileCollectionRequestStore implements CollectionRequestStore { } async save(request: CollectionRequest) { - return this.mutate((data) => { - if (data.requests.some(({ id, tokenDigest }) => id === request.id || tokenDigest === request.tokenDigest)) { - throw new Error('collection request already exists'); - } - data.requests.push(request); - }); + const data = await this.read(); + if (data.requests.some(({ id, tokenDigest }) => id === request.id || tokenDigest === request.tokenDigest)) { + throw new Error('collection request already exists'); + } + data.requests.push(request); + await this.write(data); } async markConsumed(tokenDigest: string, consumedAt: number) { - return this.mutate((data) => { - const request = data.requests.find((candidate) => candidate.tokenDigest === tokenDigest); - if (!request) throw new Error('collection request not found'); - request.consumedAt ??= consumedAt; - request.key.wrappedPrivateKey = null; - }); - } - - private mutate(update: (data: StoreData) => void) { - const mutation = this.pendingMutation.then(async () => { - const data = await this.read(); - update(data); - await this.write(data); - }); - // A failed write must not block later reconciliation attempts. - this.pendingMutation = mutation.catch(() => {}); - return mutation; + const data = await this.read(); + const request = data.requests.find((candidate) => candidate.tokenDigest === tokenDigest); + if (!request) throw new Error('collection request not found'); + request.consumedAt ??= consumedAt; + await this.write(data); } private async read(): Promise { diff --git a/lib/runtime.ts b/lib/runtime.ts index 505fb5f..1652638 100644 --- a/lib/runtime.ts +++ b/lib/runtime.ts @@ -1,7 +1,6 @@ import { join } from 'node:path'; import { createCollectionHandler } from './collection-handler'; import { FileCollectionRequestStore } from './request-store'; -import { localPrivateKeyProtector } from './private-key-protector'; import { KernelCredentialVaultClient, MockCredentialVaultClient } from './vault-client'; let handler: ReturnType | undefined; @@ -17,7 +16,7 @@ export function getCollectionHandler() { required('KERNEL_API_KEY'), process.env.KERNEL_API_BASE_URL, ); - handler = createCollectionHandler({ appOrigin, store, vault, protector: localPrivateKeyProtector() }); + handler = createCollectionHandler({ appOrigin, store, vault }); return handler; } diff --git a/lib/vault-client.ts b/lib/vault-client.ts index b393ba8..93efff8 100644 --- a/lib/vault-client.ts +++ b/lib/vault-client.ts @@ -1,6 +1,8 @@ import Kernel, { APIError, ConflictError } from '@onkernel/sdk'; import type { CredentialVaultItem, VaultItem } from '@onkernel/sdk/resources/vaults/items'; -import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react/data'; +import type { CredentialItem } from '@onkernel/vault-react/data'; +import { compactDecrypt, exportJWK, generateKeyPair, type CryptoKey } from 'jose'; +import type { EncryptedSubmission, VaultEncryptionKey } from './encrypted-submission'; import type { CollectionRequest } from './request-store'; export class VaultConflictError extends Error {} @@ -8,7 +10,8 @@ export class VaultInvalidRequestError extends Error {} export interface CredentialVaultClient { retrieve(request: CollectionRequest): Promise; - update(request: CollectionRequest, submission: CredentialSubmission): Promise; + encryptionKey(request: CollectionRequest): Promise; + update(request: CollectionRequest, submission: EncryptedSubmission): Promise; } function credentialItem(item: VaultItem): CredentialVaultItem { @@ -23,7 +26,7 @@ export class KernelCredentialVaultClient implements CredentialVaultClient { } as const; constructor(apiKey: string, baseURL?: string) { - this.kernel = new Kernel({ apiKey, maxRetries: 0, logLevel: 'off', ...(baseURL ? { baseURL } : {}) }); + this.kernel = new Kernel({ apiKey, maxRetries: 0, ...(baseURL ? { baseURL } : {}) }); } async retrieve(request: CollectionRequest) { @@ -36,7 +39,12 @@ export class KernelCredentialVaultClient implements CredentialVaultClient { ); } - async update(request: CollectionRequest, submission: CredentialSubmission) { + async encryptionKey(request: CollectionRequest): Promise { + const { kid, jwk } = await this.kernel.vaults.retrieveEncryptionKey(request.vaultId, this.requestOptions); + return { kid, jwk }; + } + + async update(request: CollectionRequest, submission: EncryptedSubmission) { try { return credentialItem( await this.kernel.vaults.items.update( @@ -59,8 +67,10 @@ export class KernelCredentialVaultClient implements CredentialVaultClient { } } +/** Stands in for Kernel, including decrypting `encrypted_value` with the vault's private key. */ export class MockCredentialVaultClient implements CredentialVaultClient { private readonly items = new Map(); + private key?: Promise<{ public: VaultEncryptionKey; privateKey: CryptoKey }>; async retrieve(request: CollectionRequest) { const item = this.items.get(request.itemId) ?? this.createItem(request.itemId); @@ -68,13 +78,18 @@ export class MockCredentialVaultClient implements CredentialVaultClient { return structuredClone(item); } - async update(request: CollectionRequest, submission: CredentialSubmission) { + async encryptionKey() { + return (await this.keyPair()).public; + } + + async update(request: CollectionRequest, submission: EncryptedSubmission) { const item = this.items.get(request.itemId) ?? this.createItem(request.itemId); if (item.version !== submission.version) throw new VaultConflictError(); - for (const [name, { value }] of Object.entries(submission.fields)) { + for (const [name, edit] of Object.entries(submission.fields)) { const field = item.spec.fields.find((candidate) => candidate.name === name); if (!field) throw new VaultInvalidRequestError(); - item.state.fields[name] = value === null + const value = 'encrypted_value' in edit ? await this.decrypt(edit.encrypted_value) : edit.value; + item.state.fields[name] = value === null || value === '' ? { has_value: false } : field.sensitive ? { has_value: true } @@ -86,6 +101,29 @@ export class MockCredentialVaultClient implements CredentialVaultClient { return structuredClone(item); } + private async decrypt(encrypted: string) { + const { privateKey, public: key } = await this.keyPair(); + try { + const { plaintext, protectedHeader } = await compactDecrypt(encrypted, privateKey, { + keyManagementAlgorithms: ['ECDH-ES'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + if (protectedHeader.kid !== key.kid) throw new Error('kid mismatch'); + return new TextDecoder('utf-8', { fatal: true }).decode(plaintext); + } catch { + throw new VaultInvalidRequestError(); + } + } + + private keyPair() { + this.key ??= (async () => { + const { publicKey, privateKey } = await generateKeyPair('ECDH-ES', { crv: 'P-256' }); + const { x, y } = await exportJWK(publicKey); + return { privateKey, public: { kid: 'vtk_mock', jwk: { kty: 'EC', crv: 'P-256', x: x!, y: y! } } }; + })(); + return this.key; + } + private createItem(id: string): CredentialItem { return { id, diff --git a/package.json b/package.json index b2a9885..0298e26 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "create-request": "bun run scripts/create-request.ts" }, "dependencies": { - "@onkernel/sdk": "^0.110.0", + "@onkernel/sdk": "^0.122.0", "@onkernel/vault-react": "^0.1.0", "jose": "^6.2.12", "next": "^16.3.5", diff --git a/scripts/create-request.ts b/scripts/create-request.ts index 2609f83..99f63bc 100644 --- a/scripts/create-request.ts +++ b/scripts/create-request.ts @@ -3,8 +3,6 @@ import { join } from 'node:path'; import Kernel from '@onkernel/sdk'; import { FileCollectionRequestStore } from '../lib/request-store'; import { collectionTokenDigest, createCollectionToken } from '../lib/server-token'; -import { createCollectionKey } from '../lib/collection-keys'; -import { localPrivateKeyProtector } from '../lib/private-key-protector'; const mock = process.env.MOCK_VAULT === 'true'; const [vaultArg, itemArg] = process.argv.slice(2); @@ -13,7 +11,6 @@ if (!mock && (!vaultArg || !itemArg)) { } const appOrigin = new URL(required('APP_ORIGIN')).origin; -const protector = localPrivateKeyProtector(); const ttlMinutes = Number(process.env.COLLECTION_REQUEST_TTL_MINUTES ?? '15'); if (!Number.isSafeInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > 24 * 60) { throw new Error('COLLECTION_REQUEST_TTL_MINUTES must be an integer from 1 to 1440'); @@ -27,12 +24,9 @@ if (!mock) { const kernel = new Kernel({ apiKey: required('KERNEL_API_KEY'), maxRetries: 0, - logLevel: 'off', ...(process.env.KERNEL_API_BASE_URL ? { baseURL: process.env.KERNEL_API_BASE_URL } : {}), }); - const item = await kernel.vaults.items.retrieve(itemKey, { id_or_name: vaultId }).catch(() => { - throw new Error('unable to retrieve the credential item'); - }); + const item = await kernel.vaults.items.retrieve(itemKey, { id_or_name: vaultId }); if (item.type !== 'credential') throw new Error('the selected vault item is not a credential'); itemId = item.id; } @@ -41,14 +35,12 @@ const token = createCollectionToken(); const store = new FileCollectionRequestStore( join(process.cwd(), '.data', 'credential-requests.json'), ); -const id = randomUUID(); await store.save({ - id, + id: randomUUID(), tokenDigest: collectionTokenDigest(token), vaultId, itemKey, itemId, - key: await createCollectionKey(id, itemId, protector), expiresAt: Date.now() + ttlMinutes * 60_000, consumedAt: null, revokedAt: null, diff --git a/tests/collection-handler.test.ts b/tests/collection-handler.test.ts index bf91b3d..09482bf 100644 --- a/tests/collection-handler.test.ts +++ b/tests/collection-handler.test.ts @@ -1,20 +1,18 @@ import { expect, mock, test } from 'bun:test'; -import { randomBytes } from 'node:crypto'; -import { CompactEncrypt, importJWK } from 'jose'; -import { createCollectionKey, type CollectionKey } from '../lib/collection-keys'; -import { CONTENT_ALGORITHM, ENVELOPE_TYPE, KEY_ALGORITHM, MAX_ENVELOPE_BYTES, MAX_PLAINTEXT_BYTES } from '../lib/credential-envelope'; -import { LocalPrivateKeyProtector } from '../lib/private-key-protector'; -import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react/data'; +import type { CredentialItem } from '@onkernel/vault-react/data'; import { createCollectionHandler } from '../lib/collection-handler'; +import type { EncryptedSubmission, VaultEncryptionKey } from '../lib/encrypted-submission'; import type { CollectionRequest, CollectionRequestStore } from '../lib/request-store'; import { collectionTokenDigest } from '../lib/server-token'; -import { KernelCredentialVaultClient, VaultConflictError, VaultInvalidRequestError, type CredentialVaultClient } from '../lib/vault-client'; +import { VaultConflictError, type CredentialVaultClient } from '../lib/vault-client'; const origin = 'https://app.example.com'; const token = 'a'.repeat(43); const digest = collectionTokenDigest(token); const authorization = `Bearer ${token}`; const now = 1_700_000_000_000; +const encryptionKey: VaultEncryptionKey = { kid: 'vtk_example', jwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } }; +const jwe = 'eyJhbGciOiJFQ0RILUVTIn0..aXY.Y2lwaGVydGV4dA.dGFn'; function credentialItem(): CredentialItem { return { @@ -41,21 +39,16 @@ function credentialItem(): CredentialItem { } class MemoryStore implements CollectionRequestStore { - request: CollectionRequest | null; - - constructor(key: CollectionKey) { - this.request = { - key, - id: 'request-1', - tokenDigest: digest, - vaultId: 'server-vault', - itemKey: 'server-key', - itemId: 'vi_example', - expiresAt: now + 60_000, - consumedAt: null, - revokedAt: null, - }; - } + request: CollectionRequest | null = { + id: 'request-1', + tokenDigest: digest, + vaultId: 'server-vault', + itemKey: 'server-key', + itemId: 'vi_example', + expiresAt: now + 60_000, + consumedAt: null, + revokedAt: null, + }; async findByTokenDigest(candidate: string) { return candidate === digest ? this.request : null; @@ -68,34 +61,21 @@ class MemoryStore implements CollectionRequestStore { async markConsumed(candidate: string, consumedAt: number) { if (!this.request || candidate !== digest) throw new Error('not found'); this.request.consumedAt = consumedAt; - this.request.key.wrappedPrivateKey = null; } } -async function setup() { - const protector = new LocalPrivateKeyProtector(randomBytes(32).toString('base64')); - const key = await createCollectionKey('request-1', 'vi_example', protector); - const store = new MemoryStore(key); +function setup() { + const store = new MemoryStore(); const retrieve = mock(async (_request: CollectionRequest): Promise => credentialItem()); const update = mock( - async (_request: CollectionRequest, _submission: CredentialSubmission): Promise => ({ + async (_request: CollectionRequest, _submission: EncryptedSubmission): Promise => ({ ...credentialItem(), version: 8, }), ); - const vault: CredentialVaultClient = { retrieve, update }; - const handler = createCollectionHandler({ appOrigin: origin, store, vault, protector, now: () => now }); - const publicKey = await importJWK(key.publicKey, KEY_ALGORITHM); - const encrypt = async (body: unknown, overrides: Record = {}) => { - const version = (body as { version?: number })?.version ?? 7; - return new CompactEncrypt(new TextEncoder().encode(JSON.stringify(body))) - .setProtectedHeader({ - alg: KEY_ALGORITHM, enc: CONTENT_ALGORITHM, typ: ENVELOPE_TYPE, - kid: key.keyId, request_id: 'request-1', item_id: 'vi_example', version, - ...overrides, - }).encrypt(publicKey); - }; - return { handler, store, retrieve, update, encrypt, protector }; + const vault: CredentialVaultClient = { retrieve, encryptionKey: async () => encryptionKey, update }; + const handler = createCollectionHandler({ appOrigin: origin, store, vault, now: () => now }); + return { handler, store, retrieve, update }; } const get = (bearer = authorization) => @@ -106,33 +86,29 @@ const get = (bearer = authorization) => const patch = (body: unknown, requestOrigin = origin, bearer = authorization) => new Request(`${origin}/api/credential-requests/current`, { method: 'PATCH', - headers: { Authorization: bearer, Origin: requestOrigin, 'Content-Type': 'application/jose' }, - body: typeof body === 'string' ? body : JSON.stringify(body), + headers: { Authorization: bearer, Origin: requestOrigin, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }); test('only a valid fragment bearer can authorize a request', async () => { - const { handler, retrieve } = await setup(); + const { handler, retrieve } = setup(); for (const request of [get(''), get('Bearer request_123'), get(`Bearer ${'a'.repeat(42)}`)]) { expect((await handler(request)).status).toBe(401); } expect(retrieve).not.toHaveBeenCalled(); }); -test('returns only the safe item projection', async () => { - const { handler } = await setup(); +test('returns the safe item projection and the vault encryption key', async () => { + const { handler } = setup(); const response = await handler(get()); expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(response.headers.get('Referrer-Policy')).toBe('no-referrer'); - const body = await response.json(); - expect(body.item).toEqual(credentialItem()); - expect(body.encryption).toMatchObject({ requestId: 'request-1', itemId: 'vi_example', version: 7 }); - expect(Object.keys(body.encryption).sort()).toEqual(['itemId', 'keyId', 'publicKey', 'requestId', 'version']); - expect(Object.keys(body.encryption.publicKey).sort()).toEqual(['crv', 'kty', 'x', 'y']); + expect(await response.json()).toEqual({ item: credentialItem(), encryption_key: encryptionKey }); }); test('rejects expired, consumed, revoked, and replaced targets', async () => { - const { handler, store, retrieve } = await setup(); + const { handler, store, retrieve } = setup(); store.request!.expiresAt = now; expect((await handler(get())).status).toBe(410); store.request!.expiresAt = now + 60_000; @@ -147,7 +123,7 @@ test('rejects expired, consumed, revoked, and replaced targets', async () => { }); test('rejects cross-origin writes before loading the item', async () => { - const { handler, retrieve } = await setup(); + const { handler, retrieve } = setup(); const response = await handler( patch({ version: 7, fields: {} }, 'https://attacker.example.com'), ); @@ -155,10 +131,10 @@ test('rejects cross-origin writes before loading the item', async () => { expect(retrieve).not.toHaveBeenCalled(); }); -test('writes the mapped target with rendered version and consumes the request', async () => { - const { handler, store, update, encrypt } = await setup(); +test('relays ciphertext to the mapped target with rendered version and consumes the request', async () => { + const { handler, store, update } = setup(); const response = await handler( - patch(await encrypt({ version: 7, fields: { password: { value: ' exact ' } } })), + patch({ version: 7, fields: { password: { encrypted_value: jwe }, note: { value: null } } }), ); expect(response.status).toBe(200); expect(update).toHaveBeenCalledTimes(1); @@ -169,214 +145,47 @@ test('writes the mapped target with rendered version and consumes the request', }); expect(update.mock.calls[0][1]).toEqual({ version: 7, - fields: { password: { value: ' exact ' } }, + fields: { password: { encrypted_value: jwe }, note: { value: null } }, }); expect(store.request?.consumedAt).toBe(now); - expect(store.request?.key.wrappedPrivateKey).toBeNull(); expect(await (await handler(get())).json()).toEqual({ code: 'consumed' }); - expect((await handler(patch(await encrypt({ version: 7, fields: {} })))).status).toBe(410); - expect(update).toHaveBeenCalledTimes(1); }); test('unchanged completion skips the vault write and consumes the request', async () => { - const { handler, store, update, encrypt } = await setup(); - expect(await (await handler(patch(await encrypt({ version: 7, fields: {} })))).json()).toEqual({ unchanged: true }); + const { handler, store, update } = setup(); + expect(await (await handler(patch({ version: 7, fields: {} }))).json()).toEqual({ unchanged: true }); expect(update).not.toHaveBeenCalled(); expect(store.request?.consumedAt).toBe(now); }); test('stale writes return a conflict without consuming the request', async () => { - const { handler, store, update, encrypt } = await setup(); - expect((await handler(patch(await encrypt({ version: 6, fields: { note: { value: 'edit' } } })))).status).toBe(409); + const { handler, store, update } = setup(); + expect((await handler(patch({ version: 6, fields: { note: { encrypted_value: jwe } } }))).status).toBe(409); expect(update).not.toHaveBeenCalled(); update.mockImplementationOnce(async () => { throw new VaultConflictError(); }); - expect((await handler(patch(await encrypt({ version: 7, fields: { note: { value: 'edit' } } })))).status).toBe(409); + expect((await handler(patch({ version: 7, fields: { note: { encrypted_value: jwe } } }))).status).toBe(409); expect(store.request?.consumedAt).toBeNull(); }); -test('rejects TOTP, unknown fields, required clearing, oversized bodies, and malformed values', async () => { - const { handler, update, encrypt } = await setup(); +test('rejects plaintext values, TOTP, unknown fields, required clearing, and malformed ciphertext', async () => { + const { handler, update } = setup(); for (const body of [ - { version: 7, fields: { otp: { value: 'seed' } } }, - { version: 7, fields: { unknown: { value: 'edit' } } }, - { version: 7, fields: { password: { value: null } } }, + { version: 7, fields: { password: { value: 'plaintext' } } }, { version: 7, fields: { note: { value: '' } } }, - { version: 7, fields: { note: { value: 42 } } }, - { version: 7, fields: { note: { value: 'edit', extra: true } } }, - { version: 7, fields: { note: { value: 'é'.repeat(8193) } } }, - { version: 7, fields: [] }, - null, + { version: 7, fields: { note: { value: null, encrypted_value: jwe } } }, + { version: 7, fields: { note: { encrypted_value: 'not-a-jwe' } } }, + { version: 7, fields: { note: { encrypted_value: `${jwe}.extra` } } }, + { version: 7, fields: { note: { encrypted_value: null } } }, + { version: 7, fields: { note: { encrypted_value: `${'a'.repeat(24 * 1024)}....` } } }, + { version: 7, fields: { otp: { encrypted_value: jwe } } }, + { version: 7, fields: { unknown: { encrypted_value: jwe } } }, + { version: 7, fields: { password: { value: null } } }, { version: 7, fields: {}, vaultId: 'attacker-vault' }, - { version: 7, fields: { note: { value: 'x'.repeat(128 * 1024) } } }, + { version: 7, fields: { note: { encrypted_value: `a${'.'.repeat(4)}${'b'.repeat(128 * 1024)}` } } }, ]) { - expect((await handler(patch(await encrypt(body)))).status).toBe(400); - } - expect(update).not.toHaveBeenCalled(); -}); - -test('rejects plaintext, malformed JWE, wrong keys, and mismatched authenticated bindings', async () => { - const { handler, update, encrypt, store } = await setup(); - const other = await setup(); - const body = { version: 7, fields: { note: { value: 'synthetic edit' } } }; - const wrappedKey = store.request!.key.wrappedPrivateKey; - const envelopes = [ - JSON.stringify(body), 'not-a-jwe', await other.encrypt(body), - await other.encrypt(body, { kid: store.request!.key.keyId }), - await encrypt(body, { kid: 'wrong-key' }), - await encrypt(body, { request_id: 'other-request' }), - await encrypt(body, { item_id: 'other-item' }), - await encrypt(body, { version: 8 }), - await encrypt(body, { version: '7' }), - await encrypt(body, { typ: 'other+jwe' }), - await encrypt(body, { enc: 'A128GCM' }), - ]; - for (const envelope of envelopes) { - const response = await handler(patch(envelope)); - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ code: 'invalid' }); - } - expect(update).not.toHaveBeenCalled(); - expect(store.request!.consumedAt).toBeNull(); - expect(store.request!.key.wrappedPrivateKey).toBe(wrappedKey); -}); - -test('rejects tampering with every JWE segment, including protected metadata', async () => { - const { handler, update, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); - for (let index = 0; index < 5; index++) { - const parts = envelope.split('.'); - parts[index] = (parts[index][0] === 'A' ? 'B' : 'A') + parts[index].slice(1); - expect((await handler(patch(parts.join('.')))).status).toBe(400); - } - const parts = envelope.split('.'); - const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString()); - header.version = 8; - parts[0] = Buffer.from(JSON.stringify(header)).toString('base64url'); - expect((await handler(patch(parts.join('.')))).status).toBe(400); - expect(update).not.toHaveBeenCalled(); -}); - -test('bounds encrypted and decrypted bodies independently and rejects oversized streams', async () => { - const { handler, update, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'x'.repeat(MAX_PLAINTEXT_BYTES) } } }); - expect(envelope.length).toBeLessThan(MAX_ENVELOPE_BYTES); - expect((await handler(patch(envelope))).status).toBe(400); - const request = patch(''); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(MAX_ENVELOPE_BYTES)); - controller.enqueue(new Uint8Array(1)); - controller.close(); - }, - }); - expect((await handler(new Request(request, { body: stream }))).status).toBe(400); - expect(update).not.toHaveBeenCalled(); -}); - -test('preserves encrypted PATCH expiry, revocation, consumption, identity, and origin checks', async () => { - const { handler, update, retrieve, store, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); - for (const state of ['expiresAt', 'revokedAt', 'consumedAt'] as const) { - const original = store.request![state]; - store.request![state] = now; - expect((await handler(patch(envelope))).status).toBe(410); - if (state === 'expiresAt') store.request!.expiresAt = original!; - else store.request![state] = original; + expect((await handler(patch(body))).status).toBe(400); } - for (const requestOrigin of ['', 'null', 'https://attacker.example.com']) { - expect((await handler(patch(envelope, requestOrigin))).status).toBe(403); - } - expect(retrieve).not.toHaveBeenCalled(); - retrieve.mockImplementationOnce(async () => ({ ...credentialItem(), id: 'replacement' })); - expect((await handler(patch(envelope))).status).toBe(404); - const wrongContentType = patch(envelope); - wrongContentType.headers.set('Content-Type', 'application/json'); - expect((await handler(wrongContentType)).status).toBe(400); expect(update).not.toHaveBeenCalled(); }); - -test('retains the key after upstream failures and allows a safe retry', async () => { - const { handler, update, store, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); - const wrappedKey = store.request!.key.wrappedPrivateKey; - for (const [error, status] of [[new VaultInvalidRequestError(), 400], [new Error('upstream details'), 502]] as const) { - update.mockImplementationOnce(async () => { throw error; }); - const response = await handler(patch(envelope)); - expect(response.status).toBe(status); - expect(await response.text()).not.toContain('upstream details'); - expect(store.request!.consumedAt).toBeNull(); - expect(store.request!.key.wrappedPrivateKey).toBe(wrappedKey); - } - expect((await handler(patch(envelope))).status).toBe(200); - expect(store.request!.key.wrappedPrivateKey).toBeNull(); -}); - -test('an uncertain committed write retains the key and version checking prevents replay', async () => { - const { handler, update, retrieve, store, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); - update.mockImplementationOnce(async () => { - retrieve.mockImplementation(async () => ({ ...credentialItem(), version: 8 })); - throw new Error('response lost'); - }); - expect((await handler(patch(envelope))).status).toBe(502); - expect((await handler(patch(envelope))).status).toBe(409); - expect(update).toHaveBeenCalledTimes(1); - expect(store.request!.consumedAt).toBeNull(); - expect(store.request!.key.wrappedPrivateKey).not.toBeNull(); -}); - -test('a failed consumption write retains key material for reconciliation', async () => { - const { handler, update, retrieve, store, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); - store.markConsumed = async () => { throw new Error('storage unavailable'); }; - update.mockImplementationOnce(async () => { - retrieve.mockImplementation(async () => ({ ...credentialItem(), version: 8 })); - return { ...credentialItem(), version: 8 }; - }); - expect((await handler(patch(envelope))).status).toBe(502); - expect((await handler(patch(envelope))).status).toBe(409); - expect(update).toHaveBeenCalledTimes(1); - expect(store.request!.consumedAt).toBeNull(); - expect(store.request!.key.wrappedPrivateKey).not.toBeNull(); -}); - -test('serializes local concurrent submissions and prevents writes after consumption', async () => { - const { handler, update, encrypt } = await setup(); - const envelope = await encrypt({ version: 7, fields: { note: { value: 'synthetic edit' } } }); - const responses = await Promise.all([handler(patch(envelope)), handler(patch(envelope))]); - expect(responses.map(({ status }) => status).sort()).toEqual([200, 409]); - expect((await handler(patch(envelope))).status).toBe(410); - expect(update).toHaveBeenCalledTimes(1); -}); - -test('accepts optional clearing and preserves the complete submission exactly', async () => { - const { handler, update, encrypt } = await setup(); - const body = { version: 7, fields: { password: { value: ' é 🔒 ' }, note: { value: null } } }; - expect((await handler(patch(await encrypt(body)))).status).toBe(200); - expect(update.mock.calls[0][1]).toEqual(body); -}); - -test('decrypts into the existing plaintext Kernel SDK PATCH with immutable identity and version', async () => { - const { store, protector, encrypt } = await setup(); - const originalFetch = globalThis.fetch; - const upstream = mock(async (_input: string | URL | Request, _init?: RequestInit) => Response.json(credentialItem())); - globalThis.fetch = Object.assign(upstream, { preconnect() {} }); - try { - const vault = new KernelCredentialVaultClient(randomBytes(32).toString('hex'), 'https://kernel.example.com'); - const handler = createCollectionHandler({ appOrigin: origin, store, protector, vault, now: () => now }); - const body = { version: 7, fields: { password: { value: ' synthetic password ' }, note: { value: null } } }; - expect((await handler(patch(await encrypt(body)))).status).toBe(200); - expect(upstream).toHaveBeenCalledTimes(2); - const [url, init] = upstream.mock.calls[1]; - expect(String(url)).toBe('https://kernel.example.com/vaults/server-vault/items/server-key'); - expect(init?.method).toBe('PATCH'); - expect(JSON.parse(String(init?.body))).toEqual({ - type: 'credential', expected_item_id: 'vi_example', version: 7, spec: { fields: body.fields }, - }); - expect(store.request!.key.wrappedPrivateKey).toBeNull(); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tests/collection-keys.test.ts b/tests/collection-keys.test.ts deleted file mode 100644 index 4dbbf13..0000000 --- a/tests/collection-keys.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { expect, test } from 'bun:test'; -import { randomBytes } from 'node:crypto'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createCollectionKey, decryptSubmission, keyBinding } from '../lib/collection-keys'; -import { encryptSubmission, MAX_PLAINTEXT_BYTES } from '../lib/credential-envelope'; -import { LocalPrivateKeyProtector } from '../lib/private-key-protector'; -import { FileCollectionRequestStore, type CollectionRequest } from '../lib/request-store'; - -const protector = () => new LocalPrivateKeyProtector(randomBytes(32).toString('base64')); - -test('creates independent keys and persists only wrapped private material, clearing it on consumption', async () => { - const directory = await mkdtemp(join(tmpdir(), 'collection-keys-')); - try { - const wrapping = protector(); - const key = await createCollectionKey('request-1', 'item-1', wrapping); - const other = await createCollectionKey('request-2', 'item-1', wrapping); - expect(key.keyId).not.toBe(other.keyId); - expect(key.publicKey).not.toEqual(other.publicKey); - expect(key.publicKey.d).toBeUndefined(); - const request: CollectionRequest = { - id: 'request-1', itemId: 'item-1', itemKey: 'item', vaultId: 'vault', tokenDigest: 'digest', - expiresAt: Date.now() + 60_000, revokedAt: null, consumedAt: null, key, - }; - const path = join(directory, 'requests.json'); - const store = new FileCollectionRequestStore(path); - await store.save(request); - const contents = await readFile(path, 'utf8'); - expect(contents).not.toContain('PRIVATE KEY'); - expect(contents).not.toContain('"d":'); - const unwrapped = await wrapping.unwrap(key.wrappedPrivateKey!, keyBinding(request)); - expect(contents).not.toContain(new TextDecoder().decode(unwrapped)); - unwrapped.fill(0); - - const body = { version: 1, fields: { password: { value: 'synthetic value' } } }; - const envelope = await encryptSubmission(body, { ...keyBinding(request), version: 1, publicKey: key.publicKey }); - const restored = await new FileCollectionRequestStore(path).findByTokenDigest('digest'); - expect((await decryptSubmission(envelope, restored!, wrapping)).submission).toEqual(body); - await expect(store.markConsumed('missing', Date.now())).rejects.toThrow(); - await store.save({ ...request, id: 'request-2', tokenDigest: 'other-digest', key: other }); - await Promise.all([ - store.markConsumed('digest', Date.now()), - store.markConsumed('other-digest', Date.now()), - ]); - expect((await store.findByTokenDigest('other-digest'))!.key.wrappedPrivateKey).toBeNull(); - const consumed = await store.findByTokenDigest('digest'); - expect(consumed!.key.wrappedPrivateKey).toBeNull(); - expect(await readFile(path, 'utf8')).not.toContain(key.wrappedPrivateKey!); - await expect(decryptSubmission(envelope, consumed!, wrapping)).rejects.toThrow(); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); - -test('wrapping rejects a wrong wrapping key, tampering, or a transplanted request/item/key binding', async () => { - const wrapping = protector(); - const key = await createCollectionKey('request-1', 'item-1', wrapping); - const binding = { requestId: 'request-1', itemId: 'item-1', keyId: key.keyId }; - await expect(protector().unwrap(key.wrappedPrivateKey!, binding)).rejects.toThrow(); - for (const field of ['requestId', 'itemId', 'keyId'] as const) { - await expect(wrapping.unwrap(key.wrappedPrivateKey!, { ...binding, [field]: 'other' })).rejects.toThrow(); - } - const parts = key.wrappedPrivateKey!.split('.'); - parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1); - await expect(wrapping.unwrap(parts.join('.'), binding)).rejects.toThrow(); -}); - -test('requires a correctly encoded 32-byte wrapping key', () => { - for (const value of ['', 'not-base64', randomBytes(16).toString('base64'), randomBytes(33).toString('base64')]) { - expect(() => new LocalPrivateKeyProtector(value)).toThrow('32 random bytes'); - } -}); - -test('browser encryption rejects oversized plaintext and inconsistent rendered versions', async () => { - const key = await createCollectionKey('request-1', 'item-1', protector()); - const encryption = { requestId: 'request-1', itemId: 'item-1', keyId: key.keyId, publicKey: key.publicKey, version: 1 }; - await expect(encryptSubmission({ version: 2, fields: {} }, encryption)).rejects.toThrow(); - await expect(encryptSubmission({ - version: 1, fields: { password: { value: 'x'.repeat(MAX_PLAINTEXT_BYTES) } }, - }, encryption)).rejects.toThrow(); -}); diff --git a/tests/collection-page.test.tsx b/tests/collection-page.test.tsx index e74b79f..64a56e5 100644 --- a/tests/collection-page.test.tsx +++ b/tests/collection-page.test.tsx @@ -1,15 +1,7 @@ import './setup'; import { afterEach, beforeEach, expect, mock, test } from 'bun:test'; -import { randomBytes } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react'; -import { createCollectionHandler } from '../lib/collection-handler'; -import { createCollectionKey } from '../lib/collection-keys'; -import { LocalPrivateKeyProtector } from '../lib/private-key-protector'; -import { FileCollectionRequestStore, type CollectionRequest } from '../lib/request-store'; -import { collectionTokenDigest } from '../lib/server-token'; +import type { CredentialItem } from '@onkernel/vault-react'; +import { compactDecrypt, exportJWK, generateKeyPair } from 'jose'; const { act } = await import('react'); const { createRoot } = await import('react-dom/client'); @@ -18,7 +10,6 @@ const token = 'a'.repeat(43); let container: HTMLDivElement; let root: ReturnType; let originalFetch: typeof fetch; -let directory: string; function item(): CredentialItem { return { @@ -26,21 +17,14 @@ function item(): CredentialItem { type: 'credential', version: 1, spec: { - fields: [ - { name: 'password', type: 'password', required: true, sensitive: true }, - { name: 'note', type: 'text', required: false, sensitive: false }, - ], - }, - state: { - status: 'pending_collection', - fields: { password: { has_value: false }, note: { has_value: false } }, + fields: [{ name: 'password', type: 'password', required: true, sensitive: true }], }, + state: { status: 'pending_collection', fields: { password: { has_value: false } } }, }; } -beforeEach(async () => { +beforeEach(() => { originalFetch = globalThis.fetch; - directory = await mkdtemp(join(tmpdir(), 'collection-page-')); window.location.href = `https://app.example.com/credentials/collect#token=${token}`; container = document.createElement('div'); document.body.append(container); @@ -51,77 +35,53 @@ afterEach(async () => { await act(async () => root.unmount()); container.remove(); globalThis.fetch = originalFetch; - await rm(directory, { recursive: true, force: true }); }); -const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -test('encrypts the complete browser submission and forwards exact values through the handler', async () => { - const origin = 'https://app.example.com'; - const protector = new LocalPrivateKeyProtector(randomBytes(32).toString('base64')); - const store = new FileCollectionRequestStore(join(directory, 'requests.json')); - const mapping: CollectionRequest = { - id: 'request-1', tokenDigest: collectionTokenDigest(token), vaultId: 'vault', - itemKey: 'credential', itemId: item().id, - key: await createCollectionKey('request-1', item().id, protector), - expiresAt: Date.now() + 60_000, consumedAt: null, revokedAt: null, - }; - await store.save(mapping); - const update = mock(async (_mapping: CollectionRequest, _submission: CredentialSubmission) => ({ ...item(), version: 2 })); - const handler = createCollectionHandler({ - appOrigin: origin, store, protector, vault: { retrieve: async () => item(), update }, - }); +test('keeps the capability in the fragment and sends only ciphertext', async () => { + const { publicKey, privateKey } = await generateKeyPair('ECDH-ES', { crv: 'P-256' }); + const { x, y } = await exportJWK(publicKey); + const encryption_key = { kid: 'vtk_example', jwk: { kty: 'EC', crv: 'P-256', x, y } }; const fetcher = Object.assign( - mock(async (input: string | URL | Request, init?: RequestInit) => { - const headers = new Headers(init?.headers); - if (init?.method === 'PATCH') headers.set('Origin', origin); - return handler(new Request(new URL(String(input), origin), { ...init, headers })); - }), + mock(async (_input: string | URL | Request, _init?: RequestInit) => + Response.json({ item: item(), encryption_key }), + ), { preconnect() {} }, ); globalThis.fetch = fetcher; + await act(async () => root.render()); await act(async () => { - root.render(); + await settle(); + await settle(); }); - for (let i = 0; i < 100 && !container.querySelector('form'); i++) { - await act(settle); - } expect(fetcher.mock.calls[0][0]).toBe('/api/credential-requests/current'); const read = fetcher.mock.calls[0][1]; expect(read?.credentials).toBe('omit'); - expect(read?.cache).toBe('no-store'); expect(new Headers(read?.headers).get('Authorization')).toBe(`Bearer ${token}`); expect(String(fetcher.mock.calls[0][0])).not.toContain(token); - const password = ' synthetic password é 🔒 '; - const note = 'synthetic non-sensitive note'; - container.querySelector('input[name="password"]')!.value = password; - container.querySelector('input[name="note"]')!.value = note; + container.querySelector('input[name="password"]')!.value = 'replacement'; await act(async () => { container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + // Encryption runs on WebCrypto, so wait for the PATCH rather than a single tick. + for (let i = 0; i < 100 && fetcher.mock.calls.length < 2; i++) await settle(); + await settle(); }); - for (let i = 0; i < 100 && window.location.hash; i++) { - await act(settle); - } const submission = fetcher.mock.calls[1]; expect(submission[0]).toBe('/api/credential-requests/current'); expect(submission[1]?.method).toBe('PATCH'); - expect(submission[1]?.credentials).toBe('omit'); - expect(submission[1]?.cache).toBe('no-store'); expect(new Headers(submission[1]?.headers).get('Authorization')).toBe(`Bearer ${token}`); - expect(new Headers(submission[1]?.headers).get('Content-Type')).toBe('application/jose'); const body = String(submission[1]?.body); - expect(body.split('.')).toHaveLength(5); - for (const value of [token, password, note, 'password', 'fields']) expect(body).not.toContain(value); - expect(update).toHaveBeenCalledTimes(1); - expect(update.mock.calls[0][1]).toEqual({ - version: 1, fields: { password: { value: password }, note: { value: note } }, - }); - const consumed = await store.findByTokenDigest(mapping.tokenDigest); - expect(consumed?.consumedAt).not.toBeNull(); - expect(consumed?.key.wrappedPrivateKey).toBeNull(); + expect(body).not.toContain(token); + expect(body).not.toContain('replacement'); + const sent = JSON.parse(body); + expect(sent.version).toBe(1); + const { plaintext, protectedHeader } = await compactDecrypt(sent.fields.password.encrypted_value, privateKey); + expect(new TextDecoder().decode(plaintext)).toBe('replacement'); + expect(protectedHeader).toMatchObject({ alg: 'ECDH-ES', enc: 'A256GCM', kid: 'vtk_example' }); expect(window.location.pathname).toBe('/credentials/collect'); expect(window.location.hash).toBe(''); }); diff --git a/tests/encrypted-submission.test.ts b/tests/encrypted-submission.test.ts new file mode 100644 index 0000000..046ebcb --- /dev/null +++ b/tests/encrypted-submission.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'bun:test'; +import { createCollectionHandler } from '../lib/collection-handler'; +import { encryptSubmission } from '../lib/encrypted-submission'; +import type { CollectionRequest, CollectionRequestStore } from '../lib/request-store'; +import { collectionTokenDigest } from '../lib/server-token'; +import { MockCredentialVaultClient, VaultInvalidRequestError } from '../lib/vault-client'; + +const origin = 'https://app.example.com'; +const token = 'b'.repeat(43); +const request: CollectionRequest = { + id: 'request-1', + tokenDigest: collectionTokenDigest(token), + vaultId: 'mock-vault', + itemKey: 'mock-credential', + itemId: 'mock-item', + expiresAt: Date.now() + 60_000, + consumedAt: null, + revokedAt: null, +}; + +test('the browser encrypts every entered value and keeps clears as null', async () => { + const vault = new MockCredentialVaultClient(); + const key = await vault.encryptionKey(); + const encrypted = await encryptSubmission( + { version: 1, fields: { email: { value: 'user@example.com' }, password: { value: 'hunter2' } } }, + key, + ); + expect(JSON.stringify(encrypted)).not.toContain('hunter2'); + expect(JSON.stringify(encrypted)).not.toContain('user@example.com'); + expect(Object.keys(encrypted.fields.password)).toEqual(['encrypted_value']); + + const cleared = await encryptSubmission({ version: 1, fields: { password: { value: null } } }, key); + expect(cleared.fields.password).toEqual({ value: null }); +}); + +test('ciphertext round-trips through the backend to the vault', async () => { + const vault = new MockCredentialVaultClient(); + const stored = { ...request }; + const store: CollectionRequestStore = { + findByTokenDigest: async (digest) => (digest === stored.tokenDigest ? stored : null), + save: async () => {}, + markConsumed: async (_digest, consumedAt) => { + stored.consumedAt = consumedAt; + }, + }; + const handler = createCollectionHandler({ appOrigin: origin, store, vault }); + const authorization = `Bearer ${token}`; + + const read = await handler( + new Request(`${origin}/api/credential-requests/current`, { headers: { Authorization: authorization } }), + ); + const { item, encryption_key } = await read.json(); + const body = JSON.stringify( + await encryptSubmission( + { version: item.version, fields: { email: { value: 'user@example.com' }, password: { value: 'hunter2' } } }, + encryption_key, + ), + ); + expect(body).not.toContain('hunter2'); + + const write = await handler( + new Request(`${origin}/api/credential-requests/current`, { + method: 'PATCH', + headers: { Authorization: authorization, Origin: origin, 'Content-Type': 'application/json' }, + body, + }), + ); + expect(write.status).toBe(200); + const updated = await write.json(); + expect(updated.state.status).toBe('ready'); + expect(updated.state.fields.email).toEqual({ has_value: true, value: 'user@example.com' }); + expect(updated.state.fields.password).toEqual({ has_value: true }); + expect(stored.consumedAt).not.toBeNull(); +}); + +test('the vault rejects ciphertext encrypted for another key', async () => { + const other = await new MockCredentialVaultClient().encryptionKey(); + const encrypted = await encryptSubmission({ version: 1, fields: { password: { value: 'hunter2' } } }, other); + await expect(new MockCredentialVaultClient().update(request, encrypted)).rejects.toBeInstanceOf( + VaultInvalidRequestError, + ); +}); From 1eba0627962d0e1f436573e008e347b3f0d797a9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:16:42 +0000 Subject: [PATCH 3/3] Keep the plaintext example and add the encrypted flow alongside it --- README.md | 32 +-- .../current-encrypted/route.ts | 12 ++ .../collect-encrypted/collection-page.tsx | 121 +++++++++++ app/credentials/collect-encrypted/page.tsx | 14 ++ app/credentials/collect/collection-page.tsx | 29 +-- app/credentials/collect/page.tsx | 2 +- app/page.tsx | 2 +- lib/collection-handler.ts | 36 +++- lib/runtime.ts | 18 +- lib/vault-client.ts | 8 +- scripts/create-request.ts | 9 +- tests/collection-handler.test.ts | 39 ++-- tests/collection-page.test.tsx | 21 +- tests/encrypted-collection-handler.test.ts | 191 ++++++++++++++++++ tests/encrypted-collection-page.test.tsx | 87 ++++++++ tests/encrypted-submission.test.ts | 2 +- 16 files changed, 520 insertions(+), 103 deletions(-) create mode 100644 app/api/credential-requests/current-encrypted/route.ts create mode 100644 app/credentials/collect-encrypted/collection-page.tsx create mode 100644 app/credentials/collect-encrypted/page.tsx create mode 100644 tests/encrypted-collection-handler.test.ts create mode 100644 tests/encrypted-collection-page.test.tsx diff --git a/README.md b/README.md index 51ff21a..ff63bb0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ A runnable Next.js example for collecting credentials with [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react). The browser renders Kernel's unstyled credential form while a same-origin backend keeps the Kernel API key private and maps one-time bearer capabilities to vault items. -Entered values are encrypted in the browser to the vault's public key, sent to this backend still encrypted, and forwarded to Kernel as `encrypted_value`. Only Kernel can decrypt them, so plaintext never appears in this app's backend, proxies, or request logs. See [Encrypted values](#encrypted-values). +It includes two flows that share the same backend pieces: + +- **`/credentials/collect`**: the page sends entered values to this backend, which forwards them to Kernel as `value`. +- **`/credentials/collect-encrypted`**: the page encrypts values in the browser to the vault's public key, this backend forwards the ciphertext as `encrypted_value`, and only Kernel can decrypt it. Use this when plaintext must never reach your backend, proxies, or request logs. See [Encrypted values](#encrypted-values). ## Run the mock flow @@ -20,7 +23,7 @@ In a second terminal, create a 15-minute collection link: bun run create-request ``` -Open the printed URL. Mock mode exercises the complete browser/backend flow without calling Kernel. Do not enter real credentials in mock mode. +Open the printed URL. Add `--encrypted` (`bun run create-request -- --encrypted`) for a link to the encrypted flow. Mock mode exercises the complete browser/backend flow, including encryption, without calling Kernel. Do not enter real credentials in mock mode. ## Connect a Kernel vault item @@ -30,6 +33,7 @@ Open the printed URL. Mock mode exercises the complete browser/backend flow with ```sh bun run create-request -- +bun run create-request -- --encrypted ``` The command retrieves the item's immutable ID, generates 32 random bytes, stores only the SHA-256 token digest, and prints the raw token once in this form: @@ -42,11 +46,11 @@ The fragment is not sent in page requests or referrers. The page sends it only i ## How it is organized -- `app/credentials/collect` reads and clears the fragment capability, renders `CredentialForm`, and encrypts the submission before sending it. -- `app/api/credential-requests/current` serves safe item data with the vault's public key and accepts versioned, encrypted field edits. -- `lib/encrypted-submission.ts` turns a `CredentialForm` submission into `encrypted_value` fields with [`jose`](https://github.com/panva/jose). -- `lib/collection-handler.ts` validates the bearer, origin, target, item ID, version, field names, ciphertext shape, and body size. -- `lib/vault-client.ts` contains real and mock vault adapters. Only the real adapter receives `KERNEL_API_KEY`. The mock adapter generates its own key pair and decrypts like Kernel does. +- `app/credentials/collect` reads and clears the fragment capability and renders `CredentialForm`. +- `app/api/credential-requests/current` serves safe item data and accepts versioned field edits. +- `lib/collection-handler.ts` validates the bearer, origin, target, item ID, version, field names, value sizes, and body size. +- `lib/vault-client.ts` contains real and mock vault adapters. Only the real adapter receives `KERNEL_API_KEY`. The mock adapter generates its own key pair and decrypts `encrypted_value` the way Kernel does. +- `app/credentials/collect-encrypted` and `app/api/credential-requests/current-encrypted` are the encrypted flow. `lib/encrypted-submission.ts` turns a `CredentialForm` submission into `encrypted_value` fields with [`jose`](https://github.com/panva/jose). - `lib/request-store.ts` defines the durable `CollectionRequestStore` boundary. - `scripts/create-request.ts` creates a short-lived link without persisting the raw token. @@ -54,19 +58,19 @@ Successful submissions mark the mapping consumed. Kernel writes include both the ## Encrypted values -1. `GET /api/credential-requests/current` returns `{ item, encryption_key }`. The backend gets the key from `kernel.vaults.retrieveEncryptionKey(vaultId)` (`GET /vaults/{id_or_name}/encryption_key`). Each vault has its own P-256 key; it is public and stable for the vault's lifetime. +The encrypted flow differs from the default flow in three places: + +1. `GET /api/credential-requests/current-encrypted` returns `{ item, encryption_key }`. The backend gets the key from `kernel.vaults.retrieveEncryptionKey(vaultId)` (`GET /vaults/{id_or_name}/encryption_key`). Each vault has its own P-256 key; it is public and stable for the vault's lifetime. 2. On submit, the page encrypts every entered value as a compact JWE (`alg: ECDH-ES`, `enc: A256GCM`, the vault's `kid` in the header). Clearing an optional field stays `{ "value": null }`. -3. The backend validates the request and passes each field through unchanged: +3. The backend passes each field through unchanged, and `kernel.vaults.items.update` sends it to Kernel, which decrypts and validates it exactly like `value`: ```json { "version": 3, "fields": { "password": { "encrypted_value": "eyJhbGciOiJFQ0RILUVTIi..." } } } ``` -4. `kernel.vaults.items.update` sends the ciphertext to Kernel, which decrypts and validates it exactly like `value`. - -The backend rejects plaintext `value` strings, so a misconfigured page cannot silently fall back to sending plaintext. Kernel rejects ciphertext encrypted for a different vault with `400 encryption_key_mismatch`, which this example reports as `invalid`. +The encrypted route rejects plaintext `value` strings, so a misconfigured page cannot silently fall back to plaintext. Kernel rejects ciphertext encrypted for a different vault with `400 encryption_key_mismatch`, which this example reports as `invalid`. -Requires `@onkernel/sdk` 0.122.0 or newer. If your backend already holds plaintext (for example, a server-to-server integration), send `value` instead; `encrypted_value` is only for values encrypted in a browser you control. +Requires `@onkernel/sdk` 0.122.0 or newer and HTTPS (WebCrypto needs a secure context; `localhost` also works). The page's JavaScript still sees raw values before encrypting them, so keep the collection route free of third-party scripts. If your backend already holds plaintext, use the default flow. ## Use a production store @@ -97,7 +101,7 @@ Add any signed-in application user ID needed by your authorization model. Verify 4. Deliver links through a channel suitable for password-reset links and keep expiry short. 5. Exclude URLs, fragments, authorization headers, request bodies, credentials, and upstream errors from logs, analytics, traces, and session replay. -The backend receives only ciphertext, but the page's JavaScript sees raw entered values before encrypting them. It is trusted application code, not an isolated Kernel collector. A compromised page or a script injected into it can read values before encryption. Keep the collection route free of third-party scripts, serve it over HTTPS (WebCrypto requires a secure context), and preserve the response's `no-store` and `no-referrer` policies. +The customer page and backend receive raw entered values. They are trusted application code, not an isolated Kernel collector. Keep the collection route free of third-party scripts and preserve the response's `no-store` and `no-referrer` policies. ## Checks diff --git a/app/api/credential-requests/current-encrypted/route.ts b/app/api/credential-requests/current-encrypted/route.ts new file mode 100644 index 0000000..edec883 --- /dev/null +++ b/app/api/credential-requests/current-encrypted/route.ts @@ -0,0 +1,12 @@ +import { getCollectionHandler } from '@/lib/runtime'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET(request: Request) { + return getCollectionHandler(true)(request); +} + +export function PATCH(request: Request) { + return getCollectionHandler(true)(request); +} diff --git a/app/credentials/collect-encrypted/collection-page.tsx b/app/credentials/collect-encrypted/collection-page.tsx new file mode 100644 index 0000000..f8aabac --- /dev/null +++ b/app/credentials/collect-encrypted/collection-page.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + CredentialForm, + CredentialFormError, + safeCredentialItem, + type CollectionErrorCode, + type CredentialItem, +} from '@onkernel/vault-react'; +import { readCollectionToken } from '@/lib/collection-token'; +import { encryptSubmission, type VaultEncryptionKey } from '@/lib/encrypted-submission'; + +const endpoint = '/api/credential-requests/current-encrypted'; +const errorCodes = new Set([ + 'stale', + 'expired', + 'consumed', + 'unavailable', + 'invalid', +]); + +async function responseError(response: Response, fallback: CollectionErrorCode) { + const body: unknown = await response.json().catch(() => null); + const code = body && typeof body === 'object' ? (body as { code?: unknown }).code : undefined; + return typeof code === 'string' && errorCodes.has(code as CollectionErrorCode) + ? (code as CollectionErrorCode) + : fallback; +} + +export function CollectionPage() { + const [token, setToken] = useState(); + + useEffect(() => { + setToken(readCollectionToken(window.location.hash)); + const reload = () => window.location.reload(); + window.addEventListener('hashchange', reload); + return () => window.removeEventListener('hashchange', reload); + }, []); + + if (token === undefined) return null; + if (!token) return collection link unavailable.; + return ; +} + +function RequestForm({ token }: { token: string }) { + const [collection, setCollection] = useState<{ item: CredentialItem; key: VaultEncryptionKey }>(); + const [failure, setFailure] = useState(); + const authorization = `Bearer ${token}`; + + useEffect(() => { + const controller = new AbortController(); + void fetch(endpoint, { + credentials: 'omit', + cache: 'no-store', + headers: { Authorization: authorization }, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new CredentialFormError(await responseError(response, 'unavailable')); + const body = await response.json(); + const item = safeCredentialItem(body.item); + const key = vaultEncryptionKey(body.encryption_key); + if (!controller.signal.aborted) setCollection({ item, key }); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setFailure(error instanceof CredentialFormError ? error.code : 'unavailable'); + } + }); + return () => controller.abort(); + }, [authorization]); + + if (failure) { + const messages: Partial> = { + expired: 'this collection link expired. request a new link.', + consumed: 'this collection was already completed.', + }; + return {messages[failure] ?? 'collection unavailable. request a new link.'}; + } + if (!collection) return

loading credential fields…

; + + return ( + { + const response = await fetch(endpoint, { + method: 'PATCH', + credentials: 'omit', + cache: 'no-store', + headers: { Authorization: authorization, 'Content-Type': 'application/json' }, + body: JSON.stringify(await encryptSubmission(submission, collection.key)), + }); + if (!response.ok) { + throw new CredentialFormError(await responseError(response, 'unavailable')); + } + window.history.replaceState(null, '', window.location.pathname); + }} + /> + ); +} + +function vaultEncryptionKey(value: unknown): VaultEncryptionKey { + const key = value as VaultEncryptionKey | undefined; + if ( + typeof key?.kid !== 'string' || + key.jwk?.kty !== 'EC' || + key.jwk.crv !== 'P-256' || + typeof key.jwk.x !== 'string' || + typeof key.jwk.y !== 'string' + ) { + throw new CredentialFormError('unavailable'); + } + return { kid: key.kid, jwk: { kty: 'EC', crv: 'P-256', x: key.jwk.x, y: key.jwk.y } }; +} + +function Message({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/app/credentials/collect-encrypted/page.tsx b/app/credentials/collect-encrypted/page.tsx new file mode 100644 index 0000000..9008619 --- /dev/null +++ b/app/credentials/collect-encrypted/page.tsx @@ -0,0 +1,14 @@ +import { CollectionPage } from './collection-page'; + +export default function Page() { + return ( +
+
+

Secure credential request

+

Connect your account

+

Your credentials are encrypted in this page. Only Kernel can decrypt them.

+ +
+
+ ); +} diff --git a/app/credentials/collect/collection-page.tsx b/app/credentials/collect/collection-page.tsx index de74582..4290d30 100644 --- a/app/credentials/collect/collection-page.tsx +++ b/app/credentials/collect/collection-page.tsx @@ -9,7 +9,6 @@ import { type CredentialItem, } from '@onkernel/vault-react'; import { readCollectionToken } from '@/lib/collection-token'; -import { encryptSubmission, type VaultEncryptionKey } from '@/lib/encrypted-submission'; const endpoint = '/api/credential-requests/current'; const errorCodes = new Set([ @@ -44,7 +43,7 @@ export function CollectionPage() { } function RequestForm({ token }: { token: string }) { - const [collection, setCollection] = useState<{ item: CredentialItem; key: VaultEncryptionKey }>(); + const [item, setItem] = useState(); const [failure, setFailure] = useState(); const authorization = `Bearer ${token}`; @@ -58,10 +57,8 @@ function RequestForm({ token }: { token: string }) { }) .then(async (response) => { if (!response.ok) throw new CredentialFormError(await responseError(response, 'unavailable')); - const body = await response.json(); - const item = safeCredentialItem(body.item); - const key = vaultEncryptionKey(body.encryption_key); - if (!controller.signal.aborted) setCollection({ item, key }); + const safe = safeCredentialItem(await response.json()); + if (!controller.signal.aborted) setItem(safe); }) .catch((error: unknown) => { if (!controller.signal.aborted) { @@ -78,12 +75,12 @@ function RequestForm({ token }: { token: string }) { }; return {messages[failure] ?? 'collection unavailable. request a new link.'}; } - if (!collection) return

loading credential fields…

; + if (!item) return

loading credential fields…

; return ( { const response = await fetch(endpoint, { @@ -91,7 +88,7 @@ function RequestForm({ token }: { token: string }) { credentials: 'omit', cache: 'no-store', headers: { Authorization: authorization, 'Content-Type': 'application/json' }, - body: JSON.stringify(await encryptSubmission(submission, collection.key)), + body: JSON.stringify(submission), }); if (!response.ok) { throw new CredentialFormError(await responseError(response, 'unavailable')); @@ -102,20 +99,6 @@ function RequestForm({ token }: { token: string }) { ); } -function vaultEncryptionKey(value: unknown): VaultEncryptionKey { - const key = value as VaultEncryptionKey | undefined; - if ( - typeof key?.kid !== 'string' || - key.jwk?.kty !== 'EC' || - key.jwk.crv !== 'P-256' || - typeof key.jwk.x !== 'string' || - typeof key.jwk.y !== 'string' - ) { - throw new CredentialFormError('unavailable'); - } - return { kid: key.kid, jwk: { kty: 'EC', crv: 'P-256', x: key.jwk.x, y: key.jwk.y } }; -} - function Message({ children }: { children: React.ReactNode }) { return

{children}

; } diff --git a/app/credentials/collect/page.tsx b/app/credentials/collect/page.tsx index 9008619..b12882e 100644 --- a/app/credentials/collect/page.tsx +++ b/app/credentials/collect/page.tsx @@ -6,7 +6,7 @@ export default function Page() {

Secure credential request

Connect your account

-

Your credentials are encrypted in this page. Only Kernel can decrypt them.

+

Your credentials are sent directly to the application that requested them.

diff --git a/app/page.tsx b/app/page.tsx index c5618ea..3f2f292 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,7 @@ export default function Home() {

Customer-hosted credential collection

Generate a one-time collection link with bun run create-request, then open the - printed URL. + printed URL. Add --encrypted for the flow that encrypts values in the browser.

diff --git a/lib/collection-handler.ts b/lib/collection-handler.ts index 4f77c33..9c17c0e 100644 --- a/lib/collection-handler.ts +++ b/lib/collection-handler.ts @@ -1,4 +1,4 @@ -import { safeCredentialItem } from '@onkernel/vault-react/data'; +import { safeCredentialItem, type CredentialSubmission } from '@onkernel/vault-react/data'; import type { EncryptedSubmission } from './encrypted-submission'; import type { CollectionRequestStore } from './request-store'; import { collectionTokenDigest } from './server-token'; @@ -52,11 +52,14 @@ export function createCollectionHandler({ appOrigin, store, vault, + encrypted = false, now = Date.now, }: { appOrigin: string; store: CollectionRequestStore; vault: CredentialVaultClient; + /** Serve the vault's encryption key and accept only `encrypted_value` edits (or clears). */ + encrypted?: boolean; now?: () => number; }) { const trustedOrigin = new URL(appOrigin).origin; @@ -80,6 +83,7 @@ export function createCollectionHandler({ const item = safeCredentialItem(await vault.retrieve(mapping)); if (item.id !== mapping.itemId) return json({ code: 'unavailable' }, 404); if (request.method === 'GET') { + if (!encrypted) return json(item); const { kid, jwk } = await vault.encryptionKey(mapping); return json({ item, encryption_key: { kid, jwk } }); } @@ -115,19 +119,33 @@ export function createCollectionHandler({ if (!field || field.type === 'totp' || !edit || typeof edit !== 'object' || Array.isArray(edit)) { return json({ code: 'invalid' }, 400); } - // Plaintext values are rejected: this backend only ever relays ciphertext or a clear. const replacement = edit as Record; - const encrypted = replacement.encrypted_value; - const clear = Object.hasOwn(replacement, 'value') && replacement.value === null && !field.required; - const ciphertext = typeof encrypted === 'string' && - encrypted.length <= maxEncryptedValueLength && - compactJwePattern.test(encrypted); - if (Object.keys(replacement).length !== 1 || !(clear || ciphertext)) { + if (encrypted) { + // Plaintext values are rejected: this route only relays ciphertext or a clear. + const value = replacement.encrypted_value; + const clear = Object.hasOwn(replacement, 'value') && replacement.value === null && !field.required; + const ciphertext = typeof value === 'string' && + value.length <= maxEncryptedValueLength && + compactJwePattern.test(value); + if (Object.keys(replacement).length !== 1 || !(clear || ciphertext)) { + return json({ code: 'invalid' }, 400); + } + continue; + } + if ( + Object.keys(replacement).length !== 1 || + !Object.hasOwn(replacement, 'value') || + (replacement.value === null && field.required) || + (replacement.value !== null && + (typeof replacement.value !== 'string' || + replacement.value.length === 0 || + new TextEncoder().encode(replacement.value).length > 16 * 1024)) + ) { return json({ code: 'invalid' }, 400); } } - const submission = input as EncryptedSubmission; + const submission = input as CredentialSubmission | EncryptedSubmission; if (submission.version !== item.version) return json({ code: 'stale' }, 409); if (!entries.length) { await store.markConsumed(digest, now()); diff --git a/lib/runtime.ts b/lib/runtime.ts index 1652638..42f04e9 100644 --- a/lib/runtime.ts +++ b/lib/runtime.ts @@ -3,10 +3,16 @@ import { createCollectionHandler } from './collection-handler'; import { FileCollectionRequestStore } from './request-store'; import { KernelCredentialVaultClient, MockCredentialVaultClient } from './vault-client'; -let handler: ReturnType | undefined; +let handlers: { plaintext: CollectionHandler; encrypted: CollectionHandler } | undefined; +type CollectionHandler = ReturnType; -export function getCollectionHandler() { - if (handler) return handler; +/** `encrypted` serves the encrypted-values example; both share one store and vault client. */ +export function getCollectionHandler(encrypted = false) { + handlers ??= create(); + return encrypted ? handlers.encrypted : handlers.plaintext; +} + +function create() { const appOrigin = required('APP_ORIGIN'); const storePath = join(process.cwd(), '.data', 'credential-requests.json'); const store = new FileCollectionRequestStore(storePath); @@ -16,8 +22,10 @@ export function getCollectionHandler() { required('KERNEL_API_KEY'), process.env.KERNEL_API_BASE_URL, ); - handler = createCollectionHandler({ appOrigin, store, vault }); - return handler; + return { + plaintext: createCollectionHandler({ appOrigin, store, vault }), + encrypted: createCollectionHandler({ appOrigin, store, vault, encrypted: true }), + }; } function required(name: string) { diff --git a/lib/vault-client.ts b/lib/vault-client.ts index 93efff8..da2530d 100644 --- a/lib/vault-client.ts +++ b/lib/vault-client.ts @@ -1,6 +1,6 @@ import Kernel, { APIError, ConflictError } from '@onkernel/sdk'; import type { CredentialVaultItem, VaultItem } from '@onkernel/sdk/resources/vaults/items'; -import type { CredentialItem } from '@onkernel/vault-react/data'; +import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react/data'; import { compactDecrypt, exportJWK, generateKeyPair, type CryptoKey } from 'jose'; import type { EncryptedSubmission, VaultEncryptionKey } from './encrypted-submission'; import type { CollectionRequest } from './request-store'; @@ -11,7 +11,7 @@ export class VaultInvalidRequestError extends Error {} export interface CredentialVaultClient { retrieve(request: CollectionRequest): Promise; encryptionKey(request: CollectionRequest): Promise; - update(request: CollectionRequest, submission: EncryptedSubmission): Promise; + update(request: CollectionRequest, submission: CredentialSubmission | EncryptedSubmission): Promise; } function credentialItem(item: VaultItem): CredentialVaultItem { @@ -44,7 +44,7 @@ export class KernelCredentialVaultClient implements CredentialVaultClient { return { kid, jwk }; } - async update(request: CollectionRequest, submission: EncryptedSubmission) { + async update(request: CollectionRequest, submission: CredentialSubmission | EncryptedSubmission) { try { return credentialItem( await this.kernel.vaults.items.update( @@ -82,7 +82,7 @@ export class MockCredentialVaultClient implements CredentialVaultClient { return (await this.keyPair()).public; } - async update(request: CollectionRequest, submission: EncryptedSubmission) { + async update(request: CollectionRequest, submission: CredentialSubmission | EncryptedSubmission) { const item = this.items.get(request.itemId) ?? this.createItem(request.itemId); if (item.version !== submission.version) throw new VaultConflictError(); for (const [name, edit] of Object.entries(submission.fields)) { diff --git a/scripts/create-request.ts b/scripts/create-request.ts index 99f63bc..db05305 100644 --- a/scripts/create-request.ts +++ b/scripts/create-request.ts @@ -5,9 +5,12 @@ import { FileCollectionRequestStore } from '../lib/request-store'; import { collectionTokenDigest, createCollectionToken } from '../lib/server-token'; const mock = process.env.MOCK_VAULT === 'true'; -const [vaultArg, itemArg] = process.argv.slice(2); +const args = process.argv.slice(2); +// --encrypted links to the example that encrypts values in the browser. +const encrypted = args.includes('--encrypted'); +const [vaultArg, itemArg] = args.filter((arg) => arg !== '--encrypted'); if (!mock && (!vaultArg || !itemArg)) { - throw new Error('usage: bun run create-request -- '); + throw new Error('usage: bun run create-request -- [--encrypted] '); } const appOrigin = new URL(required('APP_ORIGIN')).origin; @@ -46,7 +49,7 @@ await store.save({ revokedAt: null, }); -const url = new URL('/credentials/collect', appOrigin); +const url = new URL(encrypted ? '/credentials/collect-encrypted' : '/credentials/collect', appOrigin); url.hash = `token=${token}`; process.stdout.write(`${url.toString()}\n`); diff --git a/tests/collection-handler.test.ts b/tests/collection-handler.test.ts index 09482bf..8cc6fa3 100644 --- a/tests/collection-handler.test.ts +++ b/tests/collection-handler.test.ts @@ -1,7 +1,6 @@ import { expect, mock, test } from 'bun:test'; -import type { CredentialItem } from '@onkernel/vault-react/data'; +import type { CredentialItem, CredentialSubmission } from '@onkernel/vault-react/data'; import { createCollectionHandler } from '../lib/collection-handler'; -import type { EncryptedSubmission, VaultEncryptionKey } from '../lib/encrypted-submission'; import type { CollectionRequest, CollectionRequestStore } from '../lib/request-store'; import { collectionTokenDigest } from '../lib/server-token'; import { VaultConflictError, type CredentialVaultClient } from '../lib/vault-client'; @@ -11,8 +10,6 @@ const token = 'a'.repeat(43); const digest = collectionTokenDigest(token); const authorization = `Bearer ${token}`; const now = 1_700_000_000_000; -const encryptionKey: VaultEncryptionKey = { kid: 'vtk_example', jwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } }; -const jwe = 'eyJhbGciOiJFQ0RILUVTIn0..aXY.Y2lwaGVydGV4dA.dGFn'; function credentialItem(): CredentialItem { return { @@ -68,12 +65,12 @@ function setup() { const store = new MemoryStore(); const retrieve = mock(async (_request: CollectionRequest): Promise => credentialItem()); const update = mock( - async (_request: CollectionRequest, _submission: EncryptedSubmission): Promise => ({ + async (_request: CollectionRequest, _submission: CredentialSubmission): Promise => ({ ...credentialItem(), version: 8, }), ); - const vault: CredentialVaultClient = { retrieve, encryptionKey: async () => encryptionKey, update }; + const vault: CredentialVaultClient = { retrieve, encryptionKey: mock(), update }; const handler = createCollectionHandler({ appOrigin: origin, store, vault, now: () => now }); return { handler, store, retrieve, update }; } @@ -98,13 +95,13 @@ test('only a valid fragment bearer can authorize a request', async () => { expect(retrieve).not.toHaveBeenCalled(); }); -test('returns the safe item projection and the vault encryption key', async () => { +test('returns only the safe item projection', async () => { const { handler } = setup(); const response = await handler(get()); expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(response.headers.get('Referrer-Policy')).toBe('no-referrer'); - expect(await response.json()).toEqual({ item: credentialItem(), encryption_key: encryptionKey }); + expect(await response.json()).toEqual(credentialItem()); }); test('rejects expired, consumed, revoked, and replaced targets', async () => { @@ -131,10 +128,10 @@ test('rejects cross-origin writes before loading the item', async () => { expect(retrieve).not.toHaveBeenCalled(); }); -test('relays ciphertext to the mapped target with rendered version and consumes the request', async () => { +test('writes the mapped target with rendered version and consumes the request', async () => { const { handler, store, update } = setup(); const response = await handler( - patch({ version: 7, fields: { password: { encrypted_value: jwe }, note: { value: null } } }), + patch({ version: 7, fields: { password: { value: ' exact ' } } }), ); expect(response.status).toBe(200); expect(update).toHaveBeenCalledTimes(1); @@ -145,7 +142,7 @@ test('relays ciphertext to the mapped target with rendered version and consumes }); expect(update.mock.calls[0][1]).toEqual({ version: 7, - fields: { password: { encrypted_value: jwe }, note: { value: null } }, + fields: { password: { value: ' exact ' } }, }); expect(store.request?.consumedAt).toBe(now); expect(await (await handler(get())).json()).toEqual({ code: 'consumed' }); @@ -160,30 +157,24 @@ test('unchanged completion skips the vault write and consumes the request', asyn test('stale writes return a conflict without consuming the request', async () => { const { handler, store, update } = setup(); - expect((await handler(patch({ version: 6, fields: { note: { encrypted_value: jwe } } }))).status).toBe(409); + expect((await handler(patch({ version: 6, fields: { note: { value: 'edit' } } }))).status).toBe(409); expect(update).not.toHaveBeenCalled(); update.mockImplementationOnce(async () => { throw new VaultConflictError(); }); - expect((await handler(patch({ version: 7, fields: { note: { encrypted_value: jwe } } }))).status).toBe(409); + expect((await handler(patch({ version: 7, fields: { note: { value: 'edit' } } }))).status).toBe(409); expect(store.request?.consumedAt).toBeNull(); }); -test('rejects plaintext values, TOTP, unknown fields, required clearing, and malformed ciphertext', async () => { +test('rejects TOTP, unknown fields, required clearing, oversized bodies, and malformed values', async () => { const { handler, update } = setup(); for (const body of [ - { version: 7, fields: { password: { value: 'plaintext' } } }, - { version: 7, fields: { note: { value: '' } } }, - { version: 7, fields: { note: { value: null, encrypted_value: jwe } } }, - { version: 7, fields: { note: { encrypted_value: 'not-a-jwe' } } }, - { version: 7, fields: { note: { encrypted_value: `${jwe}.extra` } } }, - { version: 7, fields: { note: { encrypted_value: null } } }, - { version: 7, fields: { note: { encrypted_value: `${'a'.repeat(24 * 1024)}....` } } }, - { version: 7, fields: { otp: { encrypted_value: jwe } } }, - { version: 7, fields: { unknown: { encrypted_value: jwe } } }, + { version: 7, fields: { otp: { value: 'seed' } } }, + { version: 7, fields: { unknown: { value: 'edit' } } }, { version: 7, fields: { password: { value: null } } }, + { version: 7, fields: { note: { value: '' } } }, { version: 7, fields: {}, vaultId: 'attacker-vault' }, - { version: 7, fields: { note: { encrypted_value: `a${'.'.repeat(4)}${'b'.repeat(128 * 1024)}` } } }, + { version: 7, fields: { note: { value: 'x'.repeat(128 * 1024) } } }, ]) { expect((await handler(patch(body))).status).toBe(400); } diff --git a/tests/collection-page.test.tsx b/tests/collection-page.test.tsx index 64a56e5..2e2bf9e 100644 --- a/tests/collection-page.test.tsx +++ b/tests/collection-page.test.tsx @@ -1,7 +1,6 @@ import './setup'; import { afterEach, beforeEach, expect, mock, test } from 'bun:test'; import type { CredentialItem } from '@onkernel/vault-react'; -import { compactDecrypt, exportJWK, generateKeyPair } from 'jose'; const { act } = await import('react'); const { createRoot } = await import('react-dom/client'); @@ -39,14 +38,9 @@ afterEach(async () => { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -test('keeps the capability in the fragment and sends only ciphertext', async () => { - const { publicKey, privateKey } = await generateKeyPair('ECDH-ES', { crv: 'P-256' }); - const { x, y } = await exportJWK(publicKey); - const encryption_key = { kid: 'vtk_example', jwk: { kty: 'EC', crv: 'P-256', x, y } }; +test('keeps the capability in the fragment and same-origin authorization header', async () => { const fetcher = Object.assign( - mock(async (_input: string | URL | Request, _init?: RequestInit) => - Response.json({ item: item(), encryption_key }), - ), + mock(async (_input: string | URL | Request, _init?: RequestInit) => Response.json(item())), { preconnect() {} }, ); globalThis.fetch = fetcher; @@ -65,8 +59,6 @@ test('keeps the capability in the fragment and sends only ciphertext', async () container.querySelector('input[name="password"]')!.value = 'replacement'; await act(async () => { container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - // Encryption runs on WebCrypto, so wait for the PATCH rather than a single tick. - for (let i = 0; i < 100 && fetcher.mock.calls.length < 2; i++) await settle(); await settle(); }); @@ -74,14 +66,7 @@ test('keeps the capability in the fragment and sends only ciphertext', async () expect(submission[0]).toBe('/api/credential-requests/current'); expect(submission[1]?.method).toBe('PATCH'); expect(new Headers(submission[1]?.headers).get('Authorization')).toBe(`Bearer ${token}`); - const body = String(submission[1]?.body); - expect(body).not.toContain(token); - expect(body).not.toContain('replacement'); - const sent = JSON.parse(body); - expect(sent.version).toBe(1); - const { plaintext, protectedHeader } = await compactDecrypt(sent.fields.password.encrypted_value, privateKey); - expect(new TextDecoder().decode(plaintext)).toBe('replacement'); - expect(protectedHeader).toMatchObject({ alg: 'ECDH-ES', enc: 'A256GCM', kid: 'vtk_example' }); + expect(String(submission[1]?.body)).not.toContain(token); expect(window.location.pathname).toBe('/credentials/collect'); expect(window.location.hash).toBe(''); }); diff --git a/tests/encrypted-collection-handler.test.ts b/tests/encrypted-collection-handler.test.ts new file mode 100644 index 0000000..3cf4f71 --- /dev/null +++ b/tests/encrypted-collection-handler.test.ts @@ -0,0 +1,191 @@ +import { expect, mock, test } from 'bun:test'; +import type { CredentialItem } from '@onkernel/vault-react/data'; +import { createCollectionHandler } from '../lib/collection-handler'; +import type { EncryptedSubmission, VaultEncryptionKey } from '../lib/encrypted-submission'; +import type { CollectionRequest, CollectionRequestStore } from '../lib/request-store'; +import { collectionTokenDigest } from '../lib/server-token'; +import { VaultConflictError, type CredentialVaultClient } from '../lib/vault-client'; + +const origin = 'https://app.example.com'; +const token = 'a'.repeat(43); +const digest = collectionTokenDigest(token); +const authorization = `Bearer ${token}`; +const now = 1_700_000_000_000; +const encryptionKey: VaultEncryptionKey = { kid: 'vtk_example', jwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } }; +const jwe = 'eyJhbGciOiJFQ0RILUVTIn0..aXY.Y2lwaGVydGV4dA.dGFn'; + +function credentialItem(): CredentialItem { + return { + id: 'vi_example', + type: 'credential', + version: 7, + spec: { + description: 'Example', + fields: [ + { name: 'password', type: 'password', required: true, sensitive: true }, + { name: 'note', type: 'text', required: false, sensitive: false }, + { name: 'otp', type: 'totp', required: false, sensitive: true }, + ], + }, + state: { + status: 'pending_collection', + fields: { + password: { has_value: true }, + note: { has_value: true, value: 'saved' }, + otp: { has_value: true }, + }, + }, + }; +} + +class MemoryStore implements CollectionRequestStore { + request: CollectionRequest | null = { + id: 'request-1', + tokenDigest: digest, + vaultId: 'server-vault', + itemKey: 'server-key', + itemId: 'vi_example', + expiresAt: now + 60_000, + consumedAt: null, + revokedAt: null, + }; + + async findByTokenDigest(candidate: string) { + return candidate === digest ? this.request : null; + } + + async save(request: CollectionRequest) { + this.request = request; + } + + async markConsumed(candidate: string, consumedAt: number) { + if (!this.request || candidate !== digest) throw new Error('not found'); + this.request.consumedAt = consumedAt; + } +} + +function setup() { + const store = new MemoryStore(); + const retrieve = mock(async (_request: CollectionRequest): Promise => credentialItem()); + const update = mock( + async (_request: CollectionRequest, _submission: EncryptedSubmission): Promise => ({ + ...credentialItem(), + version: 8, + }), + ); + const vault: CredentialVaultClient = { retrieve, encryptionKey: async () => encryptionKey, update }; + const handler = createCollectionHandler({ appOrigin: origin, store, vault, encrypted: true, now: () => now }); + return { handler, store, retrieve, update }; +} + +const get = (bearer = authorization) => + new Request(`${origin}/api/credential-requests/current-encrypted`, { + headers: bearer ? { Authorization: bearer } : {}, + }); + +const patch = (body: unknown, requestOrigin = origin, bearer = authorization) => + new Request(`${origin}/api/credential-requests/current-encrypted`, { + method: 'PATCH', + headers: { Authorization: bearer, Origin: requestOrigin, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + +test('only a valid fragment bearer can authorize a request', async () => { + const { handler, retrieve } = setup(); + for (const request of [get(''), get('Bearer request_123'), get(`Bearer ${'a'.repeat(42)}`)]) { + expect((await handler(request)).status).toBe(401); + } + expect(retrieve).not.toHaveBeenCalled(); +}); + +test('returns the safe item projection and the vault encryption key', async () => { + const { handler } = setup(); + const response = await handler(get()); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Referrer-Policy')).toBe('no-referrer'); + expect(await response.json()).toEqual({ item: credentialItem(), encryption_key: encryptionKey }); +}); + +test('rejects expired, consumed, revoked, and replaced targets', async () => { + const { handler, store, retrieve } = setup(); + store.request!.expiresAt = now; + expect((await handler(get())).status).toBe(410); + store.request!.expiresAt = now + 60_000; + store.request!.consumedAt = now - 1; + expect(await (await handler(get())).json()).toEqual({ code: 'consumed' }); + store.request!.consumedAt = null; + store.request!.revokedAt = now - 1; + expect((await handler(get())).status).toBe(410); + store.request!.revokedAt = null; + retrieve.mockImplementationOnce(async () => ({ ...credentialItem(), id: 'vi_replacement' })); + expect((await handler(get())).status).toBe(404); +}); + +test('rejects cross-origin writes before loading the item', async () => { + const { handler, retrieve } = setup(); + const response = await handler( + patch({ version: 7, fields: {} }, 'https://attacker.example.com'), + ); + expect(response.status).toBe(403); + expect(retrieve).not.toHaveBeenCalled(); +}); + +test('relays ciphertext to the mapped target with rendered version and consumes the request', async () => { + const { handler, store, update } = setup(); + const response = await handler( + patch({ version: 7, fields: { password: { encrypted_value: jwe }, note: { value: null } } }), + ); + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0][0]).toMatchObject({ + vaultId: 'server-vault', + itemKey: 'server-key', + itemId: 'vi_example', + }); + expect(update.mock.calls[0][1]).toEqual({ + version: 7, + fields: { password: { encrypted_value: jwe }, note: { value: null } }, + }); + expect(store.request?.consumedAt).toBe(now); + expect(await (await handler(get())).json()).toEqual({ code: 'consumed' }); +}); + +test('unchanged completion skips the vault write and consumes the request', async () => { + const { handler, store, update } = setup(); + expect(await (await handler(patch({ version: 7, fields: {} }))).json()).toEqual({ unchanged: true }); + expect(update).not.toHaveBeenCalled(); + expect(store.request?.consumedAt).toBe(now); +}); + +test('stale writes return a conflict without consuming the request', async () => { + const { handler, store, update } = setup(); + expect((await handler(patch({ version: 6, fields: { note: { encrypted_value: jwe } } }))).status).toBe(409); + expect(update).not.toHaveBeenCalled(); + update.mockImplementationOnce(async () => { + throw new VaultConflictError(); + }); + expect((await handler(patch({ version: 7, fields: { note: { encrypted_value: jwe } } }))).status).toBe(409); + expect(store.request?.consumedAt).toBeNull(); +}); + +test('rejects plaintext values, TOTP, unknown fields, required clearing, and malformed ciphertext', async () => { + const { handler, update } = setup(); + for (const body of [ + { version: 7, fields: { password: { value: 'plaintext' } } }, + { version: 7, fields: { note: { value: '' } } }, + { version: 7, fields: { note: { value: null, encrypted_value: jwe } } }, + { version: 7, fields: { note: { encrypted_value: 'not-a-jwe' } } }, + { version: 7, fields: { note: { encrypted_value: `${jwe}.extra` } } }, + { version: 7, fields: { note: { encrypted_value: null } } }, + { version: 7, fields: { note: { encrypted_value: `${'a'.repeat(24 * 1024)}....` } } }, + { version: 7, fields: { otp: { encrypted_value: jwe } } }, + { version: 7, fields: { unknown: { encrypted_value: jwe } } }, + { version: 7, fields: { password: { value: null } } }, + { version: 7, fields: {}, vaultId: 'attacker-vault' }, + { version: 7, fields: { note: { encrypted_value: `a${'.'.repeat(4)}${'b'.repeat(128 * 1024)}` } } }, + ]) { + expect((await handler(patch(body))).status).toBe(400); + } + expect(update).not.toHaveBeenCalled(); +}); diff --git a/tests/encrypted-collection-page.test.tsx b/tests/encrypted-collection-page.test.tsx new file mode 100644 index 0000000..a5d6463 --- /dev/null +++ b/tests/encrypted-collection-page.test.tsx @@ -0,0 +1,87 @@ +import './setup'; +import { afterEach, beforeEach, expect, mock, test } from 'bun:test'; +import type { CredentialItem } from '@onkernel/vault-react'; +import { compactDecrypt, exportJWK, generateKeyPair } from 'jose'; + +const { act } = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { CollectionPage } = await import('../app/credentials/collect-encrypted/collection-page'); +const token = 'a'.repeat(43); +let container: HTMLDivElement; +let root: ReturnType; +let originalFetch: typeof fetch; + +function item(): CredentialItem { + return { + id: 'vi_example', + type: 'credential', + version: 1, + spec: { + fields: [{ name: 'password', type: 'password', required: true, sensitive: true }], + }, + state: { status: 'pending_collection', fields: { password: { has_value: false } } }, + }; +} + +beforeEach(() => { + originalFetch = globalThis.fetch; + window.location.href = `https://app.example.com/credentials/collect-encrypted#token=${token}`; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + globalThis.fetch = originalFetch; +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('keeps the capability in the fragment and sends only ciphertext', async () => { + const { publicKey, privateKey } = await generateKeyPair('ECDH-ES', { crv: 'P-256' }); + const { x, y } = await exportJWK(publicKey); + const encryption_key = { kid: 'vtk_example', jwk: { kty: 'EC', crv: 'P-256', x, y } }; + const fetcher = Object.assign( + mock(async (_input: string | URL | Request, _init?: RequestInit) => + Response.json({ item: item(), encryption_key }), + ), + { preconnect() {} }, + ); + globalThis.fetch = fetcher; + await act(async () => root.render()); + await act(async () => { + await settle(); + await settle(); + }); + + expect(fetcher.mock.calls[0][0]).toBe('/api/credential-requests/current-encrypted'); + const read = fetcher.mock.calls[0][1]; + expect(read?.credentials).toBe('omit'); + expect(new Headers(read?.headers).get('Authorization')).toBe(`Bearer ${token}`); + expect(String(fetcher.mock.calls[0][0])).not.toContain(token); + + container.querySelector('input[name="password"]')!.value = 'replacement'; + await act(async () => { + container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + // Encryption runs on WebCrypto, so wait for the PATCH rather than a single tick. + for (let i = 0; i < 100 && fetcher.mock.calls.length < 2; i++) await settle(); + await settle(); + }); + + const submission = fetcher.mock.calls[1]; + expect(submission[0]).toBe('/api/credential-requests/current-encrypted'); + expect(submission[1]?.method).toBe('PATCH'); + expect(new Headers(submission[1]?.headers).get('Authorization')).toBe(`Bearer ${token}`); + const body = String(submission[1]?.body); + expect(body).not.toContain(token); + expect(body).not.toContain('replacement'); + const sent = JSON.parse(body); + expect(sent.version).toBe(1); + const { plaintext, protectedHeader } = await compactDecrypt(sent.fields.password.encrypted_value, privateKey); + expect(new TextDecoder().decode(plaintext)).toBe('replacement'); + expect(protectedHeader).toMatchObject({ alg: 'ECDH-ES', enc: 'A256GCM', kid: 'vtk_example' }); + expect(window.location.pathname).toBe('/credentials/collect-encrypted'); + expect(window.location.hash).toBe(''); +}); diff --git a/tests/encrypted-submission.test.ts b/tests/encrypted-submission.test.ts index 046ebcb..2ae99bd 100644 --- a/tests/encrypted-submission.test.ts +++ b/tests/encrypted-submission.test.ts @@ -43,7 +43,7 @@ test('ciphertext round-trips through the backend to the vault', async () => { stored.consumedAt = consumedAt; }, }; - const handler = createCollectionHandler({ appOrigin: origin, store, vault }); + const handler = createCollectionHandler({ appOrigin: origin, store, vault, encrypted: true }); const authorization = `Bearer ${token}`; const read = await handler(