Skip to content

Commit 66eff4b

Browse files
committed
feat(pq-algorithm-id/ts): phase 1 - package foundation and canonical registry (ENG-1914)
1 parent 85ec5da commit 66eff4b

9 files changed

Lines changed: 283 additions & 6 deletions

File tree

packages/pq-algorithm-id/ts/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"dist"
1010
],
1111
"scripts": {
12-
"build": "tsc",
12+
"build": "tsc -b",
13+
"test": "bun test",
1314
"prepublishOnly": "npm run build"
1415
},
1516
"keywords": [
@@ -19,6 +20,9 @@
1920
],
2021
"author": "",
2122
"license": "MIT",
23+
"dependencies": {
24+
"pq-oid": "1.0.2"
25+
},
2226
"devDependencies": {
2327
"typescript": "^5.0.0"
2428
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { MappingTarget } from './types';
2+
3+
export type AlgorithmIdentifierErrorCode =
4+
| 'UNKNOWN_ALGORITHM'
5+
| 'UNKNOWN_IDENTIFIER'
6+
| 'UNSUPPORTED_MAPPING';
7+
8+
export class AlgorithmIdentifierError extends Error {
9+
readonly code: AlgorithmIdentifierErrorCode;
10+
11+
constructor(code: AlgorithmIdentifierErrorCode, message: string) {
12+
super(message);
13+
this.name = new.target.name;
14+
this.code = code;
15+
}
16+
}
17+
18+
export class UnknownAlgorithmError extends AlgorithmIdentifierError {
19+
readonly algorithm: string;
20+
21+
constructor(algorithm: string) {
22+
super('UNKNOWN_ALGORITHM', `Unknown algorithm '${algorithm}'.`);
23+
this.algorithm = algorithm;
24+
}
25+
}
26+
27+
export class UnknownIdentifierError extends AlgorithmIdentifierError {
28+
readonly identifierType: MappingTarget;
29+
readonly identifierValue: string | number;
30+
31+
constructor(identifierType: MappingTarget, identifierValue: string | number) {
32+
super(
33+
'UNKNOWN_IDENTIFIER',
34+
`Unknown ${identifierType} identifier '${String(identifierValue)}'.`,
35+
);
36+
this.identifierType = identifierType;
37+
this.identifierValue = identifierValue;
38+
}
39+
}
40+
41+
export class UnsupportedMappingError extends AlgorithmIdentifierError {
42+
readonly mapping: MappingTarget;
43+
readonly algorithm: string;
44+
45+
constructor(mapping: MappingTarget, algorithm: string) {
46+
super('UNSUPPORTED_MAPPING', `Algorithm '${algorithm}' does not support ${mapping} mapping.`);
47+
this.mapping = mapping;
48+
this.algorithm = algorithm;
49+
}
50+
}
Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1-
// pq-algorithm-id - Algorithm identifier mappings (JOSE, COSE, X.509)
2-
// Implementation coming soon
3-
4-
export {};
1+
export {
2+
AlgorithmIdentifierError,
3+
type AlgorithmIdentifierErrorCode,
4+
UnknownAlgorithmError,
5+
UnknownIdentifierError,
6+
UnsupportedMappingError,
7+
} from './errors';
8+
export {
9+
deriveOidFromName,
10+
getIdentifierRecord,
11+
listIdentifierRecords,
12+
listRegistryAlgorithmNames,
13+
} from './registry';
14+
export type {
15+
CoseIdentifier,
16+
IdentifierRecord,
17+
IdentifierRecordMap,
18+
JoseIdentifier,
19+
MappingTarget,
20+
X509ParametersEncoding,
21+
X509ParametersPolicy,
22+
} from './types';
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { AlgorithmName } from 'pq-oid';
2+
import { OID } from 'pq-oid';
3+
import { UnknownAlgorithmError } from './errors';
4+
import type {
5+
CoseIdentifier,
6+
IdentifierRecord,
7+
IdentifierRecordMap,
8+
JoseIdentifier,
9+
X509ParametersPolicy,
10+
} from './types';
11+
12+
const DEFAULT_X509_PARAMETERS_POLICY: Readonly<X509ParametersPolicy> = Object.freeze({
13+
defaultParametersEncoding: 'absent',
14+
acceptNull: true,
15+
acceptAbsent: true,
16+
});
17+
18+
const JOSE_IDENTIFIERS: Readonly<Partial<Record<AlgorithmName, JoseIdentifier>>> = Object.freeze({
19+
'ML-DSA-44': 'ML-DSA-44',
20+
'ML-DSA-65': 'ML-DSA-65',
21+
'ML-DSA-87': 'ML-DSA-87',
22+
});
23+
24+
const COSE_IDENTIFIERS: Readonly<Partial<Record<AlgorithmName, CoseIdentifier>>> = Object.freeze({
25+
'ML-DSA-44': -48,
26+
'ML-DSA-65': -49,
27+
'ML-DSA-87': -50,
28+
});
29+
30+
const ALGORITHM_NAMES = [
31+
'ML-KEM-512',
32+
'ML-KEM-768',
33+
'ML-KEM-1024',
34+
'ML-DSA-44',
35+
'ML-DSA-65',
36+
'ML-DSA-87',
37+
'SLH-DSA-SHA2-128s',
38+
'SLH-DSA-SHA2-128f',
39+
'SLH-DSA-SHA2-192s',
40+
'SLH-DSA-SHA2-192f',
41+
'SLH-DSA-SHA2-256s',
42+
'SLH-DSA-SHA2-256f',
43+
'SLH-DSA-SHAKE-128s',
44+
'SLH-DSA-SHAKE-128f',
45+
'SLH-DSA-SHAKE-192s',
46+
'SLH-DSA-SHAKE-192f',
47+
'SLH-DSA-SHAKE-256s',
48+
'SLH-DSA-SHAKE-256f',
49+
] as const satisfies ReadonlyArray<AlgorithmName>;
50+
51+
const IDENTIFIER_RECORDS = Object.freeze(
52+
ALGORITHM_NAMES.map((name) =>
53+
Object.freeze({
54+
name,
55+
jose: JOSE_IDENTIFIERS[name],
56+
cose: COSE_IDENTIFIERS[name],
57+
x509: DEFAULT_X509_PARAMETERS_POLICY,
58+
} satisfies IdentifierRecord),
59+
),
60+
);
61+
62+
const IDENTIFIER_RECORDS_BY_NAME: IdentifierRecordMap = Object.freeze(
63+
Object.fromEntries(
64+
IDENTIFIER_RECORDS.map(
65+
(record) => [record.name, record] satisfies [AlgorithmName, IdentifierRecord],
66+
),
67+
) as Record<AlgorithmName, IdentifierRecord>,
68+
);
69+
70+
export function listRegistryAlgorithmNames(): readonly AlgorithmName[] {
71+
return ALGORITHM_NAMES;
72+
}
73+
74+
export function listIdentifierRecords(): readonly IdentifierRecord[] {
75+
return IDENTIFIER_RECORDS;
76+
}
77+
78+
export function getIdentifierRecord(name: AlgorithmName): IdentifierRecord {
79+
const record = IDENTIFIER_RECORDS_BY_NAME[name];
80+
if (record === undefined) {
81+
throw new UnknownAlgorithmError(name);
82+
}
83+
return record;
84+
}
85+
86+
export function deriveOidFromName(name: AlgorithmName): string {
87+
return OID.fromName(name);
88+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { AlgorithmName } from 'pq-oid';
2+
3+
export type { AlgorithmName };
4+
5+
export type JoseIdentifier = 'ML-DSA-44' | 'ML-DSA-65' | 'ML-DSA-87';
6+
7+
export type CoseIdentifier = -48 | -49 | -50;
8+
9+
export type MappingTarget = 'OID' | 'JOSE' | 'COSE' | 'X509';
10+
11+
export type X509ParametersEncoding = 'absent' | 'null';
12+
13+
export interface X509ParametersPolicy {
14+
defaultParametersEncoding: X509ParametersEncoding;
15+
acceptNull: boolean;
16+
acceptAbsent: boolean;
17+
}
18+
19+
export interface IdentifierRecord {
20+
name: AlgorithmName;
21+
jose?: JoseIdentifier;
22+
cose?: CoseIdentifier;
23+
x509: X509ParametersPolicy;
24+
}
25+
26+
export type IdentifierRecordMap = Readonly<Record<AlgorithmName, IdentifierRecord>>;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { Algorithm, OID } from 'pq-oid';
3+
import { listRegistryAlgorithmNames } from '../src/registry';
4+
5+
describe('pq-oid contract', () => {
6+
test('OID.fromName and OID.toName are callable', () => {
7+
const sampleName = listRegistryAlgorithmNames()[0];
8+
const oid = OID.fromName(sampleName);
9+
expect(typeof oid).toBe('string');
10+
expect(OID.toName(oid)).toBe(sampleName);
11+
});
12+
13+
test('Algorithm.list remains callable for compatibility', () => {
14+
const names = Algorithm.list();
15+
expect(Array.isArray(names)).toBe(true);
16+
expect(names.length).toBeGreaterThan(0);
17+
});
18+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { listIdentifierRecords, listRegistryAlgorithmNames } from '../src/registry';
3+
4+
const VALID_JOSE = new Set(['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
5+
const VALID_COSE = new Set([-48, -49, -50]);
6+
7+
function hasDirectOidLiteral(value: unknown): boolean {
8+
return typeof value === 'string' && /^\d+(?:\.\d+)+$/.test(value);
9+
}
10+
11+
describe('registry invariants', () => {
12+
test('every record has required X.509 policy fields', () => {
13+
for (const record of listIdentifierRecords()) {
14+
expect(record.x509.defaultParametersEncoding).toBe('absent');
15+
expect(record.x509.acceptNull).toBe(true);
16+
expect(record.x509.acceptAbsent).toBe(true);
17+
}
18+
});
19+
20+
test('registry records do not store direct OID literals', () => {
21+
for (const record of listIdentifierRecords()) {
22+
for (const value of Object.values(record)) {
23+
expect(hasDirectOidLiteral(value)).toBe(false);
24+
}
25+
for (const value of Object.values(record.x509)) {
26+
expect(hasDirectOidLiteral(value)).toBe(false);
27+
}
28+
}
29+
});
30+
31+
test('JOSE and COSE entries are either absent or valid typed values', () => {
32+
for (const name of listRegistryAlgorithmNames()) {
33+
const record = listIdentifierRecords().find((candidate) => candidate.name === name);
34+
expect(record).toBeDefined();
35+
if (!record) {
36+
continue;
37+
}
38+
39+
if (record.jose !== undefined) {
40+
expect(VALID_JOSE.has(record.jose)).toBe(true);
41+
}
42+
if (record.cose !== undefined) {
43+
expect(Number.isInteger(record.cose)).toBe(true);
44+
expect(VALID_COSE.has(record.cose)).toBe(true);
45+
}
46+
}
47+
});
48+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { Algorithm, OID } from 'pq-oid';
3+
import { deriveOidFromName, listRegistryAlgorithmNames } from '../src/registry';
4+
5+
function asSortedSet(values: readonly string[]): string[] {
6+
return [...new Set(values)].sort();
7+
}
8+
9+
describe('registry parity', () => {
10+
test('registry names match pq-oid algorithm names', () => {
11+
const registryNames = asSortedSet(listRegistryAlgorithmNames());
12+
const canonicalNames = asSortedSet(Algorithm.list());
13+
expect(registryNames).toEqual(canonicalNames);
14+
});
15+
16+
test('every registry algorithm round-trips through OID.fromName and OID.toName', () => {
17+
for (const name of listRegistryAlgorithmNames()) {
18+
const oid = deriveOidFromName(name);
19+
expect(oid).toBe(OID.fromName(name));
20+
expect(OID.toName(oid)).toBe(name);
21+
}
22+
});
23+
});

packages/pq-algorithm-id/ts/tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
"module": "ESNext",
55
"moduleResolution": "bundler",
66
"declaration": true,
7+
"composite": true,
78
"outDir": "./dist",
89
"rootDir": "./src",
910
"strict": true,
1011
"esModuleInterop": true,
1112
"skipLibCheck": true
1213
},
13-
"include": ["src"]
14+
"include": ["src"],
15+
"references": [{ "path": "../../pq-oid/ts" }]
1416
}

0 commit comments

Comments
 (0)