diff --git a/CHANGELOG.md b/CHANGELOG.md index 813fcbd..3b1068a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,89 @@ ## Upcoming release +## [4.0.0] + +### Breaking Changes + +- **Signing goes through the new asynchronous `Signer` interface** — the + signing-side counterpart of `Level1KeyProvider`. A `Signer` wraps wherever + the private key actually lives: `localSigner(privateKey, curve)` for a raw + key in memory, or your own wrapper over WebCrypto (non-extractable keys), + an HSM or a cloud KMS. Consequences: + - `signAndEncodeTicket(ticket, level1Key, level2Key?)` becomes + `signAndEncodeTicket(ticket, { level1, level2? })` taking `Signer`s, and + returns a `Promise`. + - `signLevel1(ticket, privateKey, curve)` and + `signLevel2(ticket, privateKey, curve)` become + `signLevel1(ticket, signer)` / `signLevel2(ticket, signer)`, both async. + - `SigningKeyPair` is no longer accepted by the signing functions; it + remains only as the return type of `generateKeyPair`. + +- **One algorithm-OID policy, aligned with the verifier's "a disagreement is + an error" rule.** `signAndEncodeTicket` is authoritative: it writes the + OIDs — and the Level 2 public key — from the signers into the header, + whatever the input ticket carried, so its output is always + self-consistent. `signLevel1`/`signLevel2` sign the ticket's data + **exactly as given**: an OID present on the ticket that contradicts the + signer's curve is now an error (previously `signLevel1` silently filled + missing OIDs into the *signed* bytes without putting them on the ticket, + which produced unverifiable barcodes if the caller encoded without them), + and a missing OID stays missing. + +- **`getPublicKey` (public-key derivation) is renamed `derivePublicKey`**, + dissolving the name collision with `Level1KeyProvider.getPublicKey`. + +### New Features + +- **HSM / KMS / WebCrypto signing**: implement `Signer` over any backend + that can produce a DER ECDSA signature — the raw private key never has to + enter the library. The Level 2 signer must expose `getPublicKey()` (its + key is embedded in the barcode); a Level 1 signer can omit it. + +- **Producing barcodes that omit their algorithm OIDs** (algorithms shared + out of band, like the Car Jaune fixture) is now possible: leave the OIDs + off the ticket and use `signLevel1` — what is signed is exactly what is + encoded. The old placeholder-based flow silently signed filled-in OIDs, + making such barcodes impossible to produce correctly. + +- `signLevel1`/`signLevel2` no longer re-encode the whole ticket with + placeholder signatures (three encodes down to one); they are built on the + same composable primitives as `signAndEncodeTicket`. + +- **`controlTicket` accepts raw bytes as well as hex** — + `controlTicket(input: string | Uint8Array, options?)`. It was the last + hex-only entry point; a scanner pipeline can now pass its payload bytes + straight through, consistently with the verify/extract/collect APIs. + Hex strings keep working unchanged. + +### Migrating from 3.0.0 + +Only the signing API changed; decoding, encoding primitives (`signPayload` +included), verification, extraction and collection are untouched. + +| 3.0.0 | Now | +| --- | --- | +| `signAndEncodeTicket(ticket, l1Key, l2Key)` | `await signAndEncodeTicket(ticket, { level1: localSigner(l1Key.privateKey, l1Key.curve), level2: localSigner(l2Key.privateKey, l2Key.curve) })` | +| `signAndEncodeTicket(ticket, l1Key)` | `await signAndEncodeTicket(ticket, { level1: localSigner(l1Key.privateKey, l1Key.curve) })` | +| `signLevel1(ticket, privateKey, curve)` | `await signLevel1(ticket, localSigner(privateKey, curve))` | +| `signLevel2(ticket, privateKey, curve)` | `await signLevel2(ticket, localSigner(privateKey, curve))` | +| `getPublicKey(privateKey, curve)` | `derivePublicKey(privateKey, curve)` | + +Behavioral changes to review when migrating: + +- Every signing call is now `await`ed — callers become async. +- `signLevel1`/`signLevel2` no longer fill missing algorithm OIDs into the + signed bytes. If you relied on that, set the OIDs on the ticket yourself + before signing (e.g. `level1KeyAlg: CURVES[curve].keyAlgOid`, + `level1SigningAlg: CURVES[curve].sigAlgOid`) — which 3.0.0 already + required anyway for the final encode to match what was signed. If you + left them off deliberately, the signature now correctly covers a header + without OIDs. +- `signLevel1`/`signLevel2` now throw when the ticket's OIDs contradict the + signer's curve, instead of signing bytes that could never verify. +- A custom Level 2 `Signer` must implement `getPublicKey()`; + `localSigner` always does. + ## [3.0.0] ### Breaking Changes diff --git a/README.md b/README.md index ef980ee..912ed75 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,23 @@ const bytes = encodeTicketToBytes(ticket); ## Signing -Sign tickets with ECDSA using the two-pass signing flow (Level 1, then Level 2): +Signing goes through the `Signer` interface — the signing-side counterpart of +`Level1KeyProvider`. A signer wraps wherever the private key actually lives: +`localSigner(privateKey, curve)` for a raw key in memory, or your own wrapper +over WebCrypto (non-extractable keys), an HSM or a cloud KMS: ```ts -import { signAndEncodeTicket, generateKeyPair } from 'dosipas-ts'; +interface Signer { + curve: CurveName; // decides the OIDs and the hash + sign(data: Uint8Array): Promise; // DER-encoded ECDSA signature + getPublicKey?(): Promise; // required for Level 2 signers +} +``` + +Sign tickets with the two-level flow (Level 1, then Level 2): + +```ts +import { signAndEncodeTicket, generateKeyPair, localSigner } from 'dosipas-ts'; import type { UicBarcodeTicket } from 'dosipas-ts'; const level1Key = generateKeyPair('P-256'); @@ -178,26 +191,40 @@ const ticket: UicBarcodeTicket = { }, }; -const ticketBytes = signAndEncodeTicket( - ticket, - level1Key, - level2Key, // omit for static barcodes (Level 1 only) -); +const ticketBytes = await signAndEncodeTicket(ticket, { + level1: localSigner(level1Key.privateKey, 'P-256'), + level2: localSigner(level2Key.privateKey, 'P-256'), // omit for static barcodes +}); ``` -For finer control, sign each level independently: +`signAndEncodeTicket` treats the signers as authoritative: the algorithm OIDs +(and the Level 2 public key) are written into the header from the signers, +whatever the input ticket carried, so its output is always self-consistent. + +For finer control, sign each level independently. The low-level functions +sign the ticket's data **exactly as given** — an OID on the ticket that +contradicts the signer's curve is an error, and absent OIDs stay absent +(that is how barcodes whose algorithms are shared out of band are produced): ```ts -import { signLevel1, signLevel2 } from 'dosipas-ts'; +import { signLevel1, signLevel2, localSigner } from 'dosipas-ts'; + +const signer1 = localSigner(privateKey, 'P-256'); +const signer2 = localSigner(level2PrivateKey, 'P-256'); -const level1Sig = signLevel1(ticket, privateKey, 'P-256'); -const level2Sig = signLevel2( +const level1Sig = await signLevel1(ticket, signer1); +const level2Sig = await signLevel2( { ...ticket, level2SignedData: { ...ticket.level2SignedData, level1Signature: level1Sig } }, - level2PrivateKey, - 'P-256', + signer2, ); ``` +Key utilities: `generateKeyPair(curve)` makes a random key pair, +`derivePublicKey(privateKey, curve)` derives the uncompressed public point, +and `signPayload(data, privateKey, curve)` is the synchronous raw-key +signing primitive that `localSigner` wraps (used by the fully composable +flow below). + For a fully composable encoding flow using the low-level primitives (`encodeLevel1Data`, `encodeLevel2SignedData`, `encodeUicBarcode`), see [`examples/encoder.ts`](examples/encoder.ts). ## Signature verification @@ -348,7 +375,8 @@ Perform comprehensive validation of a ticket in a single call: ```ts import { controlTicket } from 'dosipas-ts'; -const result = await controlTicket(hexPayload, { +// Accepts a hex string or raw bytes +const result = await controlTicket(payload, { level1KeyProvider: provider, expectedIntercodeNetworkIds: new Set(['250502']), }); @@ -379,6 +407,10 @@ getEndOfValidityTime(ticket) // Date from v2 endOfValidity fields or v1 issuin getDynamicContentTime(ticket) // Date from FDC1 timestamp or Intercode ID1 dynamic fields ``` +For open tickets, `getOpenTicketValidityWindow(openTicket, issuingDetail)` +computes the `{ validFrom, validUntil }` window from the relative +day/time/UTC-offset fields. + ## Extracting signatures and signed data `extractTicket` produces the canonical `ExtractedTicket` record: the exact @@ -414,7 +446,7 @@ expected input in a camera loop, so `add` reports them as a result instead of throwing: ```ts -import { SignatureCollector } from 'dosipas-ts'; +import { SignatureCollector, signatureKey } from 'dosipas-ts'; const collector = new SignatureCollector(); @@ -432,7 +464,8 @@ for (const group of collector.groups()) { group.tickets // ExtractedTicket[] — full records, in scan order } -collector.group('3703/7') // lookup by id label (or by SignatureKey) +collector.group('3703/7') // lookup by id label... +collector.group(signatureKey({ securityProviderIA5: 'IWN8', keyId: 1 })) // ...or by key ``` For a batch already in hand, `collectSignatures(payloads)` does the same in @@ -500,7 +533,8 @@ import { SNCF_TER_SIGNATURES, SOLEA_SIGNATURES, CTS_SIGNATURES, CAR_JAUNE_SIGNAT | RSA with SHA-256 | No | Detected only | The OIDs for these live in `SIGNING_ALGORITHMS` and `KEY_ALGORITHMS` -(`src/oids.ts`), exported from the package. Those tables are the accepted +(`src/oids.ts`), exported from the package, with `getSigningAlgorithm(oid)` / +`getKeyAlgorithm(oid)` for single lookups. Those tables are the accepted values for the `keyAlg` / `signingAlg` fields described above; note that the DSA and RSA entries are recognised for reporting but never verify. diff --git a/package-lock.json b/package-lock.json index d3338f8..cf61c3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dosipas-ts", - "version": "3.0.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dosipas-ts", - "version": "3.0.0", + "version": "4.0.0", "license": "MIT", "dependencies": { "@noble/curves": "^2.0.1", diff --git a/package.json b/package.json index 6367197..21dcef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dosipas-ts", - "version": "3.0.0", + "version": "4.0.0", "description": "UIC barcode ticket encoder/decoder/verifier with Intercode 6 extensions", "type": "module", "exports": { diff --git a/src/control.ts b/src/control.ts index 27eb7e1..a1d82e6 100644 --- a/src/control.ts +++ b/src/control.ts @@ -5,7 +5,7 @@ * and running a series of focused check functions — each responsible for * verifying one specific aspect of the ticket. */ -import { decodeTicket } from './decoder.js'; +import { decodeTicket, decodeTicketFromBytes } from './decoder.js'; import { verifySignatures } from './verifier.js'; import { getEndOfValidityTime, getDynamicContentTime, getOpenTicketValidityWindow } from './time-helpers.js'; import type { @@ -616,17 +616,17 @@ function checkOpenTicketValidity( /** * Perform comprehensive validation of a dosipas ticket. * - * Decodes the ticket from hex, then runs a series of check functions covering + * Decodes the ticket, then runs a series of check functions covering * header format, security metadata, signatures, expiry, specimen/activated * flags, issuing details, transport documents, Intercode extensions, and * dynamic content freshness. * - * @param hex - Hex-encoded barcode payload. + * @param input - Barcode payload, as a hex string or raw bytes. * @param options - Control options (reference time, key provider, expected networks). * @returns Aggregated control result with individual check results. */ export async function controlTicket( - hex: string, + input: string | Uint8Array, options?: ControlOptions, ): Promise { const checks: Record = {}; @@ -636,7 +636,7 @@ export async function controlTicket( // 1. Decode let ticket: UicBarcodeTicket; try { - ticket = decodeTicket(hex); + ticket = typeof input === 'string' ? decodeTicket(input) : decodeTicketFromBytes(input); checks.decode = { name: 'Decode', passed: true, @@ -652,8 +652,8 @@ export async function controlTicket( return { valid: false, checks }; } - // Convert hex to bytes for signature verification - const bytes = hexToBytes(hex); + // Bytes for signature verification + const bytes = typeof input === 'string' ? hexToBytes(input) : input; // 2. Header checks.header = checkHeader(ticket); diff --git a/src/index.ts b/src/index.ts index e696224..69db905 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ export { controlTicket } from './control.js'; export { getIssuingTime, getEndOfValidityTime, getDynamicContentTime, getOpenTicketValidityWindow } from './time-helpers.js'; export { extractTicket, signatureKey } from './signed-data.js'; export { SignatureCollector, collectSignatures } from './signature-collector.js'; -export { signLevel1, signLevel2, signAndEncodeTicket, signPayload, generateKeyPair, getPublicKey, CURVES } from './signer.js'; +export { signLevel1, signLevel2, signAndEncodeTicket, signPayload, generateKeyPair, derivePublicKey, localSigner, CURVES } from './signer.js'; // The accepted values for the `keyAlg` / `signingAlg` fields on // Level1KeyMaterial and Level2Algorithms are the keys of these two tables. export { SIGNING_ALGORITHMS, KEY_ALGORITHMS, getSigningAlgorithm, getKeyAlgorithm } from './oids.js'; @@ -61,6 +61,6 @@ export type { RecoverLevel1KeyOptions } from './key-recovery.js'; export type { SigningAlgorithm, KeyAlgorithm } from './oids.js'; export type { ExtractedTicket, SignatureKey, LevelSignatureData } from './signed-data.js'; export type { SignatureGroup, AddResult } from './signature-collector.js'; -export type { CurveName, CurveConfig, SigningKeyPair } from './signer.js'; +export type { CurveName, CurveConfig, SigningKeyPair, Signer } from './signer.js'; export { RawBytes } from 'asn1-per-ts'; diff --git a/src/signer.ts b/src/signer.ts index 08003c2..3d53143 100644 --- a/src/signer.ts +++ b/src/signer.ts @@ -1,19 +1,27 @@ /** * Signing helpers for UIC barcode tickets. * - * Provides functions to sign Level 1 data, Level 2 data, or both, using - * ECDSA private keys. The two-pass signing flow is: + * Signing goes through the {@link Signer} interface — the signing-side + * counterpart of `Level1KeyProvider` on the verification side. A signer + * wraps wherever the private key actually lives: in memory + * ({@link localSigner}), in WebCrypto, in an HSM or a cloud KMS. All + * signing entry points are asynchronous for that reason. * - * 1. Encode with placeholder (zero) signatures - * 2. Extract signed data bytes, sign Level 1 - * 3. Re-encode with real Level 1 + placeholder Level 2 - * 4. Extract signed data bytes, sign Level 2 - * 5. Final encode with both real signatures + * Algorithm policy mirrors the verifier's "a disagreement is an error" + * rule: + * + * - {@link signAndEncodeTicket} (high level) is authoritative: it writes the + * algorithm OIDs — and the Level 2 public key — from the signers into the + * header it encodes, so its output is always self-consistent. + * - {@link signLevel1} / {@link signLevel2} (low level) sign the ticket's + * data exactly as given, because the caller controls the final encoding: + * an OID present on the ticket that contradicts the signer's curve is an + * error, and a missing OID stays missing (that is how barcodes whose + * algorithms are shared out of band are produced). */ import { p256, p384, p521 } from '@noble/curves/nist.js'; -import { encodeLevel1Data, encodeLevel2Data, encodeLevel2SignedData, encodeUicBarcode, encodeTicketToBytes } from './encoder.js'; -import { extractTicket } from './signed-data.js'; +import { encodeLevel1Data, encodeLevel2Data, encodeLevel2SignedData, encodeUicBarcode } from './encoder.js'; import { rawToDer } from './signature-utils.js'; import type { RawBytes } from 'asn1-per-ts'; import type { UicBarcodeTicket, Level1Data } from './types.js'; @@ -34,13 +42,45 @@ export interface CurveConfig { sigAlgOid: string; } -/** An ECDSA key pair for signing. */ +/** An ECDSA key pair, as returned by {@link generateKeyPair}. */ export interface SigningKeyPair { privateKey: Uint8Array; publicKey: Uint8Array; curve: CurveName; } +/** + * A signing capability: the signing-side counterpart of `Level1KeyProvider`. + * + * Implement it over whatever holds the private key — {@link localSigner} + * for a raw key in memory, or your own wrapper over WebCrypto + * (non-extractable keys), an HSM or a cloud KMS. + */ +export interface Signer { + /** + * The ECDSA curve this signer signs with. It decides the algorithm OIDs + * written into headers and the hash applied to the data (SHA-256 for + * P-256, SHA-384 for P-384, SHA-512 for P-521). + */ + readonly curve: CurveName; + + /** + * Sign data and return a DER-encoded ECDSA signature. + * + * @param data - The bytes to sign; the implementation hashes them with + * the curve's associated hash before signing. + */ + sign(data: Uint8Array): Promise; + + /** + * The signer's public key (uncompressed EC point or SPKI DER). + * + * Optional: only required when the signer is used for Level 2, whose + * public key is embedded in the barcode. A Level 1 signer can omit it. + */ + getPublicKey?(): Promise; +} + // --------------------------------------------------------------------------- // Curve registry // --------------------------------------------------------------------------- @@ -74,15 +114,6 @@ function getCurve(curve: CurveName): CurveInstance { } } -/** Maximum DER signature size for each curve. */ -function maxDerSigSize(curve: CurveName): number { - switch (curve) { - case 'P-256': return 72; - case 'P-384': return 104; - case 'P-521': return 139; - } -} - /** Component byte length for each curve. */ function componentLength(curve: CurveName): number { switch (curve) { @@ -105,15 +136,33 @@ function ecSign(data: Uint8Array, privateKey: Uint8Array, curve: CurveName): Uin } // --------------------------------------------------------------------------- -// Public signing primitive +// Signers // --------------------------------------------------------------------------- +/** + * A {@link Signer} over a raw private key held in memory. + * + * @param privateKey - The ECDSA private key bytes. + * @param curve - The ECDSA curve to sign with. + */ +export function localSigner(privateKey: Uint8Array, curve: CurveName): Signer { + return { + curve, + async sign(data: Uint8Array): Promise { + return ecSign(data, privateKey, curve); + }, + async getPublicKey(): Promise { + return getCurve(curve).getPublicKey(privateKey, false); + }, + }; +} + /** * Sign arbitrary data with ECDSA and return a DER-encoded signature. * - * This is the low-level signing primitive used by the composable encoding flow. - * It hashes the data with the curve's associated hash (SHA-256 for P-256, etc.) - * and returns a DER-encoded ECDSA signature. + * This is the low-level synchronous signing primitive over a raw private + * key; {@link localSigner} wraps it. It hashes the data with the curve's + * associated hash (SHA-256 for P-256, etc.). * * @param data - The bytes to sign (will be hashed internally). * @param privateKey - The ECDSA private key bytes. @@ -129,7 +178,7 @@ export function signPayload( } // --------------------------------------------------------------------------- -// Key generation helper +// Key helpers // --------------------------------------------------------------------------- /** @@ -152,136 +201,160 @@ export function generateKeyPair(curve: CurveName): SigningKeyPair { * @param curve - The curve. * @returns Uncompressed public key bytes (0x04 || x || y). */ -export function getPublicKey(privateKey: Uint8Array, curve: CurveName): Uint8Array { +export function derivePublicKey(privateKey: Uint8Array, curve: CurveName): Uint8Array { const c = getCurve(curve); return c.getPublicKey(privateKey, false); } +// --------------------------------------------------------------------------- +// Algorithm OID policy +// --------------------------------------------------------------------------- + +/** + * Error when a ticket carries an algorithm OID that contradicts the signer's + * curve. A missing OID is fine here — the low-level functions sign the data + * as given, and omitting the OIDs is how out-of-band-algorithm barcodes are + * produced. + */ +function assertOidsMatchCurve( + level: 1 | 2, + keyAlg: string | undefined, + signingAlg: string | undefined, + curve: CurveName, +): void { + const config = CURVES[curve]; + if (keyAlg !== undefined && keyAlg !== config.keyAlgOid) { + throw new Error( + `Ticket level${level}KeyAlg is ${keyAlg}, but the signer's curve ` + + `${curve} implies ${config.keyAlgOid}. Fix the ticket's OIDs or use a ` + + `signer for the matching curve — a silently divergent header would ` + + `not verify.`, + ); + } + if (signingAlg !== undefined && signingAlg !== config.sigAlgOid) { + throw new Error( + `Ticket level${level}SigningAlg is ${signingAlg}, but the signer's ` + + `curve ${curve} implies ${config.sigAlgOid}. Fix the ticket's OIDs or ` + + `use a signer for the matching curve — a silently divergent header ` + + `would not verify.`, + ); + } +} + // --------------------------------------------------------------------------- // Public API — individual level signing // --------------------------------------------------------------------------- /** - * Sign Level 1 data of a ticket. + * Sign the Level 1 data of a ticket, exactly as it stands. * - * Encodes the ticket with a zero-filled Level 1 signature placeholder, - * extracts the Level 1 signed data, and produces the DER signature. + * The ticket's `level1Data` is encoded as given and signed — nothing is + * added or rewritten, so the bytes signed here are the bytes a final encode + * of the same ticket will carry. In particular, absent algorithm OIDs stay + * absent (the out-of-band-algorithm case); an OID that contradicts the + * signer's curve is an error. * - * @param ticket - Ticket (level1Signature will be overwritten with a zero placeholder). - * @param privateKey - Level 1 private key bytes. - * @param curve - The ECDSA curve. + * @param ticket - The ticket whose `level1Data` to sign. + * @param signer - The Level 1 signer. * @returns DER-encoded Level 1 signature bytes. */ -export function signLevel1( +export async function signLevel1( ticket: UicBarcodeTicket, - privateKey: Uint8Array, - curve: CurveName, -): Uint8Array { - const curveConfig = CURVES[curve]; - const zeroSig = new Uint8Array(maxDerSigSize(curve)); + signer: Signer, +): Promise { const l1 = ticket.level2SignedData.level1Data; - - const prepared: UicBarcodeTicket = { - ...ticket, - level2SignedData: { - ...ticket.level2SignedData, - level1Data: { - ...l1, - level1KeyAlg: l1.level1KeyAlg ?? curveConfig.keyAlgOid, - level1SigningAlg: l1.level1SigningAlg ?? curveConfig.sigAlgOid, - }, - level1Signature: zeroSig, - }, - level2Signature: ticket.level2Signature ?? new Uint8Array(0), - }; - - const bytes = encodeTicketToBytes(prepared); - const extracted = extractTicket(bytes); - return ecSign(extracted.level1.signedBytes, privateKey, curve); + assertOidsMatchCurve(1, l1.level1KeyAlg, l1.level1SigningAlg, signer.curve); + const level1Raw = encodeLevel1Data(l1, ticket.format); + return signer.sign(level1Raw.data); } /** - * Sign Level 2 data of a ticket. + * Sign the Level 2 data of a ticket, exactly as it stands. * - * Encodes the ticket with the provided Level 1 signature and a zero-filled - * Level 2 placeholder, extracts the Level 2 signed data, and produces the - * DER signature. + * Encodes `level2SignedData` (level1Data + level1Signature + level2Data) as + * given and signs it. Same as-given policy as {@link signLevel1}: absent + * Level 2 algorithm OIDs stay absent, contradictory ones are an error. * * @param ticket - Ticket with `level2SignedData.level1Signature` already set. - * @param privateKey - Level 2 private key bytes. - * @param curve - The ECDSA curve. + * @param signer - The Level 2 signer. * @returns DER-encoded Level 2 signature bytes. */ -export function signLevel2( +export async function signLevel2( ticket: UicBarcodeTicket, - privateKey: Uint8Array, - curve: CurveName, -): Uint8Array { - if (!ticket.level2SignedData.level1Signature) { + signer: Signer, +): Promise { + const { level1Data, level1Signature, level2Data } = ticket.level2SignedData; + if (!level1Signature) { throw new Error('Level 1 signature must be set before signing Level 2'); } + assertOidsMatchCurve(2, level1Data.level2KeyAlg, level1Data.level2SigningAlg, signer.curve); - const curveConfig = CURVES[curve]; - const zeroSig = new Uint8Array(maxDerSigSize(curve)); - const l1 = ticket.level2SignedData.level1Data; - - const prepared: UicBarcodeTicket = { - ...ticket, - level2SignedData: { - ...ticket.level2SignedData, - level1Data: { - ...l1, - level2KeyAlg: l1.level2KeyAlg ?? curveConfig.keyAlgOid, - level2SigningAlg: l1.level2SigningAlg ?? curveConfig.sigAlgOid, - }, - }, - level2Signature: zeroSig, - }; - - const bytes = encodeTicketToBytes(prepared); - const extracted = extractTicket(bytes); - return ecSign(extracted.level2.signedBytes, privateKey, curve); + const headerVersion = parseInt(ticket.format.replace('U', ''), 10); + const level1Raw = encodeLevel1Data(level1Data, ticket.format); + const level2Raw = encodeLevel2SignedData({ + headerVersion, + level1Data: level1Raw, + level1Signature, + level2Data: level2Data ? encodeLevel2Data(level2Data, ticket.format) : undefined, + }); + return signer.sign(level2Raw.data); } /** * Sign and encode a complete ticket with both Level 1 and Level 2 signatures. * - * Uses the composable encoding primitives: + * The signers are authoritative for the header's security claims: the + * algorithm OIDs are written from each signer's curve, and the Level 2 + * public key from the Level 2 signer, whatever the input ticket carried — + * the output is always self-consistent. When `keys.level2` is omitted, only + * Level 1 signing is performed (static barcode mode) and the ticket's + * Level 2 fields are kept as-is. + * + * Flow, using the composable encoding primitives: * 1. Encode level1Data → sign → level1Signature * 2. Encode level2SignedData (with level1Data + signature) → sign → level2Signature * 3. Encode final UicBarcodeHeader * - * When `level2Key` is omitted, only Level 1 signing is performed (static barcode mode). - * * @param ticket - Ticket data to encode (signature fields are overwritten). - * @param level1Key - Level 1 signing key pair. - * @param level2Key - Optional Level 2 signing key pair. + * @param keys - The Level 1 signer, and optionally the Level 2 signer. The + * Level 2 signer must expose `getPublicKey` — its key is embedded in the + * barcode. * @returns Encoded ticket bytes with valid signatures. */ -export function signAndEncodeTicket( +export async function signAndEncodeTicket( ticket: UicBarcodeTicket, - level1Key: SigningKeyPair, - level2Key?: SigningKeyPair, -): Uint8Array { - const l1Curve = CURVES[level1Key.curve]; + keys: { level1: Signer; level2?: Signer }, +): Promise { + const { level1, level2 } = keys; + const l1Curve = CURVES[level1.curve]; const l1 = ticket.level2SignedData.level1Data; - // Build level1Data with algorithm OIDs and L2 public key. - // Always set OIDs from the key pair (overriding any existing values from a decoded ticket). + let level2PublicKey = l1.level2PublicKey; + if (level2) { + if (!level2.getPublicKey) { + throw new Error( + 'The Level 2 signer must expose getPublicKey(): its public key is embedded in the barcode', + ); + } + level2PublicKey = await level2.getPublicKey(); + } + + // Build level1Data with the signers' algorithm OIDs and the L2 public key. + // The signers are authoritative — existing values are overwritten. const level1Data: Level1Data = { ...l1, level1KeyAlg: l1Curve.keyAlgOid, - level2KeyAlg: level2Key ? CURVES[level2Key.curve].keyAlgOid : l1.level2KeyAlg, + level2KeyAlg: level2 ? CURVES[level2.curve].keyAlgOid : l1.level2KeyAlg, level1SigningAlg: l1Curve.sigAlgOid, - level2SigningAlg: level2Key ? CURVES[level2Key.curve].sigAlgOid : l1.level2SigningAlg, - level2PublicKey: level2Key ? level2Key.publicKey : l1.level2PublicKey, + level2SigningAlg: level2 ? CURVES[level2.curve].sigAlgOid : l1.level2SigningAlg, + level2PublicKey, }; // Step 1: Encode level1Data const level1Raw = encodeLevel1Data(level1Data, ticket.format); // Step 2: Sign level1Data - const level1Sig = signPayload(level1Raw.data, level1Key.privateKey, level1Key.curve); + const level1Sig = await level1.sign(level1Raw.data); // Step 3: Build level2Data if present let level2DataEncoded: RawBytes | undefined; @@ -299,10 +372,10 @@ export function signAndEncodeTicket( level2Data: level2DataEncoded, }); - // Step 5: Sign level2SignedData (if level2Key provided) + // Step 5: Sign level2SignedData (if a Level 2 signer is provided) let level2Sig: Uint8Array | undefined; - if (level2Key) { - level2Sig = signPayload(level2Raw.data, level2Key.privateKey, level2Key.curve); + if (level2) { + level2Sig = await level2.sign(level2Raw.data); } // Step 6: Encode final barcode diff --git a/tests/control.test.ts b/tests/control.test.ts index da833cd..c36e5b7 100644 --- a/tests/control.test.ts +++ b/tests/control.test.ts @@ -1,6 +1,7 @@ import { controlTicket, signAndEncodeTicket, + localSigner, generateKeyPair, SAMPLE_TICKET_HEX, SNCF_TER_TICKET_HEX, @@ -95,6 +96,25 @@ describe('controlTicket — decode failure', () => { }); }); +describe('controlTicket — raw bytes input', () => { + it('produces the same result as the hex form', async () => { + const bytes = new Uint8Array( + SAMPLE_TICKET_HEX.match(/.{1,2}/g)!.map((b) => parseInt(b, 16)), + ); + const fromBytes = await controlTicket(bytes); + const fromHex = await controlTicket(SAMPLE_TICKET_HEX); + expect(fromBytes.checks.decode.passed).toBe(true); + expect(Object.keys(fromBytes.checks)).toEqual(Object.keys(fromHex.checks)); + expect(fromBytes.valid).toBe(fromHex.valid); + }); + + it('returns failed decode check for garbage bytes', async () => { + const result = await controlTicket(new Uint8Array([1, 2, 3])); + expect(result.valid).toBe(false); + expect(result.checks.decode.passed).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // 2. SAMPLE_TICKET_HEX — U1, FCB2, Intercode 6, _3703.ID1 // --------------------------------------------------------------------------- @@ -245,7 +265,7 @@ describe('controlTicket — specimen', () => { endOfValidityYear: 2099, endOfValidityDay: 365, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const hex = bytesToHex(encoded); const result = await controlTicket(hex); @@ -322,7 +342,7 @@ describe('controlTicket — missing network ID', () => { endOfValidityYear: 2099, endOfValidityDay: 365, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const hex = bytesToHex(encoded); const result = await controlTicket(hex, { @@ -392,7 +412,7 @@ describe('controlTicket — zones & carriers', () => { ) { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ ...baseOpts, openTicketValue, ticketKey }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const hex = bytesToHex(encoded); return controlTicket(hex, controlOpts); } @@ -560,7 +580,7 @@ describe('controlTicket — open ticket validity', () => { ) { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ ...baseOpts, openTicketValue, ticketKey }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const hex = bytesToHex(encoded); return controlTicket(hex, controlOpts); } @@ -678,7 +698,7 @@ describe('controlTicket — open ticket validity', () => { }, }, }; - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const hex = bytesToHex(encoded); const result = await controlTicket(hex, { now: new Date('2025-06-15T12:00:00Z') }); expect(result.checks.openTicketValidity.passed).toBe(true); diff --git a/tests/key-recovery.test.ts b/tests/key-recovery.test.ts index e66849b..5ef37c5 100644 --- a/tests/key-recovery.test.ts +++ b/tests/key-recovery.test.ts @@ -3,8 +3,9 @@ import { decodeTicket, encodeTicketToBytes, signAndEncodeTicket, + localSigner, generateKeyPair, - getPublicKey, + derivePublicKey, verifyLevel1Signature, SOLEA_TICKET_HEX, CTS_TICKET_HEX, @@ -29,16 +30,19 @@ function bytesToHex(bytes: Uint8Array): string { function makeKeyPair(privateKey: Uint8Array, curve: 'P-256' | 'P-384' | 'P-521'): SigningKeyPair { return { privateKey, - publicKey: getPublicKey(privateKey, curve), + publicKey: derivePublicKey(privateKey, curve), curve, }; } /** Re-sign a decoded fixture ticket with the given key pair. */ -function resign(ticketHex: string, l1Key: SigningKeyPair): Uint8Array { +function resign(ticketHex: string, l1Key: SigningKeyPair): Promise { const ticket = decodeTicket(ticketHex); const l2Key = generateKeyPair(l1Key.curve); - return signAndEncodeTicket(ticket, l1Key, l2Key); + return signAndEncodeTicket(ticket, { + level1: localSigner(l1Key.privateKey, l1Key.curve), + level2: localSigner(l2Key.privateKey, l2Key.curve), + }); } // --------------------------------------------------------------------------- @@ -61,7 +65,7 @@ describe('recoverLevel1PublicKey on real tickets', () => { expect((await verifyLevel1Signature(cts, key)).valid).toBe(true); }); - it('yields the true key among the candidates from a single ticket', () => { + it('yields the true key among the candidates from a single ticket', async () => { const solea = hexToBytes(SOLEA_TICKET_HEX); const cts = hexToBytes(CTS_TICKET_HEX); @@ -108,45 +112,45 @@ describe('recoverLevel1PublicKey on real tickets', () => { // --------------------------------------------------------------------------- describe('recoverLevel1PublicKey on re-signed tickets', () => { - it('recovers exactly the signing key from two P-256 tickets', () => { + it('recovers exactly the signing key from two P-256 tickets', async () => { const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); - const tickets = [resign(SOLEA_TICKET_HEX, l1Key), resign(CTS_TICKET_HEX, l1Key)]; + const tickets = [await resign(SOLEA_TICKET_HEX, l1Key), await resign(CTS_TICKET_HEX, l1Key)]; const candidates = recoverLevel1PublicKey(tickets); expect(candidates).toHaveLength(1); expect(bytesToHex(candidates[0])).toBe(bytesToHex(l1Key.publicKey)); }); - it.each(['P-384', 'P-521'] as const)('recovers the signing key on %s', curve => { + it.each(['P-384', 'P-521'] as const)('recovers the signing key on %s', async curve => { const l1Key = generateKeyPair(curve); - const tickets = [resign(SOLEA_TICKET_HEX, l1Key), resign(CTS_TICKET_HEX, l1Key)]; + const tickets = [await resign(SOLEA_TICKET_HEX, l1Key), await resign(CTS_TICKET_HEX, l1Key)]; const candidates = recoverLevel1PublicKey(tickets); expect(candidates).toHaveLength(1); expect(bytesToHex(candidates[0])).toBe(bytesToHex(l1Key.publicKey)); }); - it('includes the signing key among the candidates from a single ticket', () => { + it('includes the signing key among the candidates from a single ticket', async () => { const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); - const candidates = recoverLevel1PublicKey([resign(SOLEA_TICKET_HEX, l1Key)]); + const candidates = recoverLevel1PublicKey([await resign(SOLEA_TICKET_HEX, l1Key)]); expect(candidates.length).toBeGreaterThanOrEqual(1); expect(candidates.length).toBeLessThanOrEqual(4); expect(candidates.map(bytesToHex)).toContain(bytesToHex(l1Key.publicKey)); }); - it('returns an empty array for tickets signed with different keys', () => { + it('returns an empty array for tickets signed with different keys', async () => { const keyA = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const keyB = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const tickets = [resign(SOLEA_TICKET_HEX, keyA), resign(CTS_TICKET_HEX, keyB)]; + const tickets = [await resign(SOLEA_TICKET_HEX, keyA), await resign(CTS_TICKET_HEX, keyB)]; expect(recoverLevel1PublicKey(tickets)).toEqual([]); }); - it('narrows but never drops the true key as tickets are added', () => { + it('narrows but never drops the true key as tickets are added', async () => { const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); - const t1 = resign(SOLEA_TICKET_HEX, l1Key); - const t2 = resign(CTS_TICKET_HEX, l1Key); + const t1 = await resign(SOLEA_TICKET_HEX, l1Key); + const t2 = await resign(CTS_TICKET_HEX, l1Key); const one = recoverLevel1PublicKey([t1]).map(bytesToHex); const two = recoverLevel1PublicKey([t1, t2]).map(bytesToHex); @@ -163,18 +167,18 @@ describe('recoverLevel1PublicKey on re-signed tickets', () => { // --------------------------------------------------------------------------- describe('recoverLevel1PublicKey errors', () => { - it('rejects an empty ticket list', () => { + it('rejects an empty ticket list', async () => { expect(() => recoverLevel1PublicKey([])).toThrow('At least one ticket'); }); - it('rejects a barcode without OIDs when none are configured', () => { + it('rejects a barcode without OIDs when none are configured', async () => { const carJaune = hexToBytes(CAR_JAUNE_TICKET_HEX); expect(() => recoverLevel1PublicKey([carJaune])).toThrow( /Ticket #0: Missing level 1 signing algorithm/, ); }); - it('rejects non-ECDSA level 1 signatures', () => { + it('rejects non-ECDSA level 1 signatures', async () => { // The SNCF TER ticket's level 1 signature is DSA (OID shared out of band, // like the rest of its algorithm metadata). const ter = hexToBytes(SNCF_TER_TICKET_HEX); @@ -183,27 +187,27 @@ describe('recoverLevel1PublicKey errors', () => { ).toThrow(/Ticket #0: public key recovery requires ECDSA.*DSA/); }); - it('rejects tickets that resolve to different curves', () => { - const t256 = resign(SOLEA_TICKET_HEX, makeKeyPair(FIPS_L1_PRIV, 'P-256')); - const t384 = resign(CTS_TICKET_HEX, generateKeyPair('P-384')); + it('rejects tickets that resolve to different curves', async () => { + const t256 = await resign(SOLEA_TICKET_HEX, makeKeyPair(FIPS_L1_PRIV, 'P-256')); + const t384 = await resign(CTS_TICKET_HEX, generateKeyPair('P-384')); expect(() => recoverLevel1PublicKey([t256, t384])).toThrow( /Ticket #1: .*P-384.*ticket #0.*P-256/, ); }); - it('rejects a mismatch between the barcode OIDs and the configured ones', () => { + it('rejects a mismatch between the barcode OIDs and the configured ones', async () => { const solea = hexToBytes(SOLEA_TICKET_HEX); expect(() => recoverLevel1PublicKey([solea], { keyAlg: '1.3.132.0.34' /* P-384 */ }), ).toThrow(/Ticket #0: .*mismatch/); }); - it('rejects undecodable ticket bytes with the ticket index', () => { + it('rejects undecodable ticket bytes with the ticket index', async () => { expect(() => recoverLevel1PublicKey([new Uint8Array([1, 2, 3])])).toThrow(/Ticket #0:/); }); - it('rejects a ticket without a level 1 signature', () => { + it('rejects a ticket without a level 1 signature', async () => { // Re-encode Soléa with its level 1 signature stripped. const ticket = decodeTicket(SOLEA_TICKET_HEX); const noSig: UicBarcodeTicket = { diff --git a/tests/signer.test.ts b/tests/signer.test.ts index 4a96837..7236ac0 100644 --- a/tests/signer.test.ts +++ b/tests/signer.test.ts @@ -8,6 +8,7 @@ import { signLevel1, signLevel2, signAndEncodeTicket, + localSigner, signPayload, encodeLevel2Data, encodeLevel1Data, @@ -15,12 +16,12 @@ import { encodeUicBarcode, extractTicket, generateKeyPair, - getPublicKey, + derivePublicKey, verifyLevel1Signature, verifyLevel2Signature, CURVES, } from '../src'; -import type { UicBarcodeTicket, SigningKeyPair } from '../src'; +import type { UicBarcodeTicket, SigningKeyPair, Signer } from '../src'; /** NIST FIPS 186-4 ECDSA P-256 test vector private keys. */ const FIPS_L1_PRIV = hexToBytes('c9806898a0334916c860748880a541f093b579a9b1f32934d86c363c39800357'); @@ -37,19 +38,27 @@ function bytesToHex(bytes: Uint8Array): string { function makeKeyPair(privateKey: Uint8Array, curve: 'P-256' | 'P-384' | 'P-521'): SigningKeyPair { return { privateKey, - publicKey: getPublicKey(privateKey, curve), + publicKey: derivePublicKey(privateKey, curve), curve, }; } +/** Signer set for signAndEncodeTicket from raw key pairs. */ +function signers(l1Key: SigningKeyPair, l2Key?: SigningKeyPair) { + return { + level1: localSigner(l1Key.privateKey, l1Key.curve), + ...(l2Key ? { level2: localSigner(l2Key.privateKey, l2Key.curve) } : {}), + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('signLevel1', () => { - it('produces a DER signature for Solea ticket data', () => { + it('produces a DER signature for Solea ticket data', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); - const sig = signLevel1(ticket, FIPS_L1_PRIV, 'P-256'); + const sig = await signLevel1(ticket, localSigner(FIPS_L1_PRIV, 'P-256')); expect(sig).toBeInstanceOf(Uint8Array); expect(sig.length).toBeGreaterThan(0); @@ -59,27 +68,27 @@ describe('signLevel1', () => { }); describe('signLevel2', () => { - it('requires level1Signature to be set', () => { + it('requires level1Signature to be set', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); // Clear level1Signature const noSig: UicBarcodeTicket = { ...ticket, level2SignedData: { ...ticket.level2SignedData, level1Signature: undefined }, }; - expect(() => signLevel2(noSig, FIPS_L2_PRIV, 'P-256')).toThrow( + await expect(signLevel2(noSig, localSigner(FIPS_L2_PRIV, 'P-256'))).rejects.toThrow( 'Level 1 signature must be set', ); }); - it('produces a DER signature when L1 is set', () => { + it('produces a DER signature when L1 is set', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); - const l1Sig = signLevel1(ticket, FIPS_L1_PRIV, 'P-256'); + const l1Sig = await signLevel1(ticket, localSigner(FIPS_L1_PRIV, 'P-256')); const ticketWithL1: UicBarcodeTicket = { ...ticket, level2SignedData: { ...ticket.level2SignedData, level1Signature: l1Sig }, }; - const l2Sig = signLevel2(ticketWithL1, FIPS_L2_PRIV, 'P-256'); + const l2Sig = await signLevel2(ticketWithL1, localSigner(FIPS_L2_PRIV, 'P-256')); expect(l2Sig).toBeInstanceOf(Uint8Array); expect(l2Sig[0]).toBe(0x30); @@ -87,17 +96,17 @@ describe('signLevel2', () => { }); describe('signAndEncodeTicket', () => { - it('encodes a signed ticket from Solea data', () => { + it('encodes a signed ticket from Solea data', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const bytes = signAndEncodeTicket(ticket, l1Key, l2Key); + const bytes = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); expect(bytes).toBeInstanceOf(Uint8Array); expect(bytes.length).toBeGreaterThan(100); }); - it('works without Level 2 key (static barcode)', () => { + it('works without Level 2 key (static barcode)', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); // Remove level2Data for static mode const staticTicket: UicBarcodeTicket = { @@ -106,20 +115,20 @@ describe('signAndEncodeTicket', () => { }; const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); - const bytes = signAndEncodeTicket(staticTicket, l1Key); + const bytes = await signAndEncodeTicket(staticTicket, signers(l1Key)); expect(bytes).toBeInstanceOf(Uint8Array); expect(bytes.length).toBeGreaterThan(100); }); }); describe('decode -> re-encode -> decode round-trip', () => { - it('Solea ticket round-trips through encode/decode', () => { + it('Solea ticket round-trips through encode/decode', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); // Encode with new signatures - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); // Decode the re-encoded ticket const decoded = decodeTicketFromBytes(encoded); @@ -144,12 +153,12 @@ describe('decode -> re-encode -> decode round-trip', () => { expect(l2Data!.decoded).toBeDefined(); }); - it('CTS ticket round-trips through encode/decode', () => { + it('CTS ticket round-trips through encode/decode', async () => { const ticket = decodeTicket(CTS_TICKET_HEX); const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); const decoded = decodeTicketFromBytes(encoded); expect(decoded.format).toBe('U2'); @@ -157,12 +166,12 @@ describe('decode -> re-encode -> decode round-trip', () => { expect(decoded.level2SignedData.level1Data.dataSequence[0].decoded!.issuingDetail!.intercodeIssuing).toBeDefined(); }); - it('Sample ticket round-trips through encode/decode', () => { + it('Sample ticket round-trips through encode/decode', async () => { const ticket = decodeTicket(SAMPLE_TICKET_HEX); const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); const decoded = decodeTicketFromBytes(encoded); expect(decoded.level2SignedData.level1Data.dataSequence).toHaveLength(1); @@ -178,7 +187,7 @@ describe('signature verification on re-encoded tickets', () => { const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); const result = await verifyLevel1Signature(encoded, { publicKey: l1Key.publicKey }); expect(result.valid).toBe(true); @@ -190,7 +199,7 @@ describe('signature verification on re-encoded tickets', () => { const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); const result = await verifyLevel2Signature(encoded); expect(result.valid).toBe(true); @@ -202,7 +211,7 @@ describe('signature verification on re-encoded tickets', () => { const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); const l2Key = makeKeyPair(FIPS_L2_PRIV, 'P-256'); - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); const l1Result = await verifyLevel1Signature(encoded, { publicKey: l1Key.publicKey }); const l2Result = await verifyLevel2Signature(encoded); @@ -219,7 +228,7 @@ describe('signature verification on re-encoded tickets', () => { }; const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); - const encoded = signAndEncodeTicket(staticTicket, l1Key); + const encoded = await signAndEncodeTicket(staticTicket, signers(l1Key)); const result = await verifyLevel1Signature(encoded, { publicKey: l1Key.publicKey }); expect(result.valid).toBe(true); @@ -230,7 +239,7 @@ describe('signature verification on re-encoded tickets', () => { const l1Key = generateKeyPair('P-384'); const l2Key = generateKeyPair('P-384'); - const encoded = signAndEncodeTicket(ticket, l1Key, l2Key); + const encoded = await signAndEncodeTicket(ticket, signers(l1Key, l2Key)); const l1Result = await verifyLevel1Signature(encoded, { publicKey: l1Key.publicKey }); const l2Result = await verifyLevel2Signature(encoded); @@ -245,7 +254,7 @@ describe('signature verification on re-encoded tickets', () => { // --------------------------------------------------------------------------- describe('signPayload', () => { - it('produces a DER signature for arbitrary data', () => { + it('produces a DER signature for arbitrary data', async () => { const data = new Uint8Array([1, 2, 3, 4, 5]); const sig = signPayload(data, FIPS_L1_PRIV, 'P-256'); @@ -255,7 +264,7 @@ describe('signPayload', () => { }); describe('encodeLevel1Data', () => { - it('produces bytes matching extractTicket for Solea ticket', () => { + it('produces bytes matching extractTicket for Solea ticket', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const l1 = ticket.level2SignedData.level1Data; const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); @@ -275,7 +284,7 @@ describe('encodeLevel1Data', () => { level1Data: { ...l1, level1KeyAlg: l1Curve.keyAlgOid, level1SigningAlg: l1Curve.sigAlgOid }, }, }; - const l1Sig = signLevel1(withOids, l1Key.privateKey, 'P-256'); + const l1Sig = await signLevel1(withOids, localSigner(l1Key.privateKey, 'P-256')); const fullBytes = encodeTicketToBytes({ ...withOids, level2SignedData: { ...withOids.level2SignedData, level1Signature: l1Sig }, @@ -286,7 +295,7 @@ describe('encodeLevel1Data', () => { expect(level1Raw.data).toEqual(extracted.level1.signedBytes); }); - it('produces bytes matching extractTicket for CTS ticket', () => { + it('produces bytes matching extractTicket for CTS ticket', async () => { const ticket = decodeTicket(CTS_TICKET_HEX); const l1 = ticket.level2SignedData.level1Data; const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); @@ -304,7 +313,7 @@ describe('encodeLevel1Data', () => { level1Data: { ...l1, level1KeyAlg: l1Curve.keyAlgOid, level1SigningAlg: l1Curve.sigAlgOid }, }, }; - const l1Sig = signLevel1(withOids, l1Key.privateKey, 'P-256'); + const l1Sig = await signLevel1(withOids, localSigner(l1Key.privateKey, 'P-256')); const fullBytes = encodeTicketToBytes({ ...withOids, level2SignedData: { ...withOids.level2SignedData, level1Signature: l1Sig }, @@ -473,3 +482,99 @@ describe('composable flow end-to-end', () => { expect(l1Result.valid).toBe(true); }); }); + +describe('algorithm OID policy', () => { + it('signLevel1 rejects a ticket whose OIDs contradict the signer curve', async () => { + const ticket = decodeTicket(SOLEA_TICKET_HEX); // carries P-256 OIDs + await expect(signLevel1(ticket, localSigner(FIPS_L1_PRIV, 'P-384'))).rejects.toThrow( + /level1KeyAlg.*P-384/, + ); + }); + + it('signLevel2 rejects a ticket whose Level 2 OIDs contradict the signer curve', async () => { + const ticket = decodeTicket(SOLEA_TICKET_HEX); + const withL1: UicBarcodeTicket = { + ...ticket, + level2SignedData: { ...ticket.level2SignedData, level1Signature: new Uint8Array(70) }, + }; + await expect(signLevel2(withL1, localSigner(FIPS_L2_PRIV, 'P-521'))).rejects.toThrow( + /level2KeyAlg.*P-521/, + ); + }); + + it('signLevel1 signs a barcode that omits its OIDs (out-of-band algorithms)', async () => { + const ticket = decodeTicket(SOLEA_TICKET_HEX); + const bare: UicBarcodeTicket = { + ...ticket, + level2SignedData: { + ...ticket.level2SignedData, + level2Data: undefined, + level1Data: { + ...ticket.level2SignedData.level1Data, + level1KeyAlg: undefined, + level1SigningAlg: undefined, + level2KeyAlg: undefined, + level2SigningAlg: undefined, + level2PublicKey: undefined, + }, + }, + }; + + const sig = await signLevel1(bare, localSigner(FIPS_L1_PRIV, 'P-256')); + const bytes = encodeTicketToBytes({ + ...bare, + level2SignedData: { ...bare.level2SignedData, level1Signature: sig }, + level2Signature: undefined, + }); + + // The encoded barcode really carries no Level 1 OIDs... + const extracted = extractTicket(bytes); + expect(extracted.level1.keyAlg).toBeUndefined(); + expect(extracted.level1.signingAlg).toBeUndefined(); + + // ...and verifies with the out-of-band algorithms supplied. + const result = await verifyLevel1Signature(bytes, { + publicKey: derivePublicKey(FIPS_L1_PRIV, 'P-256'), + keyAlg: CURVES['P-256'].keyAlgOid, + signingAlg: CURVES['P-256'].sigAlgOid, + }); + expect(result.valid).toBe(true); + expect(result.algorithmSource).toBe('configured'); + }); +}); + +describe('Signer interface', () => { + it('signAndEncodeTicket requires the Level 2 signer to expose getPublicKey', async () => { + const ticket = decodeTicket(SOLEA_TICKET_HEX); + const keyless: Signer = { + curve: 'P-256', + sign: async (data) => signPayload(data, FIPS_L2_PRIV, 'P-256'), + }; + await expect( + signAndEncodeTicket(ticket, { level1: localSigner(FIPS_L1_PRIV, 'P-256'), level2: keyless }), + ).rejects.toThrow('getPublicKey'); + }); + + it('a custom Signer works end-to-end (HSM-style wrapper)', async () => { + // A signer that never hands the raw private key to the library, as an + // HSM/KMS/WebCrypto wrapper would. + const hsmStyle = (privateKey: Uint8Array): Signer => ({ + curve: 'P-256', + sign: async (data) => signPayload(data, privateKey, 'P-256'), + getPublicKey: async () => derivePublicKey(privateKey, 'P-256'), + }); + + const ticket = decodeTicket(SOLEA_TICKET_HEX); + const encoded = await signAndEncodeTicket(ticket, { + level1: hsmStyle(FIPS_L1_PRIV), + level2: hsmStyle(FIPS_L2_PRIV), + }); + + const l1 = await verifyLevel1Signature(encoded, { + publicKey: derivePublicKey(FIPS_L1_PRIV, 'P-256'), + }); + const l2 = await verifyLevel2Signature(encoded); + expect(l1.valid).toBe(true); + expect(l2.valid).toBe(true); + }); +}); diff --git a/tests/test-encode.test.ts b/tests/test-encode.test.ts index d79a1e9..467b648 100644 --- a/tests/test-encode.test.ts +++ b/tests/test-encode.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect } from 'vitest'; import { signLevel1, signAndEncodeTicket, + localSigner, encodeTicketToBytes, controlTicket, - getPublicKey, + derivePublicKey, CURVES, } from '../src/index'; import type { UicBarcodeTicket, Level1KeyProvider } from '../src/index'; @@ -59,7 +60,7 @@ describe('encode → sign → control round-trip', () => { ticket.level2SignedData.level1Data.level1KeyAlg = CURVES['P-256'].keyAlgOid; ticket.level2SignedData.level1Data.level1SigningAlg = CURVES['P-256'].sigAlgOid; - const l1Sig = signLevel1(ticket, hexToBytes(FIPS_KEY_1), 'P-256'); + const l1Sig = await signLevel1(ticket, localSigner(hexToBytes(FIPS_KEY_1), 'P-256')); const withSig: UicBarcodeTicket = { ...ticket, level2SignedData: { @@ -69,7 +70,7 @@ describe('encode → sign → control round-trip', () => { }; const hex = bytesToHex(encodeTicketToBytes(withSig)); - const pubKey = getPublicKey(hexToBytes(FIPS_KEY_1), 'P-256'); + const pubKey = derivePublicKey(hexToBytes(FIPS_KEY_1), 'P-256'); const provider: Level1KeyProvider = { async getPublicKey() { return { publicKey: pubKey }; }, }; @@ -83,11 +84,11 @@ describe('encode → sign → control round-trip', () => { const l1Key = { privateKey: hexToBytes(FIPS_KEY_1), - publicKey: getPublicKey(hexToBytes(FIPS_KEY_1), 'P-256'), + publicKey: derivePublicKey(hexToBytes(FIPS_KEY_1), 'P-256'), curve: 'P-256' as const, }; - const hex = bytesToHex(signAndEncodeTicket(ticket, l1Key)); + const hex = bytesToHex(await signAndEncodeTicket(ticket, { level1: localSigner(l1Key.privateKey, l1Key.curve) })); const provider: Level1KeyProvider = { async getPublicKey() { return { publicKey: l1Key.publicKey }; }, diff --git a/tests/time-helpers.test.ts b/tests/time-helpers.test.ts index 967fb7c..723cd87 100644 --- a/tests/time-helpers.test.ts +++ b/tests/time-helpers.test.ts @@ -5,6 +5,7 @@ import { getDynamicContentTime, getOpenTicketValidityWindow, signAndEncodeTicket, + localSigner, generateKeyPair, SAMPLE_TICKET_HEX, SOLEA_TICKET_HEX, @@ -74,7 +75,7 @@ function makeTicket(opts: { // --------------------------------------------------------------------------- describe('getIssuingTime', () => { - it('computes issuing time from SAMPLE_TICKET_HEX', () => { + it('computes issuing time from SAMPLE_TICKET_HEX', async () => { const ticket = decodeTicket(SAMPLE_TICKET_HEX); const time = getIssuingTime(ticket); expect(time).toBeDefined(); @@ -83,20 +84,20 @@ describe('getIssuingTime', () => { expect(time!.toISOString()).toBe('2020-04-30T16:35:00.000Z'); }); - it('computes issuing time from SOLEA_TICKET_HEX', () => { + it('computes issuing time from SOLEA_TICKET_HEX', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const time = getIssuingTime(ticket); expect(time).toBeDefined(); expect(time!.getUTCFullYear()).toBeGreaterThanOrEqual(2020); }); - it('returns undefined when no rail ticket is decoded', () => { + it('returns undefined when no rail ticket is decoded', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, issuingDay: 100, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const hex = bytesToHex(encoded); const decoded = decodeTicket(hex); const time = getIssuingTime(decoded); @@ -106,14 +107,14 @@ describe('getIssuingTime', () => { expect(time!.toISOString()).toBe('2025-04-10T00:00:00.000Z'); }); - it('handles issuingTime=0 (midnight)', () => { + it('handles issuingTime=0 (midnight)', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, issuingDay: 1, issuingTime: 0, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getIssuingTime(decoded); expect(time).toBeDefined(); @@ -126,7 +127,7 @@ describe('getIssuingTime', () => { // --------------------------------------------------------------------------- describe('getEndOfValidityTime', () => { - it('computes v2 end-of-validity from encoded ticket', () => { + it('computes v2 end-of-validity from encoded ticket', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, @@ -136,7 +137,7 @@ describe('getEndOfValidityTime', () => { endOfValidityTime: 720, // 12:00 noon in minutes validityDuration: 3600, // 3600 seconds = 1 hour }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getEndOfValidityTime(decoded); expect(time).toBeDefined(); @@ -144,7 +145,7 @@ describe('getEndOfValidityTime', () => { expect(time!.toISOString()).toBe('2025-07-19T12:00:00.000Z'); }); - it('returns undefined when only validityDuration is present (no endOfValidity fields)', () => { + it('returns undefined when only validityDuration is present (no endOfValidity fields)', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, @@ -152,26 +153,26 @@ describe('getEndOfValidityTime', () => { issuingTime: 60, // 1:00 AM validityDuration: 600, // 600 seconds — level2 dynamic duration, not used here }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getEndOfValidityTime(decoded); // validityDuration alone does not produce an end-of-validity expect(time).toBeUndefined(); }); - it('returns undefined when no validity duration and no end-of-validity fields', () => { + it('returns undefined when no validity duration and no end-of-validity fields', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, issuingDay: 1, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getEndOfValidityTime(decoded); expect(time).toBeUndefined(); }); - it('computes from real Solea ticket (v2 header)', () => { + it('computes from real Solea ticket (v2 header)', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const time = getEndOfValidityTime(ticket); // Solea has v2 header with end-of-validity fields @@ -185,7 +186,7 @@ describe('getEndOfValidityTime', () => { // --------------------------------------------------------------------------- describe('getDynamicContentTime', () => { - it('computes Intercode dynamic content time from SAMPLE_TICKET_HEX', () => { + it('computes Intercode dynamic content time from SAMPLE_TICKET_HEX', async () => { const ticket = decodeTicket(SAMPLE_TICKET_HEX); const time = getDynamicContentTime(ticket); expect(time).toBeDefined(); @@ -196,7 +197,7 @@ describe('getDynamicContentTime', () => { expect(time!.toISOString()).toBe('2020-04-30T14:35:10.000Z'); }); - it('computes FDC1 dynamic content time from SOLEA_TICKET_HEX', () => { + it('computes FDC1 dynamic content time from SOLEA_TICKET_HEX', async () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const time = getDynamicContentTime(ticket); // SOLEA has FDC1 with dynamicContentTimeStamp @@ -206,7 +207,7 @@ describe('getDynamicContentTime', () => { } }); - it('computes FDC1 dynamic content time from CTS_TICKET_HEX', () => { + it('computes FDC1 dynamic content time from CTS_TICKET_HEX', async () => { const ticket = decodeTicket(CTS_TICKET_HEX); const time = getDynamicContentTime(ticket); if (time) { @@ -214,19 +215,19 @@ describe('getDynamicContentTime', () => { } }); - it('returns undefined when no level2Data present', () => { + it('returns undefined when no level2Data present', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, issuingDay: 1, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getDynamicContentTime(decoded); expect(time).toBeUndefined(); }); - it('computes FDC1 time from encoded ticket with dynamicContentTimeStamp', () => { + it('computes FDC1 time from encoded ticket with dynamicContentTimeStamp', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, @@ -241,7 +242,7 @@ describe('getDynamicContentTime', () => { }, }, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getDynamicContentTime(decoded); expect(time).toBeDefined(); @@ -249,7 +250,7 @@ describe('getDynamicContentTime', () => { expect(time!.toISOString()).toBe('2025-02-19T12:00:00.000Z'); }); - it('computes Intercode time from encoded ticket with dynamic data', () => { + it('computes Intercode time from encoded ticket with dynamic data', async () => { const keys = generateKeyPair('P-256'); const ticket = makeTicket({ issuingYear: 2025, @@ -263,7 +264,7 @@ describe('getDynamicContentTime', () => { }, }, }); - const encoded = signAndEncodeTicket(ticket, keys); + const encoded = await signAndEncodeTicket(ticket, { level1: localSigner(keys.privateKey, keys.curve) }); const decoded = decodeTicket(bytesToHex(encoded)); const time = getDynamicContentTime(decoded); expect(time).toBeDefined(); @@ -288,7 +289,7 @@ describe('getOpenTicketValidityWindow', () => { activated: true, }; - it('computes same-day ticket with explicit times', () => { + it('computes same-day ticket with explicit times', async () => { const ot: OpenTicketData = { returnIncluded: false, validFromDay: 0, @@ -303,7 +304,7 @@ describe('getOpenTicketValidityWindow', () => { expect(w!.validUntil.toISOString()).toBe('2025-01-15T12:00:00.000Z'); }); - it('computes multi-day ticket with default times (absent → 00:00 / 23:59)', () => { + it('computes multi-day ticket with default times (absent → 00:00 / 23:59)', async () => { const ot: OpenTicketData = { returnIncluded: false, validFromDay: 1, @@ -318,7 +319,7 @@ describe('getOpenTicketValidityWindow', () => { expect(w!.validUntil.toISOString()).toBe('2025-01-18T23:59:00.000Z'); }); - it('applies validFromUTCOffset correctly', () => { + it('applies validFromUTCOffset correctly', async () => { const ot: OpenTicketData = { returnIncluded: false, validFromDay: 0, @@ -335,7 +336,7 @@ describe('getOpenTicketValidityWindow', () => { expect(w!.validUntil.toISOString()).toBe('2025-01-15T11:00:00.000Z'); }); - it('defaults all fields when absent', () => { + it('defaults all fields when absent', async () => { const ot: OpenTicketData = { returnIncluded: false, // All time fields absent → validFromDay=0, validFromTime=0, validUntilDay=0, validUntilTime=1439 @@ -347,7 +348,7 @@ describe('getOpenTicketValidityWindow', () => { expect(w!.validUntil.toISOString()).toBe('2025-01-15T23:59:00.000Z'); }); - it('validUntilUTCOffset falls back to validFromUTCOffset', () => { + it('validUntilUTCOffset falls back to validFromUTCOffset', async () => { const ot: OpenTicketData = { returnIncluded: false, validFromDay: 0, @@ -364,7 +365,7 @@ describe('getOpenTicketValidityWindow', () => { expect(w!.validUntil.toISOString()).toBe('2025-01-15T11:00:00.000Z'); }); - it('handles negative validFromDay (day before issuing)', () => { + it('handles negative validFromDay (day before issuing)', async () => { const ot: OpenTicketData = { returnIncluded: false, validFromDay: -1, @@ -380,7 +381,7 @@ describe('getOpenTicketValidityWindow', () => { expect(w!.validUntil.toISOString()).toBe('2025-01-14T23:59:00.000Z'); }); - it('uses distinct validUntilUTCOffset when provided', () => { + it('uses distinct validUntilUTCOffset when provided', async () => { const ot: OpenTicketData = { returnIncluded: false, validFromDay: 0, diff --git a/tests/verifier.test.ts b/tests/verifier.test.ts index d5ef6d4..d27685f 100644 --- a/tests/verifier.test.ts +++ b/tests/verifier.test.ts @@ -9,7 +9,7 @@ import { encodeLevel2SignedData, encodeUicBarcode, signPayload, - getPublicKey, + derivePublicKey, CURVES, extractTicket, decodeTicket, @@ -24,6 +24,7 @@ import { CAR_JAUNE_TICKET_HEX, CAR_JAUNE_SIGNATURES, signAndEncodeTicket, + localSigner, generateKeyPair, } from '../src'; import { CAR_JAUNE_TICKETS_HEX } from './fixtures/car-jaune-tickets'; @@ -48,27 +49,27 @@ function toHex(bytes: Uint8Array): string { // --------------------------------------------------------------------------- describe('OID mapping', () => { - it('maps ECDSA with SHA-256 signing algorithm', () => { + it('maps ECDSA with SHA-256 signing algorithm', async () => { const alg = getSigningAlgorithm('1.2.840.10045.4.3.2'); expect(alg).toEqual({ hash: 'SHA-256', type: 'ECDSA' }); }); - it('maps DSA with SHA-224 signing algorithm', () => { + it('maps DSA with SHA-224 signing algorithm', async () => { const alg = getSigningAlgorithm('2.16.840.1.101.3.4.3.1'); expect(alg).toEqual({ hash: 'SHA-224', type: 'DSA' }); }); - it('maps P-256 key algorithm', () => { + it('maps P-256 key algorithm', async () => { const alg = getKeyAlgorithm('1.2.840.10045.3.1.7'); expect(alg).toEqual({ curve: 'P-256', type: 'EC' }); }); - it('returns undefined for unknown OID', () => { + it('returns undefined for unknown OID', async () => { expect(getSigningAlgorithm('1.2.3.4.5')).toBeUndefined(); expect(getKeyAlgorithm('1.2.3.4.5')).toBeUndefined(); }); - it('returns correct component lengths', () => { + it('returns correct component lengths', async () => { expect(curveComponentLength('P-256')).toBe(32); expect(curveComponentLength('P-384')).toBe(48); expect(curveComponentLength('P-521')).toBe(66); @@ -80,7 +81,7 @@ describe('OID mapping', () => { // --------------------------------------------------------------------------- describe('derToRaw', () => { - it('converts 71-byte ECDSA P-256 DER signature (one padded integer)', () => { + it('converts 71-byte ECDSA P-256 DER signature (one padded integer)', async () => { const der = hexToBytes(SOLEA_SIGNATURES.level1SignatureHex); expect(der.length).toBe(71); @@ -96,7 +97,7 @@ describe('derToRaw', () => { ); }); - it('converts 72-byte ECDSA P-256 DER signature (both padded)', () => { + it('converts 72-byte ECDSA P-256 DER signature (both padded)', async () => { const der = hexToBytes(CTS_SIGNATURES.level1SignatureHex); expect(der.length).toBe(72); @@ -111,7 +112,7 @@ describe('derToRaw', () => { ); }); - it('converts 46-byte DSA DER signature', () => { + it('converts 46-byte DSA DER signature', async () => { const der = hexToBytes(SNCF_TER_SIGNATURES.level1SignatureHex); expect(der.length).toBe(46); @@ -126,7 +127,7 @@ describe('derToRaw', () => { ); }); - it('rejects invalid DER', () => { + it('rejects invalid DER', async () => { expect(() => derToRaw(new Uint8Array([0x01, 0x02]), 32)).toThrow(); }); }); @@ -136,14 +137,14 @@ describe('derToRaw', () => { // --------------------------------------------------------------------------- describe('extractEcPublicKeyPoint', () => { - it('passes through uncompressed point (65 bytes)', () => { + it('passes through uncompressed point (65 bytes)', async () => { const point = new Uint8Array(65); point[0] = 0x04; const result = extractEcPublicKeyPoint(point); expect(result).toBe(point); }); - it('passes through compressed point (33 bytes)', () => { + it('passes through compressed point (33 bytes)', async () => { const point = hexToBytes( '028f3d46312f69e918100e8c4ea1d3fb726d118271174fba406dd97e089c44d972' ); @@ -158,7 +159,7 @@ describe('extractEcPublicKeyPoint', () => { // --------------------------------------------------------------------------- describe('extractTicket', () => { - it('extracts signed bytes from SNCF TER ticket', () => { + it('extracts signed bytes from SNCF TER ticket', async () => { const bytes = hexToBytes(SNCF_TER_TICKET_HEX); const extracted = extractTicket(bytes); @@ -169,7 +170,7 @@ describe('extractTicket', () => { expect(extracted.level2.signedBytes.length).toBeGreaterThan(extracted.level1.signedBytes.length); }); - it('extracts correct security metadata from Soléa ticket', () => { + it('extracts correct security metadata from Soléa ticket', async () => { const bytes = hexToBytes(SOLEA_TICKET_HEX); const extracted = extractTicket(bytes); const ticket = decodeTicket(SOLEA_TICKET_HEX); @@ -189,7 +190,7 @@ describe('extractTicket', () => { expect(extracted.level2.signingAlg).toBe('1.2.840.10045.4.3.2'); }); - it('extracts signatures matching decoded ticket for CTS', () => { + it('extracts signatures matching decoded ticket for CTS', async () => { const bytes = hexToBytes(CTS_TICKET_HEX); const extracted = extractTicket(bytes); const ticket = decodeTicket(CTS_TICKET_HEX); @@ -277,7 +278,7 @@ describe('findKeyInXml / parseKeysXml', () => { `; - it('finds key by issuer code and key ID', () => { + it('finds key by issuer code and key ID', async () => { const key = findKeyInXml(sampleXml, 1187, 1); expect(key).not.toBeNull(); expect(key!.publicKey).toBeInstanceOf(Uint8Array); @@ -286,12 +287,12 @@ describe('findKeyInXml / parseKeysXml', () => { expect(key!.signingAlg).toBeUndefined(); }); - it('returns null for non-matching key', () => { + it('returns null for non-matching key', async () => { const key = findKeyInXml(sampleXml, 1187, 99); expect(key).toBeNull(); }); - it('skips entries whose base64 is malformed instead of throwing', () => { + it('skips entries whose base64 is malformed instead of throwing', async () => { // The live UIC registry contains an entry whose base64 length is 1 mod 4 // (issuer 1182, key 2). atob rejects it; one bad row must not cost every key. const withBadEntry = sampleXml.replace( @@ -307,7 +308,7 @@ describe('findKeyInXml / parseKeysXml', () => { expect(keys.map(k => k.issuerCode)).not.toContain(1182); }); - it('surfaces a malformed key as an error rather than a miss', () => { + it('surfaces a malformed key as an error rather than a miss', async () => { const withBadEntry = sampleXml.replace( '', 'Broken1182' + @@ -319,7 +320,7 @@ describe('findKeyInXml / parseKeysXml', () => { expect(() => findKeyInXml(withBadEntry, 1182, 2)).toThrow(/Malformed base64/); }); - it('parses all keys', () => { + it('parses all keys', async () => { const keys = parseKeysXml(sampleXml); expect(keys).toHaveLength(2); expect(keys[0].issuerCode).toBe(1187); @@ -394,7 +395,7 @@ describe('high-S / low-S signature acceptance', () => { const force = (der: Uint8Array) => (sHalf(der) === half ? der : flipS(der)); const ticket = decodeTicket(SOLEA_TICKET_HEX); - const publicKey = getPublicKey(PRIV, 'P-256'); + const publicKey = derivePublicKey(PRIV, 'P-256'); const cfg = CURVES['P-256']; const level1Raw = encodeLevel1Data( @@ -483,7 +484,7 @@ describe('level 1 verification with configured algorithms', () => { }); it('rejects the wrong public key even with correct algorithms', async () => { - const wrong = getPublicKey(generateKeyPair('P-256').privateKey, 'P-256'); + const wrong = derivePublicKey(generateKeyPair('P-256').privateKey, 'P-256'); const result = await verifyLevel1Signature(carJaune, { publicKey: wrong, ...algorithms }); expect(result.valid).toBe(false); }); @@ -521,7 +522,7 @@ describe('level 1 verification with configured algorithms', () => { it('fails loudly when a configured algorithm contradicts the barcode', async () => { const key = generateKeyPair('P-256'); const ticket = decodeTicket(SOLEA_TICKET_HEX); - const signed = signAndEncodeTicket(ticket, key); + const signed = await signAndEncodeTicket(ticket, { level1: localSigner(key.privateKey, key.curve) }); const result = await verifyLevel1Signature(signed, { publicKey: key.publicKey, @@ -541,7 +542,7 @@ describe('level 2 verification with configured algorithms', () => { /** Encode a signed barcode that carries a Level 2 signature but no Level 2 OIDs. */ function encodeWithoutLevel2Oids(): { bytes: Uint8Array; publicKey: Uint8Array } { const ticket = decodeTicket(SOLEA_TICKET_HEX); - const publicKey = getPublicKey(PRIV_L2, 'P-256'); + const publicKey = derivePublicKey(PRIV_L2, 'P-256'); const cfg = CURVES['P-256']; const level1Raw = encodeLevel1Data(