Skip to content

Commit 3f96040

Browse files
committed
feat(pq-algorithm-id/ts): phase 2 - implement conversion and x509 mapping apis (ENG-1915)
1 parent 3f64865 commit 3f96040

6 files changed

Lines changed: 459 additions & 6 deletions

File tree

.claude/skills/implement-plan-linear/SKILL.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ How should I proceed?
3939
For each phase, execute in this order:
4040

4141
1. Implement all code changes for the phase.
42-
2. Update plan checkboxes for completed implementation work.
43-
3. Run automated acceptance criteria.
44-
4. Pause and wait for explicit manual verification confirmation.
45-
5. After confirmation, run Graphite stack commands.
46-
6. Update Linear issue status/comments with outcome and links.
42+
2. Run Biome autofix/check for touched TypeScript/JavaScript/JSON files and fix issues until clean.
43+
3. Update plan checkboxes for completed implementation work.
44+
4. Run automated acceptance criteria.
45+
5. Pause and wait for explicit manual verification confirmation.
46+
6. After confirmation, run Graphite stack commands.
47+
7. Update Linear issue status/comments with outcome and links.
4748

4849
## Implementation Philosophy
4950

@@ -55,6 +56,7 @@ For each phase, execute in this order:
5556
## Acceptance Criteria
5657

5758
- Run the exact commands required by the plan and repository acceptance checklist.
59+
- For TypeScript/JavaScript/JSON changes, always run Biome with autofix first (for example `bunx biome check --write <paths>`), then run Biome again without `--write` to confirm clean output.
5860
- If phase-specific commands are not provided, default to the repo acceptance criteria in `CLAUDE.md`.
5961
- Do not continue to Graphite or final Linear state updates until automated checks pass.
6062
- 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
151153
For each completed phase, report:
152154

153155
- files changed
156+
- Biome commands run and pass/fail result
154157
- acceptance commands and pass/fail result
155158
- manual verification confirmation state
156159
- Linear updates performed
@@ -167,4 +170,4 @@ For each completed phase, report:
167170
- Re-read relevant plan and code before assuming root cause.
168171
- Consider whether the codebase evolved since the plan was written.
169172
- Surface blockers with exact command errors or plan/code mismatches.
170-
- Ask one focused question to unblock.
173+
- Ask one focused question to unblock.

