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' ;
23import type { CollectionRequestStore } from './request-store' ;
34import { 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' ;
75import {
86 VaultConflictError ,
97 VaultInvalidRequestError ,
108 type CredentialVaultClient ,
119} from './vault-client' ;
1210
1311const bearerPattern = / ^ B e a r e r ( [ A - Z a - z 0 - 9 _ - ] { 43 } ) $ / ;
12+ // Compact JWE: five base64url segments. Kernel rejects anything it cannot decrypt.
13+ const compactJwePattern = / ^ [ A - Z a - z 0 - 9 _ - ] + ( \. [ A - Z a - z 0 - 9 _ - ] * ) { 4 } $ / ;
14+ const maxEncryptedValueLength = 24 * 1024 ;
1415
1516function 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
5051export 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}
0 commit comments