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
83 changes: 83 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>`.
- `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
Expand Down
68 changes: 51 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>; // DER-encoded ECDSA signature
getPublicKey?(): Promise<Uint8Array>; // 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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']),
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 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": "3.0.0",
"version": "4.0.0",
"description": "UIC barcode ticket encoder/decoder/verifier with Intercode 6 extensions",
"type": "module",
"exports": {
Expand Down
14 changes: 7 additions & 7 deletions src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ControlResult> {
const checks: Record<string, CheckResult> = {};
Expand All @@ -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,
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Loading
Loading