diff --git a/packages/pq-algorithm-id/ts/package.json b/packages/pq-algorithm-id/ts/package.json index 12bb509..30cd996 100644 --- a/packages/pq-algorithm-id/ts/package.json +++ b/packages/pq-algorithm-id/ts/package.json @@ -9,7 +9,8 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc -b", + "test": "bun test", "prepublishOnly": "npm run build" }, "keywords": [ @@ -19,6 +20,9 @@ ], "author": "", "license": "MIT", + "dependencies": { + "pq-oid": "1.0.2" + }, "devDependencies": { "typescript": "^5.0.0" } diff --git a/packages/pq-algorithm-id/ts/src/errors.ts b/packages/pq-algorithm-id/ts/src/errors.ts new file mode 100644 index 0000000..59a2a20 --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/errors.ts @@ -0,0 +1,50 @@ +import type { MappingTarget } from './types'; + +export type AlgorithmIdentifierErrorCode = + | 'UNKNOWN_ALGORITHM' + | 'UNKNOWN_IDENTIFIER' + | 'UNSUPPORTED_MAPPING'; + +export class AlgorithmIdentifierError extends Error { + readonly code: AlgorithmIdentifierErrorCode; + + constructor(code: AlgorithmIdentifierErrorCode, message: string) { + super(message); + this.name = new.target.name; + this.code = code; + } +} + +export class UnknownAlgorithmError extends AlgorithmIdentifierError { + readonly algorithm: string; + + constructor(algorithm: string) { + super('UNKNOWN_ALGORITHM', `Unknown algorithm '${algorithm}'.`); + this.algorithm = algorithm; + } +} + +export class UnknownIdentifierError extends AlgorithmIdentifierError { + readonly identifierType: MappingTarget; + readonly identifierValue: string | number; + + constructor(identifierType: MappingTarget, identifierValue: string | number) { + super( + 'UNKNOWN_IDENTIFIER', + `Unknown ${identifierType} identifier '${String(identifierValue)}'.`, + ); + this.identifierType = identifierType; + this.identifierValue = identifierValue; + } +} + +export class UnsupportedMappingError extends AlgorithmIdentifierError { + readonly mapping: MappingTarget; + readonly algorithm: string; + + constructor(mapping: MappingTarget, algorithm: string) { + super('UNSUPPORTED_MAPPING', `Algorithm '${algorithm}' does not support ${mapping} mapping.`); + this.mapping = mapping; + this.algorithm = algorithm; + } +} diff --git a/packages/pq-algorithm-id/ts/src/index.ts b/packages/pq-algorithm-id/ts/src/index.ts index d1278ce..692c7e7 100644 --- a/packages/pq-algorithm-id/ts/src/index.ts +++ b/packages/pq-algorithm-id/ts/src/index.ts @@ -1,4 +1,22 @@ -// pq-algorithm-id - Algorithm identifier mappings (JOSE, COSE, X.509) -// Implementation coming soon - -export {}; +export { + AlgorithmIdentifierError, + type AlgorithmIdentifierErrorCode, + UnknownAlgorithmError, + UnknownIdentifierError, + UnsupportedMappingError, +} from './errors'; +export { + deriveOidFromName, + getIdentifierRecord, + listIdentifierRecords, + listRegistryAlgorithmNames, +} from './registry'; +export type { + CoseIdentifier, + IdentifierRecord, + IdentifierRecordMap, + JoseIdentifier, + MappingTarget, + X509ParametersEncoding, + X509ParametersPolicy, +} from './types'; diff --git a/packages/pq-algorithm-id/ts/src/registry.ts b/packages/pq-algorithm-id/ts/src/registry.ts new file mode 100644 index 0000000..feaa06b --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/registry.ts @@ -0,0 +1,88 @@ +import type { AlgorithmName } from 'pq-oid'; +import { OID } from 'pq-oid'; +import { UnknownAlgorithmError } from './errors'; +import type { + CoseIdentifier, + IdentifierRecord, + IdentifierRecordMap, + JoseIdentifier, + X509ParametersPolicy, +} from './types'; + +const DEFAULT_X509_PARAMETERS_POLICY: Readonly = Object.freeze({ + defaultParametersEncoding: 'absent', + acceptNull: true, + acceptAbsent: true, +}); + +const JOSE_IDENTIFIERS: Readonly>> = Object.freeze({ + 'ML-DSA-44': 'ML-DSA-44', + 'ML-DSA-65': 'ML-DSA-65', + 'ML-DSA-87': 'ML-DSA-87', +}); + +const COSE_IDENTIFIERS: Readonly>> = Object.freeze({ + 'ML-DSA-44': -48, + 'ML-DSA-65': -49, + 'ML-DSA-87': -50, +}); + +const ALGORITHM_NAMES = [ + 'ML-KEM-512', + 'ML-KEM-768', + 'ML-KEM-1024', + 'ML-DSA-44', + 'ML-DSA-65', + 'ML-DSA-87', + 'SLH-DSA-SHA2-128s', + 'SLH-DSA-SHA2-128f', + 'SLH-DSA-SHA2-192s', + 'SLH-DSA-SHA2-192f', + 'SLH-DSA-SHA2-256s', + 'SLH-DSA-SHA2-256f', + 'SLH-DSA-SHAKE-128s', + 'SLH-DSA-SHAKE-128f', + 'SLH-DSA-SHAKE-192s', + 'SLH-DSA-SHAKE-192f', + 'SLH-DSA-SHAKE-256s', + 'SLH-DSA-SHAKE-256f', +] as const satisfies ReadonlyArray; + +const IDENTIFIER_RECORDS = Object.freeze( + ALGORITHM_NAMES.map((name) => + Object.freeze({ + name, + jose: JOSE_IDENTIFIERS[name], + cose: COSE_IDENTIFIERS[name], + x509: DEFAULT_X509_PARAMETERS_POLICY, + } satisfies IdentifierRecord), + ), +); + +const IDENTIFIER_RECORDS_BY_NAME: IdentifierRecordMap = Object.freeze( + Object.fromEntries( + IDENTIFIER_RECORDS.map( + (record) => [record.name, record] satisfies [AlgorithmName, IdentifierRecord], + ), + ) as Record, +); + +export function listRegistryAlgorithmNames(): readonly AlgorithmName[] { + return ALGORITHM_NAMES; +} + +export function listIdentifierRecords(): readonly IdentifierRecord[] { + return IDENTIFIER_RECORDS; +} + +export function getIdentifierRecord(name: AlgorithmName): IdentifierRecord { + const record = IDENTIFIER_RECORDS_BY_NAME[name]; + if (record === undefined) { + throw new UnknownAlgorithmError(name); + } + return record; +} + +export function deriveOidFromName(name: AlgorithmName): string { + return OID.fromName(name); +} diff --git a/packages/pq-algorithm-id/ts/src/types.ts b/packages/pq-algorithm-id/ts/src/types.ts new file mode 100644 index 0000000..3efd062 --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/types.ts @@ -0,0 +1,26 @@ +import type { AlgorithmName } from 'pq-oid'; + +export type { AlgorithmName }; + +export type JoseIdentifier = 'ML-DSA-44' | 'ML-DSA-65' | 'ML-DSA-87'; + +export type CoseIdentifier = -48 | -49 | -50; + +export type MappingTarget = 'OID' | 'JOSE' | 'COSE' | 'X509'; + +export type X509ParametersEncoding = 'absent' | 'null'; + +export interface X509ParametersPolicy { + defaultParametersEncoding: X509ParametersEncoding; + acceptNull: boolean; + acceptAbsent: boolean; +} + +export interface IdentifierRecord { + name: AlgorithmName; + jose?: JoseIdentifier; + cose?: CoseIdentifier; + x509: X509ParametersPolicy; +} + +export type IdentifierRecordMap = Readonly>; diff --git a/packages/pq-algorithm-id/ts/tests/pq-oid-contract.test.ts b/packages/pq-algorithm-id/ts/tests/pq-oid-contract.test.ts new file mode 100644 index 0000000..ad2c38f --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/pq-oid-contract.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import { Algorithm, OID } from 'pq-oid'; +import { listRegistryAlgorithmNames } from '../src/registry'; + +describe('pq-oid contract', () => { + test('OID.fromName and OID.toName are callable', () => { + const sampleName = listRegistryAlgorithmNames()[0]; + const oid = OID.fromName(sampleName); + expect(typeof oid).toBe('string'); + expect(OID.toName(oid)).toBe(sampleName); + }); + + test('Algorithm.list remains callable for compatibility', () => { + const names = Algorithm.list(); + expect(Array.isArray(names)).toBe(true); + expect(names.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts b/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts new file mode 100644 index 0000000..2b322eb --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test'; +import { listIdentifierRecords, listRegistryAlgorithmNames } from '../src/registry'; + +const VALID_JOSE = new Set(['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']); +const VALID_COSE = new Set([-48, -49, -50]); + +function hasDirectOidLiteral(value: unknown): boolean { + return typeof value === 'string' && /^\d+(?:\.\d+)+$/.test(value); +} + +describe('registry invariants', () => { + test('every record has required X.509 policy fields', () => { + for (const record of listIdentifierRecords()) { + expect(record.x509.defaultParametersEncoding).toBe('absent'); + expect(record.x509.acceptNull).toBe(true); + expect(record.x509.acceptAbsent).toBe(true); + } + }); + + test('registry records do not store direct OID literals', () => { + for (const record of listIdentifierRecords()) { + for (const value of Object.values(record)) { + expect(hasDirectOidLiteral(value)).toBe(false); + } + for (const value of Object.values(record.x509)) { + expect(hasDirectOidLiteral(value)).toBe(false); + } + } + }); + + test('JOSE and COSE entries are either absent or valid typed values', () => { + for (const name of listRegistryAlgorithmNames()) { + const record = listIdentifierRecords().find((candidate) => candidate.name === name); + expect(record).toBeDefined(); + if (!record) { + continue; + } + + if (record.jose !== undefined) { + expect(VALID_JOSE.has(record.jose)).toBe(true); + } + if (record.cose !== undefined) { + expect(Number.isInteger(record.cose)).toBe(true); + expect(VALID_COSE.has(record.cose)).toBe(true); + } + } + }); +}); diff --git a/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts b/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts new file mode 100644 index 0000000..89ee8bf --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test'; +import { Algorithm, OID } from 'pq-oid'; +import { deriveOidFromName, listRegistryAlgorithmNames } from '../src/registry'; + +function asSortedSet(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +describe('registry parity', () => { + test('registry names match pq-oid algorithm names', () => { + const registryNames = asSortedSet(listRegistryAlgorithmNames()); + const canonicalNames = asSortedSet(Algorithm.list()); + expect(registryNames).toEqual(canonicalNames); + }); + + test('every registry algorithm round-trips through OID.fromName and OID.toName', () => { + for (const name of listRegistryAlgorithmNames()) { + const oid = deriveOidFromName(name); + expect(oid).toBe(OID.fromName(name)); + expect(OID.toName(oid)).toBe(name); + } + }); +}); diff --git a/packages/pq-algorithm-id/ts/tsconfig.json b/packages/pq-algorithm-id/ts/tsconfig.json index 0e1d7ae..f19ea47 100644 --- a/packages/pq-algorithm-id/ts/tsconfig.json +++ b/packages/pq-algorithm-id/ts/tsconfig.json @@ -4,11 +4,13 @@ "module": "ESNext", "moduleResolution": "bundler", "declaration": true, + "composite": true, "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["src"], + "references": [{ "path": "../../pq-oid/ts" }] }