Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,48 @@

## Upcoming release

## [3.0.0]

### 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 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
from one or more barcodes signed with the same key. Each signature yields
Expand All @@ -14,6 +54,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
Expand Down
88 changes: 72 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
};

Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -375,29 +379,81 @@ 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

`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 { extractTicket } from 'dosipas-ts';

const ticket = extractTicket(barcodeBytes);

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

For custom verification workflows, extract the exact signed bytes from a barcode:
When scanning many QR codes (e.g. with a camera in a control app), feed each
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 { extractSignedData } from 'dosipas-ts';
import { SignatureCollector } from 'dosipas-ts';

const extracted = extractSignedData(barcodeBytes);
const collector = new SignatureCollector();

extracted.level1DataBytes // bytes signed by level1Signature
extracted.level2SignedBytes // bytes signed by level2Signature
extracted.security // security metadata (algorithms, keys, signatures)
for (const payload of scans) {
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.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 (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);
Expand Down
7 changes: 2 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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
// Level1KeyMaterial and Level2Algorithms are the keys of these two tables.
Expand Down Expand Up @@ -58,7 +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 { 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';
21 changes: 11 additions & 10 deletions src/key-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -94,15 +95,16 @@ const HASHES: Record<string, (msg: Uint8Array) => 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
* algorithms cannot be resolved, or the tickets resolve to different
* algorithms.
*/
export function recoverLevel1PublicKey(
tickets: Uint8Array[],
tickets: Array<Uint8Array | ExtractedTicket>,
options?: RecoverLevel1KeyOptions,
): Uint8Array[] {
if (tickets.length === 0) {
Expand All @@ -117,17 +119,16 @@ export function recoverLevel1PublicKey(
for (let i = 0; i < tickets.length; i++) {
let ticketCandidates: Set<string>;
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,
});
Expand Down Expand Up @@ -158,8 +159,8 @@ export function recoverLevel1PublicKey(
}

ticketCandidates = recoverCandidates(
extracted.level1DataBytes,
security.level1Signature,
level1.signedBytes,
level1.signature,
curve,
hash,
);
Expand Down
Loading
Loading