Skip to content

Commit 2eaeba1

Browse files
committed
Encrypt values to the vault's Kernel key instead of per-request keys
1 parent ac1ccbb commit 2eaeba1

19 files changed

Lines changed: 358 additions & 728 deletions

‎.env.example‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ MOCK_VAULT=true
88
KERNEL_API_KEY=
99
# KERNEL_API_BASE_URL=https://api.onkernel.com
1010

11-
# Required in BOTH mock and real mode. Server-only; never use a NEXT_PUBLIC_ variable.
12-
# Generate 32 random bytes as base64 once (see README); use the same key for CLI and server.
13-
# Keep outside the request store. Production should use KMS/HSM-backed key wrapping.
14-
COLLECTION_KEY_WRAPPING_KEY=
15-
16-
# Local links and wrapped per-request private keys are written to .data/credential-requests.json.
11+
# Local links are written to .data/credential-requests.json.
1712
# Replace FileCollectionRequestStore with your database in production.
1813
COLLECTION_REQUEST_TTL_MINUTES=15

‎README.md‎

Lines changed: 54 additions & 43 deletions
Large diffs are not rendered by default.

‎app/credentials/collect/collection-page.tsx‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
type CredentialItem,
1010
} from '@onkernel/vault-react';
1111
import { readCollectionToken } from '@/lib/collection-token';
12-
import { encryptSubmission, type CollectionEncryption } from '@/lib/credential-envelope';
12+
import { encryptSubmission, type VaultEncryptionKey } from '@/lib/encrypted-submission';
1313

