Skip to content

Commit 68c252b

Browse files
eacetampcode-com
andcommitted
harden pq-key-fingerprint input validation and contracts
Amp-Thread-ID: https://ampcode.com/threads/T-019cd872-e9a0-7597-829a-36b5522c012d Co-authored-by: Amp <amp@ampcode.com>
1 parent 61d1860 commit 68c252b

4 files changed

Lines changed: 88 additions & 13 deletions

File tree

packages/pq-key-fingerprint/ts/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ function fingerprintJWK(jwk: PQPublicJwk, options?: FingerprintOptions): Promise
123123

124124
## Compatibility Note
125125

126+
Canonical fingerprint identity for interoperability is `SHA-256` with `hex` output (the default). Alternate digest or encoding choices are intended for advanced use-cases where both producer and consumer explicitly agree on format.
127+
126128
All exported fingerprint entrypoints enforce a strict local error boundary by design. Upstream parser/validation failures from `pq-key-encoder` are translated into `pq-key-fingerprint` error classes (subclasses of `FingerprintError`) before they leave this package. This behavior is intentional and part of the package contract.
127129

128-
`options` must be an object when provided and only supports `digest` plus `encoding`. Unknown option keys and invalid option values (for example, empty `digest`/`encoding` strings) are rejected rather than silently defaulting.
130+
`options` must be a plain object when provided and only supports `digest` plus `encoding`. Unknown option keys and invalid option values (for example, empty `digest`/`encoding` strings) are rejected rather than silently defaulting.
129131

130132
The fingerprint preimage format is stable and versioned as:
131133

@@ -139,6 +141,8 @@ Fingerprint digests are algorithm-scoped: the digest input is domain-separated a
139141

140142
Runtime requirement: a WebCrypto `subtle.digest` implementation and `TextEncoder` must be available in the current runtime.
141143

144+
Supported runtime baseline: Node.js 18+, Bun 1+, and modern browsers that expose global WebCrypto plus `TextEncoder`.
145+
142146
## License
143147

144148
MIT

packages/pq-key-fingerprint/ts/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
"type": "module",
66
"main": "./dist/index.js",
77
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"engines": {
16+
"node": ">=18"
17+
},
18+
"sideEffects": false,
819
"files": [
920
"dist"
1021
],