packages/pq-algorithm-id/ts/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
UnknownIdentifierError,
66
UnsupportedMappingError,
77
} from './errors';
8+
export { fromCose, fromJose, fromOid, toCose, toJose, toOid } from './lookup';
89
export {
910
deriveOidFromName,
1011
getIdentifierRecord,
@@ -20,3 +21,11 @@ export type {
2021
X509ParametersEncoding,
2122
X509ParametersPolicy,
2223
} from './types';
24+
export {
25+
fromX509AlgorithmIdentifier,
26+
toX509AlgorithmIdentifier,
27+
type X509AlgorithmIdentifier,
28+
type X509AlgorithmIdentifierInput,
29+
type X509AlgorithmIdentifierOptions,
30+
type X509NormalizedParameters,
31+
} from './x509';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { OID } from 'pq-oid';
2+
import { UnknownAlgorithmError, UnknownIdentifierError, UnsupportedMappingError } from './errors';
3+
import { getIdentifierRecord, listIdentifierRecords } from './registry';
4+
import type { AlgorithmName, CoseIdentifier, JoseIdentifier } from './types';
5+
6+
const JOSE_TO_NAME = new Map<JoseIdentifier, AlgorithmName>();
7+
const COSE_TO_NAME = new Map<CoseIdentifier, AlgorithmName>();
8+
9+
for (const record of listIdentifierRecords()) {
10+
if (record.jose !== undefined) {
11+
JOSE_TO_NAME.set(record.jose, record.name);
12+
}
13+
if (record.cose !== undefined) {
14+
COSE_TO_NAME.set(record.cose, record.name);
15+
}
16+
}
17+
18+
function isCanonicalOid(oid: string): boolean {
19+
if (oid.length === 0 || oid.trim() !== oid) {
20+
return false;
21+
}
22+
23+
if (!/^\d+(?:\.\d+)+$/.test(oid)) {
24+
return false;
25+
}
26+
27+
const arcs = oid.split('.');
28+
if (arcs.some((arc) => arc.length > 1 && arc.startsWith('0'))) {
29+
return false;
30+
}
31+
32+
const firstArc = Number(arcs[0]);
33+
if (!Number.isInteger(firstArc) || firstArc < 0 || firstArc > 2) {
34+
return false;
35+
}
36+
37+
const secondArc = Number(arcs[1]);
38+
if (!Number.isInteger(secondArc)) {
39+
return false;
40+
}
41+
42+
if ((firstArc === 0 || firstArc === 1) && (secondArc < 0 || secondArc > 39)) {
43+
return false;
44+
}
45+
46+
return true;
47+
}
48+
49+
export function toOid(name: AlgorithmName): string {
50+
try {
51+
return OID.fromName(getIdentifierRecord(name).name);
52+
} catch {
53+
throw new UnknownAlgorithmError(name);
54+
}
55+
}
56+
57+
export function fromOid(oid: string): AlgorithmName {
58+
if (!isCanonicalOid(oid)) {
59+
throw new UnknownIdentifierError('OID', oid);
60+
}
61+
62+
try {
63+
const name = OID.toName(oid);
64+
getIdentifierRecord(name);
65+
return name;
66+
} catch {
67+
throw new UnknownIdentifierError('OID', oid);
68+
}
69+
}
70+
71+
export function toJose(name: AlgorithmName): JoseIdentifier {
72+
const record = getIdentifierRecord(name);
73+
if (record.jose === undefined) {
74+
throw new UnsupportedMappingError('JOSE', name);
75+
}
76+
return record.jose;
77+
}
78+
79+
export function fromJose(jose: string): AlgorithmName {
80+
const name = JOSE_TO_NAME.get(jose as JoseIdentifier);
81+
if (name === undefined) {
82+
throw new UnknownIdentifierError('JOSE', jose);
83+
}
84+
return name;
85+
}
86+
87+
export function toCose(name: AlgorithmName): CoseIdentifier {
88+
const record = getIdentifierRecord(name);
89+
if (record.cose === undefined) {
90+
throw new UnsupportedMappingError('COSE', name);
91+
}
92+
return record.cose;
93+
}
94+
95+
export function fromCose(cose: number): AlgorithmName {
96+
if (!Number.isFinite(cose) || !Number.isInteger(cose)) {
97+
throw new UnknownIdentifierError('COSE', cose);
98+
}
99+
100+
const name = COSE_TO_NAME.get(cose as CoseIdentifier);
101+
if (name === undefined) {
102+
throw new UnknownIdentifierError('COSE', cose);
103+
}
104+
return name;
105+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
AlgorithmIdentifierError,
3+
UnknownIdentifierError,
4+
UnsupportedMappingError,
5+
} from './errors';
6+
import { fromOid, toOid } from './lookup';
7+
import { getIdentifierRecord } from './registry';
8+
import type { AlgorithmName, X509ParametersEncoding } from './types';
9+
10+
export type X509NormalizedParameters = { kind: 'absent' } | { kind: 'null' };
11+
12+
export interface X509AlgorithmIdentifier {
13+
oid: string;
14+
parameters: X509NormalizedParameters;
15+
}
16+
17+
export interface X509AlgorithmIdentifierInput {
18+
oid: string;
19+
parameters?: unknown;
20+
}
21+
22+
export interface X509AlgorithmIdentifierOptions {
23+
parametersEncoding?: X509ParametersEncoding;
24+
}
25+
26+
const ABSENT_PARAMETERS: X509NormalizedParameters = Object.freeze({ kind: 'absent' });
27+
const NULL_PARAMETERS: X509NormalizedParameters = Object.freeze({ kind: 'null' });
28+
29+
function validateParametersForAlgorithm(
30+
name: AlgorithmName,
31+
encoding: X509ParametersEncoding,
32+
): void {
33+
const policy = getIdentifierRecord(name).x509;
34+
if (encoding === 'absent' && !policy.acceptAbsent) {
35+
throw new UnsupportedMappingError('X509', name);
36+
}
37+
if (encoding === 'null' && !policy.acceptNull) {
38+
throw new UnsupportedMappingError('X509', name);
39+
}
40+
}
41+
42+
function normalizeParameters(input: unknown): X509NormalizedParameters {
43+
if (input === undefined) {
44+
return ABSENT_PARAMETERS;
45+
}
46+
47+
if (input === null) {
48+
return NULL_PARAMETERS;
49+
}
50+
51+
if (typeof input === 'object' && input !== null) {
52+
const kind = (input as { kind?: unknown }).kind;
53+
if (kind === 'absent') {
54+
return ABSENT_PARAMETERS;
55+
}
56+
if (kind === 'null') {
57+
return NULL_PARAMETERS;
58+
}
59+
}
60+
61+
throw new AlgorithmIdentifierError(
62+
'UNKNOWN_IDENTIFIER',
63+
"Unknown X509 parameters. Expected undefined, null, { kind: 'absent' }, or { kind: 'null' }.",
64+
);
65+
}
66+
67+
export function toX509AlgorithmIdentifier(
68+
name: AlgorithmName,
69+
options?: X509AlgorithmIdentifierOptions,
70+
): X509AlgorithmIdentifier {
71+
const policy = getIdentifierRecord(name).x509;
72+
const parametersEncoding = options?.parametersEncoding ?? policy.defaultParametersEncoding;
73+
validateParametersForAlgorithm(name, parametersEncoding);
74+
75+
return {
76+
oid: toOid(name),
77+
parameters: parametersEncoding === 'null' ? NULL_PARAMETERS : ABSENT_PARAMETERS,
78+
};
79+
}
80+
81+
export function fromX509AlgorithmIdentifier(
82+
input: X509AlgorithmIdentifierInput,
83+
): X509AlgorithmIdentifier {
84+
if (typeof input !== 'object' || input === null) {
85+
throw new AlgorithmIdentifierError('UNKNOWN_IDENTIFIER', 'X509 input must be an object.');
86+
}
87+
88+
if (typeof input.oid !== 'string') {
89+
throw new UnknownIdentifierError('OID', String(input.oid));
90+
}
91+
92+
const name = fromOid(input.oid);
93+
const normalizedParameters = normalizeParameters(input.parameters);
94+
validateParametersForAlgorithm(name, normalizedParameters.kind);
95+
96+
return {
97+
oid: toOid(name),
98+
parameters: normalizedParameters,
99+
};
100+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { OID } from 'pq-oid';
3+
import { UnknownIdentifierError, UnsupportedMappingError } from '../src/errors';
4+
import { fromCose, fromJose, fromOid, toCose, toJose, toOid } from '../src/lookup';
5+
import { listIdentifierRecords, listRegistryAlgorithmNames } from '../src/registry';
6+
7+
function expectError<T extends Error>(
8+
fn: () => unknown,
9+
errorType: new (...args: never[]) => T,
10+
): T {
11+
try {
12+
fn();
13+
} catch (error) {
14+
expect(error).toBeInstanceOf(errorType);
15+
return error as T;
16+
}
17+
throw new Error('Expected function to throw.');
18+
}
19+
20+
describe('lookup - OID mapping', () => {
21+
test('round-trip name <-> oid for all supported algorithms with fromOid(toOid(...))', () => {
22+
for (const name of listRegistryAlgorithmNames()) {
23+
expect(toOid(name)).toBe(OID.fromName(name));
24+
expect(fromOid(toOid(name))).toBe(name);
25+
}
26+
});
27+
28+
test('fromOid enforces canonical dotted format and strict arc rules', () => {
29+
const invalidOids = [
30+
'2.16.840.1.101.3.4.3.18 ',
31+
' 2.16.840.1.101.3.4.3.18',
32+
'2..16.840.1.101.3.4.3.18',
33+
'2.16.840.1.101.3.4.3.',
34+
'2.16.840.1.101.3.4.3.1a',
35+
'+2.16.840.1.101.3.4.3.18',
36+
'2.-16.840.1.101.3.4.3.18',
37+
'2.16.840.1.101.3.4.3.018',
38+
'03.16.840.1.101.3.4.3.18',
39+
'3.16.840.1.101.3.4.3.18',
40+
'1.40.840.1.101.3.4.3.18',
41+
];
42+
43+
for (const oid of invalidOids) {
44+
const error = expectError(() => fromOid(oid), UnknownIdentifierError);
45+
expect(error.code).toBe('UNKNOWN_IDENTIFIER');
46+
expect(error.identifierType).toBe('OID');
47+
expect(error.identifierValue).toBe(oid);
48+
}
49+
});
50+
51+
test('fromOid rejects unknown canonical OIDs', () => {
52+
const unknownOid = '2.16.840.1.101.3.4.3.255';
53+
const error = expectError(() => fromOid(unknownOid), UnknownIdentifierError);
54+
expect(error.code).toBe('UNKNOWN_IDENTIFIER');
55+
expect(error.identifierType).toBe('OID');
56+
});
57+
});
58+
59+
describe('lookup - JOSE mapping', () => {
60+
test('ML-DSA JOSE values stay parity-compatible with pq-oid', () => {
61+
const names = ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'] as const;
62+
for (const name of names) {
63+
expect(toJose(name)).toBe(OID.toJOSE(name));
64+
expect(fromJose(OID.toJOSE(name))).toBe(name);
65+
}
66+
});
67+
68+
test('unsupported JOSE mapping throws actionable typed error', () => {
69+
const error = expectError(() => toJose('ML-KEM-512'), UnsupportedMappingError);
70+
expect(error.code).toBe('UNSUPPORTED_MAPPING');
71+
expect(error.mapping).toBe('JOSE');
72+
expect(error.algorithm).toBe('ML-KEM-512');
73+
});
74+
75+
test('fromJose is strict exact-match: rejects whitespace and case variants', () => {
76+
const invalidJose = ['ml-dsa-44', 'ML-DSA-44 ', ' ML-DSA-44', 'RS256'];
77+
for (const jose of invalidJose) {
78+
const error = expectError(() => fromJose(jose), UnknownIdentifierError);
79+
expect(error.code).toBe('UNKNOWN_IDENTIFIER');
80+
expect(error.identifierType).toBe('JOSE');
81+
expect(error.identifierValue).toBe(jose);
82+
}
83+
});
84+
85+
test('JOSE mapping is unique and invertible via fromJose(toJose(...))', () => {
86+
const joseSupported = listIdentifierRecords().filter((record) => record.jose !== undefined);
87+
const joseValues = joseSupported.map((record) => toJose(record.name));
88+
expect(new Set(joseValues).size).toBe(joseValues.length);
89+
90+
for (const record of joseSupported) {
91+
expect(fromJose(toJose(record.name))).toBe(record.name);
92+
}
93+
});
94+
});
95+
96+
describe('lookup - COSE mapping', () => {
97+
test('ML-DSA COSE values stay parity-compatible with pq-oid', () => {
98+
const names = ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'] as const;
99+
for (const name of names) {
100+
expect(toCose(name)).toBe(OID.toCOSE(name));
101+
expect(fromCose(OID.toCOSE(name))).toBe(name);
102+
}
103+
});
104+
105+
test('unsupported COSE mapping throws actionable typed error', () => {
106+
const error = expectError(() => toCose('SLH-DSA-SHA2-128s'), UnsupportedMappingError);
107+
expect(error.code).toBe('UNSUPPORTED_MAPPING');
108+
expect(error.mapping).toBe('COSE');
109+
expect(error.algorithm).toBe('SLH-DSA-SHA2-128s');
110+
});
111+
112+
test('fromCose rejects non-integer, float, NaN, Infinity, and unknown values', () => {
113+
const invalidCose = [-48.1, Number.NaN, Number.POSITIVE_INFINITY, -999, ' -48'] as const;
114+
for (const cose of invalidCose) {
115+
const error = expectError(() => fromCose(cose as unknown as number), UnknownIdentifierError);
116+
expect(error.code).toBe('UNKNOWN_IDENTIFIER');
117+
expect(error.identifierType).toBe('COSE');
118+
expect(error.identifierValue).toBe(cose);
119+
}
120+
});
121+
122+
test('COSE mapping is unique and invertible via fromCose(toCose(...))', () => {
123+
const coseSupported = listIdentifierRecords().filter((record) => record.cose !== undefined);
124+
const coseValues = coseSupported.map((record) => toCose(record.name));
125+
expect(new Set(coseValues).size).toBe(coseValues.length);
126+
127+
for (const record of coseSupported) {
128+
expect(fromCose(toCose(record.name))).toBe(record.name);
129+
}
130+
});
131+
});

0 commit comments

Comments
 (0)