From 0f3cd24eca8a88f7715e8f3c1faeb6a4cbfaa598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 04:44:19 +0000 Subject: [PATCH 1/5] feat: classify scanned barcode signatures by issuer and keyId New SignatureCollector class and collectSignatures(payloads) helper: feed each scanned QR payload in, signatures are extracted via extractSignedData and grouped automatically by Level 1 key identity (securityProviderNum or securityProviderIA5, plus keyId), with byte-for-byte deduplication of rescans. Groups plug into findKeyInXml and recoverLevel1PublicKey. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BMYUez7fcvaL34RUjxKZRJ --- CHANGELOG.md | 9 ++ src/index.ts | 2 + src/signature-collector.ts | 198 ++++++++++++++++++++++++++++++ tests/signature-collector.test.ts | 140 +++++++++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 src/signature-collector.ts create mode 100644 tests/signature-collector.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac56006..a399472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### New Features +- **Signature collection from scanned barcodes**: new `SignatureCollector` + class (and one-shot `collectSignatures(payloads)` helper) classifies scanned + barcode payloads automatically by Level 1 key identity — issuer + (`securityProviderNum` or `securityProviderIA5`) plus `keyId` — extracting + each ticket's signatures and algorithm OIDs along the way. Rescans of the + same barcode are deduplicated byte-for-byte. Each group's tickets feed + directly into `findKeyInXml` (registry lookup) and `recoverLevel1PublicKey` + (key recovery from observed tickets). + - **ECDSA public key recovery from observed tickets**: new `recoverLevel1PublicKey(tickets, options?)` recovers the Level 1 public key from one or more barcodes signed with the same key. Each signature yields diff --git a/src/index.ts b/src/index.ts index 67bda7f..c6780d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { recoverLevel1PublicKey } from './key-recovery.js'; export { controlTicket } from './control.js'; export { getIssuingTime, getEndOfValidityTime, getDynamicContentTime, getOpenTicketValidityWindow } from './time-helpers.js'; export { extractSignedData } from './signed-data.js'; +export { SignatureCollector, collectSignatures } from './signature-collector.js'; export { signLevel1, signLevel2, signAndEncodeTicket, signPayload, generateKeyPair, getPublicKey, CURVES } from './signer.js'; // The accepted values for the `keyAlg` / `signingAlg` fields on // Level1KeyMaterial and Level2Algorithms are the keys of these two tables. @@ -59,6 +60,7 @@ export type { UicPublicKeyEntry } from './verifier.js'; export type { RecoverLevel1KeyOptions } from './key-recovery.js'; export type { SigningAlgorithm, KeyAlgorithm } from './oids.js'; export type { ExtractedSignedData } from './signed-data.js'; +export type { CollectedTicket, SignatureGroup, AddScanResult } from './signature-collector.js'; export type { CurveName, CurveConfig, SigningKeyPair } from './signer.js'; export { RawBytes } from 'asn1-per-ts'; diff --git a/src/signature-collector.ts b/src/signature-collector.ts new file mode 100644 index 0000000..b81461e --- /dev/null +++ b/src/signature-collector.ts @@ -0,0 +1,198 @@ +/** + * Signature collection from scanned UIC barcodes. + * + * When scanning many QR codes (e.g. with a camera in a control app), each + * scan yields a raw barcode payload. The collector extracts each payload's + * signatures via {@link extractSignedData} and classifies them automatically + * by Level 1 key identity — issuer (`securityProviderNum` or + * `securityProviderIA5`) plus `keyId` — deduplicating repeated scans of the + * same barcode along the way. + * + * The resulting groups plug directly into the rest of the library: + * `findKeyInXml(xml, group.securityProviderNum, group.keyId)` looks the key + * up in the UIC registry, and `recoverLevel1PublicKey(group.tickets.map(t => + * t.bytes))` recovers it from the observed tickets when it is not published. + */ +import { extractSignedData } from './signed-data.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** One collected ticket: its payload and the signatures extracted from it. */ +export interface CollectedTicket { + /** Raw barcode payload bytes, exactly as scanned. */ + bytes: Uint8Array; + /** Level 1 signature (DER), absent when the barcode carries none. */ + level1Signature?: Uint8Array; + /** Level 2 signature (DER), absent for static barcodes. */ + level2Signature?: Uint8Array; + /** Level 1 key algorithm OID, when the barcode carries one. */ + level1KeyAlg?: string; + /** Level 1 signing algorithm OID, when the barcode carries one. */ + level1SigningAlg?: string; +} + +/** Tickets sharing one Level 1 key identity (issuer + keyId). */ +export interface SignatureGroup { + /** + * Canonical group label: `"/"`, where issuer is the RICS + * code or the IA5 string (`"1187/1"`, `"IWN8/1"`). A missing part reads + * `"?"`. For display; group identity is the three fields below. + */ + id: string; + /** Issuer RICS code, when the barcode identifies its issuer numerically. */ + securityProviderNum?: number; + /** Issuer IA5 string, when the barcode identifies its issuer as text. */ + securityProviderIA5?: string; + /** Key identifier within the issuer's key set. */ + keyId?: number; + /** Distinct collected tickets, in scan order. */ + tickets: CollectedTicket[]; +} + +/** Result of feeding one scan to {@link SignatureCollector.add}. */ +export interface AddScanResult { + /** The group the scan was classified into (live object, updated in place). */ + group: SignatureGroup; + /** The collected ticket record for this payload. */ + ticket: CollectedTicket; + /** True when this exact payload had already been collected (rescan). */ + duplicate: boolean; +} + +// --------------------------------------------------------------------------- +// Collector +// --------------------------------------------------------------------------- + +/** + * Accumulates scanned barcode payloads and classifies their signatures by + * Level 1 key identity (issuer + keyId). + * + * ```ts + * const collector = new SignatureCollector(); + * for (const payload of scans) { + * try { + * const { group, duplicate } = collector.add(payload); + * if (!duplicate) console.log(`ticket for key ${group.id}`); + * } catch { + * // not a decodable UIC barcode — ignore the scan + * } + * } + * const groups = collector.groups(); + * ``` + */ +export class SignatureCollector { + private readonly groupsByKey = new Map(); + private readonly ticketsByPayload = new Map(); + + /** + * Classify one scanned payload. + * + * Rescans of an already-collected payload are detected byte-for-byte and + * reported with `duplicate: true` without growing any group. + * + * @param bytes - Raw barcode payload bytes. + * @returns The group the payload belongs to and its ticket record. + * @throws When the payload is not a decodable UIC barcode. + */ + add(bytes: Uint8Array): AddScanResult { + const payloadHex = bytesToHex(bytes); + const seen = this.ticketsByPayload.get(payloadHex); + if (seen) { + return { ...seen, duplicate: true }; + } + + const { security } = extractSignedData(bytes); + const { securityProviderNum, securityProviderIA5, keyId } = security; + + // Disambiguate numeric and IA5 issuers (an IA5 issuer could spell a number). + const mapKey = + (securityProviderNum != null ? `n:${securityProviderNum}` : `i:${securityProviderIA5 ?? ''}`) + + `|${keyId ?? ''}`; + + let group = this.groupsByKey.get(mapKey); + if (!group) { + group = { + id: `${securityProviderNum ?? securityProviderIA5 ?? '?'}/${keyId ?? '?'}`, + securityProviderNum, + securityProviderIA5, + keyId, + tickets: [], + }; + this.groupsByKey.set(mapKey, group); + } + + const ticket: CollectedTicket = { + bytes, + level1Signature: security.level1Signature, + level2Signature: security.level2Signature, + level1KeyAlg: security.level1KeyAlg, + level1SigningAlg: security.level1SigningAlg, + }; + group.tickets.push(ticket); + + const result: AddScanResult = { group, ticket, duplicate: false }; + this.ticketsByPayload.set(payloadHex, result); + return result; + } + + /** + * All groups collected so far, sorted for stable display: numeric issuers + * first in ascending order, then IA5 issuers alphabetically, then by keyId. + */ + groups(): SignatureGroup[] { + return [...this.groupsByKey.values()].sort(compareGroups); + } + + /** Number of distinct tickets collected (duplicates excluded). */ + get size(): number { + return this.ticketsByPayload.size; + } +} + +/** + * Classify a batch of payloads by Level 1 key identity in one call. + * + * Convenience wrapper over {@link SignatureCollector} for when all scans are + * already in hand. Duplicated payloads are collected once. + * + * @param payloads - Raw barcode payload bytes of each scan. + * @returns Signature groups, sorted as {@link SignatureCollector.groups}. + * @throws When a payload is not a decodable UIC barcode, naming its index. + */ +export function collectSignatures(payloads: Iterable): SignatureGroup[] { + const collector = new SignatureCollector(); + let i = 0; + for (const payload of payloads) { + try { + collector.add(payload); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'unknown error'; + throw new Error(`Payload #${i}: ${msg}`); + } + i++; + } + return collector.groups(); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function compareGroups(a: SignatureGroup, b: SignatureGroup): number { + const aNum = a.securityProviderNum; + const bNum = b.securityProviderNum; + if (aNum != null && bNum != null && aNum !== bNum) return aNum - bNum; + if ((aNum != null) !== (bNum != null)) return aNum != null ? -1 : 1; + const aIa5 = a.securityProviderIA5 ?? ''; + const bIa5 = b.securityProviderIA5 ?? ''; + if (aIa5 !== bIa5) return aIa5 < bIa5 ? -1 : 1; + return (a.keyId ?? -1) - (b.keyId ?? -1); +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex; +} diff --git a/tests/signature-collector.test.ts b/tests/signature-collector.test.ts new file mode 100644 index 0000000..e12a4b8 --- /dev/null +++ b/tests/signature-collector.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; + +import { + SignatureCollector, + collectSignatures, + SAMPLE_TICKET_HEX, + SNCF_TER_TICKET_HEX, + SOLEA_TICKET_HEX, + CTS_TICKET_HEX, + BUS_ARDECHE_TICKET_HEX, + BUS_AIN_TICKET_HEX, + DROME_BUS_TICKET_HEX, + GRAND_EST_U1_FCB3_HEX, + CAR_JAUNE_TICKET_HEX, + SOLEA_SIGNATURES, + CAR_JAUNE_SIGNATURES, +} from '../src'; + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex; +} + +const ALL_TICKETS_HEX = [ + SAMPLE_TICKET_HEX, + SNCF_TER_TICKET_HEX, + SOLEA_TICKET_HEX, + CTS_TICKET_HEX, + BUS_ARDECHE_TICKET_HEX, + BUS_AIN_TICKET_HEX, + DROME_BUS_TICKET_HEX, + GRAND_EST_U1_FCB3_HEX, + CAR_JAUNE_TICKET_HEX, +]; + +describe('SignatureCollector', () => { + it('classifies scans by issuer and keyId', () => { + const collector = new SignatureCollector(); + for (const hex of ALL_TICKETS_HEX) collector.add(hexToBytes(hex)); + + const groups = collector.groups(); + expect(groups.map(g => g.id)).toEqual([ + '1187/0', // SNCF TER + '3703/1', // sample ticket + '3703/2', // Grand Est + '3703/7', // Soléa + CTS share one key + '5152/3', // the three bus networks share one key + 'IWN8/1', // Car Jaune (IA5 issuer) + ]); + expect(groups.map(g => g.tickets.length)).toEqual([1, 1, 1, 2, 3, 1]); + expect(collector.size).toBe(9); + }); + + it('exposes the key identity fields on each group', () => { + const collector = new SignatureCollector(); + collector.add(hexToBytes(SOLEA_TICKET_HEX)); + collector.add(hexToBytes(CAR_JAUNE_TICKET_HEX)); + + const [solea, carJaune] = collector.groups(); + expect(solea.securityProviderNum).toBe(3703); + expect(solea.securityProviderIA5).toBeUndefined(); + expect(solea.keyId).toBe(7); + expect(carJaune.securityProviderNum).toBeUndefined(); + expect(carJaune.securityProviderIA5).toBe(CAR_JAUNE_SIGNATURES.securityProviderIA5); + expect(carJaune.keyId).toBe(CAR_JAUNE_SIGNATURES.keyId); + }); + + it('extracts the signatures and Level 1 algorithm OIDs of each ticket', () => { + const collector = new SignatureCollector(); + const { ticket } = collector.add(hexToBytes(SOLEA_TICKET_HEX)); + + expect(bytesToHex(ticket.level1Signature!)).toBe(SOLEA_SIGNATURES.level1SignatureHex); + // The level 2 signature is a DER SEQUENCE (its exact bytes are not fixtured). + expect(ticket.level2Signature![0]).toBe(0x30); + expect(ticket.level1KeyAlg).toBe(SOLEA_SIGNATURES.level1KeyAlg); + expect(ticket.level1SigningAlg).toBe(SOLEA_SIGNATURES.level1SigningAlg); + }); + + it('leaves level2Signature undefined for static barcodes', () => { + const collector = new SignatureCollector(); + const { ticket } = collector.add(hexToBytes(CAR_JAUNE_TICKET_HEX)); + expect(ticket.level2Signature).toBeUndefined(); + expect(bytesToHex(ticket.level1Signature!)).toBe(CAR_JAUNE_SIGNATURES.level1SignatureHex); + }); + + it('deduplicates rescans of the same barcode', () => { + const collector = new SignatureCollector(); + const first = collector.add(hexToBytes(SOLEA_TICKET_HEX)); + const again = collector.add(hexToBytes(SOLEA_TICKET_HEX)); + + expect(first.duplicate).toBe(false); + expect(again.duplicate).toBe(true); + expect(again.group).toBe(first.group); + expect(first.group.tickets).toHaveLength(1); + expect(collector.size).toBe(1); + }); + + it('reports the live group on each add', () => { + const collector = new SignatureCollector(); + const first = collector.add(hexToBytes(SOLEA_TICKET_HEX)); + const second = collector.add(hexToBytes(CTS_TICKET_HEX)); + + expect(second.group).toBe(first.group); + expect(first.group.tickets).toHaveLength(2); + }); + + it('throws on a payload that is not a UIC barcode', () => { + const collector = new SignatureCollector(); + expect(() => collector.add(new Uint8Array([1, 2, 3]))).toThrow(); + expect(collector.size).toBe(0); + }); +}); + +describe('collectSignatures', () => { + it('classifies a batch in one call', () => { + const groups = collectSignatures(ALL_TICKETS_HEX.map(hexToBytes)); + expect(groups.map(g => [g.id, g.tickets.length])).toEqual([ + ['1187/0', 1], + ['3703/1', 1], + ['3703/2', 1], + ['3703/7', 2], + ['5152/3', 3], + ['IWN8/1', 1], + ]); + }); + + it('names the failing payload index', () => { + const payloads = [hexToBytes(SOLEA_TICKET_HEX), new Uint8Array([0xff])]; + expect(() => collectSignatures(payloads)).toThrow(/Payload #1/); + }); +}); From 9885e750e68aa2ff7e916943c27ef520edbf00a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 04:44:47 +0000 Subject: [PATCH 2/5] chore: normalize package-lock peer flags from npm install Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BMYUez7fcvaL34RUjxKZRJ --- package-lock.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d08a0d..abc04ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -898,7 +898,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1234,7 +1233,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1439,7 +1437,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", From 86933eee739a972c584335b8d461c45810b08959 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 05:09:45 +0000 Subject: [PATCH 3/5] docs: document signature collection in the README Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BMYUez7fcvaL34RUjxKZRJ --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index a0bc040..da3383d 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,44 @@ extracted.level2SignedBytes // bytes signed by level2Signature extracted.security // security metadata (algorithms, keys, signatures) ``` +## Collecting signatures from many scans + +When scanning many QR codes (e.g. with a camera in a control app), feed each +scanned payload to a `SignatureCollector`: signatures are extracted and +classified automatically by Level 1 key identity — issuer +(`securityProviderNum` or `securityProviderIA5`) plus `keyId` — and rescans of +the same barcode are deduplicated byte-for-byte: + +```ts +import { SignatureCollector } from 'dosipas-ts'; + +const collector = new SignatureCollector(); + +for (const payload of scans) { + try { + const { group, duplicate } = collector.add(payload); + if (!duplicate) console.log(`new ticket for key ${group.id}`); // e.g. "1187/1" + } catch { + // not a decodable UIC barcode — ignore the scan + } +} + +for (const group of collector.groups()) { + group.id // "1187/1", "IWN8/1", ... + group.securityProviderNum // 1187 (or undefined for IA5 issuers) + group.securityProviderIA5 // "IWN8" (or undefined for numeric issuers) + group.keyId // 1 + group.tickets // [{ bytes, level1Signature, level2Signature, ... }] +} +``` + +For a batch already in hand, `collectSignatures(payloads)` does the same in +one call. Each group plugs directly into the rest of the library: look its key +up in the UIC registry with +`findKeyInXml(xml, group.securityProviderNum, group.keyId)`, or recover it +from the observed tickets with +`recoverLevel1PublicKey(group.tickets.map(t => t.bytes))`. + ## UIC public key XML utilities ```ts From 706f5733e3d79581b5906aa52fcb5283fccb5f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:08:06 +0000 Subject: [PATCH 4/5] feat!: canonical ExtractedTicket record shared by extraction, verification and collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace extractSignedData/ExtractedSignedData with extractTicket/ ExtractedTicket: the same information restructured per level (signedBytes, signature, keyAlg, signingAlg, plus level2.publicKey) with the payload and a new SignatureKey identity (issuer + keyId, canonical id label). The record is now the lingua franca: verifySignatures, verifyLevel1Signature, verifyLevel2Signature and recoverLevel1PublicKey accept it in place of raw bytes so a scan pipeline decodes each payload once; findKeyInXml gains a SignatureKey overload; Level1KeyProvider.getPublicKey takes a SignatureKey. SignatureCollector stores full ExtractedTicket records (no lossy copy) and its add() returns { status: 'added' | 'duplicate' | 'invalid' } instead of throwing — unreadable scans are expected input in a camera loop. New collector.group(key | id) lookup and signatureKey() helper. BREAKING CHANGE: extractSignedData/ExtractedSignedData removed; Level1KeyProvider.getPublicKey signature changed (and keyId is no longer defaulted to 0). Migration guide from 2.0.0 in CHANGELOG.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BMYUez7fcvaL34RUjxKZRJ --- CHANGELOG.md | 92 ++++++++++++-- README.md | 88 +++++++------ src/index.ts | 6 +- src/key-recovery.ts | 21 ++-- src/signature-collector.ts | 197 ++++++++++++++---------------- src/signed-data.ts | 136 +++++++++++++++------ src/signer.ts | 10 +- src/types.ts | 12 +- src/verifier.ts | 84 +++++++------ tests/signature-collector.test.ts | 88 +++++++++---- tests/signer.test.ts | 14 +-- tests/verifier.test.ts | 46 +++---- 12 files changed, 503 insertions(+), 291 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a399472..54ecf0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,45 @@ ## Upcoming release +### Breaking Changes + +- **`extractSignedData` is replaced by `extractTicket`**, which returns the + new canonical `ExtractedTicket` record: the same information restructured + per level (`level1` / `level2`, each with `signedBytes`, `signature`, + `keyAlg`, `signingAlg`, plus `level2.publicKey`), together with the payload + (`bytes`) and the Level 1 key identity (`key: SignatureKey`). The + `ExtractedSignedData` type and its flat `security` bag are gone. See + *Migrating from 2.0.0* below. + +- **`Level1KeyProvider.getPublicKey` now takes a `SignatureKey`**: + `getPublicKey(key, keyAlg?)` instead of + `getPublicKey(securityProvider, keyId, keyAlg?)`. The key identity fields + are `key.securityProviderNum`, `key.securityProviderIA5` and `key.keyId` + (each undefined when absent from the barcode — `keyId` is no longer + defaulted to `0`), and `key.id` is a canonical label for messages. + ### New Features +- **One extraction, used everywhere**: `verifySignatures`, + `verifyLevel1Signature`, `verifyLevel2Signature` and + `recoverLevel1PublicKey` now accept an `ExtractedTicket` in place of raw + payload bytes, so a scan pipeline decodes each payload exactly once. + `findKeyInXml` gains an overload taking a `SignatureKey` + (`findKeyInXml(xml, ticket.key)`), returning `null` for IA5-identified + issuers, which the registry cannot contain. A `signatureKey(fields)` helper + builds a `SignatureKey` with its canonical `id` computed. + - **Signature collection from scanned barcodes**: new `SignatureCollector` - class (and one-shot `collectSignatures(payloads)` helper) classifies scanned - barcode payloads automatically by Level 1 key identity — issuer - (`securityProviderNum` or `securityProviderIA5`) plus `keyId` — extracting - each ticket's signatures and algorithm OIDs along the way. Rescans of the - same barcode are deduplicated byte-for-byte. Each group's tickets feed - directly into `findKeyInXml` (registry lookup) and `recoverLevel1PublicKey` - (key recovery from observed tickets). + class (and one-shot `collectSignatures(payloads)` helper) classifies + scanned payloads automatically by Level 1 key identity (`SignatureKey`: + issuer + keyId). `add(bytes | ExtractedTicket)` returns + `{ status: 'added' | 'duplicate' | 'invalid', ... }` — rescans of the same + barcode are deduplicated byte-for-byte, and unreadable scans are reported + rather than thrown, as expected in a camera loop. Groups store full + `ExtractedTicket` records, so they feed directly into + `findKeyInXml(xml, group.key)`, `recoverLevel1PublicKey(group.tickets)` + and `verifySignatures(ticket, options)`. `collector.group(key | id)` looks + a single group up. - **ECDSA public key recovery from observed tickets**: new `recoverLevel1PublicKey(tickets, options?)` recovers the Level 1 public key @@ -23,6 +52,55 @@ This is the technique that produced the Car Jaune fixture key, now available as an API. +### Migrating from 2.0.0 + +These changes apply when upgrading from `2.0.0` (or any earlier `1.x`/`2.x` +release carrying `extractSignedData`). Only extraction and the key-provider +interface changed; `decodeTicket`, encoding, signing and every call that +passes raw payload bytes to the verify functions keep working unchanged. + +**Extraction** — rename the call and move the field accesses: + +| 2.0.0 | Now | +| --- | --- | +| `extractSignedData(bytes)` | `extractTicket(bytes)` | +| `ExtractedSignedData` (type) | `ExtractedTicket` | +| `.level1DataBytes` | `.level1.signedBytes` | +| `.level2SignedBytes` | `.level2.signedBytes` | +| `.security.level1Signature` | `.level1.signature` | +| `.security.level2Signature` | `.level2.signature` | +| `.security.level1KeyAlg` / `.level1SigningAlg` | `.level1.keyAlg` / `.level1.signingAlg` | +| `.security.level2KeyAlg` / `.level2SigningAlg` | `.level2.keyAlg` / `.level2.signingAlg` | +| `.security.level2PublicKey` | `.level2.publicKey` | +| `.security.securityProviderNum` / `IA5` | `.key.securityProviderNum` / `IA5` | +| `.security.keyId` | `.key.keyId` | + +**Key providers** — one parameter object instead of three arguments: + +```ts +// 2.0.0 +const provider: Level1KeyProvider = { + async getPublicKey(securityProvider, keyId) { + const key = findKeyInXml(xml, securityProvider.num!, keyId); + if (!key) throw new Error('Key not found'); + return key; + }, +}; + +// Now +const provider: Level1KeyProvider = { + async getPublicKey(key) { + const material = findKeyInXml(xml, key); // null for IA5 issuers + if (!material) throw new Error(`Key not found: ${key.id}`); + return material; + }, +}; +``` + +Note that `keyId` is no longer defaulted to `0` before reaching the provider: +a barcode carrying no `keyId` now yields `key.keyId === undefined`. If your +provider relied on the old default, apply `key.keyId ?? 0` yourself. + ## [2.0.0] ### Breaking Changes diff --git a/README.md b/README.md index da3383d..ef980ee 100644 --- a/README.md +++ b/README.md @@ -237,13 +237,14 @@ import type { Level1KeyProvider } from 'dosipas-ts'; const xml = fs.readFileSync('uic-publickeys.xml', 'utf-8'); const provider: Level1KeyProvider = { - async getPublicKey(securityProvider, keyId) { - // Note: securityProvider.num is undefined for issuers that identify + async getPublicKey(key) { + // `key` is a SignatureKey: issuer + keyId as extracted from the barcode. + // Note: key.securityProviderNum is undefined for issuers that identify // themselves with an IA5 string instead of a numeric RICS code — those - // are not in the UIC registry, so branch on securityProvider.ia5. - const key = findKeyInXml(xml, securityProvider.num!, keyId); - if (!key) throw new Error('Key not found'); - return key; + // are not in the UIC registry, so branch on key.securityProviderIA5. + const material = findKeyInXml(xml, key); // null for IA5 issuers + if (!material) throw new Error(`Key not found: ${key.id}`); + return material; }, }; @@ -319,6 +320,9 @@ import { recoverLevel1PublicKey } from 'dosipas-ts'; const candidates = recoverLevel1PublicKey([ticketBytes1, ticketBytes2]); // [Uint8Array] — one uncompressed EC point (0x04 || x || y) + +// Extracted tickets are accepted too — e.g. a signature group's tickets: +const candidates2 = recoverLevel1PublicKey(group.tickets); ``` With a single ticket, expect two candidates (either verifies that ticket — @@ -375,27 +379,39 @@ getEndOfValidityTime(ticket) // Date from v2 endOfValidity fields or v1 issuin getDynamicContentTime(ticket) // Date from FDC1 timestamp or Intercode ID1 dynamic fields ``` -## Extracting signed data +## Extracting signatures and signed data -For custom verification workflows, extract the exact signed bytes from a barcode: +`extractTicket` produces the canonical `ExtractedTicket` record: the exact +signed bytes, the signatures, the algorithm OIDs and the Level 1 key identity, +structured per level: ```ts -import { extractSignedData } from 'dosipas-ts'; +import { extractTicket } from 'dosipas-ts'; -const extracted = extractSignedData(barcodeBytes); +const ticket = extractTicket(barcodeBytes); -extracted.level1DataBytes // bytes signed by level1Signature -extracted.level2SignedBytes // bytes signed by level2Signature -extracted.security // security metadata (algorithms, keys, signatures) +ticket.key // SignatureKey: issuer + keyId +ticket.key.id // canonical label, e.g. "1187/1" or "IWN8/1" +ticket.level1.signedBytes // exact bytes covered by the Level 1 signature +ticket.level1.signature // Level 1 signature (DER), if present +ticket.level1.keyAlg // key algorithm OID, if the barcode carries one +ticket.level1.signingAlg // signing algorithm OID, if the barcode carries one +ticket.level2 // same shape, plus the embedded level2 publicKey ``` +`verifySignatures`, `verifyLevel1Signature`, `verifyLevel2Signature`, +`recoverLevel1PublicKey` and `SignatureCollector.add` all accept an +`ExtractedTicket` in place of raw payload bytes, so a scan pipeline decodes +each payload exactly once. + ## Collecting signatures from many scans When scanning many QR codes (e.g. with a camera in a control app), feed each -scanned payload to a `SignatureCollector`: signatures are extracted and -classified automatically by Level 1 key identity — issuer -(`securityProviderNum` or `securityProviderIA5`) plus `keyId` — and rescans of -the same barcode are deduplicated byte-for-byte: +scanned payload to a `SignatureCollector`: tickets are extracted and +classified automatically by Level 1 key identity (`SignatureKey`), and rescans +of the same barcode are deduplicated byte-for-byte. Unreadable scans are +expected input in a camera loop, so `add` reports them as a result instead of +throwing: ```ts import { SignatureCollector } from 'dosipas-ts'; @@ -403,39 +419,41 @@ import { SignatureCollector } from 'dosipas-ts'; const collector = new SignatureCollector(); for (const payload of scans) { - try { - const { group, duplicate } = collector.add(payload); - if (!duplicate) console.log(`new ticket for key ${group.id}`); // e.g. "1187/1" - } catch { - // not a decodable UIC barcode — ignore the scan + const result = collector.add(payload); // Uint8Array or ExtractedTicket + switch (result.status) { + case 'added': console.log(`new ticket for key ${result.group.key.id}`); break; + case 'duplicate': break; // same barcode scanned again — nothing changed + case 'invalid': break; // not a decodable UIC barcode — nothing changed } } for (const group of collector.groups()) { - group.id // "1187/1", "IWN8/1", ... - group.securityProviderNum // 1187 (or undefined for IA5 issuers) - group.securityProviderIA5 // "IWN8" (or undefined for numeric issuers) - group.keyId // 1 - group.tickets // [{ bytes, level1Signature, level2Signature, ... }] + group.key // SignatureKey — securityProviderNum/IA5, keyId, id label + group.tickets // ExtractedTicket[] — full records, in scan order } + +collector.group('3703/7') // lookup by id label (or by SignatureKey) ``` For a batch already in hand, `collectSignatures(payloads)` does the same in -one call. Each group plugs directly into the rest of the library: look its key -up in the UIC registry with -`findKeyInXml(xml, group.securityProviderNum, group.keyId)`, or recover it -from the observed tickets with -`recoverLevel1PublicKey(group.tickets.map(t => t.bytes))`. +one call (and throws on an undecodable payload, naming its index). Each group +plugs directly into the rest of the library: look its key up in the UIC +registry with `findKeyInXml(xml, group.key)`, recover it from the observed +tickets with `recoverLevel1PublicKey(group.tickets)`, or verify without +re-decoding with `verifySignatures(ticket, options)`. ## UIC public key XML utilities ```ts import { findKeyInXml, parseKeysXml } from 'dosipas-ts'; -// Find a specific key -const key = findKeyInXml(xml, 1187, 1); // issuerCode, keyId +// Find a specific key — by issuer RICS code + keyId, or by a SignatureKey +// (from an extracted ticket or a signature group) +const key = findKeyInXml(xml, 1187, 1); +const key2 = findKeyInXml(xml, extractTicket(bytes).key); // Returns { publicKey: Uint8Array } or null — no algorithm metadata, -// see the note under "Using a key provider" above. +// see the note under "Using a key provider" above. A SignatureKey whose +// issuer is an IA5 string yields null (the registry is keyed by RICS codes). // Parse all keys const keys = parseKeysXml(xml); diff --git a/src/index.ts b/src/index.ts index c6780d5..e696224 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ export { verifySignatures, verifyLevel1Signature, verifyLevel2Signature, findKey export { recoverLevel1PublicKey } from './key-recovery.js'; export { controlTicket } from './control.js'; export { getIssuingTime, getEndOfValidityTime, getDynamicContentTime, getOpenTicketValidityWindow } from './time-helpers.js'; -export { extractSignedData } from './signed-data.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'; // The accepted values for the `keyAlg` / `signingAlg` fields on @@ -59,8 +59,8 @@ export type { export type { UicPublicKeyEntry } from './verifier.js'; export type { RecoverLevel1KeyOptions } from './key-recovery.js'; export type { SigningAlgorithm, KeyAlgorithm } from './oids.js'; -export type { ExtractedSignedData } from './signed-data.js'; -export type { CollectedTicket, SignatureGroup, AddScanResult } from './signature-collector.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 { RawBytes } from 'asn1-per-ts'; diff --git a/src/key-recovery.ts b/src/key-recovery.ts index 441ecb6..f690414 100644 --- a/src/key-recovery.ts +++ b/src/key-recovery.ts @@ -19,7 +19,8 @@ import { p256, p384, p521 } from '@noble/curves/nist.js'; import { sha256, sha384, sha512 } from '@noble/hashes/sha2.js'; -import { extractSignedData } from './signed-data.js'; +import { toExtractedTicket } from './signed-data.js'; +import type { ExtractedTicket } from './signed-data.js'; import { resolveAlgorithms, curveComponentLength } from './oids.js'; import { derToRaw } from './signature-utils.js'; @@ -94,7 +95,8 @@ const HASHES: Record Uint8Array> = { * Jaune fixture), pass them via `options`. All tickets must resolve to the * same ECDSA curve and hash. * - * @param tickets - Raw barcode payload bytes of the observed tickets. + * @param tickets - The observed tickets: raw payload bytes, or extracted + * tickets (e.g. a signature group's `tickets`). * @param options - Algorithm OIDs to use when the barcodes omit their own. * @returns Candidate public keys as uncompressed EC points (0x04 || x || y). * @throws When no ticket is supplied, a ticket cannot be processed, the @@ -102,7 +104,7 @@ const HASHES: Record Uint8Array> = { * algorithms. */ export function recoverLevel1PublicKey( - tickets: Uint8Array[], + tickets: Array, options?: RecoverLevel1KeyOptions, ): Uint8Array[] { if (tickets.length === 0) { @@ -117,17 +119,16 @@ export function recoverLevel1PublicKey( for (let i = 0; i < tickets.length; i++) { let ticketCandidates: Set; try { - const extracted = extractSignedData(tickets[i]); - const { security } = extracted; + const { level1 } = toExtractedTicket(tickets[i]); - if (!security.level1Signature) { + if (!level1.signature) { throw new Error('missing level 1 signature'); } const resolved = resolveAlgorithms({ level: 1, - barcodeSigningAlg: security.level1SigningAlg, - barcodeKeyAlg: security.level1KeyAlg, + barcodeSigningAlg: level1.signingAlg, + barcodeKeyAlg: level1.keyAlg, configuredSigningAlg: options?.signingAlg, configuredKeyAlg: options?.keyAlg, }); @@ -158,8 +159,8 @@ export function recoverLevel1PublicKey( } ticketCandidates = recoverCandidates( - extracted.level1DataBytes, - security.level1Signature, + level1.signedBytes, + level1.signature, curve, hash, ); diff --git a/src/signature-collector.ts b/src/signature-collector.ts index b81461e..b56d337 100644 --- a/src/signature-collector.ts +++ b/src/signature-collector.ts @@ -2,64 +2,46 @@ * Signature collection from scanned UIC barcodes. * * When scanning many QR codes (e.g. with a camera in a control app), each - * scan yields a raw barcode payload. The collector extracts each payload's - * signatures via {@link extractSignedData} and classifies them automatically - * by Level 1 key identity — issuer (`securityProviderNum` or - * `securityProviderIA5`) plus `keyId` — deduplicating repeated scans of the - * same barcode along the way. + * scan yields a raw barcode payload. The collector extracts each payload via + * {@link extractTicket} and classifies it automatically by Level 1 key + * identity ({@link SignatureKey}: issuer + keyId), deduplicating repeated + * scans of the same barcode along the way. Unreadable scans are expected + * input in a camera loop, so `add` reports them as a result instead of + * throwing. * - * The resulting groups plug directly into the rest of the library: - * `findKeyInXml(xml, group.securityProviderNum, group.keyId)` looks the key - * up in the UIC registry, and `recoverLevel1PublicKey(group.tickets.map(t => - * t.bytes))` recovers it from the observed tickets when it is not published. + * Groups store the canonical {@link ExtractedTicket} records, so they plug + * directly into the rest of the library: `findKeyInXml(xml, group.key)` looks + * the key up in the UIC registry, `recoverLevel1PublicKey(group.tickets)` + * recovers it from the observed tickets when it is not published, and + * `verifySignatures(ticket, options)` verifies without re-decoding. */ -import { extractSignedData } from './signed-data.js'; +import { + extractTicket, + toExtractedTicket, + type ExtractedTicket, + type SignatureKey, +} from './signed-data.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/** One collected ticket: its payload and the signatures extracted from it. */ -export interface CollectedTicket { - /** Raw barcode payload bytes, exactly as scanned. */ - bytes: Uint8Array; - /** Level 1 signature (DER), absent when the barcode carries none. */ - level1Signature?: Uint8Array; - /** Level 2 signature (DER), absent for static barcodes. */ - level2Signature?: Uint8Array; - /** Level 1 key algorithm OID, when the barcode carries one. */ - level1KeyAlg?: string; - /** Level 1 signing algorithm OID, when the barcode carries one. */ - level1SigningAlg?: string; -} - /** Tickets sharing one Level 1 key identity (issuer + keyId). */ export interface SignatureGroup { - /** - * Canonical group label: `"/"`, where issuer is the RICS - * code or the IA5 string (`"1187/1"`, `"IWN8/1"`). A missing part reads - * `"?"`. For display; group identity is the three fields below. - */ - id: string; - /** Issuer RICS code, when the barcode identifies its issuer numerically. */ - securityProviderNum?: number; - /** Issuer IA5 string, when the barcode identifies its issuer as text. */ - securityProviderIA5?: string; - /** Key identifier within the issuer's key set. */ - keyId?: number; + /** The shared key identity. `key.id` is the display label (e.g. "1187/1"). */ + key: SignatureKey; /** Distinct collected tickets, in scan order. */ - tickets: CollectedTicket[]; + tickets: ExtractedTicket[]; } /** Result of feeding one scan to {@link SignatureCollector.add}. */ -export interface AddScanResult { - /** The group the scan was classified into (live object, updated in place). */ - group: SignatureGroup; - /** The collected ticket record for this payload. */ - ticket: CollectedTicket; - /** True when this exact payload had already been collected (rescan). */ - duplicate: boolean; -} +export type AddResult = + /** A new ticket, classified into `group` (a live object, updated in place). */ + | { status: 'added'; group: SignatureGroup; ticket: ExtractedTicket } + /** This exact payload was already collected — nothing changed. */ + | { status: 'duplicate'; group: SignatureGroup; ticket: ExtractedTicket } + /** The payload is not a decodable UIC barcode — nothing changed. */ + | { status: 'invalid'; error: Error }; // --------------------------------------------------------------------------- // Collector @@ -72,68 +54,51 @@ export interface AddScanResult { * ```ts * const collector = new SignatureCollector(); * for (const payload of scans) { - * try { - * const { group, duplicate } = collector.add(payload); - * if (!duplicate) console.log(`ticket for key ${group.id}`); - * } catch { - * // not a decodable UIC barcode — ignore the scan - * } + * const result = collector.add(payload); + * if (result.status === 'added') console.log(`ticket for key ${result.group.key.id}`); * } * const groups = collector.groups(); * ``` */ export class SignatureCollector { private readonly groupsByKey = new Map(); - private readonly ticketsByPayload = new Map(); + private readonly resultsByPayload = new Map(); /** - * Classify one scanned payload. - * - * Rescans of an already-collected payload are detected byte-for-byte and - * reported with `duplicate: true` without growing any group. + * Classify one scan. * - * @param bytes - Raw barcode payload bytes. - * @returns The group the payload belongs to and its ticket record. - * @throws When the payload is not a decodable UIC barcode. + * Accepts raw payload bytes or an already-extracted ticket (to avoid + * decoding twice when the scan was extracted for verification). Rescans of + * an already-collected payload are detected byte-for-byte and reported as + * `'duplicate'` without growing any group; undecodable payloads are + * reported as `'invalid'`. */ - add(bytes: Uint8Array): AddScanResult { - const payloadHex = bytesToHex(bytes); - const seen = this.ticketsByPayload.get(payloadHex); - if (seen) { - return { ...seen, duplicate: true }; + add(input: Uint8Array | ExtractedTicket): AddResult { + let ticket: ExtractedTicket; + try { + ticket = toExtractedTicket(input); + } catch (e: unknown) { + return { + status: 'invalid', + error: e instanceof Error ? e : new Error('unknown extraction error'), + }; } - const { security } = extractSignedData(bytes); - const { securityProviderNum, securityProviderIA5, keyId } = security; - - // Disambiguate numeric and IA5 issuers (an IA5 issuer could spell a number). - const mapKey = - (securityProviderNum != null ? `n:${securityProviderNum}` : `i:${securityProviderIA5 ?? ''}`) + - `|${keyId ?? ''}`; + const payloadHex = bytesToHex(ticket.bytes); + const seen = this.resultsByPayload.get(payloadHex); + if (seen) { + return { ...seen, status: 'duplicate' }; + } - let group = this.groupsByKey.get(mapKey); + let group = this.groupsByKey.get(mapKeyOf(ticket.key)); if (!group) { - group = { - id: `${securityProviderNum ?? securityProviderIA5 ?? '?'}/${keyId ?? '?'}`, - securityProviderNum, - securityProviderIA5, - keyId, - tickets: [], - }; - this.groupsByKey.set(mapKey, group); + group = { key: ticket.key, tickets: [] }; + this.groupsByKey.set(mapKeyOf(ticket.key), group); } - - const ticket: CollectedTicket = { - bytes, - level1Signature: security.level1Signature, - level2Signature: security.level2Signature, - level1KeyAlg: security.level1KeyAlg, - level1SigningAlg: security.level1SigningAlg, - }; group.tickets.push(ticket); - const result: AddScanResult = { group, ticket, duplicate: false }; - this.ticketsByPayload.set(payloadHex, result); + const result = { status: 'added' as const, group, ticket }; + this.resultsByPayload.set(payloadHex, result); return result; } @@ -145,9 +110,19 @@ export class SignatureCollector { return [...this.groupsByKey.values()].sort(compareGroups); } + /** + * Look one group up by its key, or by its canonical `id` label. On the + * rare label collision (a purely numeric IA5 issuer), a string matches the + * numeric-issuer group first; pass a {@link SignatureKey} to be exact. + */ + group(key: SignatureKey | string): SignatureGroup | undefined { + if (typeof key !== 'string') return this.groupsByKey.get(mapKeyOf(key)); + return this.groups().find(g => g.key.id === key); + } + /** Number of distinct tickets collected (duplicates excluded). */ get size(): number { - return this.ticketsByPayload.size; + return this.resultsByPayload.size; } } @@ -155,21 +130,22 @@ export class SignatureCollector { * Classify a batch of payloads by Level 1 key identity in one call. * * Convenience wrapper over {@link SignatureCollector} for when all scans are - * already in hand. Duplicated payloads are collected once. + * already in hand. Duplicated payloads are collected once. Unlike the + * collector's `add`, an undecodable payload here is a programming error, so + * it throws, naming the payload's index. * - * @param payloads - Raw barcode payload bytes of each scan. + * @param payloads - Raw payload bytes (or extracted tickets) of each scan. * @returns Signature groups, sorted as {@link SignatureCollector.groups}. - * @throws When a payload is not a decodable UIC barcode, naming its index. */ -export function collectSignatures(payloads: Iterable): SignatureGroup[] { +export function collectSignatures( + payloads: Iterable, +): SignatureGroup[] { const collector = new SignatureCollector(); let i = 0; for (const payload of payloads) { - try { - collector.add(payload); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'unknown error'; - throw new Error(`Payload #${i}: ${msg}`); + const result = collector.add(payload); + if (result.status === 'invalid') { + throw new Error(`Payload #${i}: ${result.error.message}`); } i++; } @@ -180,15 +156,26 @@ export function collectSignatures(payloads: Iterable): SignatureGrou // Helpers // --------------------------------------------------------------------------- +/** + * Grouping key: unlike `key.id`, it cannot collide between a numeric issuer + * and an IA5 issuer that spells the same number. + */ +function mapKeyOf(key: SignatureKey): string { + const issuer = key.securityProviderNum != null + ? `n:${key.securityProviderNum}` + : `i:${key.securityProviderIA5 ?? ''}`; + return `${issuer}|${key.keyId ?? ''}`; +} + function compareGroups(a: SignatureGroup, b: SignatureGroup): number { - const aNum = a.securityProviderNum; - const bNum = b.securityProviderNum; + const aNum = a.key.securityProviderNum; + const bNum = b.key.securityProviderNum; if (aNum != null && bNum != null && aNum !== bNum) return aNum - bNum; if ((aNum != null) !== (bNum != null)) return aNum != null ? -1 : 1; - const aIa5 = a.securityProviderIA5 ?? ''; - const bIa5 = b.securityProviderIA5 ?? ''; + const aIa5 = a.key.securityProviderIA5 ?? ''; + const bIa5 = b.key.securityProviderIA5 ?? ''; if (aIa5 !== bIa5) return aIa5 < bIa5 ? -1 : 1; - return (a.keyId ?? -1) - (b.keyId ?? -1); + return (a.key.keyId ?? -1) - (b.key.keyId ?? -1); } function bytesToHex(bytes: Uint8Array): string { diff --git a/src/signed-data.ts b/src/signed-data.ts index 00f675b..6a5092b 100644 --- a/src/signed-data.ts +++ b/src/signed-data.ts @@ -3,6 +3,11 @@ * * Uses `decodeWithMetadata` to extract the exact original bytes that were * signed, avoiding any re-encoding that could introduce mismatches. + * + * `extractTicket` produces the canonical {@link ExtractedTicket} record that + * the rest of the library consumes: the verifier, the key-recovery API and + * the signature collector all accept it in place of raw payload bytes, so a + * scan pipeline decodes each payload exactly once. */ import { SchemaCodec, @@ -35,25 +40,71 @@ function getHeaderCodec(version: number): SchemaCodec { // Types // --------------------------------------------------------------------------- -/** Extracted signed data bytes and security metadata from a UIC barcode. */ -export interface ExtractedSignedData { - /** The exact bytes of `level1Data` — signed by level1Signature. */ - level1DataBytes: Uint8Array; - /** The exact bytes of `level2SignedData` — signed by level2Signature. */ - level2SignedBytes: Uint8Array; - - /** Security metadata extracted from the decoded header. */ - security: { - securityProviderNum?: number; - securityProviderIA5?: string; - keyId?: number; - level1KeyAlg?: string; - level2KeyAlg?: string; - level1SigningAlg?: string; - level2SigningAlg?: string; - level2PublicKey?: Uint8Array; - level1Signature?: Uint8Array; - level2Signature?: Uint8Array; +/** + * Identity of a Level 1 signing key: issuer + keyId. + * + * This is the classification key for collected signatures, the lookup key + * for `Level1KeyProvider`, and what `findKeyInXml` accepts. + */ +export interface SignatureKey { + /** Issuer RICS code, when the barcode identifies its issuer numerically. */ + securityProviderNum?: number; + /** Issuer IA5 string, when the barcode identifies its issuer as text. */ + securityProviderIA5?: string; + /** Key identifier within the issuer's key set. */ + keyId?: number; + /** + * Canonical label: `"/"`, where issuer is the RICS code or + * the IA5 string (`"1187/1"`, `"IWN8/1"`); a missing part reads `"?"`. + * A purely numeric IA5 issuer could spell the same label as a RICS code, + * so grouping and equality use the three fields above, not this string. + */ + id: string; +} + +/** Build a {@link SignatureKey}, computing its canonical `id`. */ +export function signatureKey( + fields: Omit, +): SignatureKey { + const { securityProviderNum, securityProviderIA5, keyId } = fields; + return { + securityProviderNum, + securityProviderIA5, + keyId, + id: `${securityProviderNum ?? securityProviderIA5 ?? '?'}/${keyId ?? '?'}`, + }; +} + +/** One level's signed bytes, signature and algorithm OIDs. */ +export interface LevelSignatureData { + /** The exact original bytes covered by this level's signature. */ + signedBytes: Uint8Array; + /** This level's signature (DER), absent when the barcode carries none. */ + signature?: Uint8Array; + /** Key algorithm OID, when the barcode carries one. */ + keyAlg?: string; + /** Signing algorithm OID, when the barcode carries one. */ + signingAlg?: string; +} + +/** + * Everything extractable from one barcode payload, structured per level. + * + * The canonical record consumed across the library: `verifySignatures`, + * `verifyLevel1Signature`, `verifyLevel2Signature`, `recoverLevel1PublicKey` + * and `SignatureCollector.add` all accept it in place of raw payload bytes. + */ +export interface ExtractedTicket { + /** Raw barcode payload bytes, exactly as scanned. */ + bytes: Uint8Array; + /** Level 1 key identity (issuer + keyId). */ + key: SignatureKey; + /** Level 1: signed bytes, signature and algorithm OIDs. */ + level1: LevelSignatureData; + /** Level 2, plus its embedded public key. Static barcodes carry no signature. */ + level2: LevelSignatureData & { + /** The Level 2 public key embedded in `level1Data`, when present. */ + publicKey?: Uint8Array; }; } @@ -62,16 +113,18 @@ export interface ExtractedSignedData { // --------------------------------------------------------------------------- /** - * Extract the signed data bytes and security metadata from a UIC barcode. + * Extract the signed data bytes, signatures and key identity from a UIC + * barcode. * * This decodes the barcode header with metadata tracking, then reads the * exact original bytes from the source buffer via `rawBytes`. No re-encoding * is performed. * * @param bytes - The raw barcode payload bytes. - * @returns Extracted signed bytes and security metadata. + * @returns The canonical extracted ticket record. + * @throws When the payload is not a decodable UIC barcode. */ -export function extractSignedData(bytes: Uint8Array): ExtractedSignedData { +export function extractTicket(bytes: Uint8Array): ExtractedTicket { // Peek the header version const peekBuf = BitBuffer.from(bytes); peekBuf.readBit(); // skip optional bitmap @@ -102,20 +155,35 @@ export function extractSignedData(bytes: Uint8Array): ExtractedSignedData { // Extract security metadata by stripping metadata from the relevant nodes const l1Fields = level1DataNode.value as Record; - const security: ExtractedSignedData['security'] = { - securityProviderNum: getNodeValue(l1Fields.securityProviderNum) as number | undefined, - securityProviderIA5: getNodeValue(l1Fields.securityProviderIA5) as string | undefined, - keyId: getNodeValue(l1Fields.keyId) as number | undefined, - level1KeyAlg: getNodeValue(l1Fields.level1KeyAlg) as string | undefined, - level2KeyAlg: getNodeValue(l1Fields.level2KeyAlg) as string | undefined, - level1SigningAlg: getNodeValue(l1Fields.level1SigningAlg) as string | undefined, - level2SigningAlg: getNodeValue(l1Fields.level2SigningAlg) as string | undefined, - level2PublicKey: getNodeValue(l1Fields.level2PublicKey) as Uint8Array | undefined, - level1Signature: getNodeValue(l2Fields.level1Signature) as Uint8Array | undefined, - level2Signature: getNodeValue(headerFields.level2Signature) as Uint8Array | undefined, + return { + bytes, + key: signatureKey({ + securityProviderNum: getNodeValue(l1Fields.securityProviderNum) as number | undefined, + securityProviderIA5: getNodeValue(l1Fields.securityProviderIA5) as string | undefined, + keyId: getNodeValue(l1Fields.keyId) as number | undefined, + }), + level1: { + signedBytes: level1DataBytes, + signature: getNodeValue(l2Fields.level1Signature) as Uint8Array | undefined, + keyAlg: getNodeValue(l1Fields.level1KeyAlg) as string | undefined, + signingAlg: getNodeValue(l1Fields.level1SigningAlg) as string | undefined, + }, + level2: { + signedBytes: level2SignedBytes, + signature: getNodeValue(headerFields.level2Signature) as Uint8Array | undefined, + keyAlg: getNodeValue(l1Fields.level2KeyAlg) as string | undefined, + signingAlg: getNodeValue(l1Fields.level2SigningAlg) as string | undefined, + publicKey: getNodeValue(l1Fields.level2PublicKey) as Uint8Array | undefined, + }, }; +} - return { level1DataBytes, level2SignedBytes, security }; +/** + * Resolve a payload-or-ticket input to an {@link ExtractedTicket}, extracting + * only when given raw bytes. Shared by the APIs that accept either form. + */ +export function toExtractedTicket(input: Uint8Array | ExtractedTicket): ExtractedTicket { + return input instanceof Uint8Array ? extractTicket(input) : input; } /** diff --git a/src/signer.ts b/src/signer.ts index cb2716e..08003c2 100644 --- a/src/signer.ts +++ b/src/signer.ts @@ -13,7 +13,7 @@ import { p256, p384, p521 } from '@noble/curves/nist.js'; import { encodeLevel1Data, encodeLevel2Data, encodeLevel2SignedData, encodeUicBarcode, encodeTicketToBytes } from './encoder.js'; -import { extractSignedData } from './signed-data.js'; +import { extractTicket } from './signed-data.js'; import { rawToDer } from './signature-utils.js'; import type { RawBytes } from 'asn1-per-ts'; import type { UicBarcodeTicket, Level1Data } from './types.js'; @@ -196,8 +196,8 @@ export function signLevel1( }; const bytes = encodeTicketToBytes(prepared); - const extracted = extractSignedData(bytes); - return ecSign(extracted.level1DataBytes, privateKey, curve); + const extracted = extractTicket(bytes); + return ecSign(extracted.level1.signedBytes, privateKey, curve); } /** @@ -239,8 +239,8 @@ export function signLevel2( }; const bytes = encodeTicketToBytes(prepared); - const extracted = extractSignedData(bytes); - return ecSign(extracted.level2SignedBytes, privateKey, curve); + const extracted = extractTicket(bytes); + return ecSign(extracted.level2.signedBytes, privateKey, curve); } /** diff --git a/src/types.ts b/src/types.ts index 6f64f06..6b6cd34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ * `UicBarcodeHeader` ASN.1 schema hierarchy, with decoded sub-structures * available on `dataSequence[i].decoded` and `level2Data.decoded`. */ +import type { SignatureKey } from './signed-data.js'; // --------------------------------------------------------------------------- // UicBarcodeHeader — matches ASN.1 schema @@ -541,9 +542,16 @@ export interface VerifyOptions { /** Provider interface for looking up Level 1 key material. */ export interface Level1KeyProvider { + /** + * Resolve the Level 1 key material for one key identity. + * + * @param key - The key identity from the barcode: issuer + * (`securityProviderNum` or `securityProviderIA5`) plus `keyId`, with + * fields absent from the barcode left undefined. + * @param keyAlg - The barcode's `level1KeyAlg` OID, when it carries one. + */ getPublicKey( - securityProvider: { num?: number; ia5?: string }, - keyId: number, + key: SignatureKey, keyAlg?: string, ): Promise; } diff --git a/src/verifier.ts b/src/verifier.ts index 6f75126..04b8b60 100644 --- a/src/verifier.ts +++ b/src/verifier.ts @@ -9,7 +9,8 @@ */ import { p256, p384, p521 } from '@noble/curves/nist.js'; -import { extractSignedData } from './signed-data.js'; +import { toExtractedTicket } from './signed-data.js'; +import type { ExtractedTicket, SignatureKey } from './signed-data.js'; import { curveComponentLength, resolveAlgorithms } from './oids.js'; import type { ResolvedAlgorithms } from './oids.js'; import { derToRaw, extractEcPublicKeyPoint } from './signature-utils.js'; @@ -127,30 +128,29 @@ function verifyEcdsa( * `level1Data.level2PublicKey` field. Its algorithm OIDs can still be absent, * in which case they may be supplied via `algorithms`. * - * @param bytes - Raw barcode payload bytes. + * @param input - Raw barcode payload bytes, or an already-extracted ticket. * @param algorithms - Algorithm OIDs to use when the barcode omits its own. * @returns Verification result with valid flag and optional error. */ export async function verifyLevel2Signature( - bytes: Uint8Array, + input: Uint8Array | ExtractedTicket, algorithms?: Level2Algorithms, ): Promise { try { - const extracted = extractSignedData(bytes); - const { security } = extracted; + const { level2 } = toExtractedTicket(input); - if (!security.level2Signature) { + if (!level2.signature) { return { valid: false, error: 'Missing level 2 signature' }; } - if (!security.level2PublicKey) { + if (!level2.publicKey) { return { valid: false, error: 'Missing level 2 public key' }; } const resolved = resolveAlgorithms({ level: 2, - barcodeSigningAlg: security.level2SigningAlg, - barcodeKeyAlg: security.level2KeyAlg, + barcodeSigningAlg: level2.signingAlg, + barcodeKeyAlg: level2.keyAlg, configuredSigningAlg: algorithms?.signingAlg, configuredKeyAlg: algorithms?.keyAlg, }); @@ -167,9 +167,9 @@ export async function verifyLevel2Signature( return verifyEcdsaResult( 2, - security.level2Signature, - extracted.level2SignedBytes, - security.level2PublicKey, + level2.signature, + level2.signedBytes, + level2.publicKey, resolved, ); } catch (e: unknown) { @@ -184,26 +184,25 @@ export async function verifyLevel2Signature( * embedded in the barcode. When the barcode also omits its algorithm OIDs, * supply them on the key material. * - * @param bytes - Raw barcode payload bytes. + * @param input - Raw barcode payload bytes, or an already-extracted ticket. * @param key - The Level 1 public key and, optionally, its algorithms. * @returns Verification result with valid flag and optional error. */ export async function verifyLevel1Signature( - bytes: Uint8Array, + input: Uint8Array | ExtractedTicket, key: Level1KeyMaterial, ): Promise { try { - const extracted = extractSignedData(bytes); - const { security } = extracted; + const { level1 } = toExtractedTicket(input); - if (!security.level1Signature) { + if (!level1.signature) { return { valid: false, error: 'Missing level 1 signature' }; } const resolved = resolveAlgorithms({ level: 1, - barcodeSigningAlg: security.level1SigningAlg, - barcodeKeyAlg: security.level1KeyAlg, + barcodeSigningAlg: level1.signingAlg, + barcodeKeyAlg: level1.keyAlg, configuredSigningAlg: key.signingAlg, configuredKeyAlg: key.keyAlg, }); @@ -226,8 +225,8 @@ export async function verifyLevel1Signature( return verifyEcdsaResult( 1, - security.level1Signature, - extracted.level1DataBytes, + level1.signature, + level1.signedBytes, key.publicKey, resolved, ); @@ -239,32 +238,37 @@ export async function verifyLevel1Signature( /** * Verify both Level 1 and Level 2 signatures on a UIC barcode. * - * @param bytes - Raw barcode payload bytes. + * @param input - Raw barcode payload bytes, or an already-extracted ticket. * @param options - Verification options (key provider or explicit key). * @returns Combined verification results for both levels. */ export async function verifySignatures( - bytes: Uint8Array, + input: Uint8Array | ExtractedTicket, options?: VerifyOptions, ): Promise { + let extracted: ExtractedTicket; + try { + extracted = toExtractedTicket(input); + } catch (e: unknown) { + const error = e instanceof Error ? e.message : 'Verification failed'; + return { level1: { valid: false, error }, level2: { valid: false, error } }; + } + // Level 2 verification (self-contained) - const level2 = await verifyLevel2Signature(bytes, options?.level2Algorithms); + const level2 = await verifyLevel2Signature(extracted, options?.level2Algorithms); // Level 1 verification (needs external key material) let level1: SignatureLevelResult; if (options?.level1Key) { - level1 = await verifyLevel1Signature(bytes, options.level1Key); + level1 = await verifyLevel1Signature(extracted, options.level1Key); } else if (options?.level1KeyProvider) { try { - const extracted = extractSignedData(bytes); - const { security } = extracted; const material = await options.level1KeyProvider.getPublicKey( - { num: security.securityProviderNum, ia5: security.securityProviderIA5 }, - security.keyId ?? 0, - security.level1KeyAlg, + extracted.key, + extracted.level1.keyAlg, ); - level1 = await verifyLevel1Signature(bytes, material); + level1 = await verifyLevel1Signature(extracted, material); } catch (e: unknown) { level1 = { valid: false, @@ -297,15 +301,25 @@ export async function verifySignatures( * ``` * * @param xml - XML string from https://railpublickey.uic.org/download.php - * @param issuerCode - The issuer RICS code (securityProviderNum) - * @param keyId - The key identifier + * @param issuerCode - The issuer RICS code (securityProviderNum), or a + * {@link SignatureKey} (e.g. from an extracted ticket or a signature group) + * @param keyId - The key identifier (omitted when a SignatureKey is passed) * @returns Key material holding the decoded public key, or null if not found. + * A SignatureKey identifying its issuer with an IA5 string yields null: + * the registry is keyed by numeric RICS codes. */ +export function findKeyInXml(xml: string, key: SignatureKey): Level1KeyMaterial | null; +export function findKeyInXml(xml: string, issuerCode: number, keyId: number): Level1KeyMaterial | null; export function findKeyInXml( xml: string, - issuerCode: number, - keyId: number, + keyOrIssuer: SignatureKey | number, + maybeKeyId?: number, ): Level1KeyMaterial | null { + const issuerCode = typeof keyOrIssuer === 'number' + ? keyOrIssuer + : keyOrIssuer.securityProviderNum; + const keyId = typeof keyOrIssuer === 'number' ? maybeKeyId! : keyOrIssuer.keyId; + if (issuerCode == null || keyId == null) return null; // Simple regex-based XML parser (no DOM dependency for Node.js compatibility) const keyRegex = /([\s\S]*?)<\/key>/g; let match: RegExpExecArray | null; diff --git a/tests/signature-collector.test.ts b/tests/signature-collector.test.ts index e12a4b8..7ecfda6 100644 --- a/tests/signature-collector.test.ts +++ b/tests/signature-collector.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest'; import { SignatureCollector, collectSignatures, + extractTicket, + signatureKey, SAMPLE_TICKET_HEX, SNCF_TER_TICKET_HEX, SOLEA_TICKET_HEX, @@ -45,10 +47,12 @@ const ALL_TICKETS_HEX = [ describe('SignatureCollector', () => { it('classifies scans by issuer and keyId', () => { const collector = new SignatureCollector(); - for (const hex of ALL_TICKETS_HEX) collector.add(hexToBytes(hex)); + for (const hex of ALL_TICKETS_HEX) { + expect(collector.add(hexToBytes(hex)).status).toBe('added'); + } const groups = collector.groups(); - expect(groups.map(g => g.id)).toEqual([ + expect(groups.map(g => g.key.id)).toEqual([ '1187/0', // SNCF TER '3703/1', // sample ticket '3703/2', // Grand Est @@ -60,36 +64,55 @@ describe('SignatureCollector', () => { expect(collector.size).toBe(9); }); - it('exposes the key identity fields on each group', () => { + it('exposes the key identity on each group', () => { const collector = new SignatureCollector(); collector.add(hexToBytes(SOLEA_TICKET_HEX)); collector.add(hexToBytes(CAR_JAUNE_TICKET_HEX)); const [solea, carJaune] = collector.groups(); - expect(solea.securityProviderNum).toBe(3703); - expect(solea.securityProviderIA5).toBeUndefined(); - expect(solea.keyId).toBe(7); - expect(carJaune.securityProviderNum).toBeUndefined(); - expect(carJaune.securityProviderIA5).toBe(CAR_JAUNE_SIGNATURES.securityProviderIA5); - expect(carJaune.keyId).toBe(CAR_JAUNE_SIGNATURES.keyId); + expect(solea.key.securityProviderNum).toBe(3703); + expect(solea.key.securityProviderIA5).toBeUndefined(); + expect(solea.key.keyId).toBe(7); + expect(carJaune.key.securityProviderNum).toBeUndefined(); + expect(carJaune.key.securityProviderIA5).toBe(CAR_JAUNE_SIGNATURES.securityProviderIA5); + expect(carJaune.key.keyId).toBe(CAR_JAUNE_SIGNATURES.keyId); }); - it('extracts the signatures and Level 1 algorithm OIDs of each ticket', () => { + it('stores the full extracted ticket in each group', () => { const collector = new SignatureCollector(); - const { ticket } = collector.add(hexToBytes(SOLEA_TICKET_HEX)); + const result = collector.add(hexToBytes(SOLEA_TICKET_HEX)); + if (result.status !== 'added') throw new Error(`unexpected status ${result.status}`); + + const { ticket } = result; + expect(bytesToHex(ticket.level1.signature!)).toBe(SOLEA_SIGNATURES.level1SignatureHex); + expect(ticket.level1.keyAlg).toBe(SOLEA_SIGNATURES.level1KeyAlg); + expect(ticket.level1.signingAlg).toBe(SOLEA_SIGNATURES.level1SigningAlg); + expect(ticket.level1.signedBytes.length).toBeGreaterThan(0); + // Level 2 is carried in full too: signature, signed bytes and public key. + expect(ticket.level2.signature![0]).toBe(0x30); + expect(ticket.level2.signedBytes.length).toBeGreaterThan(ticket.level1.signedBytes.length); + expect(ticket.level2.publicKey).toBeDefined(); + }); + + it('accepts an already-extracted ticket', () => { + const collector = new SignatureCollector(); + const ticket = extractTicket(hexToBytes(SOLEA_TICKET_HEX)); - expect(bytesToHex(ticket.level1Signature!)).toBe(SOLEA_SIGNATURES.level1SignatureHex); - // The level 2 signature is a DER SEQUENCE (its exact bytes are not fixtured). - expect(ticket.level2Signature![0]).toBe(0x30); - expect(ticket.level1KeyAlg).toBe(SOLEA_SIGNATURES.level1KeyAlg); - expect(ticket.level1SigningAlg).toBe(SOLEA_SIGNATURES.level1SigningAlg); + const result = collector.add(ticket); + if (result.status !== 'added') throw new Error(`unexpected status ${result.status}`); + expect(result.ticket).toBe(ticket); + + // The same payload as raw bytes is a duplicate of the extracted ticket. + expect(collector.add(hexToBytes(SOLEA_TICKET_HEX)).status).toBe('duplicate'); }); - it('leaves level2Signature undefined for static barcodes', () => { + it('leaves level2 signature undefined for static barcodes', () => { const collector = new SignatureCollector(); - const { ticket } = collector.add(hexToBytes(CAR_JAUNE_TICKET_HEX)); - expect(ticket.level2Signature).toBeUndefined(); - expect(bytesToHex(ticket.level1Signature!)).toBe(CAR_JAUNE_SIGNATURES.level1SignatureHex); + const result = collector.add(hexToBytes(CAR_JAUNE_TICKET_HEX)); + if (result.status !== 'added') throw new Error(`unexpected status ${result.status}`); + + expect(result.ticket.level2.signature).toBeUndefined(); + expect(bytesToHex(result.ticket.level1.signature!)).toBe(CAR_JAUNE_SIGNATURES.level1SignatureHex); }); it('deduplicates rescans of the same barcode', () => { @@ -97,8 +120,8 @@ describe('SignatureCollector', () => { const first = collector.add(hexToBytes(SOLEA_TICKET_HEX)); const again = collector.add(hexToBytes(SOLEA_TICKET_HEX)); - expect(first.duplicate).toBe(false); - expect(again.duplicate).toBe(true); + if (first.status !== 'added') throw new Error(`unexpected status ${first.status}`); + if (again.status !== 'duplicate') throw new Error(`unexpected status ${again.status}`); expect(again.group).toBe(first.group); expect(first.group.tickets).toHaveLength(1); expect(collector.size).toBe(1); @@ -109,21 +132,36 @@ describe('SignatureCollector', () => { const first = collector.add(hexToBytes(SOLEA_TICKET_HEX)); const second = collector.add(hexToBytes(CTS_TICKET_HEX)); + if (first.status !== 'added' || second.status !== 'added') throw new Error('unexpected status'); expect(second.group).toBe(first.group); expect(first.group.tickets).toHaveLength(2); }); - it('throws on a payload that is not a UIC barcode', () => { + it('reports an unreadable scan as invalid without throwing', () => { const collector = new SignatureCollector(); - expect(() => collector.add(new Uint8Array([1, 2, 3]))).toThrow(); + const result = collector.add(new Uint8Array([1, 2, 3])); + + expect(result.status).toBe('invalid'); + if (result.status === 'invalid') expect(result.error).toBeInstanceOf(Error); expect(collector.size).toBe(0); + expect(collector.groups()).toHaveLength(0); + }); + + it('looks a group up by key or by id label', () => { + const collector = new SignatureCollector(); + collector.add(hexToBytes(SOLEA_TICKET_HEX)); + collector.add(hexToBytes(CAR_JAUNE_TICKET_HEX)); + + expect(collector.group('3703/7')?.key.securityProviderNum).toBe(3703); + expect(collector.group(signatureKey({ securityProviderIA5: 'IWN8', keyId: 1 }))?.key.id).toBe('IWN8/1'); + expect(collector.group('9999/9')).toBeUndefined(); }); }); describe('collectSignatures', () => { it('classifies a batch in one call', () => { const groups = collectSignatures(ALL_TICKETS_HEX.map(hexToBytes)); - expect(groups.map(g => [g.id, g.tickets.length])).toEqual([ + expect(groups.map(g => [g.key.id, g.tickets.length])).toEqual([ ['1187/0', 1], ['3703/1', 1], ['3703/2', 1], diff --git a/tests/signer.test.ts b/tests/signer.test.ts index a67cd21..4a96837 100644 --- a/tests/signer.test.ts +++ b/tests/signer.test.ts @@ -13,7 +13,7 @@ import { encodeLevel1Data, encodeLevel2SignedData, encodeUicBarcode, - extractSignedData, + extractTicket, generateKeyPair, getPublicKey, verifyLevel1Signature, @@ -255,7 +255,7 @@ describe('signPayload', () => { }); describe('encodeLevel1Data', () => { - it('produces bytes matching extractSignedData for Solea ticket', () => { + it('produces bytes matching extractTicket for Solea ticket', () => { const ticket = decodeTicket(SOLEA_TICKET_HEX); const l1 = ticket.level2SignedData.level1Data; const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); @@ -281,12 +281,12 @@ describe('encodeLevel1Data', () => { level2SignedData: { ...withOids.level2SignedData, level1Signature: l1Sig }, level2Signature: new Uint8Array(0), }); - const extracted = extractSignedData(fullBytes); + const extracted = extractTicket(fullBytes); - expect(level1Raw.data).toEqual(extracted.level1DataBytes); + expect(level1Raw.data).toEqual(extracted.level1.signedBytes); }); - it('produces bytes matching extractSignedData for CTS ticket', () => { + it('produces bytes matching extractTicket for CTS ticket', () => { const ticket = decodeTicket(CTS_TICKET_HEX); const l1 = ticket.level2SignedData.level1Data; const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256'); @@ -310,9 +310,9 @@ describe('encodeLevel1Data', () => { level2SignedData: { ...withOids.level2SignedData, level1Signature: l1Sig }, level2Signature: new Uint8Array(0), }); - const extracted = extractSignedData(fullBytes); + const extracted = extractTicket(fullBytes); - expect(level1Raw.data).toEqual(extracted.level1DataBytes); + expect(level1Raw.data).toEqual(extracted.level1.signedBytes); }); }); diff --git a/tests/verifier.test.ts b/tests/verifier.test.ts index 3ca9303..d5ef6d4 100644 --- a/tests/verifier.test.ts +++ b/tests/verifier.test.ts @@ -11,7 +11,7 @@ import { signPayload, getPublicKey, CURVES, - extractSignedData, + extractTicket, decodeTicket, findKeyInXml, parseKeysXml, @@ -157,50 +157,50 @@ describe('extractEcPublicKeyPoint', () => { // Signed data extraction tests // --------------------------------------------------------------------------- -describe('extractSignedData', () => { +describe('extractTicket', () => { it('extracts signed bytes from SNCF TER ticket', () => { const bytes = hexToBytes(SNCF_TER_TICKET_HEX); - const extracted = extractSignedData(bytes); + const extracted = extractTicket(bytes); - expect(extracted.level1DataBytes).toBeInstanceOf(Uint8Array); - expect(extracted.level1DataBytes.length).toBeGreaterThan(0); - expect(extracted.level2SignedBytes).toBeInstanceOf(Uint8Array); - expect(extracted.level2SignedBytes.length).toBeGreaterThan(0); - expect(extracted.level2SignedBytes.length).toBeGreaterThan(extracted.level1DataBytes.length); + expect(extracted.level1.signedBytes).toBeInstanceOf(Uint8Array); + expect(extracted.level1.signedBytes.length).toBeGreaterThan(0); + expect(extracted.level2.signedBytes).toBeInstanceOf(Uint8Array); + expect(extracted.level2.signedBytes.length).toBeGreaterThan(0); + expect(extracted.level2.signedBytes.length).toBeGreaterThan(extracted.level1.signedBytes.length); }); it('extracts correct security metadata from Soléa ticket', () => { const bytes = hexToBytes(SOLEA_TICKET_HEX); - const extracted = extractSignedData(bytes); + const extracted = extractTicket(bytes); const ticket = decodeTicket(SOLEA_TICKET_HEX); // Verify extraction matches decodeTicket output const l1 = ticket.level2SignedData.level1Data; - expect(extracted.security.securityProviderNum).toBe(l1.securityProviderNum); - expect(extracted.security.keyId).toBe(l1.keyId); - expect(extracted.security.level1SigningAlg).toBe(l1.level1SigningAlg); - expect(extracted.security.level2SigningAlg).toBe(l1.level2SigningAlg); - expect(extracted.security.level1KeyAlg).toBe(l1.level1KeyAlg); - expect(extracted.security.level2KeyAlg).toBe(l1.level2KeyAlg); + expect(extracted.key.securityProviderNum).toBe(l1.securityProviderNum); + expect(extracted.key.keyId).toBe(l1.keyId); + expect(extracted.level1.signingAlg).toBe(l1.level1SigningAlg); + expect(extracted.level2.signingAlg).toBe(l1.level2SigningAlg); + expect(extracted.level1.keyAlg).toBe(l1.level1KeyAlg); + expect(extracted.level2.keyAlg).toBe(l1.level2KeyAlg); // Also verify specific expected values - expect(extracted.security.securityProviderNum).toBe(3703); - expect(extracted.security.keyId).toBe(7); - expect(extracted.security.level2SigningAlg).toBe('1.2.840.10045.4.3.2'); + expect(extracted.key.securityProviderNum).toBe(3703); + expect(extracted.key.keyId).toBe(7); + expect(extracted.level2.signingAlg).toBe('1.2.840.10045.4.3.2'); }); it('extracts signatures matching decoded ticket for CTS', () => { const bytes = hexToBytes(CTS_TICKET_HEX); - const extracted = extractSignedData(bytes); + const extracted = extractTicket(bytes); const ticket = decodeTicket(CTS_TICKET_HEX); - expect(extracted.security.level1Signature).toBeDefined(); - expect(toHex(extracted.security.level1Signature!)).toBe( + expect(extracted.level1.signature).toBeDefined(); + expect(toHex(extracted.level1.signature!)).toBe( toHex(ticket.level2SignedData.level1Signature!) ); - expect(extracted.security.level2Signature).toBeDefined(); - expect(toHex(extracted.security.level2Signature!)).toBe( + expect(extracted.level2.signature).toBeDefined(); + expect(toHex(extracted.level2.signature!)).toBe( toHex(ticket.level2Signature!) ); }); From 41449ac9e85d9f8c9ea641d405a63a75f17126c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:33:29 +0000 Subject: [PATCH 5/5] chore(release): 3.0.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BMYUez7fcvaL34RUjxKZRJ --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ecf0c..813fcbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Upcoming release +## [3.0.0] + ### Breaking Changes - **`extractSignedData` is replaced by `extractTicket`**, which returns the diff --git a/package-lock.json b/package-lock.json index abc04ca..d3338f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dosipas-ts", - "version": "2.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dosipas-ts", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "@noble/curves": "^2.0.1", diff --git a/package.json b/package.json index 45750f7..6367197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dosipas-ts", - "version": "2.0.0", + "version": "3.0.0", "description": "UIC barcode ticket encoder/decoder/verifier with Intercode 6 extensions", "type": "module", "exports": {