Skip to content

Commit e370a25

Browse files
authored
feat(pq-key-fingerprint/ts): phase 2 - implement fingerprint core logic (ENG-1761) (#23)
## Summary <!-- What does this PR do? Keep it brief. --> ## Package(s) <!-- Which package(s) are modified? --> ## Languages - [ ] TypeScript - [ ] Rust ## Checklist - [ ] Tests pass for all modified packages - [ ] Linting/formatting passes (`biome check`, `cargo fmt`) - [ ] Both language implementations are consistent (or noted as follow-up) - [ ] Package README updated if public API changed - [ ] No unnecessary dependencies added ## Related Issues <!-- Link any related issues: Fixes #123, Closes #456 -->
1 parent f37e523 commit e370a25

3 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import {
2+
assertKeyData,
3+
encodeBase64,
4+
encodeBase64Url,
5+
fromJWK,
6+
fromPEM,
7+
fromSPKI,
8+
type AlgorithmName,
9+
type KeyData,
10+
type PQJwk,
11+
} from 'pq-key-encoder';
12+
import {
13+
FingerprintError,
14+
InvalidFingerprintInputError,
15+
InvalidKeyTypeError,
16+
RuntimeCapabilityError,
17+
UnsupportedDigestError,
18+
} from './errors';
19+
import type {
20+
FingerprintDigest,
21+
FingerprintEncoding,
22+
FingerprintOptions,
23+
FingerprintResult,
24+
PublicKeyData,
25+
PublicKeyInput,
26+
} from './types';
27+
28+
const DEFAULT_DIGEST: FingerprintDigest = 'SHA-256';
29+
const DEFAULT_ENCODING: FingerprintEncoding = 'hex';
30+
31+
const SUPPORTED_DIGESTS = new Set<FingerprintDigest>(['SHA-256', 'SHA-384', 'SHA-512']);
32+
const SUPPORTED_ENCODINGS = new Set<FingerprintEncoding>(['hex', 'base64', 'base64url', 'bytes']);
33+
34+
function resolveDigest(digest?: FingerprintDigest): FingerprintDigest {
35+
if (!digest) {
36+
return DEFAULT_DIGEST;
37+
}
38+
if (!SUPPORTED_DIGESTS.has(digest)) {
39+
throw new UnsupportedDigestError(`Unsupported digest: ${String(digest)}.`);
40+
}
41+
return digest;
42+
}
43+
44+
function resolveEncoding(encoding?: FingerprintEncoding): FingerprintEncoding {
45+
if (!encoding) {
46+
return DEFAULT_ENCODING;
47+
}
48+
if (!SUPPORTED_ENCODINGS.has(encoding)) {
49+
throw new InvalidFingerprintInputError(`Unsupported encoding: ${String(encoding)}.`);
50+
}
51+
return encoding;
52+
}
53+
54+
function bytesToHex(bytes: Uint8Array): string {
55+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
56+
}
57+
58+
function encodeFingerprint(bytes: Uint8Array, encoding: FingerprintEncoding): FingerprintResult {
59+
if (encoding === 'bytes') {
60+
return bytes;
61+
}
62+
if (encoding === 'hex') {
63+
return bytesToHex(bytes);
64+
}
65+
if (encoding === 'base64') {
66+
return encodeBase64(bytes);
67+
}
68+
if (encoding === 'base64url') {
69+
return encodeBase64Url(bytes);
70+
}
71+
72+
throw new InvalidFingerprintInputError(`Unsupported encoding: ${String(encoding)}.`);
73+
}
74+
75+
async function digestBytes(bytes: Uint8Array, digest: FingerprintDigest): Promise<Uint8Array> {
76+
const subtle = globalThis.crypto?.subtle;
77+
if (!subtle) {
78+
throw new RuntimeCapabilityError('WebCrypto subtle.digest is not available in this runtime.');
79+
}
80+
81+
const digestInput = bytes.buffer.slice(
82+
bytes.byteOffset,
83+
bytes.byteOffset + bytes.byteLength,
84+
) as ArrayBuffer;
85+
const digestResult = await subtle.digest(digest, digestInput);
86+
return new Uint8Array(digestResult);
87+
}
88+
89+
function ensurePublicKeyData(keyData: KeyData): PublicKeyData {
90+
if (keyData.type !== 'public') {
91+
throw new InvalidKeyTypeError('Only public keys can be fingerprinted.');
92+
}
93+
94+
const publicKeyData: PublicKeyData = {
95+
alg: keyData.alg,
96+
type: 'public',
97+
bytes: keyData.bytes,
98+
};
99+
assertKeyData(publicKeyData, 'public');
100+
return publicKeyData;
101+
}
102+
103+
function normalizePublicKeyInput(input: PublicKeyInput): PublicKeyData {
104+
if (typeof input !== 'object' || input === null) {
105+
throw new InvalidFingerprintInputError('input must be a public key object.');
106+
}
107+
108+
if ('type' in input) {
109+
return ensurePublicKeyData(input as KeyData);
110+
}
111+
112+
if (!('alg' in input) || !('bytes' in input)) {
113+
throw new InvalidFingerprintInputError('input must include alg and bytes.');
114+
}
115+
116+
const keyData: KeyData = {
117+
alg: input.alg,
118+
type: 'public',
119+
bytes: input.bytes,
120+
};
121+
return ensurePublicKeyData(keyData);
122+
}
123+
124+
async function fingerprintKeyData(
125+
keyData: KeyData,
126+
options: FingerprintOptions = {},
127+
): Promise<FingerprintResult> {
128+
const digest = resolveDigest(options.digest);
129+
const encoding = resolveEncoding(options.encoding);
130+
const publicKeyData = ensurePublicKeyData(keyData);
131+
const digestOutput = await digestBytes(publicKeyData.bytes, digest);
132+
return encodeFingerprint(digestOutput, encoding);
133+
}
134+
135+
function translateError(error: unknown): FingerprintError {
136+
if (error instanceof FingerprintError) {
137+
return error;
138+
}
139+
140+
if (error instanceof Error) {
141+
return new InvalidFingerprintInputError(error.message);
142+
}
143+
144+
return new InvalidFingerprintInputError('Fingerprinting failed due to an unknown error.');
145+
}
146+
147+
async function withErrorBoundary<T>(operation: () => Promise<T>): Promise<T> {
148+
try {
149+
return await operation();
150+
} catch (error) {
151+
throw translateError(error);
152+
}
153+
}
154+
155+
export async function fingerprintPublicKey(
156+
input: PublicKeyInput,
157+
options: FingerprintOptions = {},
158+
): Promise<FingerprintResult> {
159+
return withErrorBoundary(async () => {
160+
const keyData = normalizePublicKeyInput(input);
161+
return fingerprintKeyData(keyData, options);
162+
});
163+
}
164+
165+
export async function fingerprintPublicKeyBytes(
166+
bytes: Uint8Array,
167+
alg: AlgorithmName,
168+
options: FingerprintOptions = {},
169+
): Promise<FingerprintResult> {
170+
return withErrorBoundary(async () => {
171+
const keyData: KeyData = {
172+
alg,
173+
type: 'public',
174+
bytes,
175+
};
176+
return fingerprintKeyData(keyData, options);
177+
});
178+
}
179+
180+
export async function fingerprintSPKI(
181+
spki: Uint8Array,
182+
options: FingerprintOptions = {},
183+
): Promise<FingerprintResult> {
184+
return withErrorBoundary(async () => {
185+
const keyData = fromSPKI(spki);
186+
return fingerprintKeyData(keyData, options);
187+
});
188+
}
189+
190+
export async function fingerprintPEM(
191+
pem: string,
192+
options: FingerprintOptions = {},
193+
): Promise<FingerprintResult> {
194+
return withErrorBoundary(async () => {
195+
const keyData = fromPEM(pem);
196+
return fingerprintKeyData(keyData, options);
197+
});
198+
}
199+
200+
export async function fingerprintJWK(
201+
jwk: PQJwk,
202+
options: FingerprintOptions = {},
203+
): Promise<FingerprintResult> {
204+
return withErrorBoundary(async () => {
205+
const keyData = fromJWK(jwk);
206+
return fingerprintKeyData(keyData, options);
207+
});
208+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
export * from './errors';
2+
export {
3+
fingerprintJWK,
4+
fingerprintPEM,
5+
fingerprintPublicKey,
6+
fingerprintPublicKeyBytes,
7+
fingerprintSPKI,
8+
} from './fingerprint';
29
export * from './types';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { KeyEncoderError } from 'pq-key-encoder';
3+
import {
4+
FingerprintError,
5+
fingerprintJWK,
6+
fingerprintPEM,
7+
fingerprintPublicKey,
8+
fingerprintPublicKeyBytes,
9+
fingerprintSPKI,
10+
} from '../src';
11+
12+
async function expectTranslatedError(promise: Promise<unknown>): Promise<void> {
13+
try {
14+
await promise;
15+
throw new Error('Expected fingerprint API to throw.');
16+
} catch (error) {
17+
expect(error).toBeInstanceOf(FingerprintError);
18+
expect(error).not.toBeInstanceOf(KeyEncoderError);
19+
}
20+
}
21+
22+
describe('fingerprint API contract', () => {
23+
it('fingerprintPublicKey translates upstream validation failures', async () => {
24+
await expectTranslatedError(
25+
fingerprintPublicKey({
26+
alg: 'ML-KEM-512',
27+
type: 'public',
28+
bytes: new Uint8Array(1),
29+
}),
30+
);
31+
});
32+
33+
it('fingerprintPublicKeyBytes translates upstream validation failures', async () => {
34+
await expectTranslatedError(fingerprintPublicKeyBytes(new Uint8Array(0), 'ML-KEM-512'));
35+
});
36+
37+
it('fingerprintSPKI translates upstream parse failures', async () => {
38+
await expectTranslatedError(fingerprintSPKI(new Uint8Array([0x30, 0x00])));
39+
});
40+
41+
it('fingerprintPEM translates upstream parse failures', async () => {
42+
await expectTranslatedError(fingerprintPEM('not-a-pem-block'));
43+
});
44+
45+
it('fingerprintJWK translates upstream parse failures', async () => {
46+
await expectTranslatedError(
47+
fingerprintJWK({
48+
kty: 'PQC',
49+
alg: 'ML-KEM-512',
50+
x: 'AQ',
51+
}),
52+
);
53+
});
54+
});

0 commit comments

Comments
 (0)