Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 83 additions & 13 deletions packages/pq-jws/ts/src/jws.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,8 +9,69 @@ import type {
VerifyJwsCompactOptions,
} from './types';

export async function signJwsCompact(_input: SignJwsCompactInput): Promise<string> {
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<string> {
assertSupportedAlgorithm(input.protectedHeader.alg);

const payloadBytes =
typeof input.payload === 'string' ? textEncoder.encode(input.payload) : input.payload;
const encodedProtectedHeader = encodeProtectedHeader(input.protectedHeader);
Comment on lines +53 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate full protected header before signing

signJwsCompact only checks alg and then serializes input.protectedHeader directly, so it can emit tokens with headers that this same package later rejects during parse/verify (for example b64: false, malformed crit, or runtime-invalid field types like non-string kid). That creates a round-trip break where signing succeeds but verification fails for the generated token, which is avoidable by running the same protected-header validation rules on the signing path.

Useful? React with 👍 / 👎.

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(
Expand All @@ -18,24 +80,32 @@ export async function verifyJwsCompact(
options: VerifyJwsCompactOptions = {},
): Promise<boolean> {
const parsed = parseJwsCompact(compact, options.parseOptions);
assertSupportedAlgorithm(parsed.protectedHeader.alg);

const verificationResult = await verifier(parsed.signingInput, parsed.signature, {
protectedHeader: parsed.protectedHeader,
payload: parsed.payload,
encodedProtectedHeader: parsed.encodedProtectedHeader,
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<T>(_parsed: ParsedCompactJws): T {
throw new JwsError('decodePayloadJson is not implemented yet.');
export function decodePayloadJson<T>(parsed: ParsedCompactJws): T {
const payloadText = decodePayloadText(parsed);

try {
return JSON.parse(payloadText) as T;
} catch {
throw new JwsValidationError('JWS payload is not valid JSON.');
}
}
26 changes: 26 additions & 0 deletions packages/pq-jws/ts/tests/compact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
164 changes: 164 additions & 0 deletions packages/pq-jws/ts/tests/jws.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, number>>(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);
});
});