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..ff63bb0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ 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. +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 Requires Bun and Node.js 20.9 or newer. @@ -18,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 @@ -28,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: @@ -43,12 +49,29 @@ The fragment is not sent in page requests or referrers. The page sends it only i - `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/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. 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. +## Encrypted values + +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 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..." } } } +``` + +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 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 `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. 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/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/bun.lock b/bun.lock new file mode 100644 index 0000000..d01a6c9 --- /dev/null +++ b/bun.lock @@ -0,0 +1,192 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "vault-react-example", + "dependencies": { + "@onkernel/sdk": "^0.122.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.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=="], + + "@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..9c17c0e 100644 --- a/lib/collection-handler.ts +++ b/lib/collection-handler.ts @@ -1,4 +1,5 @@ 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'; import { @@ -8,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, { @@ -48,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; @@ -75,7 +82,11 @@ export function createCollectionHandler({ 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.method === 'GET') { + if (!encrypted) return json(item); + const { kid, jwk } = await vault.encryptionKey(mapping); + return json({ item, encryption_key: { kid, jwk } }); + } if (!request.headers.get('Content-Type')?.startsWith('application/json')) { return json({ code: 'invalid' }, 400); } @@ -109,6 +120,18 @@ export function createCollectionHandler({ return json({ code: 'invalid' }, 400); } const replacement = edit as Record; + 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') || @@ -122,7 +145,7 @@ export function createCollectionHandler({ } } - const submission = input as CredentialSubmission; + 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/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/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 17523ac..da2530d 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 { 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: CredentialSubmission | EncryptedSubmission): Promise; } function credentialItem(item: VaultItem): CredentialVaultItem { @@ -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: CredentialSubmission | 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: CredentialSubmission | 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 b995e03..0298e26 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "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", "react": "^19.2.3", "react-dom": "^19.2.3" 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 d9f6e58..8cc6fa3 100644 --- a/tests/collection-handler.test.ts +++ b/tests/collection-handler.test.ts @@ -70,7 +70,7 @@ function setup() { version: 8, }), ); - const vault: CredentialVaultClient = { retrieve, update }; + const vault: CredentialVaultClient = { retrieve, encryptionKey: mock(), update }; const handler = createCollectionHandler({ appOrigin: origin, store, vault, now: () => now }); return { handler, store, retrieve, update }; } 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 new file mode 100644 index 0000000..2ae99bd --- /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, encrypted: true }); + 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, + ); +}); 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;