Skip to content
Merged
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
6 changes: 5 additions & 1 deletion packages/pq-algorithm-id/ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"dist"
],
"scripts": {
"build": "tsc",
"build": "tsc -b",
"test": "bun test",
"prepublishOnly": "npm run build"
},
"keywords": [
Expand All @@ -19,6 +20,9 @@
],
"author": "",
"license": "MIT",
"dependencies": {
"pq-oid": "1.0.2"
Comment thread
eacet marked this conversation as resolved.
},
"devDependencies": {
"typescript": "^5.0.0"
}
Expand Down
50 changes: 50 additions & 0 deletions packages/pq-algorithm-id/ts/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
26 changes: 22 additions & 4 deletions packages/pq-algorithm-id/ts/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
eacet marked this conversation as resolved.
88 changes: 88 additions & 0 deletions packages/pq-algorithm-id/ts/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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<X509ParametersPolicy> = Object.freeze({
defaultParametersEncoding: 'absent',
acceptNull: true,
acceptAbsent: true,
});

const JOSE_IDENTIFIERS: Readonly<Partial<Record<AlgorithmName, JoseIdentifier>>> = Object.freeze({
'ML-DSA-44': 'ML-DSA-44',
'ML-DSA-65': 'ML-DSA-65',
'ML-DSA-87': 'ML-DSA-87',
});

const COSE_IDENTIFIERS: Readonly<Partial<Record<AlgorithmName, CoseIdentifier>>> = 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<AlgorithmName>;

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<AlgorithmName, IdentifierRecord>,
);
Comment thread
eacet marked this conversation as resolved.

export function listRegistryAlgorithmNames(): readonly AlgorithmName[] {
return ALGORITHM_NAMES;
Comment thread
eacet marked this conversation as resolved.
}

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);
}
Comment thread
eacet marked this conversation as resolved.
26 changes: 26 additions & 0 deletions packages/pq-algorithm-id/ts/src/types.ts
Original file line number Diff line number Diff line change
@@ -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<Record<AlgorithmName, IdentifierRecord>>;
Comment thread
eacet marked this conversation as resolved.
18 changes: 18 additions & 0 deletions packages/pq-algorithm-id/ts/tests/pq-oid-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
48 changes: 48 additions & 0 deletions packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
Comment thread
eacet marked this conversation as resolved.
});
23 changes: 23 additions & 0 deletions packages/pq-algorithm-id/ts/tests/registry-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
4 changes: 3 additions & 1 deletion packages/pq-algorithm-id/ts/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}
Loading