diff --git a/.claude/skills/implement-plan-linear/SKILL.md b/.claude/skills/implement-plan-linear/SKILL.md index a186097..637ee9b 100644 --- a/.claude/skills/implement-plan-linear/SKILL.md +++ b/.claude/skills/implement-plan-linear/SKILL.md @@ -39,11 +39,12 @@ How should I proceed? For each phase, execute in this order: 1. Implement all code changes for the phase. -2. Update plan checkboxes for completed implementation work. -3. Run automated acceptance criteria. -4. Pause and wait for explicit manual verification confirmation. -5. After confirmation, run Graphite stack commands. -6. Update Linear issue status/comments with outcome and links. +2. Run Biome autofix/check for touched TypeScript/JavaScript/JSON files and fix issues until clean. +3. Update plan checkboxes for completed implementation work. +4. Run automated acceptance criteria. +5. Pause and wait for explicit manual verification confirmation. +6. After confirmation, run Graphite stack commands. +7. Update Linear issue status/comments with outcome and links. ## Implementation Philosophy @@ -55,6 +56,7 @@ For each phase, execute in this order: ## Acceptance Criteria - Run the exact commands required by the plan and repository acceptance checklist. +- For TypeScript/JavaScript/JSON changes, always run Biome with autofix first (for example `bunx biome check --write `), then run Biome again without `--write` to confirm clean output. - If phase-specific commands are not provided, default to the repo acceptance criteria in `CLAUDE.md`. - Do not continue to Graphite or final Linear state updates until automated checks pass. - If checks fail, fix issues and rerun until passing or blocked. @@ -151,6 +153,7 @@ Keep sub-agent usage focused; provide specific questions and file paths when spa For each completed phase, report: - files changed +- Biome commands run and pass/fail result - acceptance commands and pass/fail result - manual verification confirmation state - Linear updates performed @@ -167,4 +170,4 @@ For each completed phase, report: - Re-read relevant plan and code before assuming root cause. - Consider whether the codebase evolved since the plan was written. - Surface blockers with exact command errors or plan/code mismatches. -- Ask one focused question to unblock. \ No newline at end of file +- Ask one focused question to unblock. diff --git a/packages/pq-algorithm-id/ts/src/index.ts b/packages/pq-algorithm-id/ts/src/index.ts index 692c7e7..e461c8e 100644 --- a/packages/pq-algorithm-id/ts/src/index.ts +++ b/packages/pq-algorithm-id/ts/src/index.ts @@ -5,6 +5,7 @@ export { UnknownIdentifierError, UnsupportedMappingError, } from './errors'; +export { fromCose, fromJose, fromOid, toCose, toJose, toOid } from './lookup'; export { deriveOidFromName, getIdentifierRecord, @@ -20,3 +21,11 @@ export type { X509ParametersEncoding, X509ParametersPolicy, } from './types'; +export { + fromX509AlgorithmIdentifier, + toX509AlgorithmIdentifier, + type X509AlgorithmIdentifier, + type X509AlgorithmIdentifierInput, + type X509AlgorithmIdentifierOptions, + type X509NormalizedParameters, +} from './x509'; diff --git a/packages/pq-algorithm-id/ts/src/lookup.ts b/packages/pq-algorithm-id/ts/src/lookup.ts new file mode 100644 index 0000000..bd858cc --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/lookup.ts @@ -0,0 +1,105 @@ +import { OID } from 'pq-oid'; +import { UnknownAlgorithmError, UnknownIdentifierError, UnsupportedMappingError } from './errors'; +import { getIdentifierRecord, listIdentifierRecords } from './registry'; +import type { AlgorithmName, CoseIdentifier, JoseIdentifier } from './types'; + +const JOSE_TO_NAME = new Map(); +const COSE_TO_NAME = new Map(); + +for (const record of listIdentifierRecords()) { + if (record.jose !== undefined) { + JOSE_TO_NAME.set(record.jose, record.name); + } + if (record.cose !== undefined) { + COSE_TO_NAME.set(record.cose, record.name); + } +} + +function isCanonicalOid(oid: string): boolean { + if (oid.length === 0 || oid.trim() !== oid) { + return false; + } + + if (!/^\d+(?:\.\d+)+$/.test(oid)) { + return false; + } + + const arcs = oid.split('.'); + if (arcs.some((arc) => arc.length > 1 && arc.startsWith('0'))) { + return false; + } + + const firstArc = Number(arcs[0]); + if (!Number.isInteger(firstArc) || firstArc < 0 || firstArc > 2) { + return false; + } + + const secondArc = Number(arcs[1]); + if (!Number.isInteger(secondArc)) { + return false; + } + + if ((firstArc === 0 || firstArc === 1) && (secondArc < 0 || secondArc > 39)) { + return false; + } + + return true; +} + +export function toOid(name: AlgorithmName): string { + try { + return OID.fromName(getIdentifierRecord(name).name); + } catch { + throw new UnknownAlgorithmError(name); + } +} + +export function fromOid(oid: string): AlgorithmName { + if (!isCanonicalOid(oid)) { + throw new UnknownIdentifierError('OID', oid); + } + + try { + const name = OID.toName(oid); + getIdentifierRecord(name); + return name; + } catch { + throw new UnknownIdentifierError('OID', oid); + } +} + +export function toJose(name: AlgorithmName): JoseIdentifier { + const record = getIdentifierRecord(name); + if (record.jose === undefined) { + throw new UnsupportedMappingError('JOSE', name); + } + return record.jose; +} + +export function fromJose(jose: string): AlgorithmName { + const name = JOSE_TO_NAME.get(jose as JoseIdentifier); + if (name === undefined) { + throw new UnknownIdentifierError('JOSE', jose); + } + return name; +} + +export function toCose(name: AlgorithmName): CoseIdentifier { + const record = getIdentifierRecord(name); + if (record.cose === undefined) { + throw new UnsupportedMappingError('COSE', name); + } + return record.cose; +} + +export function fromCose(cose: number): AlgorithmName { + if (!Number.isFinite(cose) || !Number.isInteger(cose)) { + throw new UnknownIdentifierError('COSE', cose); + } + + const name = COSE_TO_NAME.get(cose as CoseIdentifier); + if (name === undefined) { + throw new UnknownIdentifierError('COSE', cose); + } + return name; +} diff --git a/packages/pq-algorithm-id/ts/src/x509.ts b/packages/pq-algorithm-id/ts/src/x509.ts new file mode 100644 index 0000000..cb3533e --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/x509.ts @@ -0,0 +1,100 @@ +import { + AlgorithmIdentifierError, + UnknownIdentifierError, + UnsupportedMappingError, +} from './errors'; +import { fromOid, toOid } from './lookup'; +import { getIdentifierRecord } from './registry'; +import type { AlgorithmName, X509ParametersEncoding } from './types'; + +export type X509NormalizedParameters = { kind: 'absent' } | { kind: 'null' }; + +export interface X509AlgorithmIdentifier { + oid: string; + parameters: X509NormalizedParameters; +} + +export interface X509AlgorithmIdentifierInput { + oid: string; + parameters?: unknown; +} + +export interface X509AlgorithmIdentifierOptions { + parametersEncoding?: X509ParametersEncoding; +} + +const ABSENT_PARAMETERS: X509NormalizedParameters = Object.freeze({ kind: 'absent' }); +const NULL_PARAMETERS: X509NormalizedParameters = Object.freeze({ kind: 'null' }); + +function validateParametersForAlgorithm( + name: AlgorithmName, + encoding: X509ParametersEncoding, +): void { + const policy = getIdentifierRecord(name).x509; + if (encoding === 'absent' && !policy.acceptAbsent) { + throw new UnsupportedMappingError('X509', name); + } + if (encoding === 'null' && !policy.acceptNull) { + throw new UnsupportedMappingError('X509', name); + } +} + +function normalizeParameters(input: unknown): X509NormalizedParameters { + if (input === undefined) { + return ABSENT_PARAMETERS; + } + + if (input === null) { + return NULL_PARAMETERS; + } + + if (typeof input === 'object' && input !== null) { + const kind = (input as { kind?: unknown }).kind; + if (kind === 'absent') { + return ABSENT_PARAMETERS; + } + if (kind === 'null') { + return NULL_PARAMETERS; + } + } + + throw new AlgorithmIdentifierError( + 'UNKNOWN_IDENTIFIER', + "Unknown X509 parameters. Expected undefined, null, { kind: 'absent' }, or { kind: 'null' }.", + ); +} + +export function toX509AlgorithmIdentifier( + name: AlgorithmName, + options?: X509AlgorithmIdentifierOptions, +): X509AlgorithmIdentifier { + const policy = getIdentifierRecord(name).x509; + const parametersEncoding = options?.parametersEncoding ?? policy.defaultParametersEncoding; + validateParametersForAlgorithm(name, parametersEncoding); + + return { + oid: toOid(name), + parameters: parametersEncoding === 'null' ? NULL_PARAMETERS : ABSENT_PARAMETERS, + }; +} + +export function fromX509AlgorithmIdentifier( + input: X509AlgorithmIdentifierInput, +): X509AlgorithmIdentifier { + if (typeof input !== 'object' || input === null) { + throw new AlgorithmIdentifierError('UNKNOWN_IDENTIFIER', 'X509 input must be an object.'); + } + + if (typeof input.oid !== 'string') { + throw new UnknownIdentifierError('OID', String(input.oid)); + } + + const name = fromOid(input.oid); + const normalizedParameters = normalizeParameters(input.parameters); + validateParametersForAlgorithm(name, normalizedParameters.kind); + + return { + oid: toOid(name), + parameters: normalizedParameters, + }; +} diff --git a/packages/pq-algorithm-id/ts/tests/lookup.test.ts b/packages/pq-algorithm-id/ts/tests/lookup.test.ts new file mode 100644 index 0000000..fe65baf --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/lookup.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test'; +import { OID } from 'pq-oid'; +import { UnknownIdentifierError, UnsupportedMappingError } from '../src/errors'; +import { fromCose, fromJose, fromOid, toCose, toJose, toOid } from '../src/lookup'; +import { listIdentifierRecords, listRegistryAlgorithmNames } from '../src/registry'; + +function expectError( + fn: () => unknown, + errorType: new (...args: never[]) => T, +): T { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(errorType); + return error as T; + } + throw new Error('Expected function to throw.'); +} + +describe('lookup - OID mapping', () => { + test('round-trip name <-> oid for all supported algorithms with fromOid(toOid(...))', () => { + for (const name of listRegistryAlgorithmNames()) { + expect(toOid(name)).toBe(OID.fromName(name)); + expect(fromOid(toOid(name))).toBe(name); + } + }); + + test('fromOid enforces canonical dotted format and strict arc rules', () => { + const invalidOids = [ + '2.16.840.1.101.3.4.3.18 ', + ' 2.16.840.1.101.3.4.3.18', + '2..16.840.1.101.3.4.3.18', + '2.16.840.1.101.3.4.3.', + '2.16.840.1.101.3.4.3.1a', + '+2.16.840.1.101.3.4.3.18', + '2.-16.840.1.101.3.4.3.18', + '2.16.840.1.101.3.4.3.018', + '03.16.840.1.101.3.4.3.18', + '3.16.840.1.101.3.4.3.18', + '1.40.840.1.101.3.4.3.18', + ]; + + for (const oid of invalidOids) { + const error = expectError(() => fromOid(oid), UnknownIdentifierError); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('OID'); + expect(error.identifierValue).toBe(oid); + } + }); + + test('fromOid rejects unknown canonical OIDs', () => { + const unknownOid = '2.16.840.1.101.3.4.3.255'; + const error = expectError(() => fromOid(unknownOid), UnknownIdentifierError); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('OID'); + }); +}); + +describe('lookup - JOSE mapping', () => { + test('ML-DSA JOSE values stay parity-compatible with pq-oid', () => { + const names = ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'] as const; + for (const name of names) { + expect(toJose(name)).toBe(OID.toJOSE(name)); + expect(fromJose(OID.toJOSE(name))).toBe(name); + } + }); + + test('unsupported JOSE mapping throws actionable typed error', () => { + const error = expectError(() => toJose('ML-KEM-512'), UnsupportedMappingError); + expect(error.code).toBe('UNSUPPORTED_MAPPING'); + expect(error.mapping).toBe('JOSE'); + expect(error.algorithm).toBe('ML-KEM-512'); + }); + + test('fromJose is strict exact-match: rejects whitespace and case variants', () => { + const invalidJose = ['ml-dsa-44', 'ML-DSA-44 ', ' ML-DSA-44', 'RS256']; + for (const jose of invalidJose) { + const error = expectError(() => fromJose(jose), UnknownIdentifierError); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('JOSE'); + expect(error.identifierValue).toBe(jose); + } + }); + + test('JOSE mapping is unique and invertible via fromJose(toJose(...))', () => { + const joseSupported = listIdentifierRecords().filter((record) => record.jose !== undefined); + const joseValues = joseSupported.map((record) => toJose(record.name)); + expect(new Set(joseValues).size).toBe(joseValues.length); + + for (const record of joseSupported) { + expect(fromJose(toJose(record.name))).toBe(record.name); + } + }); +}); + +describe('lookup - COSE mapping', () => { + test('ML-DSA COSE values stay parity-compatible with pq-oid', () => { + const names = ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'] as const; + for (const name of names) { + expect(toCose(name)).toBe(OID.toCOSE(name)); + expect(fromCose(OID.toCOSE(name))).toBe(name); + } + }); + + test('unsupported COSE mapping throws actionable typed error', () => { + const error = expectError(() => toCose('SLH-DSA-SHA2-128s'), UnsupportedMappingError); + expect(error.code).toBe('UNSUPPORTED_MAPPING'); + expect(error.mapping).toBe('COSE'); + expect(error.algorithm).toBe('SLH-DSA-SHA2-128s'); + }); + + test('fromCose rejects non-integer, float, NaN, Infinity, and unknown values', () => { + const invalidCose = [-48.1, Number.NaN, Number.POSITIVE_INFINITY, -999, ' -48'] as const; + for (const cose of invalidCose) { + const error = expectError(() => fromCose(cose as unknown as number), UnknownIdentifierError); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('COSE'); + expect(error.identifierValue).toBe(cose); + } + }); + + test('COSE mapping is unique and invertible via fromCose(toCose(...))', () => { + const coseSupported = listIdentifierRecords().filter((record) => record.cose !== undefined); + const coseValues = coseSupported.map((record) => toCose(record.name)); + expect(new Set(coseValues).size).toBe(coseValues.length); + + for (const record of coseSupported) { + expect(fromCose(toCose(record.name))).toBe(record.name); + } + }); +}); diff --git a/packages/pq-algorithm-id/ts/tests/x509.test.ts b/packages/pq-algorithm-id/ts/tests/x509.test.ts new file mode 100644 index 0000000..cb311e4 --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/x509.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { AlgorithmIdentifierError, UnknownIdentifierError } from '../src/errors'; +import { toOid } from '../src/lookup'; +import { fromX509AlgorithmIdentifier, toX509AlgorithmIdentifier } from '../src/x509'; + +function expectError( + fn: () => unknown, + errorType: new (...args: never[]) => T, +): T { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(errorType); + return error as T; + } + throw new Error('Expected function to throw.'); +} + +describe('x509 - generation', () => { + test('toX509AlgorithmIdentifier emits parameters absent by default', () => { + const descriptor = toX509AlgorithmIdentifier('ML-KEM-512'); + expect(descriptor).toEqual({ + oid: toOid('ML-KEM-512'), + parameters: { kind: 'absent' }, + }); + }); + + test('toX509AlgorithmIdentifier emits kind: null only when explicitly requested', () => { + const descriptor = toX509AlgorithmIdentifier('ML-DSA-65', { parametersEncoding: 'null' }); + expect(descriptor).toEqual({ + oid: toOid('ML-DSA-65'), + parameters: { kind: 'null' }, + }); + }); +}); + +describe('x509 - parsing and normalize behavior', () => { + const oid = toOid('SLH-DSA-SHAKE-256f'); + + test('normalize missing and undefined parameters to kind: absent', () => { + expect(fromX509AlgorithmIdentifier({ oid })).toEqual({ + oid, + parameters: { kind: 'absent' }, + }); + expect(fromX509AlgorithmIdentifier({ oid, parameters: undefined })).toEqual({ + oid, + parameters: { kind: 'absent' }, + }); + }); + + test('normalize null and kind: null to kind: null', () => { + expect(fromX509AlgorithmIdentifier({ oid, parameters: null })).toEqual({ + oid, + parameters: { kind: 'null' }, + }); + expect(fromX509AlgorithmIdentifier({ oid, parameters: { kind: 'null' } })).toEqual({ + oid, + parameters: { kind: 'null' }, + }); + }); + + test('normalize kind: absent to kind: absent', () => { + expect(fromX509AlgorithmIdentifier({ oid, parameters: { kind: 'absent' } })).toEqual({ + oid, + parameters: { kind: 'absent' }, + }); + }); + + test('reject unknown parameters and unexpected kind values', () => { + const invalidParameters = [{}, { kind: 'zero' }, { kind: 1 }]; + for (const parameters of invalidParameters) { + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters }), + AlgorithmIdentifierError, + ); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + } + }); + + test('reject non-object parameters', () => { + const invalidParameters = [42, false, 'null', []]; + for (const parameters of invalidParameters) { + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters }), + AlgorithmIdentifierError, + ); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + } + }); + + test('reject non-object x509 input and invalid OID shapes', () => { + expectError(() => fromX509AlgorithmIdentifier('bad' as never), AlgorithmIdentifierError); + + const invalidOids = ['2.16.840.1.101.3.4.3.18 ', '+2.16.840.1.101.3.4.3.18', '1.40.3']; + for (const invalidOid of invalidOids) { + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid: invalidOid }), + UnknownIdentifierError, + ); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('OID'); + expect(error.identifierValue).toBe(invalidOid); + } + }); +});