packages/pq-key-fingerprint/ts/src/fingerprint.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ const SUPPORTED_DIGESTS = new Set<FingerprintDigest>(['SHA-256', 'SHA-384', 'SHA
3737
const SUPPORTED_ENCODINGS = new Set<FingerprintEncoding>(['hex', 'base64', 'base64url', 'bytes']);
3838
const ALLOWED_OPTION_KEYS = new Set<keyof FingerprintOptions>(['digest', 'encoding']);
3939

40+
type UnknownRecord = Record<PropertyKey, unknown>;
41+
42+
function isPlainObject(value: unknown): value is UnknownRecord {
43+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
44+
return false;
45+
}
46+
const prototype = Object.getPrototypeOf(value);
47+
return prototype === Object.prototype || prototype === null;
48+
}
49+
50+
function hasOwn(record: UnknownRecord, key: string): boolean {
51+
return Object.prototype.hasOwnProperty.call(record, key);
52+
}
53+
4054
function resolveDigest(digest: unknown): FingerprintDigest {
4155
if (digest === undefined) {
4256
return DEFAULT_DIGEST;
@@ -61,17 +75,25 @@ function normalizeOptions(options: unknown): FingerprintOptions {
6175
if (options === undefined) {
6276
return {};
6377
}
64-
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
65-
throw new InvalidFingerprintInputError('options must be an object.');
78+
if (!isPlainObject(options)) {
79+
throw new InvalidFingerprintInputError('options must be a plain object.');
6680
}
6781

68-
for (const key of Object.keys(options)) {
69-
if (!ALLOWED_OPTION_KEYS.has(key as keyof FingerprintOptions)) {
70-
throw new InvalidFingerprintInputError(`Unknown option: ${key}.`);
82+
for (const key of Reflect.ownKeys(options)) {
83+
if (typeof key !== 'string' || !ALLOWED_OPTION_KEYS.has(key as keyof FingerprintOptions)) {
84+
throw new InvalidFingerprintInputError(`Unknown option: ${String(key)}.`);
7185
}
7286
}
7387

74-
return options as FingerprintOptions;
88+
const normalized: FingerprintOptions = {};
89+
if (hasOwn(options, 'digest')) {
90+
normalized.digest = options.digest as FingerprintDigest | undefined;
91+
}
92+
if (hasOwn(options, 'encoding')) {
93+
normalized.encoding = options.encoding as FingerprintEncoding | undefined;
94+
}
95+
96+
return normalized;
7597
}
7698

7799
function getTextEncoder(): TextEncoder {
@@ -172,22 +194,33 @@ function ensurePublicKeyData(keyData: KeyData): PublicKeyData {
172194
}
173195

174196
function normalizePublicKeyInput(input: PublicKeyInput): PublicKeyData {
175-
if (typeof input !== 'object' || input === null) {
197+
if (!isPlainObject(input)) {
176198
throw new InvalidFingerprintInputError('input must be a public key object.');
177199
}
178200

179-
if ('type' in input) {
180-
return ensurePublicKeyData(input as KeyData);
201+
const inputRecord = input as UnknownRecord;
202+
203+
if (hasOwn(inputRecord, 'type')) {
204+
if (!hasOwn(inputRecord, 'alg') || !hasOwn(inputRecord, 'bytes')) {
205+
throw new InvalidFingerprintInputError('input must include type, alg, and bytes.');
206+
}
207+
208+
const keyData: KeyData = {
209+
alg: inputRecord.alg as AlgorithmName,
210+
type: inputRecord.type as KeyData['type'],
211+
bytes: inputRecord.bytes as Uint8Array,
212+
};
213+
return ensurePublicKeyData(keyData);
181214
}
182215

183-
if (!('alg' in input) || !('bytes' in input)) {
216+
if (!hasOwn(inputRecord, 'alg') || !hasOwn(inputRecord, 'bytes')) {
184217
throw new InvalidFingerprintInputError('input must include alg and bytes.');
185218
}
186219

187220
const keyData: KeyData = {
188-
alg: input.alg,
221+
alg: inputRecord.alg as AlgorithmName,
189222
type: 'public',
190-
bytes: input.bytes,
223+
bytes: inputRecord.bytes as Uint8Array,
191224
};
192225
return ensurePublicKeyData(keyData);
193226
}

packages/pq-key-fingerprint/ts/tests/fingerprint.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,33 @@ describe('fingerprint error behavior', () => {
196196
extra: true,
197197
} as never),
198198
).rejects.toBeInstanceOf(InvalidFingerprintInputError);
199+
200+
await expect(
201+
fingerprintPublicKeyBytes(
202+
VECTOR_BYTES,
203+
'SLH-DSA-SHA2-128s',
204+
Object.create({
205+
digest: 'SHA-512',
206+
}) as never,
207+
),
208+
).rejects.toBeInstanceOf(InvalidFingerprintInputError);
209+
});
210+
211+
it('rejects non-canonical algorithm names', async () => {
212+
await expect(
213+
fingerprintPublicKeyBytes(VECTOR_BYTES, 'slh-dsa-sha2-128s' as never),
214+
).rejects.toBeInstanceOf(InvalidFingerprintInputError);
215+
});
216+
217+
it('rejects prototype-derived public key inputs', async () => {
218+
const inheritedInput = Object.create({
219+
alg: 'SLH-DSA-SHA2-128s',
220+
bytes: VECTOR_BYTES,
221+
});
222+
223+
await expect(fingerprintPublicKey(inheritedInput as never)).rejects.toBeInstanceOf(
224+
InvalidFingerprintInputError,
225+
);
199226
});
200227

201228
it('rejects algorithm names with NUL bytes', async () => {

0 commit comments

Comments
 (0)