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
15 changes: 9 additions & 6 deletions .claude/skills/implement-plan-linear/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <paths>`), 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.
Expand Down Expand Up @@ -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
Expand All @@ -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.
- Ask one focused question to unblock.
9 changes: 9 additions & 0 deletions packages/pq-algorithm-id/ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
UnknownIdentifierError,
UnsupportedMappingError,
} from './errors';
export { fromCose, fromJose, fromOid, toCose, toJose, toOid } from './lookup';
export {
deriveOidFromName,
getIdentifierRecord,
Expand All @@ -20,3 +21,11 @@ export type {
X509ParametersEncoding,
X509ParametersPolicy,
} from './types';
export {
fromX509AlgorithmIdentifier,
toX509AlgorithmIdentifier,
type X509AlgorithmIdentifier,
type X509AlgorithmIdentifierInput,
type X509AlgorithmIdentifierOptions,
type X509NormalizedParameters,
} from './x509';
105 changes: 105 additions & 0 deletions packages/pq-algorithm-id/ts/src/lookup.ts
Original file line number Diff line number Diff line change
@@ -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<JoseIdentifier, AlgorithmName>();
const COSE_TO_NAME = new Map<CoseIdentifier, AlgorithmName>();

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;
}
100 changes: 100 additions & 0 deletions packages/pq-algorithm-id/ts/src/x509.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
131 changes: 131 additions & 0 deletions packages/pq-algorithm-id/ts/tests/lookup.test.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Error>(
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);
}
});
});
Loading
Loading