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] 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;