1414
const endpoint = '/api/credential-requests/current';
1515
const errorCodes = new Set<CollectionErrorCode>([
@@ -44,10 +44,7 @@ export function CollectionPage() {
4444
}
4545

4646
function RequestForm({ token }: { token: string }) {
47-
const [collection, setCollection] = useState<{
48-
item: CredentialItem;
49-
encryption: CollectionEncryption;
50-
}>();
47+
const [collection, setCollection] = useState<{ item: CredentialItem; key: VaultEncryptionKey }>();
5148
const [failure, setFailure] = useState<CollectionErrorCode>();
5249
const authorization = `Bearer ${token}`;
5350

@@ -61,12 +58,10 @@ function RequestForm({ token }: { token: string }) {
6158
})
6259
.then(async (response) => {
6360
if (!response.ok) throw new CredentialFormError(await responseError(response, 'unavailable'));
64-
const data = await response.json();
65-
const item = safeCredentialItem(data.item);
66-
if (data.encryption.itemId !== item.id || data.encryption.version !== item.version) {
67-
throw new CredentialFormError('unavailable');
68-
}
69-
if (!controller.signal.aborted) setCollection({ item, encryption: data.encryption });
61+
const body = await response.json();
62+
const item = safeCredentialItem(body.item);
63+
const key = vaultEncryptionKey(body.encryption_key);
64+
if (!controller.signal.aborted) setCollection({ item, key });
7065
})
7166
.catch((error: unknown) => {
7267
if (!controller.signal.aborted) {
@@ -91,13 +86,12 @@ function RequestForm({ token }: { token: string }) {
9186
item={collection.item}
9287
submitLabel="Save credentials"
9388
onSubmit={async (submission) => {
94-
const envelope = await encryptSubmission(submission, collection.encryption);
9589
const response = await fetch(endpoint, {
9690
method: 'PATCH',
9791
credentials: 'omit',
9892
cache: 'no-store',
99-
headers: { Authorization: authorization, 'Content-Type': 'application/jose' },
100-
body: envelope,
93+
headers: { Authorization: authorization, 'Content-Type': 'application/json' },
94+
body: JSON.stringify(await encryptSubmission(submission, collection.key)),
10195
});
10296
if (!response.ok) {
10397
throw new CredentialFormError(await responseError(response, 'unavailable'));
@@ -108,6 +102,20 @@ function RequestForm({ token }: { token: string }) {
108102
);
109103
}
110104

105+
function vaultEncryptionKey(value: unknown): VaultEncryptionKey {
106+
const key = value as VaultEncryptionKey | undefined;
107+
if (
108+
typeof key?.kid !== 'string' ||
109+
key.jwk?.kty !== 'EC' ||
110+
key.jwk.crv !== 'P-256' ||
111+
typeof key.jwk.x !== 'string' ||
112+
typeof key.jwk.y !== 'string'
113+
) {
114+
throw new CredentialFormError('unavailable');
115+
}
116+
return { kid: key.kid, jwk: { kty: 'EC', crv: 'P-256', x: key.jwk.x, y: key.jwk.y } };
117+
}
118+
111119
function Message({ children }: { children: React.ReactNode }) {
112120
return <p role="alert">{children}</p>;
113121
}

‎app/credentials/collect/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default function Page() {
66
<section className="collection-card">
77
<p className="eyebrow">Secure credential request</p>
88
<h1>Connect your account</h1>
9-
<p className="intro">Your credentials are sent directly to the application that requested them.</p>
9+
<p className="intro">Your credentials are encrypted in this page. Only Kernel can decrypt them.</p>
1010
<CollectionPage />
1111
</section>
1212
</main>

‎bun.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/collection-handler.ts‎

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import { safeCredentialItem, type CredentialSubmission } from '@onkernel/vault-react/data';
1+
import { safeCredentialItem } from '@onkernel/vault-react/data';
2+
import type { EncryptedSubmission } from './encrypted-submission';
23
import type { CollectionRequestStore } from './request-store';
34
import { collectionTokenDigest } from './server-token';
4-
import { decryptSubmission, keyBinding } from './collection-keys';
5-
import { MAX_ENVELOPE_BYTES } from './credential-envelope';
6-
import type { PrivateKeyProtector } from './private-key-protector';
75
import {
86
VaultConflictError,
97
VaultInvalidRequestError,
108
type CredentialVaultClient,
119
} from './vault-client';
1210

1311
const bearerPattern = /^Bearer ([A-Za-z0-9_-]{43})$/;
12+
// Compact JWE: five base64url segments. Kernel rejects anything it cannot decrypt.
13+
const compactJwePattern = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]*){4}$/;
14+
const maxEncryptedValueLength = 24 * 1024;
1415

1516
function json(body: unknown, status = 200) {
1617
return Response.json(body, {
@@ -19,7 +20,7 @@ function json(body: unknown, status = 200) {
1920
});
2021
}
2122

22-
async function readEnvelope(request: Request): Promise<string> {
23+
async function readJson(request: Request): Promise<unknown> {
2324
const reader = request.body?.getReader();
2425
if (!reader) throw new Error('invalid request');
2526
const chunks: Uint8Array[] = [];
@@ -29,7 +30,7 @@ async function readEnvelope(request: Request): Promise<string> {
2930
const { done, value } = await reader.read();
3031
if (done) break;
3132
size += value.byteLength;
32-
if (size > MAX_ENVELOPE_BYTES) {
33+
if (size > 128 * 1024) {
3334
await reader.cancel();
3435
throw new Error('invalid request');
3536
}
@@ -44,69 +45,51 @@ async function readEnvelope(request: Request): Promise<string> {
4445
body.set(chunk, offset);
4546
offset += chunk.byteLength;
4647
}
47-
return new TextDecoder('utf-8', { fatal: true }).decode(body);
48+
return JSON.parse(new TextDecoder().decode(body));
4849
}
4950

5051
export function createCollectionHandler({
5152
appOrigin,
5253
store,
5354
vault,
54-
protector,
5555
now = Date.now,
5656
}: {
5757
appOrigin: string;
5858
store: CollectionRequestStore;
5959
vault: CredentialVaultClient;
60-
protector: PrivateKeyProtector;
6160
now?: () => number;
6261
}) {
6362
const trustedOrigin = new URL(appOrigin).origin;
64-
// Local-only serialization. Production needs a durable, distributed request claim.
65-
const submitting = new Set<string>();
6663

6764
return async (request: Request): Promise<Response> => {
68-
let claimedDigest: string | undefined;
6965
try {
7066
if (!['GET', 'PATCH'].includes(request.method)) return json({ code: 'invalid' }, 405);
7167
const bearer = bearerPattern.exec(request.headers.get('Authorization') ?? '')?.[1];
7268
if (!bearer) return json({ code: 'unavailable' }, 401);
7369

7470
const digest = collectionTokenDigest(bearer);
75-
if (request.method === 'PATCH') {
76-
if (request.headers.get('Origin') !== trustedOrigin) return json({ code: 'unavailable' }, 403);
77-
if (submitting.has(digest)) return json({ code: 'unavailable' }, 409);
78-
submitting.add(digest);
79-
claimedDigest = digest;
80-
}
8171
const mapping = await store.findByTokenDigest(digest);
8272
if (!mapping) return json({ code: 'unavailable' }, 404);
8373
if (mapping.revokedAt !== null) return json({ code: 'unavailable' }, 410);
8474
if (mapping.consumedAt !== null) return json({ code: 'consumed' }, 410);
8575
if (mapping.expiresAt <= now()) return json({ code: 'expired' }, 410);
76+
if (request.method === 'PATCH' && request.headers.get('Origin') !== trustedOrigin) {
77+
return json({ code: 'unavailable' }, 403);
78+
}
8679

8780
const item = safeCredentialItem(await vault.retrieve(mapping));
8881
if (item.id !== mapping.itemId) return json({ code: 'unavailable' }, 404);
8982
if (request.method === 'GET') {
90-
const { x, y } = mapping.key.publicKey;
91-
return json({
92-
item,
93-
encryption: {
94-
...keyBinding(mapping),
95-
version: item.version,
96-
publicKey: { kty: 'EC', crv: 'P-256', x, y },
97-
},
98-
});
83+
const { kid, jwk } = await vault.encryptionKey(mapping);
84+
return json({ item, encryption_key: { kid, jwk } });
9985
}
100-
if (request.headers.get('Content-Type')?.split(';')[0].trim() !== 'application/jose') {
86+
if (!request.headers.get('Content-Type')?.startsWith('application/json')) {
10187
return json({ code: 'invalid' }, 400);
10288
}
10389

10490
let body: unknown;
105-
let boundVersion: number;
10691
try {
107-
const decrypted = await decryptSubmission(await readEnvelope(request), mapping, protector);
108-
body = decrypted.submission;
109-
boundVersion = decrypted.version;
92+
body = await readJson(request);
11093
} catch {
11194
return json({ code: 'invalid' }, 400);
11295
}
@@ -115,7 +98,6 @@ export function createCollectionHandler({
11598
}
11699
const input = body as Record<string, unknown>;
117100
if (
118-
input.version !== boundVersion ||
119101
!Number.isSafeInteger(input.version) ||
120102
Number(input.version) < 1 ||
121103
!input.fields ||
@@ -133,21 +115,19 @@ export function createCollectionHandler({
133115
if (!field || field.type === 'totp' || !edit || typeof edit !== 'object' || Array.isArray(edit)) {
134116
return json({ code: 'invalid' }, 400);
135117
}
118+
// Plaintext values are rejected: this backend only ever relays ciphertext or a clear.
136119
const replacement = edit as Record<string, unknown>;
137-
if (
138-
Object.keys(replacement).length !== 1 ||
139-
!Object.hasOwn(replacement, 'value') ||
140-
(replacement.value === null && field.required) ||
141-
(replacement.value !== null &&
142-
(typeof replacement.value !== 'string' ||
143-
replacement.value.length === 0 ||
144-
new TextEncoder().encode(replacement.value).length > 16 * 1024))
145-
) {
120+
const encrypted = replacement.encrypted_value;
121+
const clear = Object.hasOwn(replacement, 'value') && replacement.value === null && !field.required;
122+
const ciphertext = typeof encrypted === 'string' &&
123+
encrypted.length <= maxEncryptedValueLength &&
124+
compactJwePattern.test(encrypted);
125+
if (Object.keys(replacement).length !== 1 || !(clear || ciphertext)) {
146126
return json({ code: 'invalid' }, 400);
147127
}
148128
}
149129

150-
const submission = input as CredentialSubmission;
130+
const submission = input as EncryptedSubmission;
151131
if (submission.version !== item.version) return json({ code: 'stale' }, 409);
152132
if (!entries.length) {
153133
await store.markConsumed(digest, now());
@@ -156,18 +136,15 @@ export function createCollectionHandler({
156136

157137
try {
158138
const updated = safeCredentialItem(await vault.update(mapping, submission));
159-
if (updated.id !== mapping.itemId) throw new Error('unexpected vault item');
160139
await store.markConsumed(digest, now());
161-
return json({ saved: true });
140+
return json(updated);
162141
} catch (error) {
163142
if (error instanceof VaultConflictError) return json({ code: 'stale' }, 409);
164143
if (error instanceof VaultInvalidRequestError) return json({ code: 'invalid' }, 400);
165144
throw error;
166145
}
167146
} catch {
168147
return json({ code: 'unavailable' }, 502);
169-
} finally {
170-
if (claimedDigest) submitting.delete(claimedDigest);
171148
}
172149
};
173150
}

‎lib/collection-keys.ts‎

Lines changed: 0 additions & 82 deletions
This file was deleted.

0 commit comments

Comments
 (0)