@@ -3,6 +3,9 @@ import type { AlgorithmName, JwkExportOptions, KeyData, PQJwk } from './types';
33import { decodeBase64Url , encodeBase64Url } from './utils/base64' ;
44import { assertKeyData , getAlgorithmInfo } from './utils/validation' ;
55
6+ const MAX_JSON_SIZE = 65_536 ;
7+ const MAX_JSON_FIELDS = 32 ;
8+
69/** Validate that the key type is public or private. */
710function assertKeyType ( keyType : KeyData [ 'type' ] ) : void {
811 if ( keyType !== 'public' && keyType !== 'private' ) {
@@ -39,6 +42,64 @@ function requirePublicKey(algorithm: AlgorithmName, publicKey: unknown): Uint8Ar
3942 return publicKey ;
4043}
4144
45+ /**
46+ * Extract top-level keys from a JSON object string.
47+ * Properly handles string escaping and nesting depth.
48+ */
49+ function extractTopLevelKeys ( json : string ) : string [ ] {
50+ const keys : string [ ] = [ ] ;
51+ let i = 0 ;
52+ const len = json . length ;
53+
54+ // Find opening brace
55+ while ( i < len && json [ i ] !== '{' ) i ++ ;
56+ if ( i >= len ) return keys ;
57+ i ++ ; // skip '{'
58+
59+ let depth = 0 ;
60+
61+ while ( i < len ) {
62+ const ch = json [ i ] ;
63+
64+ if ( ch === '"' ) {
65+ // Parse string
66+ const start = i + 1 ;
67+ i ++ ; // skip opening quote
68+ while ( i < len && json [ i ] !== '"' ) {
69+ if ( json [ i ] === '\\' ) i ++ ; // skip escaped character
70+ i ++ ;
71+ }
72+ const end = i ;
73+ i ++ ; // skip closing quote
74+
75+ // If at top level and next non-ws char is ':', this is a key
76+ if ( depth === 0 ) {
77+ let j = i ;
78+ while ( j < len && ( json [ j ] === ' ' || json [ j ] === '\t' || json [ j ] === '\n' || json [ j ] === '\r' ) ) j ++ ;
79+ if ( j < len && json [ j ] === ':' ) {
80+ const raw = json . slice ( start , end ) ;
81+ try {
82+ keys . push ( JSON . parse ( `"${ raw } "` ) ) ;
83+ } catch {
84+ keys . push ( raw ) ;
85+ }
86+ }
87+ }
88+ } else if ( ch === '{' || ch === '[' ) {
89+ depth ++ ;
90+ i ++ ;
91+ } else if ( ch === '}' || ch === ']' ) {
92+ if ( depth === 0 ) break ; // end of root object
93+ depth -- ;
94+ i ++ ;
95+ } else {
96+ i ++ ;
97+ }
98+ }
99+
100+ return keys ;
101+ }
102+
42103/** Convert key data to a PQ JWK. */
43104export function toJWK ( key : KeyData , options : JwkExportOptions = { } ) : PQJwk {
44105 assertKeyType ( key . type ) ;
@@ -49,31 +110,43 @@ export function toJWK(key: KeyData, options: JwkExportOptions = {}): PQJwk {
49110 throw new InvalidInputError ( 'includePrivate is not valid for public keys.' ) ;
50111 }
51112 const encoded = encodeBase64Url ( key . bytes ) ;
52- return {
113+ const jwk : PQJwk = {
53114 kty : 'PQC' ,
54115 alg : key . alg ,
55116 x : encoded ,
56117 } ;
118+ if ( options . kid !== undefined ) {
119+ jwk . kid = options . kid ;
120+ }
121+ return jwk ;
57122 }
58123
59124 const publicKey = requirePublicKey ( key . alg , options . publicKey ) ;
60125
61126 const publicEncoded = encodeBase64Url ( publicKey ) ;
62127 if ( ! options . includePrivate ) {
63- return {
128+ const jwk : PQJwk = {
64129 kty : 'PQC' ,
65130 alg : key . alg ,
66131 x : publicEncoded ,
67132 } ;
133+ if ( options . kid !== undefined ) {
134+ jwk . kid = options . kid ;
135+ }
136+ return jwk ;
68137 }
69138
70139 const privateEncoded = encodeBase64Url ( key . bytes ) ;
71- return {
140+ const jwk : PQJwk = {
72141 kty : 'PQC' ,
73142 alg : key . alg ,
74143 x : publicEncoded ,
75144 d : privateEncoded ,
76145 } ;
146+ if ( options . kid !== undefined ) {
147+ jwk . kid = options . kid ;
148+ }
149+ return jwk ;
77150}
78151
79152/** Parse a PQ JWK into raw key data. */
@@ -82,11 +155,12 @@ export function fromJWK(jwk: PQJwk): KeyData {
82155 throw new InvalidInputError ( 'jwk must be an object.' ) ;
83156 }
84157
85- const { kty, alg, x, d } = jwk as {
158+ const { kty, alg, x, d, kid } = jwk as {
86159 kty ?: unknown ;
87160 alg ?: unknown ;
88161 x ?: unknown ;
89162 d ?: unknown ;
163+ kid ?: unknown ;
90164 } ;
91165
92166 if ( kty !== 'PQC' ) {
@@ -101,6 +175,9 @@ export function fromJWK(jwk: PQJwk): KeyData {
101175 if ( d !== undefined && typeof d !== 'string' ) {
102176 throw new InvalidInputError ( 'JWK d must be a string when provided.' ) ;
103177 }
178+ if ( kid !== undefined && typeof kid !== 'string' ) {
179+ throw new InvalidInputError ( 'JWK kid must be a string when provided.' ) ;
180+ }
104181
105182 const algorithm = parseAlgorithmName ( alg ) ;
106183 const isPrivate = typeof d === 'string' ;
@@ -122,3 +199,58 @@ export function fromJWK(jwk: PQJwk): KeyData {
122199 bytes : publicBytes ,
123200 } ;
124201}
202+
203+ /** Serialize key data to a JWK JSON string. */
204+ export function toJWKString ( key : KeyData , options : JwkExportOptions = { } ) : string {
205+ const jwk = toJWK ( key , options ) ;
206+ return JSON . stringify ( jwk ) ;
207+ }
208+
209+ /**
210+ * Parse a JWK JSON string into raw key data.
211+ *
212+ * Enforces resource limits matching the Rust implementation:
213+ * - Input size capped at 64 KiB
214+ * - Field count capped at 32
215+ * - Duplicate known fields are rejected
216+ */
217+ export function fromJWKString ( json : string ) : KeyData {
218+ if ( typeof json !== 'string' ) {
219+ throw new InvalidInputError ( 'json must be a string.' ) ;
220+ }
221+
222+ const trimmed = json . trim ( ) ;
223+ if ( trimmed . length > MAX_JSON_SIZE ) {
224+ throw new InvalidInputError ( 'JWK input exceeds maximum size.' ) ;
225+ }
226+
227+ // Extract top-level keys to enforce field count and duplicate detection
228+ const keys = extractTopLevelKeys ( trimmed ) ;
229+ if ( keys . length > MAX_JSON_FIELDS ) {
230+ throw new InvalidInputError ( 'Too many fields in JWK object.' ) ;
231+ }
232+
233+ const knownFields = [ 'kty' , 'alg' , 'x' , 'd' , 'kid' ] ;
234+ for ( const field of knownFields ) {
235+ let count = 0 ;
236+ for ( const key of keys ) {
237+ if ( key === field ) count ++ ;
238+ }
239+ if ( count > 1 ) {
240+ throw new InvalidInputError ( `Duplicate '${ field } ' field.` ) ;
241+ }
242+ }
243+
244+ let parsed : unknown ;
245+ try {
246+ parsed = JSON . parse ( trimmed ) ;
247+ } catch {
248+ throw new InvalidInputError ( 'Invalid JSON.' ) ;
249+ }
250+
251+ if ( typeof parsed !== 'object' || parsed === null || Array . isArray ( parsed ) ) {
252+ throw new InvalidInputError ( 'JWK must be a JSON object.' ) ;
253+ }
254+
255+ return fromJWK ( parsed as PQJwk ) ;
256+ }
0 commit comments