From a861e0825c16a70ace67b3a599e6250fdf9c3609 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Wed, 11 Mar 2026 01:00:00 +0300 Subject: [PATCH 1/2] chore(pq-algorithm-id/ts): phase 4 - release hardening and adoption validation (ENG-1917) --- CHANGELOG.md | 11 +++++ packages/pq-algorithm-id/ts/README.md | 10 ++++ packages/pq-algorithm-id/ts/package.json | 7 +++ .../pq-algorithm-id/ts/tests/compat.test.ts | 47 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 packages/pq-algorithm-id/ts/tests/compat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ed6c7..e5cae98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,14 @@ All notable changes to this project will be documented in this file. For per-package release history, see the [GitHub Releases](https://github.com/multivmlabs/post-quantum-packages/releases) page. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- Implemented Phase 4 release hardening for `pq-algorithm-id/ts`, including cross-package compatibility tests. + +### Changed + +- Documented publish policy for `pq-algorithm-id`: bump exact pinned `pq-oid` dependency first, then release `pq-algorithm-id`, with no required cross-package release sequencing workflow. +- Clarified migration posture that `pq-oid` remains the low-level OID primitive layer while `pq-algorithm-id` is canonical for identifier mappings. diff --git a/packages/pq-algorithm-id/ts/README.md b/packages/pq-algorithm-id/ts/README.md index 1952376..33bba55 100644 --- a/packages/pq-algorithm-id/ts/README.md +++ b/packages/pq-algorithm-id/ts/README.md @@ -70,6 +70,16 @@ Suggested downstream migration order: 3. `pq-jws` 4. `pq-jwk` +## Publish Policy + +Dependency and release policy for `0.x` series: + +1. When `pq-algorithm-id` needs newer `pq-oid` behavior, first bump the exact pinned `pq-oid` dependency version in this package. +2. Then release a new `pq-algorithm-id` version. +3. No cross-package release sequencing workflow is required by this plan. + +Until `1.0.0`, `pq-algorithm-id` uses an exact `pq-oid` version pin (`0.x.y` style exact semver, no range operators), and upstream dependency bumps are the trigger for `pq-algorithm-id` releases. + ## License MIT diff --git a/packages/pq-algorithm-id/ts/package.json b/packages/pq-algorithm-id/ts/package.json index 30cd996..a44959d 100644 --- a/packages/pq-algorithm-id/ts/package.json +++ b/packages/pq-algorithm-id/ts/package.json @@ -5,6 +5,13 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "files": [ "dist" ], diff --git a/packages/pq-algorithm-id/ts/tests/compat.test.ts b/packages/pq-algorithm-id/ts/tests/compat.test.ts new file mode 100644 index 0000000..abc53ef --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/compat.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test'; +import { Algorithm, type AlgorithmName, OID } from 'pq-oid'; +import { toCose, toJose, toOid } from '../src'; + +const MLDsaNames = ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'] as const; + +const PUBLIC_OID_CONSTANTS: Readonly> = { + 'ML-KEM-512': OID.ML_KEM_512, + 'ML-KEM-768': OID.ML_KEM_768, + 'ML-KEM-1024': OID.ML_KEM_1024, + 'ML-DSA-44': OID.ML_DSA_44, + 'ML-DSA-65': OID.ML_DSA_65, + 'ML-DSA-87': OID.ML_DSA_87, + 'SLH-DSA-SHA2-128s': OID.SLH_DSA_SHA2_128s, + 'SLH-DSA-SHA2-128f': OID.SLH_DSA_SHA2_128f, + 'SLH-DSA-SHA2-192s': OID.SLH_DSA_SHA2_192s, + 'SLH-DSA-SHA2-192f': OID.SLH_DSA_SHA2_192f, + 'SLH-DSA-SHA2-256s': OID.SLH_DSA_SHA2_256s, + 'SLH-DSA-SHA2-256f': OID.SLH_DSA_SHA2_256f, + 'SLH-DSA-SHAKE-128s': OID.SLH_DSA_SHAKE_128s, + 'SLH-DSA-SHAKE-128f': OID.SLH_DSA_SHAKE_128f, + 'SLH-DSA-SHAKE-192s': OID.SLH_DSA_SHAKE_192s, + 'SLH-DSA-SHAKE-192f': OID.SLH_DSA_SHAKE_192f, + 'SLH-DSA-SHAKE-256s': OID.SLH_DSA_SHAKE_256s, + 'SLH-DSA-SHAKE-256f': OID.SLH_DSA_SHAKE_256f, +}; + +describe('compatibility with pq-oid', () => { + test('ML-DSA JOSE and COSE values remain parity-compatible', () => { + for (const name of MLDsaNames) { + expect(toJose(name)).toBe(OID.toJOSE(name)); + expect(toCose(name)).toBe(OID.toCOSE(name)); + } + }); + + test('toOid matches pq-oid public constants for all algorithms', () => { + for (const [name, oid] of Object.entries(PUBLIC_OID_CONSTANTS)) { + expect(toOid(name as AlgorithmName)).toBe(oid); + } + }); + + test('toOid remains parity-compatible with OID.fromName for all algorithms', () => { + for (const name of Algorithm.list()) { + expect(toOid(name)).toBe(OID.fromName(name)); + } + }); +}); From 98e2a0f78a54fb7e54010e180d9d6d3e2f7afe7b Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Thu, 12 Mar 2026 15:08:07 +0300 Subject: [PATCH 2/2] fix(pq-algorithm-id): hardening commit Amp-Thread-ID: https://ampcode.com/threads/T-019ce1d8-0c2d-71bf-83c8-432465792d05 Co-authored-by: Amp --- packages/pq-algorithm-id/ts/README.md | 32 +- packages/pq-algorithm-id/ts/package.json | 6 +- packages/pq-algorithm-id/ts/src/errors.ts | 71 ++- packages/pq-algorithm-id/ts/src/index.ts | 16 +- packages/pq-algorithm-id/ts/src/lookup.ts | 103 +++-- packages/pq-algorithm-id/ts/src/registry.ts | 142 +++--- packages/pq-algorithm-id/ts/src/types.ts | 14 +- packages/pq-algorithm-id/ts/src/validation.ts | 13 + .../pq-algorithm-id/ts/src/value-format.ts | 63 +++ packages/pq-algorithm-id/ts/src/x509.ts | 234 ++++++++-- .../pq-algorithm-id/ts/tests/lookup.test.ts | 197 +++++++- .../ts/tests/node-dist-smoke.mjs | 26 ++ .../ts/tests/registry-invariants.test.ts | 84 +++- .../ts/tests/registry-parity.test.ts | 15 +- .../pq-algorithm-id/ts/tests/x509.test.ts | 429 ++++++++++++++++-- packages/pq-oid/ts/README.md | 4 +- packages/pq-oid/ts/src/index.ts | 4 + packages/pq-oid/ts/src/oid.ts | 33 ++ packages/pq-oid/ts/tests/oid.test.ts | 30 +- 19 files changed, 1300 insertions(+), 216 deletions(-) create mode 100644 packages/pq-algorithm-id/ts/src/validation.ts create mode 100644 packages/pq-algorithm-id/ts/src/value-format.ts create mode 100644 packages/pq-algorithm-id/ts/tests/node-dist-smoke.mjs diff --git a/packages/pq-algorithm-id/ts/README.md b/packages/pq-algorithm-id/ts/README.md index 33bba55..ce72b6d 100644 --- a/packages/pq-algorithm-id/ts/README.md +++ b/packages/pq-algorithm-id/ts/README.md @@ -21,6 +21,8 @@ import { fromJose, fromOid, fromX509AlgorithmIdentifier, + normalizeX509AlgorithmIdentifier, + resolveX509AlgorithmIdentifier, toCose, toJose, toOid, @@ -44,22 +46,40 @@ toX509AlgorithmIdentifier('ML-KEM-768'); // parameters: { kind: 'absent' } // } -fromX509AlgorithmIdentifier({ +normalizeX509AlgorithmIdentifier({ oid: '2.16.840.1.101.3.4.3.18', - parameters: null, + parameters: { kind: 'absent' }, }); // { // oid: '2.16.840.1.101.3.4.3.18', -// parameters: { kind: 'null' } +// parameters: { kind: 'absent' } +// } + +resolveX509AlgorithmIdentifier({ + oid: '2.16.840.1.101.3.4.3.18', + parameters: { kind: 'absent' }, +}); +// { +// name: 'ML-DSA-65', +// oid: '2.16.840.1.101.3.4.3.18', +// parameters: { kind: 'absent' } // } ``` ## Behavior Notes - `fromOid`, `fromJose`, and `fromCose` are strict lookups (no trimming, no case normalization). -- `toX509AlgorithmIdentifier(name)` emits `parameters: { kind: 'absent' }` by default. -- `fromX509AlgorithmIdentifier(input)` accepts `parameters` as `undefined`, `null`, `{ kind: 'absent' }`, or `{ kind: 'null' }`. +- `toOid`, `toJose`, and `toCose` require `name` to be a runtime string and throw `InvalidArgumentError` for non-string inputs. +- `toX509AlgorithmIdentifier(name)` emits `parameters: { kind: 'absent' }` and rejects unsupported `parametersEncoding` values with `InvalidArgumentError`; explicit `parametersEncoding: undefined` is treated the same as omission. +- `normalizeX509AlgorithmIdentifier(input)` treats both a missing `parameters` property and explicit `parameters: undefined` as absent. +- `normalizeX509AlgorithmIdentifier(input)` accepts only `null`, `{ kind: 'absent' }`, and `{ kind: 'null' }` for explicit parameter values, but strict X.509 policy only permits `parameters: { kind: 'absent' }`. +- `resolveX509AlgorithmIdentifier(input)` is an opt-in parser that returns `{ name, oid, parameters }` when you need the resolved algorithm name. +- `fromX509AlgorithmIdentifier(input)` is retained as a backwards-compatible alias for `normalizeX509AlgorithmIdentifier(input)`. +- X.509 APIs reject unknown own properties on `input`, `options`, and explicit `parameters` objects. +- X.509 APIs are designed for plain data objects; `Proxy` traps can execute during object inspection, and related inspection failures are surfaced as `InvalidArgumentError` with `cause`. +- Malformed API inputs (for example non-string `oid` or unexpected `parametersEncoding`) throw `InvalidArgumentError`. - Unsupported mappings (for example `toJose('ML-KEM-512')`) throw typed errors. +- Diagnostic values in thrown messages are escaped and truncated to bounded previews to avoid oversized error payloads; raw metadata fields on typed errors are intentionally non-enumerable. ## Adoption Order @@ -78,7 +98,7 @@ Dependency and release policy for `0.x` series: 2. Then release a new `pq-algorithm-id` version. 3. No cross-package release sequencing workflow is required by this plan. -Until `1.0.0`, `pq-algorithm-id` uses an exact `pq-oid` version pin (`0.x.y` style exact semver, no range operators), and upstream dependency bumps are the trigger for `pq-algorithm-id` releases. +Until `1.0.0`, `pq-algorithm-id` uses an exact `pq-oid` version pin (exact semver, no range operators), and upstream dependency bumps are the trigger for `pq-algorithm-id` releases. ## License diff --git a/packages/pq-algorithm-id/ts/package.json b/packages/pq-algorithm-id/ts/package.json index a44959d..2e91f3c 100644 --- a/packages/pq-algorithm-id/ts/package.json +++ b/packages/pq-algorithm-id/ts/package.json @@ -18,7 +18,8 @@ "scripts": { "build": "tsc -b", "test": "bun test", - "prepublishOnly": "npm run build" + "test:node-dist": "node ./tests/node-dist-smoke.mjs", + "prepublishOnly": "npm test && npm run build && npm run test:node-dist" }, "keywords": [ "post-quantum", @@ -27,6 +28,9 @@ ], "author": "", "license": "MIT", + "engines": { + "node": ">=18" + }, "dependencies": { "pq-oid": "1.0.2" }, diff --git a/packages/pq-algorithm-id/ts/src/errors.ts b/packages/pq-algorithm-id/ts/src/errors.ts index 59a2a20..571a67d 100644 --- a/packages/pq-algorithm-id/ts/src/errors.ts +++ b/packages/pq-algorithm-id/ts/src/errors.ts @@ -1,50 +1,89 @@ -import type { MappingTarget } from './types'; +import type { MappingTarget } from './types.js'; +import { describeUnknownValue } from './value-format.js'; export type AlgorithmIdentifierErrorCode = + | 'INVALID_ARGUMENT' + | 'REGISTRY_INVARIANT' | 'UNKNOWN_ALGORITHM' | 'UNKNOWN_IDENTIFIER' | 'UNSUPPORTED_MAPPING'; +interface ErrorOptionsLike { + readonly cause?: unknown; +} + +function defineReadonlyNonEnumerableField( + target: object, + propertyName: string, + value: unknown, +): void { + Object.defineProperty(target, propertyName, { + value, + enumerable: false, + writable: false, + configurable: false, + }); +} + export class AlgorithmIdentifierError extends Error { readonly code: AlgorithmIdentifierErrorCode; - constructor(code: AlgorithmIdentifierErrorCode, message: string) { - super(message); + constructor(code: AlgorithmIdentifierErrorCode, message: string, options?: ErrorOptionsLike) { + super(message, options); + Object.setPrototypeOf(this, new.target.prototype); this.name = new.target.name; this.code = code; } } export class UnknownAlgorithmError extends AlgorithmIdentifierError { - readonly algorithm: string; + readonly algorithm!: string; constructor(algorithm: string) { - super('UNKNOWN_ALGORITHM', `Unknown algorithm '${algorithm}'.`); - this.algorithm = algorithm; + super('UNKNOWN_ALGORITHM', `Unknown algorithm '${describeUnknownValue(algorithm)}'.`); + defineReadonlyNonEnumerableField(this, 'algorithm', algorithm); + } +} + +export class InvalidArgumentError extends AlgorithmIdentifierError { + readonly argumentName: string; + + constructor(argumentName: string, message: string, options?: ErrorOptionsLike) { + super('INVALID_ARGUMENT', `Invalid argument '${argumentName}': ${message}`, options); + this.argumentName = argumentName; + } +} + +export class RegistryInvariantError extends AlgorithmIdentifierError { + constructor(message: string, options?: ErrorOptionsLike) { + super('REGISTRY_INVARIANT', message, options); } } export class UnknownIdentifierError extends AlgorithmIdentifierError { - readonly identifierType: MappingTarget; - readonly identifierValue: string | number; + readonly identifierType!: MappingTarget; + readonly identifierValue!: string | number; constructor(identifierType: MappingTarget, identifierValue: string | number) { super( 'UNKNOWN_IDENTIFIER', - `Unknown ${identifierType} identifier '${String(identifierValue)}'.`, + `Unknown ${identifierType} identifier '${describeUnknownValue(identifierValue)}'.`, ); - this.identifierType = identifierType; - this.identifierValue = identifierValue; + defineReadonlyNonEnumerableField(this, 'identifierType', identifierType); + defineReadonlyNonEnumerableField(this, 'identifierValue', identifierValue); } } export class UnsupportedMappingError extends AlgorithmIdentifierError { - readonly mapping: MappingTarget; - readonly algorithm: string; + 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; + super( + 'UNSUPPORTED_MAPPING', + `Algorithm '${describeUnknownValue(algorithm)}' does not support ${mapping} mapping.`, + ); + defineReadonlyNonEnumerableField(this, 'mapping', mapping); + defineReadonlyNonEnumerableField(this, 'algorithm', algorithm); } } diff --git a/packages/pq-algorithm-id/ts/src/index.ts b/packages/pq-algorithm-id/ts/src/index.ts index e461c8e..6d10220 100644 --- a/packages/pq-algorithm-id/ts/src/index.ts +++ b/packages/pq-algorithm-id/ts/src/index.ts @@ -1,18 +1,21 @@ export { AlgorithmIdentifierError, type AlgorithmIdentifierErrorCode, + InvalidArgumentError, + RegistryInvariantError, UnknownAlgorithmError, UnknownIdentifierError, UnsupportedMappingError, -} from './errors'; -export { fromCose, fromJose, fromOid, toCose, toJose, toOid } from './lookup'; +} from './errors.js'; +export { fromCose, fromJose, fromOid, toCose, toJose, toOid } from './lookup.js'; export { deriveOidFromName, getIdentifierRecord, listIdentifierRecords, listRegistryAlgorithmNames, -} from './registry'; +} from './registry.js'; export type { + AlgorithmName, CoseIdentifier, IdentifierRecord, IdentifierRecordMap, @@ -20,12 +23,15 @@ export type { MappingTarget, X509ParametersEncoding, X509ParametersPolicy, -} from './types'; +} from './types.js'; export { fromX509AlgorithmIdentifier, + normalizeX509AlgorithmIdentifier, + resolveX509AlgorithmIdentifier, toX509AlgorithmIdentifier, + type ResolvedX509AlgorithmIdentifier, type X509AlgorithmIdentifier, type X509AlgorithmIdentifierInput, type X509AlgorithmIdentifierOptions, type X509NormalizedParameters, -} from './x509'; +} from './x509.js'; diff --git a/packages/pq-algorithm-id/ts/src/lookup.ts b/packages/pq-algorithm-id/ts/src/lookup.ts index bd858cc..7db83ac 100644 --- a/packages/pq-algorithm-id/ts/src/lookup.ts +++ b/packages/pq-algorithm-id/ts/src/lookup.ts @@ -1,74 +1,72 @@ -import { OID } from 'pq-oid'; -import { UnknownAlgorithmError, UnknownIdentifierError, UnsupportedMappingError } from './errors'; -import { getIdentifierRecord, listIdentifierRecords } from './registry'; -import type { AlgorithmName, CoseIdentifier, JoseIdentifier } from './types'; +import { isCanonicalOid } from 'pq-oid'; +import { + InvalidArgumentError, + RegistryInvariantError, + UnknownIdentifierError, + UnsupportedMappingError, +} from './errors.js'; +import { deriveOidFromName, getIdentifierRecord, listIdentifierRecords } from './registry.js'; +import type { AlgorithmName, CoseIdentifier, JoseIdentifier } from './types.js'; +import { assertAlgorithmNameInput } from './validation.js'; +import { describeUnknownValue } from './value-format.js'; const JOSE_TO_NAME = new Map(); const COSE_TO_NAME = new Map(); +const OID_TO_NAME = new Map(); + +function registerUniqueIdentifier( + map: Map, + identifierType: 'JOSE' | 'COSE' | 'OID', + identifier: T, + algorithmName: AlgorithmName, +): void { + const existingName = map.get(identifier); + if (existingName !== undefined) { + throw new RegistryInvariantError( + `Duplicate ${identifierType} identifier '${String(identifier)}' for '${existingName}' and '${algorithmName}'.`, + ); + } + map.set(identifier, algorithmName); +} for (const record of listIdentifierRecords()) { + registerUniqueIdentifier(OID_TO_NAME, 'OID', deriveOidFromName(record.name), record.name); + if (record.jose !== undefined) { - JOSE_TO_NAME.set(record.jose, record.name); + registerUniqueIdentifier(JOSE_TO_NAME, 'JOSE', record.jose, record.name); } if (record.cose !== undefined) { - COSE_TO_NAME.set(record.cose, record.name); + registerUniqueIdentifier(COSE_TO_NAME, 'COSE', 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); - } + assertAlgorithmNameInput(name); + return deriveOidFromName(name); } export function fromOid(oid: string): AlgorithmName { + if (typeof oid !== 'string') { + throw new InvalidArgumentError('oid', 'Expected OID to be a string.'); + } + if (!isCanonicalOid(oid)) { - throw new UnknownIdentifierError('OID', oid); + throw new InvalidArgumentError( + 'oid', + `Expected canonical dotted OID (for example '2.16.840.1.101.3.4.3.18'), received '${describeUnknownValue(oid)}'.`, + ); } - try { - const name = OID.toName(oid); - getIdentifierRecord(name); - return name; - } catch { + const name = OID_TO_NAME.get(oid); + if (name === undefined) { throw new UnknownIdentifierError('OID', oid); } + + return getIdentifierRecord(name).name; } export function toJose(name: AlgorithmName): JoseIdentifier { + assertAlgorithmNameInput(name); const record = getIdentifierRecord(name); if (record.jose === undefined) { throw new UnsupportedMappingError('JOSE', name); @@ -77,6 +75,10 @@ export function toJose(name: AlgorithmName): JoseIdentifier { } export function fromJose(jose: string): AlgorithmName { + if (typeof jose !== 'string') { + throw new InvalidArgumentError('jose', 'Expected JOSE identifier to be a string.'); + } + const name = JOSE_TO_NAME.get(jose as JoseIdentifier); if (name === undefined) { throw new UnknownIdentifierError('JOSE', jose); @@ -85,6 +87,7 @@ export function fromJose(jose: string): AlgorithmName { } export function toCose(name: AlgorithmName): CoseIdentifier { + assertAlgorithmNameInput(name); const record = getIdentifierRecord(name); if (record.cose === undefined) { throw new UnsupportedMappingError('COSE', name); @@ -93,8 +96,8 @@ export function toCose(name: AlgorithmName): CoseIdentifier { } export function fromCose(cose: number): AlgorithmName { - if (!Number.isFinite(cose) || !Number.isInteger(cose)) { - throw new UnknownIdentifierError('COSE', cose); + if (typeof cose !== 'number' || !Number.isSafeInteger(cose)) { + throw new InvalidArgumentError('cose', 'Expected COSE identifier to be a safe integer.'); } const name = COSE_TO_NAME.get(cose as CoseIdentifier); diff --git a/packages/pq-algorithm-id/ts/src/registry.ts b/packages/pq-algorithm-id/ts/src/registry.ts index feaa06b..20aaff5 100644 --- a/packages/pq-algorithm-id/ts/src/registry.ts +++ b/packages/pq-algorithm-id/ts/src/registry.ts @@ -1,88 +1,124 @@ import type { AlgorithmName } from 'pq-oid'; import { OID } from 'pq-oid'; -import { UnknownAlgorithmError } from './errors'; +import { RegistryInvariantError, UnknownAlgorithmError } from './errors.js'; import type { CoseIdentifier, IdentifierRecord, - IdentifierRecordMap, JoseIdentifier, X509ParametersPolicy, -} from './types'; +} from './types.js'; +import { assertAlgorithmNameInput } from './validation.js'; const DEFAULT_X509_PARAMETERS_POLICY: Readonly = Object.freeze({ defaultParametersEncoding: 'absent', - acceptNull: true, + acceptNull: false, 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', -}); +type IdentifierRecordFields = { + readonly jose?: JoseIdentifier; + readonly cose?: CoseIdentifier; + readonly x509: Readonly; +}; -const COSE_IDENTIFIERS: Readonly>> = Object.freeze({ - 'ML-DSA-44': -48, - 'ML-DSA-65': -49, - 'ML-DSA-87': -50, -}); +function assertX509PolicyInvariant(name: AlgorithmName, policy: Readonly): void { + if (!policy.acceptAbsent && !policy.acceptNull) { + throw new RegistryInvariantError( + `Algorithm '${name}' does not accept any X509 parameters encodings.`, + ); + } -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; + if (policy.defaultParametersEncoding === 'absent' && !policy.acceptAbsent) { + throw new RegistryInvariantError( + `Algorithm '${name}' has default X509 parameters encoding 'absent' that is not accepted by its policy.`, + ); + } + + if (policy.defaultParametersEncoding === 'null' && !policy.acceptNull) { + throw new RegistryInvariantError( + `Algorithm '${name}' has default X509 parameters encoding 'null' that is not accepted by its policy.`, + ); + } +} + +const IDENTIFIER_FIELDS_BY_NAME = Object.freeze({ + 'ML-KEM-512': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'ML-KEM-768': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'ML-KEM-1024': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'ML-DSA-44': { + jose: 'ML-DSA-44', + cose: -48, + x509: DEFAULT_X509_PARAMETERS_POLICY, + }, + 'ML-DSA-65': { + jose: 'ML-DSA-65', + cose: -49, + x509: DEFAULT_X509_PARAMETERS_POLICY, + }, + 'ML-DSA-87': { + jose: 'ML-DSA-87', + cose: -50, + x509: DEFAULT_X509_PARAMETERS_POLICY, + }, + 'SLH-DSA-SHA2-128s': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHA2-128f': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHA2-192s': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHA2-192f': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHA2-256s': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHA2-256f': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHAKE-128s': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHAKE-128f': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHAKE-192s': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHAKE-192f': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHAKE-256s': { x509: DEFAULT_X509_PARAMETERS_POLICY }, + 'SLH-DSA-SHAKE-256f': { x509: DEFAULT_X509_PARAMETERS_POLICY }, +} as const satisfies Readonly>); + +const ALGORITHM_NAMES = Object.freeze( + Object.keys(IDENTIFIER_FIELDS_BY_NAME) as AlgorithmName[], +); const IDENTIFIER_RECORDS = Object.freeze( - ALGORITHM_NAMES.map((name) => - Object.freeze({ + ALGORITHM_NAMES.map((name) => { + const fields = IDENTIFIER_FIELDS_BY_NAME[name]; + assertX509PolicyInvariant(name, fields.x509); + + return Object.freeze({ name, - jose: JOSE_IDENTIFIERS[name], - cose: COSE_IDENTIFIERS[name], - x509: DEFAULT_X509_PARAMETERS_POLICY, - } satisfies IdentifierRecord), - ), + ...fields, + } satisfies IdentifierRecord); + }), ); -const IDENTIFIER_RECORDS_BY_NAME: IdentifierRecordMap = Object.freeze( - Object.fromEntries( - IDENTIFIER_RECORDS.map( - (record) => [record.name, record] satisfies [AlgorithmName, IdentifierRecord], - ), - ) as Record, +const IDENTIFIER_RECORDS_BY_NAME: ReadonlyMap = new Map( + IDENTIFIER_RECORDS.map((record) => [record.name, record] as const), ); export function listRegistryAlgorithmNames(): readonly AlgorithmName[] { - return ALGORITHM_NAMES; + return [...ALGORITHM_NAMES]; } export function listIdentifierRecords(): readonly IdentifierRecord[] { - return IDENTIFIER_RECORDS; + return [...IDENTIFIER_RECORDS]; } -export function getIdentifierRecord(name: AlgorithmName): IdentifierRecord { - const record = IDENTIFIER_RECORDS_BY_NAME[name]; +export function getIdentifierRecord(name: unknown): IdentifierRecord { + assertAlgorithmNameInput(name); + + const record = IDENTIFIER_RECORDS_BY_NAME.get(name as AlgorithmName); if (record === undefined) { throw new UnknownAlgorithmError(name); } return record; } -export function deriveOidFromName(name: AlgorithmName): string { - return OID.fromName(name); +export function deriveOidFromName(name: unknown): string { + const record = getIdentifierRecord(name); + try { + return OID.fromName(record.name); + } catch (error) { + throw new RegistryInvariantError(`pq-oid rejected registered algorithm '${record.name}'.`, { + cause: error, + }); + } } diff --git a/packages/pq-algorithm-id/ts/src/types.ts b/packages/pq-algorithm-id/ts/src/types.ts index 3efd062..2b44341 100644 --- a/packages/pq-algorithm-id/ts/src/types.ts +++ b/packages/pq-algorithm-id/ts/src/types.ts @@ -11,16 +11,16 @@ export type MappingTarget = 'OID' | 'JOSE' | 'COSE' | 'X509'; export type X509ParametersEncoding = 'absent' | 'null'; export interface X509ParametersPolicy { - defaultParametersEncoding: X509ParametersEncoding; - acceptNull: boolean; - acceptAbsent: boolean; + readonly defaultParametersEncoding: X509ParametersEncoding; + readonly acceptNull: boolean; + readonly acceptAbsent: boolean; } export interface IdentifierRecord { - name: AlgorithmName; - jose?: JoseIdentifier; - cose?: CoseIdentifier; - x509: X509ParametersPolicy; + readonly name: AlgorithmName; + readonly jose?: JoseIdentifier; + readonly cose?: CoseIdentifier; + readonly x509: X509ParametersPolicy; } export type IdentifierRecordMap = Readonly>; diff --git a/packages/pq-algorithm-id/ts/src/validation.ts b/packages/pq-algorithm-id/ts/src/validation.ts new file mode 100644 index 0000000..e6d225d --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/validation.ts @@ -0,0 +1,13 @@ +import { InvalidArgumentError } from './errors.js'; +import { describeUnknownValue } from './value-format.js'; + +export function assertAlgorithmNameInput(name: unknown): asserts name is string { + if (typeof name === 'string') { + return; + } + + throw new InvalidArgumentError( + 'name', + `Expected algorithm name to be a string, received '${describeUnknownValue(name)}'.`, + ); +} diff --git a/packages/pq-algorithm-id/ts/src/value-format.ts b/packages/pq-algorithm-id/ts/src/value-format.ts new file mode 100644 index 0000000..8174270 --- /dev/null +++ b/packages/pq-algorithm-id/ts/src/value-format.ts @@ -0,0 +1,63 @@ +const MAX_VALUE_PREVIEW_CHARS = 200; + +function truncatePreview(value: string): string { + if (value.length <= MAX_VALUE_PREVIEW_CHARS) { + return value; + } + + return `${value.slice(0, MAX_VALUE_PREVIEW_CHARS)}...<${value.length} chars total>`; +} + +export function escapeStringForMessage(value: string): string { + return truncatePreview(value).replace(/['\\\u0000-\u001f\u007f\u2028\u2029]/g, (character) => { + switch (character) { + case "'": + return "\\'"; + case '\\': + return '\\\\'; + case '\n': + return '\\n'; + case '\r': + return '\\r'; + case '\t': + return '\\t'; + case '\u0000': + return '\\0'; + default: { + const codePoint = character.charCodeAt(0); + if (codePoint <= 0xff) { + return `\\x${codePoint.toString(16).padStart(2, '0')}`; + } + return `\\u${codePoint.toString(16).padStart(4, '0')}`; + } + } + }); +} + +export function describePropertyKey(propertyKey: string | symbol): string { + if (typeof propertyKey === 'string') { + return escapeStringForMessage(propertyKey); + } + return escapeStringForMessage(String(propertyKey)); +} + +export function describeUnknownValue(value: unknown): string { + if (value === null) { + return 'null'; + } + + switch (typeof value) { + case 'string': + return escapeStringForMessage(value); + case 'number': + case 'boolean': + case 'undefined': + return String(value); + case 'bigint': + return truncatePreview(String(value)); + case 'symbol': + return escapeStringForMessage(String(value)); + default: + return `<${typeof value}>`; + } +} diff --git a/packages/pq-algorithm-id/ts/src/x509.ts b/packages/pq-algorithm-id/ts/src/x509.ts index cb3533e..e674f68 100644 --- a/packages/pq-algorithm-id/ts/src/x509.ts +++ b/packages/pq-algorithm-id/ts/src/x509.ts @@ -1,11 +1,9 @@ -import { - AlgorithmIdentifierError, - UnknownIdentifierError, - UnsupportedMappingError, -} from './errors'; -import { fromOid, toOid } from './lookup'; -import { getIdentifierRecord } from './registry'; -import type { AlgorithmName, X509ParametersEncoding } from './types'; +import { InvalidArgumentError } from './errors.js'; +import { fromOid, toOid } from './lookup.js'; +import { getIdentifierRecord } from './registry.js'; +import type { AlgorithmName, X509ParametersEncoding } from './types.js'; +import { assertAlgorithmNameInput } from './validation.js'; +import { describePropertyKey, describeUnknownValue } from './value-format.js'; export type X509NormalizedParameters = { kind: 'absent' } | { kind: 'null' }; @@ -14,6 +12,10 @@ export interface X509AlgorithmIdentifier { parameters: X509NormalizedParameters; } +export interface ResolvedX509AlgorithmIdentifier extends X509AlgorithmIdentifier { + name: AlgorithmName; +} + export interface X509AlgorithmIdentifierInput { oid: string; parameters?: unknown; @@ -26,20 +28,104 @@ export interface X509AlgorithmIdentifierOptions { const ABSENT_PARAMETERS: X509NormalizedParameters = Object.freeze({ kind: 'absent' }); const NULL_PARAMETERS: X509NormalizedParameters = Object.freeze({ kind: 'null' }); +function getOwnDataProperty( + input: object, + propertyName: string, + argumentName: string, +): { hasProperty: boolean; value: unknown } { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(input, propertyName); + } catch (error) { + throw new InvalidArgumentError(argumentName, `Failed to inspect property '${propertyName}'.`, { + cause: error, + }); + } + + if (descriptor === undefined) { + return { hasProperty: false, value: undefined }; + } + + if (!('value' in descriptor)) { + throw new InvalidArgumentError( + argumentName, + `Expected '${propertyName}' to be a data property, but received an accessor property.`, + ); + } + + return { hasProperty: true, value: descriptor.value }; +} + +function assertNoUnknownOwnProperties( + input: object, + allowedProperties: readonly string[], + argumentName: string, +): void { + let ownKeys: (string | symbol)[]; + try { + ownKeys = Reflect.ownKeys(input); + } catch (error) { + throw new InvalidArgumentError(argumentName, 'Failed to inspect object properties.', { + cause: error, + }); + } + + for (const propertyKey of ownKeys) { + if (typeof propertyKey === 'string' && allowedProperties.includes(propertyKey)) { + continue; + } + + throw new InvalidArgumentError( + argumentName, + `Unknown property '${describePropertyKey(propertyKey)}'. Allowed properties: ${allowedProperties.map((property) => `'${property}'`).join(', ')}.`, + ); + } +} + +function formatAllowedEncodings(acceptAbsent: boolean, acceptNull: boolean): string { + const allowedEncodings: X509ParametersEncoding[] = []; + if (acceptAbsent) { + allowedEncodings.push('absent'); + } + if (acceptNull) { + allowedEncodings.push('null'); + } + + if (allowedEncodings.length === 0) { + return ''; + } + + return allowedEncodings.map((encoding) => `'${encoding}'`).join(', '); +} + function validateParametersForAlgorithm( name: AlgorithmName, encoding: X509ParametersEncoding, + argumentName: 'parameters' | 'parametersEncoding', ): void { const policy = getIdentifierRecord(name).x509; if (encoding === 'absent' && !policy.acceptAbsent) { - throw new UnsupportedMappingError('X509', name); + throw new InvalidArgumentError( + argumentName, + `Algorithm '${name}' does not accept X509 parameters encoding '${encoding}'. Allowed values: ${formatAllowedEncodings(policy.acceptAbsent, policy.acceptNull)}.`, + ); } if (encoding === 'null' && !policy.acceptNull) { - throw new UnsupportedMappingError('X509', name); + throw new InvalidArgumentError( + argumentName, + `Algorithm '${name}' does not accept X509 parameters encoding '${encoding}'. Allowed values: ${formatAllowedEncodings(policy.acceptAbsent, policy.acceptNull)}.`, + ); } } -function normalizeParameters(input: unknown): X509NormalizedParameters { +function normalizeParameters( + input: unknown, + hasOwnParametersProperty: boolean, +): X509NormalizedParameters { + if (!hasOwnParametersProperty) { + return ABSENT_PARAMETERS; + } + if (input === undefined) { return ABSENT_PARAMETERS; } @@ -48,29 +134,75 @@ function normalizeParameters(input: unknown): X509NormalizedParameters { 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; + if (typeof input === 'object' && !Array.isArray(input)) { + assertNoUnknownOwnProperties(input, ['kind'], 'parameters'); + const kindProperty = getOwnDataProperty(input, 'kind', 'parameters'); + if (kindProperty.hasProperty) { + if (kindProperty.value === 'absent') { + return ABSENT_PARAMETERS; + } + if (kindProperty.value === 'null') { + return NULL_PARAMETERS; + } } } - throw new AlgorithmIdentifierError( - 'UNKNOWN_IDENTIFIER', - "Unknown X509 parameters. Expected undefined, null, { kind: 'absent' }, or { kind: 'null' }.", + throw new InvalidArgumentError( + 'parameters', + "Unknown X509 parameters. Expected null, { kind: 'absent' }, or { kind: 'null' }.", + ); +} + +function assertX509ParametersEncoding( + encoding: unknown, +): asserts encoding is X509ParametersEncoding { + if (encoding === 'absent' || encoding === 'null') { + return; + } + + throw new InvalidArgumentError( + 'parametersEncoding', + `Unknown X509 parameters encoding '${describeUnknownValue(encoding)}'. Expected 'absent' or 'null'.`, ); } +function assertX509AlgorithmIdentifierOptions( + options: unknown, +): asserts options is X509AlgorithmIdentifierOptions { + if (options === undefined) { + return; + } + + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + throw new InvalidArgumentError('options', 'X509 options must be an object when provided.'); + } + + assertNoUnknownOwnProperties(options, ['parametersEncoding'], 'options'); +} + export function toX509AlgorithmIdentifier( name: AlgorithmName, options?: X509AlgorithmIdentifierOptions, ): X509AlgorithmIdentifier { + assertAlgorithmNameInput(name); + assertX509AlgorithmIdentifierOptions(options); const policy = getIdentifierRecord(name).x509; - const parametersEncoding = options?.parametersEncoding ?? policy.defaultParametersEncoding; - validateParametersForAlgorithm(name, parametersEncoding); + let parametersEncoding: unknown = policy.defaultParametersEncoding; + if (options !== undefined) { + const parametersEncodingProperty = getOwnDataProperty( + options, + 'parametersEncoding', + 'parametersEncoding', + ); + if (parametersEncodingProperty.hasProperty) { + if (parametersEncodingProperty.value !== undefined) { + parametersEncoding = parametersEncodingProperty.value; + } + } + } + + assertX509ParametersEncoding(parametersEncoding); + validateParametersForAlgorithm(name, parametersEncoding, 'parametersEncoding'); return { oid: toOid(name), @@ -78,23 +210,57 @@ export function toX509AlgorithmIdentifier( }; } -export function fromX509AlgorithmIdentifier( - input: X509AlgorithmIdentifierInput, -): X509AlgorithmIdentifier { - if (typeof input !== 'object' || input === null) { - throw new AlgorithmIdentifierError('UNKNOWN_IDENTIFIER', 'X509 input must be an object.'); +function parseX509AlgorithmIdentifier( + input: unknown, +): { name: AlgorithmName; parameters: X509NormalizedParameters } { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new InvalidArgumentError('input', 'X509 input must be an object.'); } - if (typeof input.oid !== 'string') { - throw new UnknownIdentifierError('OID', String(input.oid)); + assertNoUnknownOwnProperties(input, ['oid', 'parameters'], 'input'); + + const oidProperty = getOwnDataProperty(input, 'oid', 'oid'); + if (!oidProperty.hasProperty || typeof oidProperty.value !== 'string') { + throw new InvalidArgumentError('oid', 'Expected OID to be a string.'); } - const name = fromOid(input.oid); - const normalizedParameters = normalizeParameters(input.parameters); - validateParametersForAlgorithm(name, normalizedParameters.kind); + const parametersProperty = getOwnDataProperty(input, 'parameters', 'parameters'); + + const name = fromOid(oidProperty.value); + const parameters = normalizeParameters(parametersProperty.value, parametersProperty.hasProperty); + validateParametersForAlgorithm(name, parameters.kind, 'parameters'); + + return { name, parameters }; +} + +export function resolveX509AlgorithmIdentifier( + input: unknown, +): ResolvedX509AlgorithmIdentifier { + const { name, parameters } = parseX509AlgorithmIdentifier(input); return { + name, oid: toOid(name), - parameters: normalizedParameters, + parameters, }; } + +export function normalizeX509AlgorithmIdentifier( + input: unknown, +): X509AlgorithmIdentifier { + const { oid, parameters } = resolveX509AlgorithmIdentifier(input); + + return { + oid, + parameters, + }; +} + +/** + * @deprecated Use normalizeX509AlgorithmIdentifier to make normalization intent explicit. + */ +export function fromX509AlgorithmIdentifier( + input: unknown, +): X509AlgorithmIdentifier { + return normalizeX509AlgorithmIdentifier(input); +} diff --git a/packages/pq-algorithm-id/ts/tests/lookup.test.ts b/packages/pq-algorithm-id/ts/tests/lookup.test.ts index fe65baf..6663bbb 100644 --- a/packages/pq-algorithm-id/ts/tests/lookup.test.ts +++ b/packages/pq-algorithm-id/ts/tests/lookup.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { OID } from 'pq-oid'; -import { UnknownIdentifierError, UnsupportedMappingError } from '../src/errors'; +import { + InvalidArgumentError, + UnknownAlgorithmError, + UnknownIdentifierError, + UnsupportedMappingError, +} from '../src/errors'; import { fromCose, fromJose, fromOid, toCose, toJose, toOid } from '../src/lookup'; import { listIdentifierRecords, listRegistryAlgorithmNames } from '../src/registry'; @@ -41,10 +46,47 @@ describe('lookup - OID mapping', () => { ]; 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); + const error = expectError(() => fromOid(oid), InvalidArgumentError); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('oid'); + } + }); + + test('fromOid treats large second arcs as canonical when first arc is 2', () => { + const canonicalUnknownOid = `2.${'9'.repeat(256)}`; + const unknownError = expectError(() => fromOid(canonicalUnknownOid), UnknownIdentifierError); + expect(unknownError.code).toBe('UNKNOWN_IDENTIFIER'); + expect(unknownError.identifierType).toBe('OID'); + expect(unknownError.identifierValue).toBe(canonicalUnknownOid); + + const invalidOid = `1.${'9'.repeat(256)}`; + const invalidError = expectError(() => fromOid(invalidOid), InvalidArgumentError); + expect(invalidError.code).toBe('INVALID_ARGUMENT'); + expect(invalidError.argumentName).toBe('oid'); + }); + + test('fromOid error messages escape control characters', () => { + const invalidOid = '2.16.840.1.101.3.4.3.18\ninjected'; + const error = expectError(() => fromOid(invalidOid), InvalidArgumentError); + + expect(error.message).toContain('\\n'); + expect(error.message.includes('\n')).toBe(false); + }); + + test('fromOid truncates oversized diagnostic previews', () => { + const oversizedInvalidOid = `${'2'.repeat(5000)}.bad`; + const error = expectError(() => fromOid(oversizedInvalidOid), InvalidArgumentError); + + expect(error.message).toContain('<5004 chars total>'); + expect(error.message.length).toBeLessThan(450); + }); + + test('fromOid rejects non-string runtime values with InvalidArgumentError', () => { + const invalidInputs = [123, null, {}, []] as const; + for (const value of invalidInputs) { + const error = expectError(() => fromOid(value as unknown as string), InvalidArgumentError); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('oid'); } }); @@ -82,6 +124,23 @@ describe('lookup - JOSE mapping', () => { } }); + test('fromJose rejects non-string runtime values with InvalidArgumentError', () => { + const invalidJose = [123, null, {}, []] as const; + for (const jose of invalidJose) { + const error = expectError(() => fromJose(jose as unknown as string), InvalidArgumentError); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('jose'); + } + }); + + test('fromJose error messages escape control characters', () => { + const maliciousJose = 'ML-DSA-44\ninjected'; + const error = expectError(() => fromJose(maliciousJose), UnknownIdentifierError); + + expect(error.message).toContain('\\n'); + expect(error.message.includes('\n')).toBe(false); + }); + 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)); @@ -109,16 +168,33 @@ describe('lookup - COSE mapping', () => { 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; + test('fromCose rejects malformed runtime values with InvalidArgumentError', () => { + const invalidCose = [-48.1, Number.NaN, Number.POSITIVE_INFINITY, ' -48', null] 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); + const error = expectError(() => fromCose(cose as unknown as number), InvalidArgumentError); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('cose'); } }); + test('fromCose rejects unsafe integers to prevent precision ambiguity', () => { + const invalidCose = [Number.MAX_SAFE_INTEGER + 1, Number.MIN_SAFE_INTEGER - 1] as const; + + for (const cose of invalidCose) { + const error = expectError(() => fromCose(cose), InvalidArgumentError); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('cose'); + expect(error.message).toContain('safe integer'); + } + }); + + test('fromCose rejects unknown integer values with UnknownIdentifierError', () => { + const error = expectError(() => fromCose(-999), UnknownIdentifierError); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('COSE'); + expect(error.identifierValue).toBe(-999); + }); + 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)); @@ -129,3 +205,102 @@ describe('lookup - COSE mapping', () => { } }); }); + +describe('lookup - runtime algorithm guards', () => { + test('toOid rejects unknown runtime algorithm values', () => { + const error = expectError(() => toOid('NOT-AN-ALGORITHM' as never), UnknownAlgorithmError); + expect(error.code).toBe('UNKNOWN_ALGORITHM'); + }); + + test('unknown algorithm errors retain raw algorithm field and escaped message text', () => { + const rawAlgorithmName = 'BAD\nALGORITHM'; + const error = expectError(() => toOid(rawAlgorithmName as never), UnknownAlgorithmError); + + expect(error.code).toBe('UNKNOWN_ALGORITHM'); + expect(error.algorithm).toBe(rawAlgorithmName); + expect(error.message).toContain('BAD\\nALGORITHM'); + expect(error.message.includes('\n')).toBe(false); + }); + + test('raw error metadata fields remain accessible but are non-enumerable', () => { + const oversizedAlgorithmName = `UNKNOWN-${'A'.repeat(3000)}`; + const unknownAlgorithmError = expectError( + () => toOid(oversizedAlgorithmName as never), + UnknownAlgorithmError, + ); + expect(Object.keys(unknownAlgorithmError)).not.toContain('algorithm'); + expect(unknownAlgorithmError.algorithm).toBe(oversizedAlgorithmName); + expect(Object.getOwnPropertyDescriptor(unknownAlgorithmError, 'algorithm')?.enumerable).toBe(false); + expect(unknownAlgorithmError.message).toContain('<3008 chars total>'); + + const oversizedJose = `ML-DSA-${'B'.repeat(3000)}`; + const unknownIdentifierError = expectError(() => fromJose(oversizedJose), UnknownIdentifierError); + expect(Object.keys(unknownIdentifierError)).not.toContain('identifierValue'); + expect(unknownIdentifierError.identifierValue).toBe(oversizedJose); + expect(Object.getOwnPropertyDescriptor(unknownIdentifierError, 'identifierValue')?.enumerable).toBe( + false, + ); + expect(unknownIdentifierError.message).toContain('<3007 chars total>'); + }); + + test('toOid truncates oversized bigint diagnostics', () => { + const oversizedBigint = BigInt('9'.repeat(5000)); + const error = expectError(() => toOid(oversizedBigint as never), InvalidArgumentError); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('name'); + expect(error.message).toContain('<5000 chars total>'); + expect(error.message.length).toBeLessThan(450); + }); + + test('toOid, toJose, and toCose reject non-string runtime algorithm values', () => { + const invalidNames = [null, 42, {}, [], Symbol('x')] as const; + + for (const name of invalidNames) { + const oidError = expectError(() => toOid(name as never), InvalidArgumentError); + expect(oidError.code).toBe('INVALID_ARGUMENT'); + expect(oidError.argumentName).toBe('name'); + + const joseError = expectError(() => toJose(name as never), InvalidArgumentError); + expect(joseError.code).toBe('INVALID_ARGUMENT'); + expect(joseError.argumentName).toBe('name'); + + const coseError = expectError(() => toCose(name as never), InvalidArgumentError); + expect(coseError.code).toBe('INVALID_ARGUMENT'); + expect(coseError.argumentName).toBe('name'); + } + }); + + test('toOid escapes control characters in symbol diagnostic output', () => { + const error = expectError(() => toOid(Symbol('x\ny') as never), InvalidArgumentError); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('name'); + expect(error.message).toContain('Symbol(x\\ny)'); + expect(error.message.includes('\n')).toBe(false); + }); + + test('prototype-chain property names are rejected as unknown runtime algorithms', () => { + const prototypePropertyNames = ['constructor', '__proto__', 'toString'] as const; + + for (const name of prototypePropertyNames) { + const oidError = expectError(() => toOid(name as never), UnknownAlgorithmError); + expect(oidError.code).toBe('UNKNOWN_ALGORITHM'); + + const joseError = expectError(() => toJose(name as never), UnknownAlgorithmError); + expect(joseError.code).toBe('UNKNOWN_ALGORITHM'); + + const coseError = expectError(() => toCose(name as never), UnknownAlgorithmError); + expect(coseError.code).toBe('UNKNOWN_ALGORITHM'); + } + }); + + test('toJose rejects unknown runtime algorithm values', () => { + const error = expectError(() => toJose('NOT-AN-ALGORITHM' as never), UnknownAlgorithmError); + expect(error.code).toBe('UNKNOWN_ALGORITHM'); + }); + + test('toCose rejects unknown runtime algorithm values', () => { + const error = expectError(() => toCose('NOT-AN-ALGORITHM' as never), UnknownAlgorithmError); + expect(error.code).toBe('UNKNOWN_ALGORITHM'); + }); +}); diff --git a/packages/pq-algorithm-id/ts/tests/node-dist-smoke.mjs b/packages/pq-algorithm-id/ts/tests/node-dist-smoke.mjs new file mode 100644 index 0000000..c899ed7 --- /dev/null +++ b/packages/pq-algorithm-id/ts/tests/node-dist-smoke.mjs @@ -0,0 +1,26 @@ +import { readdir, readFile } from 'node:fs/promises'; + +const distUrl = new URL('../dist/', import.meta.url); +const entries = await readdir(distUrl, { withFileTypes: true }); + +const relativeSpecifierPattern = + /(?:import|export)\s+(?:[^'"`]*?\sfrom\s*)?['"](\.{1,2}\/[^'"]+)['"]/g; + +for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.js')) { + continue; + } + + const fileUrl = new URL(entry.name, distUrl); + const source = await readFile(fileUrl, 'utf8'); + + let match; + while ((match = relativeSpecifierPattern.exec(source)) !== null) { + const specifier = match[1]; + if (!specifier.endsWith('.js')) { + throw new Error( + `Dist smoke check failed: ${entry.name} contains non-.js relative import/export specifier '${specifier}'.`, + ); + } + } +} diff --git a/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts b/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts index 2b322eb..52a9c32 100644 --- a/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts +++ b/packages/pq-algorithm-id/ts/tests/registry-invariants.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { listIdentifierRecords, listRegistryAlgorithmNames } from '../src/registry'; +import { InvalidArgumentError, UnknownAlgorithmError } from '../src/errors'; +import { + deriveOidFromName, + getIdentifierRecord, + 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]); @@ -8,15 +14,57 @@ function hasDirectOidLiteral(value: unknown): boolean { return typeof value === 'string' && /^\d+(?:\.\d+)+$/.test(value); } +function expectError( + 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('registry invariants', () => { + test('listIdentifierRecords returns an isolated array copy on each call', () => { + const first = listIdentifierRecords(); + const second = listIdentifierRecords(); + + expect(first).not.toBe(second); + + const mutableCopy = [...first]; + mutableCopy.pop(); + + expect(listIdentifierRecords().length).toBe(first.length); + }); + 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.acceptNull).toBe(false); expect(record.x509.acceptAbsent).toBe(true); } }); + test('x509 defaultParametersEncoding is always accepted by its policy', () => { + for (const record of listIdentifierRecords()) { + if (record.x509.defaultParametersEncoding === 'absent') { + expect(record.x509.acceptAbsent).toBe(true); + } + if (record.x509.defaultParametersEncoding === 'null') { + expect(record.x509.acceptNull).toBe(true); + } + } + }); + + test('x509 policy always accepts at least one parameters encoding', () => { + for (const record of listIdentifierRecords()) { + expect(record.x509.acceptAbsent || record.x509.acceptNull).toBe(true); + } + }); + test('registry records do not store direct OID literals', () => { for (const record of listIdentifierRecords()) { for (const value of Object.values(record)) { @@ -45,4 +93,36 @@ describe('registry invariants', () => { } } }); + + test('registry APIs reject non-string runtime algorithm values', () => { + const invalidNames = [null, 42, {}, [], Symbol('x')] as const; + + for (const name of invalidNames) { + const recordError = expectError( + () => getIdentifierRecord(name as never), + InvalidArgumentError, + ); + expect(recordError.code).toBe('INVALID_ARGUMENT'); + expect(recordError.argumentName).toBe('name'); + + const oidError = expectError( + () => deriveOidFromName(name as never), + InvalidArgumentError, + ); + expect(oidError.code).toBe('INVALID_ARGUMENT'); + expect(oidError.argumentName).toBe('name'); + } + }); + + test('registry APIs reject unknown runtime algorithm strings', () => { + const unknownNames = ['NOT-AN-ALGORITHM', '__proto__'] as const; + + for (const name of unknownNames) { + const recordError = expectError(() => getIdentifierRecord(name), UnknownAlgorithmError); + expect(recordError.code).toBe('UNKNOWN_ALGORITHM'); + + const oidError = expectError(() => deriveOidFromName(name), UnknownAlgorithmError); + expect(oidError.code).toBe('UNKNOWN_ALGORITHM'); + } + }); }); diff --git a/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts b/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts index 89ee8bf..342e362 100644 --- a/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts +++ b/packages/pq-algorithm-id/ts/tests/registry-parity.test.ts @@ -1,12 +1,25 @@ import { describe, expect, test } from 'bun:test'; import { Algorithm, OID } from 'pq-oid'; -import { deriveOidFromName, listRegistryAlgorithmNames } from '../src/registry'; +import { + deriveOidFromName, + listIdentifierRecords, + listRegistryAlgorithmNames, +} from '../src/registry'; function asSortedSet(values: readonly string[]): string[] { return [...new Set(values)].sort(); } describe('registry parity', () => { + test('registry names are unique and mapped to exactly one record each', () => { + const names = listRegistryAlgorithmNames(); + expect(new Set(names).size).toBe(names.length); + + const recordNames = listIdentifierRecords().map((record) => record.name); + expect(new Set(recordNames).size).toBe(recordNames.length); + expect(asSortedSet(recordNames)).toEqual(asSortedSet(names)); + }); + test('registry names match pq-oid algorithm names', () => { const registryNames = asSortedSet(listRegistryAlgorithmNames()); const canonicalNames = asSortedSet(Algorithm.list()); diff --git a/packages/pq-algorithm-id/ts/tests/x509.test.ts b/packages/pq-algorithm-id/ts/tests/x509.test.ts index cb311e4..e19c53f 100644 --- a/packages/pq-algorithm-id/ts/tests/x509.test.ts +++ b/packages/pq-algorithm-id/ts/tests/x509.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { AlgorithmIdentifierError, UnknownIdentifierError } from '../src/errors'; +import { InvalidArgumentError, UnknownAlgorithmError, UnknownIdentifierError } from '../src/errors'; import { toOid } from '../src/lookup'; -import { fromX509AlgorithmIdentifier, toX509AlgorithmIdentifier } from '../src/x509'; +import type { X509ParametersEncoding } from '../src/types'; +import { + fromX509AlgorithmIdentifier, + normalizeX509AlgorithmIdentifier, + resolveX509AlgorithmIdentifier, + toX509AlgorithmIdentifier, +} from '../src/x509'; function expectError( fn: () => unknown, @@ -25,40 +31,227 @@ describe('x509 - generation', () => { }); }); - test('toX509AlgorithmIdentifier emits kind: null only when explicitly requested', () => { - const descriptor = toX509AlgorithmIdentifier('ML-DSA-65', { parametersEncoding: 'null' }); + test('toX509AlgorithmIdentifier rejects null parameter encoding for strict conformance', () => { + const error = expectError( + () => toX509AlgorithmIdentifier('ML-DSA-65', { parametersEncoding: 'null' }), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parametersEncoding'); + expect(error.message).toContain( + "Algorithm 'ML-DSA-65' does not accept X509 parameters encoding 'null'", + ); + }); + + test('toX509AlgorithmIdentifier rejects unknown parametersEncoding values at runtime', () => { + const error = expectError( + () => + toX509AlgorithmIdentifier('ML-KEM-512', { + parametersEncoding: 'unexpected' as unknown as X509ParametersEncoding, + }), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.message).toContain("Unknown X509 parameters encoding 'unexpected'"); + }); + + test('toX509AlgorithmIdentifier treats explicit undefined parametersEncoding as omitted', () => { + expect( + toX509AlgorithmIdentifier('ML-KEM-512', { + parametersEncoding: undefined, + }), + ).toEqual({ + oid: toOid('ML-KEM-512'), + parameters: { kind: 'absent' }, + }); + }); + + test('toX509AlgorithmIdentifier rejects unknown runtime algorithm values', () => { + const error = expectError( + () => toX509AlgorithmIdentifier('NOT-AN-ALGORITHM' as never), + UnknownAlgorithmError, + ); + expect(error.code).toBe('UNKNOWN_ALGORITHM'); + }); + + test('toX509AlgorithmIdentifier rejects malformed options values at runtime', () => { + const invalidOptions = [42, false, null, 'bad-options', []] as const; + + for (const options of invalidOptions) { + const error = expectError( + () => toX509AlgorithmIdentifier('ML-KEM-512', options as never), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('options'); + } + }); + + test('toX509AlgorithmIdentifier rejects non-string runtime algorithm values', () => { + const invalidNames = [null, 42, {}, [], Symbol('x')] as const; + + for (const name of invalidNames) { + const error = expectError( + () => toX509AlgorithmIdentifier(name as never), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('name'); + } + }); + + test('toX509AlgorithmIdentifier ignores inherited parametersEncoding options', () => { + const inheritedOptions = Object.create({ parametersEncoding: 'null' }); + const descriptor = toX509AlgorithmIdentifier('ML-KEM-512', inheritedOptions as never); + expect(descriptor).toEqual({ - oid: toOid('ML-DSA-65'), - parameters: { kind: 'null' }, + oid: toOid('ML-KEM-512'), + parameters: { kind: 'absent' }, + }); + }); + + test('toX509AlgorithmIdentifier rejects unknown options properties', () => { + const error = expectError( + () => toX509AlgorithmIdentifier('ML-KEM-512', { parameterEncoding: 'absent' } as never), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('options'); + expect(error.message).toContain("Unknown property 'parameterEncoding'"); + }); + + test('toX509AlgorithmIdentifier escapes symbol-key options properties in diagnostics', () => { + const symbolKey = Symbol('extra\noption'); + const error = expectError( + () => toX509AlgorithmIdentifier('ML-KEM-512', { [symbolKey]: true } as never), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('options'); + expect(error.message).toContain("Unknown property 'Symbol(extra\\noption)'"); + expect(error.message.includes('\n')).toBe(false); + }); + + test('toX509AlgorithmIdentifier rejects accessor-based parametersEncoding values', () => { + const options = {}; + Object.defineProperty(options, 'parametersEncoding', { + get: () => 'null', + enumerable: true, }); + + const error = expectError( + () => toX509AlgorithmIdentifier('ML-KEM-512', options as never), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parametersEncoding'); + }); + + test('toX509AlgorithmIdentifier rejects prototype-chain algorithm values with UnknownAlgorithmError', () => { + const error = expectError( + () => toX509AlgorithmIdentifier('__proto__' as never), + UnknownAlgorithmError, + ); + expect(error.code).toBe('UNKNOWN_ALGORITHM'); + }); + + test('toX509AlgorithmIdentifier wraps proxy ownKeys trap errors', () => { + const options = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('proxy ownKeys trap in options'); + }, + }, + ); + + const error = expectError( + () => toX509AlgorithmIdentifier('ML-KEM-512', options as never), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('options'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('proxy ownKeys trap in options'); + }); + + test('toX509AlgorithmIdentifier wraps proxy descriptor trap errors', () => { + const options = new Proxy( + {}, + { + getOwnPropertyDescriptor: () => { + throw new Error('proxy descriptor trap in options'); + }, + }, + ); + + const error = expectError( + () => toX509AlgorithmIdentifier('ML-KEM-512', options as never), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parametersEncoding'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('proxy descriptor trap in options'); }); }); describe('x509 - parsing and normalize behavior', () => { const oid = toOid('SLH-DSA-SHAKE-256f'); - test('normalize missing and undefined parameters to kind: absent', () => { + test('normalize missing parameters to kind: absent', () => { expect(fromX509AlgorithmIdentifier({ oid })).toEqual({ oid, parameters: { kind: 'absent' }, }); - expect(fromX509AlgorithmIdentifier({ oid, parameters: undefined })).toEqual({ - oid, + }); + + test('normalizeX509AlgorithmIdentifier is equivalent to fromX509AlgorithmIdentifier', () => { + const input = { oid, parameters: { kind: 'absent' as const } }; + + expect(normalizeX509AlgorithmIdentifier(input)).toEqual(fromX509AlgorithmIdentifier(input)); + }); + + test('resolveX509AlgorithmIdentifier returns normalized name + oid + parameters', () => { + expect( + resolveX509AlgorithmIdentifier({ + oid: toOid('ML-DSA-65'), + parameters: { kind: 'absent' }, + }), + ).toEqual({ + name: 'ML-DSA-65', + oid: toOid('ML-DSA-65'), parameters: { kind: 'absent' }, }); }); - test('normalize null and kind: null to kind: null', () => { - expect(fromX509AlgorithmIdentifier({ oid, parameters: null })).toEqual({ - oid, - parameters: { kind: 'null' }, - }); - expect(fromX509AlgorithmIdentifier({ oid, parameters: { kind: 'null' } })).toEqual({ + test('normalize explicit undefined parameters values to kind: absent', () => { + expect(fromX509AlgorithmIdentifier({ oid, parameters: undefined })).toEqual({ oid, - parameters: { kind: 'null' }, + parameters: { kind: 'absent' }, }); }); + test('reject null and kind: null parameters for strict conformance', () => { + const nullError = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters: null }), + InvalidArgumentError, + ); + expect(nullError.code).toBe('INVALID_ARGUMENT'); + expect(nullError.argumentName).toBe('parameters'); + + const explicitNullError = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters: { kind: 'null' } }), + InvalidArgumentError, + ); + expect(explicitNullError.code).toBe('INVALID_ARGUMENT'); + expect(explicitNullError.argumentName).toBe('parameters'); + }); + test('normalize kind: absent to kind: absent', () => { expect(fromX509AlgorithmIdentifier({ oid, parameters: { kind: 'absent' } })).toEqual({ oid, @@ -66,14 +259,88 @@ describe('x509 - parsing and normalize behavior', () => { }); }); + test('reject inherited oid values from prototype chain', () => { + const inheritedInput = Object.create({ oid }); + const error = expectError( + () => fromX509AlgorithmIdentifier(inheritedInput), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('oid'); + }); + + test('reject unknown x509 input properties', () => { + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid, params: { kind: 'absent' } }), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('input'); + expect(error.message).toContain("Unknown property 'params'"); + }); + + test('reject symbol keys on x509 input and parameter objects', () => { + const inputSymbol = Symbol('x509-input-extra'); + const parametersSymbol = Symbol('x509-parameters-extra'); + + const inputError = expectError( + () => fromX509AlgorithmIdentifier({ oid, [inputSymbol]: true }), + InvalidArgumentError, + ); + expect(inputError.code).toBe('INVALID_ARGUMENT'); + expect(inputError.argumentName).toBe('input'); + expect(inputError.message).toContain(`Unknown property '${inputSymbol.toString()}'`); + + const parametersError = expectError( + () => + fromX509AlgorithmIdentifier({ + oid, + parameters: { kind: 'absent', [parametersSymbol]: true }, + }), + InvalidArgumentError, + ); + expect(parametersError.code).toBe('INVALID_ARGUMENT'); + expect(parametersError.argumentName).toBe('parameters'); + expect(parametersError.message).toContain(`Unknown property '${parametersSymbol.toString()}'`); + }); + + test('reject inherited kind values from prototype chain', () => { + const parameters = Object.create({ kind: 'absent' }); + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters }), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parameters'); + }); + + test('reject accessor-based oid properties without invoking getter', () => { + const input = {}; + Object.defineProperty(input, 'oid', { + get: () => oid, + enumerable: true, + }); + + const error = expectError( + () => fromX509AlgorithmIdentifier(input), + InvalidArgumentError, + ); + + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('oid'); + }); + test('reject unknown parameters and unexpected kind values', () => { - const invalidParameters = [{}, { kind: 'zero' }, { kind: 1 }]; + const invalidParameters = [{}, { kind: 'zero' }, { kind: 1 }, { kind: 'absent', raw: 1 }]; for (const parameters of invalidParameters) { const error = expectError( () => fromX509AlgorithmIdentifier({ oid, parameters }), - AlgorithmIdentifierError, + InvalidArgumentError, ); - expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parameters'); } }); @@ -82,24 +349,132 @@ describe('x509 - parsing and normalize behavior', () => { for (const parameters of invalidParameters) { const error = expectError( () => fromX509AlgorithmIdentifier({ oid, parameters }), - AlgorithmIdentifierError, + InvalidArgumentError, ); - expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.code).toBe('INVALID_ARGUMENT'); } }); - test('reject non-object x509 input and invalid OID shapes', () => { - expectError(() => fromX509AlgorithmIdentifier('bad' as never), AlgorithmIdentifierError); + test('reject non-object x509 input and malformed OID shapes', () => { + const nonObjectError = expectError( + () => fromX509AlgorithmIdentifier('bad'), + InvalidArgumentError, + ); + expect(nonObjectError.code).toBe('INVALID_ARGUMENT'); + expect(nonObjectError.argumentName).toBe('input'); + + const arrayInputError = expectError( + () => fromX509AlgorithmIdentifier([]), + InvalidArgumentError, + ); + expect(arrayInputError.code).toBe('INVALID_ARGUMENT'); + expect(arrayInputError.argumentName).toBe('input'); + + const nonStringOidError = expectError( + () => fromX509AlgorithmIdentifier({ oid: 123 }), + InvalidArgumentError, + ); + expect(nonStringOidError.code).toBe('INVALID_ARGUMENT'); const invalidOids = ['2.16.840.1.101.3.4.3.18 ', '+2.16.840.1.101.3.4.3.18', '1.40.3']; for (const invalidOid of invalidOids) { const error = expectError( () => fromX509AlgorithmIdentifier({ oid: invalidOid }), - UnknownIdentifierError, + InvalidArgumentError, ); - expect(error.code).toBe('UNKNOWN_IDENTIFIER'); - expect(error.identifierType).toBe('OID'); - expect(error.identifierValue).toBe(invalidOid); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('oid'); } }); + + test('reject unknown canonical OID values', () => { + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid: '2.16.840.1.101.3.4.3.255' }), + UnknownIdentifierError, + ); + expect(error.code).toBe('UNKNOWN_IDENTIFIER'); + expect(error.identifierType).toBe('OID'); + expect(error.identifierValue).toBe('2.16.840.1.101.3.4.3.255'); + }); + + test('fromX509AlgorithmIdentifier wraps proxy ownKeys trap errors', () => { + const input = new Proxy( + { oid }, + { + ownKeys: () => { + throw new Error('proxy ownKeys trap in input'); + }, + }, + ); + + const error = expectError( + () => fromX509AlgorithmIdentifier(input), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('input'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('proxy ownKeys trap in input'); + }); + + test('fromX509AlgorithmIdentifier wraps proxy descriptor trap errors', () => { + const input = new Proxy( + { oid }, + { + getOwnPropertyDescriptor: () => { + throw new Error('proxy descriptor trap in input'); + }, + }, + ); + + const error = expectError( + () => fromX509AlgorithmIdentifier(input), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('oid'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('proxy descriptor trap in input'); + }); + + test('fromX509AlgorithmIdentifier wraps nested parameters proxy ownKeys trap errors', () => { + const parameters = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('proxy ownKeys trap in parameters'); + }, + }, + ); + + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters }), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parameters'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('proxy ownKeys trap in parameters'); + }); + + test('fromX509AlgorithmIdentifier wraps nested parameters proxy descriptor trap errors', () => { + const parameters = new Proxy( + {}, + { + ownKeys: () => ['kind'], + getOwnPropertyDescriptor: () => { + throw new Error('proxy descriptor trap in parameters'); + }, + }, + ); + + const error = expectError( + () => fromX509AlgorithmIdentifier({ oid, parameters }), + InvalidArgumentError, + ); + expect(error.code).toBe('INVALID_ARGUMENT'); + expect(error.argumentName).toBe('parameters'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('proxy descriptor trap in parameters'); + }); }); diff --git a/packages/pq-oid/ts/README.md b/packages/pq-oid/ts/README.md index c6360f7..726483c 100644 --- a/packages/pq-oid/ts/README.md +++ b/packages/pq-oid/ts/README.md @@ -51,9 +51,9 @@ OID.fromBytes(bytes) // '2.16.840.1.101.3.4.4.1' // JOSE/COSE mappings (ML-DSA only, compatibility path) OID.toJOSE('ML-DSA-65') // 'ML-DSA-65' -OID.toCOSE('ML-DSA-65') // -48 +OID.toCOSE('ML-DSA-65') // -49 OID.fromJOSE('ML-DSA-65') // 'ML-DSA-65' -OID.fromCOSE(-48) // 'ML-DSA-65' +OID.fromCOSE(-49) // 'ML-DSA-65' // Algorithm metadata Algorithm.get('ML-DSA-65') diff --git a/packages/pq-oid/ts/src/index.ts b/packages/pq-oid/ts/src/index.ts index 8bbf985..4d3968e 100644 --- a/packages/pq-oid/ts/src/index.ts +++ b/packages/pq-oid/ts/src/index.ts @@ -6,6 +6,7 @@ import { fromJOSE as fromJOSEMapping, toJOSE as toJOSEMapping } from './mappings import { // Lookup functions fromName, + isCanonicalOid, ML_DSA_44, ML_DSA_65, ML_DSA_87, @@ -80,6 +81,7 @@ export const OID = { // Name/OID conversion functions fromName, toName, + isCanonicalOid, // DER encoding/decoding functions toBytes: encodeOid, @@ -97,3 +99,5 @@ export const OID = { /** @deprecated Use fromCose() from 'pq-algorithm-id'. */ fromCOSE, }; + +export { fromName, isCanonicalOid, toName } from './oid'; diff --git a/packages/pq-oid/ts/src/oid.ts b/packages/pq-oid/ts/src/oid.ts index 0d62f70..888498f 100644 --- a/packages/pq-oid/ts/src/oid.ts +++ b/packages/pq-oid/ts/src/oid.ts @@ -77,6 +77,39 @@ export const OID_TO_NAME: Record = Object.fromEntries( Object.entries(NAME_TO_OID).map(([name, oid]) => [oid, name as AlgorithmName]), ); +export 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 = arcs[0]; + if (firstArc !== '0' && firstArc !== '1' && firstArc !== '2') { + return false; + } + + const secondArc = arcs[1]; + if (firstArc === '0' || firstArc === '1') { + if (secondArc.length === 1) { + return true; + } + + if (secondArc.length > 2 || secondArc > '39') { + return false; + } + } + + return true; +} + export function fromName(name: AlgorithmName): string { const oid = NAME_TO_OID[name]; if (!oid) { diff --git a/packages/pq-oid/ts/tests/oid.test.ts b/packages/pq-oid/ts/tests/oid.test.ts index 07b2478..3dff1a9 100644 --- a/packages/pq-oid/ts/tests/oid.test.ts +++ b/packages/pq-oid/ts/tests/oid.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { fromName, toName } from '../src/oid'; +import { fromName, isCanonicalOid, toName } from '../src/oid'; import type { AlgorithmName } from '../src/types'; // Expected OID values from NIST/IETF standards @@ -133,3 +133,31 @@ describe('toName()', () => { expect(() => toName('1.2.3.4.5.6.7.8.9')).toThrow(); }); }); + +describe('isCanonicalOid()', () => { + it('accepts canonical dotted OIDs', () => { + const validOids = ['2.16.840.1.101.3.4.3.18', '0.39', '1.39.0', '2.999999999999999999999']; + + for (const oid of validOids) { + expect(isCanonicalOid(oid)).toBe(true); + } + }); + + it('rejects malformed or non-canonical OIDs', () => { + 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.018', + '03.16.840.1.101.3.4.3.18', + '1.40.3', + '+2.16.840.1.101.3.4.3.18', + '2.16.840.1.101.3.4.3.a', + ]; + + for (const oid of invalidOids) { + expect(isCanonicalOid(oid)).toBe(false); + } + }); +});