diff --git a/packages/pq-jws/ts/src/jws.ts b/packages/pq-jws/ts/src/jws.ts index ca278a1..2574394 100644 --- a/packages/pq-jws/ts/src/jws.ts +++ b/packages/pq-jws/ts/src/jws.ts @@ -1,5 +1,6 @@ -import { parseJwsCompact } from './compact'; -import { JwsError } from './errors'; +import { Algorithm, type MLDSAAlgorithm, OID } from 'pq-oid'; +import { encodeBase64Url } from './base64url'; +import { encodeProtectedHeader, parseJwsCompact, serializeJwsCompact } from './compact'; import { JwsValidationError } from './errors'; import type { JwsVerifier, @@ -8,8 +9,69 @@ import type { VerifyJwsCompactOptions, } from './types'; -export async function signJwsCompact(_input: SignJwsCompactInput): Promise { - throw new JwsError('signJwsCompact is not implemented yet.'); +const textEncoder = new TextEncoder(); +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); + +const SUPPORTED_JWS_ALGORITHMS = new Set( + Algorithm.listByFamily('ML-DSA').map((algorithmName) => + OID.toJOSE(algorithmName as MLDSAAlgorithm), + ), +); + +function assertSupportedAlgorithm(alg: string): void { + try { + OID.fromJOSE(alg); + } catch { + throw new JwsValidationError( + `Protected header algorithm '${alg}' is not a supported ML-DSA JOSE identifier.`, + ); + } + + if (!SUPPORTED_JWS_ALGORITHMS.has(alg)) { + throw new JwsValidationError( + `Protected header algorithm '${alg}' is outside the supported ML-DSA allowlist.`, + ); + } +} + +function assertSignatureBytes(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new JwsValidationError('Signer callback must resolve to a Uint8Array signature.'); + } + return value; +} + +function assertVerificationResult(value: unknown): boolean { + if (typeof value !== 'boolean') { + throw new JwsValidationError('Verifier callback must resolve to a boolean value.'); + } + + return value; +} + +export async function signJwsCompact(input: SignJwsCompactInput): Promise { + assertSupportedAlgorithm(input.protectedHeader.alg); + + const payloadBytes = + typeof input.payload === 'string' ? textEncoder.encode(input.payload) : input.payload; + const encodedProtectedHeader = encodeProtectedHeader(input.protectedHeader); + const encodedPayload = encodeBase64Url(payloadBytes); + const signingInput = textEncoder.encode(`${encodedProtectedHeader}.${encodedPayload}`); + + const signature = assertSignatureBytes( + await input.signer(signingInput, { + protectedHeader: input.protectedHeader, + payload: payloadBytes, + encodedProtectedHeader, + encodedPayload, + }), + ); + + return serializeJwsCompact({ + protectedHeader: encodedProtectedHeader, + payload: encodedPayload, + signature: encodeBase64Url(signature), + }); } export async function verifyJwsCompact( @@ -18,6 +80,8 @@ export async function verifyJwsCompact( options: VerifyJwsCompactOptions = {}, ): Promise { const parsed = parseJwsCompact(compact, options.parseOptions); + assertSupportedAlgorithm(parsed.protectedHeader.alg); + const verificationResult = await verifier(parsed.signingInput, parsed.signature, { protectedHeader: parsed.protectedHeader, payload: parsed.payload, @@ -25,17 +89,23 @@ export async function verifyJwsCompact( encodedPayload: parsed.encodedPayload, }); - if (typeof verificationResult !== 'boolean') { - throw new JwsValidationError('Verifier callback must resolve to a boolean value.'); - } - - return verificationResult; + return assertVerificationResult(verificationResult); } -export function decodePayloadText(_parsed: ParsedCompactJws): string { - throw new JwsError('decodePayloadText is not implemented yet.'); +export function decodePayloadText(parsed: ParsedCompactJws): string { + try { + return utf8Decoder.decode(parsed.payload); + } catch { + throw new JwsValidationError('JWS payload is not valid UTF-8 text.'); + } } -export function decodePayloadJson(_parsed: ParsedCompactJws): T { - throw new JwsError('decodePayloadJson is not implemented yet.'); +export function decodePayloadJson(parsed: ParsedCompactJws): T { + const payloadText = decodePayloadText(parsed); + + try { + return JSON.parse(payloadText) as T; + } catch { + throw new JwsValidationError('JWS payload is not valid JSON.'); + } } diff --git a/packages/pq-jws/ts/tests/compact.test.ts b/packages/pq-jws/ts/tests/compact.test.ts index 427a4a0..462057e 100644 --- a/packages/pq-jws/ts/tests/compact.test.ts +++ b/packages/pq-jws/ts/tests/compact.test.ts @@ -118,6 +118,32 @@ describe('parseJwsCompact', () => { expect(() => parseJwsCompact(compact)).toThrow(JwsValidationError); }); + it('fails closed on unknown critical parameters', () => { + const compact = makeCompact( + JSON.stringify({ alg: 'ML-DSA-44', crit: ['exp'], exp: 'required' }), + makeBytes(8), + makeBytes(8), + ); + + expect(() => parseJwsCompact(compact)).toThrow(JwsValidationError); + }); + + it('enforces crit uniqueness and presence requirements', () => { + const duplicateCrit = makeCompact( + JSON.stringify({ alg: 'ML-DSA-44', crit: ['kid', 'kid'], kid: 'k1' }), + makeBytes(8), + makeBytes(8), + ); + expect(() => parseJwsCompact(duplicateCrit)).toThrow(JwsValidationError); + + const missingCritField = makeCompact( + JSON.stringify({ alg: 'ML-DSA-44', crit: ['kid'] }), + makeBytes(8), + makeBytes(8), + ); + expect(() => parseJwsCompact(missingCritField)).toThrow(JwsValidationError); + }); + it('validates default bounds and override behavior', () => { expect(DEFAULT_JWS_COMPACT_PARSE_OPTIONS).toEqual({ maxCompactLength: 262_144, diff --git a/packages/pq-jws/ts/tests/jws.test.ts b/packages/pq-jws/ts/tests/jws.test.ts new file mode 100644 index 0000000..8eea825 --- /dev/null +++ b/packages/pq-jws/ts/tests/jws.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'bun:test'; +import { Algorithm, type MLDSAAlgorithm, OID } from 'pq-oid'; +import { decodeBase64Url } from '../src/base64url'; +import { parseJwsCompact } from '../src/compact'; +import { JwsFormatError, JwsValidationError } from '../src/errors'; +import { decodePayloadJson, decodePayloadText, signJwsCompact, verifyJwsCompact } from '../src/jws'; + +const textDecoder = new TextDecoder(); + +const ML_DSA_ALGORITHMS = Algorithm.listByFamily('ML-DSA').map((algorithmName) => + OID.toJOSE(algorithmName as MLDSAAlgorithm), +); + +describe('signJwsCompact', () => { + it('signs compact JWS and forwards deterministic callback context', async () => { + let capturedInput = ''; + let capturedPayload = ''; + let capturedHeader = ''; + let capturedEncodedHeader = ''; + let capturedEncodedPayload = ''; + + const payload = 'hello-ml-dsa-check'; + const compact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44', kid: 'k1' }, + payload, + signer: async (signingInput, context) => { + capturedInput = textDecoder.decode(signingInput); + capturedPayload = textDecoder.decode(context.payload); + capturedHeader = context.protectedHeader.alg; + capturedEncodedHeader = context.encodedProtectedHeader; + capturedEncodedPayload = context.encodedPayload; + return new Uint8Array([1, 2, 3, 4]); + }, + }); + + const parsed = parseJwsCompact(compact); + expect(capturedHeader).toBe('ML-DSA-44'); + expect(capturedPayload).toBe(payload); + expect(capturedInput).toBe(`${capturedEncodedHeader}.${capturedEncodedPayload}`); + expect(parsed.encodedProtectedHeader).toBe(capturedEncodedHeader); + expect(parsed.encodedPayload).toBe(capturedEncodedPayload); + expect(Array.from(parsed.signature)).toEqual([1, 2, 3, 4]); + }); + + it('rejects unsupported or non-ML-DSA algorithms', async () => { + await expect( + signJwsCompact({ + protectedHeader: { alg: 'ML-KEM-512' }, + payload: 'hello', + signer: async () => new Uint8Array([1]), + }), + ).rejects.toThrow(JwsValidationError); + }); + + it('validates signer return type', async () => { + await expect( + signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44' }, + payload: 'hello', + signer: async () => 'not-bytes' as unknown as Uint8Array, + }), + ).rejects.toThrow(JwsValidationError); + }); + + it('accepts all ML-DSA algorithms derived from pq-oid public API', async () => { + for (const alg of ML_DSA_ALGORITHMS) { + const compact = await signJwsCompact({ + protectedHeader: { alg }, + payload: 'ok', + signer: async () => new Uint8Array([9, 8, 7]), + }); + + const parsed = parseJwsCompact(compact); + expect(parsed.protectedHeader.alg).toBe(alg); + } + }); +}); + +describe('verifyJwsCompact', () => { + it('returns true on positive path and false on explicit signature mismatch', async () => { + const compact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44' }, + payload: 'verify-me', + signer: async () => new Uint8Array([10, 11, 12]), + }); + + const verified = await verifyJwsCompact(compact, async () => true); + expect(verified).toBe(true); + + const mismatch = await verifyJwsCompact(compact, async () => false); + expect(mismatch).toBe(false); + }); + + it('throws for malformed compact input while preserving mismatch=false behavior', async () => { + await expect(verifyJwsCompact('not-a-jws', async () => true)).rejects.toThrow(JwsFormatError); + }); + + it('propagates verifier callback errors unchanged', async () => { + const compact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44' }, + payload: 'verify-me', + signer: async () => new Uint8Array([1]), + }); + const verifierError = new Error('verifier exploded'); + + await expect( + verifyJwsCompact(compact, async () => { + throw verifierError; + }), + ).rejects.toBe(verifierError); + }); + + it('enforces verifier boolean return type', async () => { + const compact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44' }, + payload: 'verify-me', + signer: async () => new Uint8Array([1]), + }); + + await expect( + verifyJwsCompact(compact, async () => 'true' as unknown as boolean), + ).rejects.toThrow(JwsValidationError); + }); + + it('rejects compact tokens that use unsupported algorithms', async () => { + const compact = `${Buffer.from('{"alg":"SLH-DSA-SHA2-128s"}').toString('base64url')}.${Buffer.from('a').toString('base64url')}.${Buffer.from([1]).toString('base64url')}`; + + await expect(verifyJwsCompact(compact, async () => true)).rejects.toThrow(JwsValidationError); + }); +}); + +describe('payload decoders', () => { + it('decodes payload text and JSON without changing signing semantics', async () => { + const payloadText = '{"z":2,"a":1}'; + const compact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-65' }, + payload: payloadText, + signer: async () => new Uint8Array([5]), + }); + const parsed = parseJwsCompact(compact); + + expect(decodePayloadText(parsed)).toBe(payloadText); + expect(decodePayloadJson>(parsed)).toEqual({ z: 2, a: 1 }); + expect(textDecoder.decode(decodeBase64Url(parsed.encodedPayload))).toBe(payloadText); + }); + + it('rejects non-UTF-8 and non-JSON payload decode attempts', async () => { + const nonUtf8Compact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44' }, + payload: new Uint8Array([0xc3, 0x28]), + signer: async () => new Uint8Array([1]), + }); + const nonUtf8Parsed = parseJwsCompact(nonUtf8Compact); + expect(() => decodePayloadText(nonUtf8Parsed)).toThrow(JwsValidationError); + + const nonJsonCompact = await signJwsCompact({ + protectedHeader: { alg: 'ML-DSA-44' }, + payload: 'not-json', + signer: async () => new Uint8Array([1]), + }); + const nonJsonParsed = parseJwsCompact(nonJsonCompact); + expect(() => decodePayloadJson(nonJsonParsed)).toThrow(JwsValidationError); + }); +});