Skip to content

Commit 05cdeb8

Browse files
authored
Add JWK parsing limits and align TS implementation with Rust (#15)
* Align TS imple with Rust * Fix PR feedback * Bump version
1 parent 309a370 commit 05cdeb8

6 files changed

Lines changed: 471 additions & 50 deletions

File tree

Cargo.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.

packages/pq-key-encoder/ts/src/jwk.ts

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import type { AlgorithmName, JwkExportOptions, KeyData, PQJwk } from './types';
33
import { decodeBase64Url, encodeBase64Url } from './utils/base64';
44
import { 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. */
710
function 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. */
43104
export 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+
}

packages/pq-key-encoder/ts/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ export type PQJwk = PQPublicJwk | PQPrivateJwk;
3232
export type JwkExportOptions = {
3333
includePrivate?: boolean;
3434
publicKey?: Uint8Array;
35+
kid?: string;
3536
};

packages/pq-key-encoder/ts/src/utils/base64.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { InvalidEncodingError } from '../errors';
22

33
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
44

5+
const BASE64_VALUES: Record<string, number> = {};
6+
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
7+
for (let i = 0; i < BASE64_ALPHABET.length; i += 1) {
8+
BASE64_VALUES[BASE64_ALPHABET[i]] = i;
9+
}
10+
511
declare const Buffer:
612
| {
713
from(data: Uint8Array): { toString(encoding: 'base64'): string };
@@ -32,6 +38,21 @@ function binaryStringToBytes(input: string): Uint8Array {
3238
return bytes;
3339
}
3440

41+
/** Validate that trailing bits in base64 are zero (RFC 4648 §3.5). */
42+
function validateTrailingBits(normalized: string): void {
43+
const unpadded = normalized.replace(/=+$/, '');
44+
const remainder = unpadded.length % 4;
45+
if (remainder === 2) {
46+
if ((BASE64_VALUES[unpadded[unpadded.length - 1]] & 0x0f) !== 0) {
47+
throw new InvalidEncodingError('Non-zero trailing bits in base64.');
48+
}
49+
} else if (remainder === 3) {
50+
if ((BASE64_VALUES[unpadded[unpadded.length - 1]] & 0x03) !== 0) {
51+
throw new InvalidEncodingError('Non-zero trailing bits in base64.');
52+
}
53+
}
54+
}
55+
3556
/** Normalize base64 input by stripping whitespace and padding. */
3657
export function normalizeBase64(input: string): string {
3758
const cleaned = stripWhitespace(input);
@@ -75,6 +96,7 @@ export function decodeBase64(input: string): Uint8Array {
7596
if (normalized.length === 0) {
7697
return new Uint8Array();
7798
}
99+
validateTrailingBits(normalized);
78100
if (typeof globalThis.atob === 'function') {
79101
return binaryStringToBytes(globalThis.atob(normalized));
80102
}

0 commit comments

Comments
 (0)