dosipas-ts 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -18
- package/dist/control.d.ts +15 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/control.js +445 -0
- package/dist/control.js.map +1 -0
- package/dist/decoder.d.ts +2 -2
- package/dist/decoder.d.ts.map +1 -1
- package/dist/decoder.js +85 -77
- package/dist/decoder.js.map +1 -1
- package/dist/encoder.d.ts +38 -1
- package/dist/encoder.d.ts.map +1 -1
- package/dist/encoder.js +155 -15
- package/dist/encoder.js.map +1 -1
- package/dist/fixtures.d.ts +6 -0
- package/dist/fixtures.d.ts.map +1 -1
- package/dist/fixtures.js +36 -0
- package/dist/fixtures.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +1 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +2 -0
- package/dist/schemas.js.map +1 -1
- package/dist/signature-utils.d.ts +12 -2
- package/dist/signature-utils.d.ts.map +1 -1
- package/dist/signature-utils.js +47 -2
- package/dist/signature-utils.js.map +1 -1
- package/dist/signer.d.ts +89 -0
- package/dist/signer.d.ts.map +1 -0
- package/dist/signer.js +239 -0
- package/dist/signer.js.map +1 -0
- package/dist/time-helpers.d.ts +40 -0
- package/dist/time-helpers.d.ts.map +1 -0
- package/dist/time-helpers.js +93 -0
- package/dist/time-helpers.js.map +1 -0
- package/dist/types.d.ts +223 -59
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -2
- package/dist/types.js.map +1 -1
- package/package.json +11 -3
- package/schemas/uic-barcode/uicDynamicContentData_v1.asn +152 -0
- package/schemas/uic-barcode/uicDynamicContentData_v1.schema.json +314 -0
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# dosipas-ts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **[Try the online playground](https://sysdevrun.github.io/dosipas-ts/)** — decode, encode, sign, verify, and control UIC barcode tickets in your browser.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Decode, encode, sign, verify, and control UIC barcode tickets with Intercode 6 extensions in TypeScript.
|
|
6
|
+
|
|
7
|
+
Handles the full UIC barcode envelope (header versions 1 and 2), FCB rail ticket data (versions 1, 2, and 3), Intercode 6 issuing extensions, dynamic data (both Intercode ID1 and FDC1 formats), and two-level ECDSA signature verification and signing.
|
|
8
|
+
|
|
9
|
+
ASN.1 PER unaligned payloads are parsed using [`asn1-per-ts`](https://github.com/sysdevrun/asn1-per-ts).
|
|
6
10
|
|
|
7
11
|
## Install
|
|
8
12
|
|
|
@@ -10,7 +14,10 @@ Handles the full UIC barcode envelope (header versions 1 and 2), FCB rail ticket
|
|
|
10
14
|
npm install dosipas-ts
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- **Node.js >= 20** — Node 18 is not supported because `globalThis.crypto` (Web Crypto API) is not available as a stable global until Node 20. The `@noble/curves` and `@noble/hashes` dependencies rely on it for cryptographic operations.
|
|
20
|
+
- **ESM-only** — this package uses `"type": "module"` and provides only ESM exports.
|
|
14
21
|
|
|
15
22
|
## Decoding
|
|
16
23
|
|
|
@@ -24,31 +31,57 @@ const ticket = decodeTicket('815563dd8e76...');
|
|
|
24
31
|
const ticket = decodeTicketFromBytes(bytes);
|
|
25
32
|
```
|
|
26
33
|
|
|
27
|
-
The returned `UicBarcodeTicket`
|
|
34
|
+
The returned `UicBarcodeTicket` follows the UIC barcode ASN.1 schema hierarchy:
|
|
28
35
|
|
|
29
36
|
```ts
|
|
30
|
-
ticket.format
|
|
31
|
-
ticket.
|
|
32
|
-
ticket.
|
|
33
|
-
ticket.
|
|
34
|
-
ticket.
|
|
35
|
-
ticket.dynamicData // IntercodeDynamicData (Intercode 6 Level 2, if present)
|
|
36
|
-
ticket.level2Signature // Uint8Array (if present)
|
|
37
|
+
ticket.format // "U1" or "U2"
|
|
38
|
+
ticket.level2SignedData.level1Data // security metadata + data sequence
|
|
39
|
+
ticket.level2SignedData.level1Signature // Level 1 signature bytes
|
|
40
|
+
ticket.level2SignedData.level2Data // dynamic content block (FDC1 or Intercode ID1)
|
|
41
|
+
ticket.level2Signature // Level 2 signature bytes
|
|
37
42
|
```
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
Security metadata and algorithm OIDs live on `level1Data`:
|
|
40
45
|
|
|
41
46
|
```ts
|
|
42
|
-
const
|
|
47
|
+
const l1 = ticket.level2SignedData.level1Data;
|
|
48
|
+
|
|
49
|
+
l1.securityProviderNum // RICS code of the security provider
|
|
50
|
+
l1.keyId // key ID for signature lookup
|
|
51
|
+
l1.level1KeyAlg // Level 1 key algorithm OID
|
|
52
|
+
l1.level1SigningAlg // Level 1 signing algorithm OID
|
|
53
|
+
l1.level2KeyAlg // Level 2 key algorithm OID
|
|
54
|
+
l1.level2SigningAlg // Level 2 signing algorithm OID
|
|
55
|
+
l1.level2PublicKey // Level 2 public key bytes (embedded in barcode)
|
|
56
|
+
l1.endOfValidityYear // v2 headers only
|
|
57
|
+
l1.endOfValidityDay // v2 headers only
|
|
58
|
+
l1.validityDuration // seconds
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Rail ticket data is in `level1Data.dataSequence`:
|
|
43
62
|
|
|
44
|
-
|
|
63
|
+
```ts
|
|
64
|
+
const entry = ticket.level2SignedData.level1Data.dataSequence[0];
|
|
65
|
+
|
|
66
|
+
entry.dataFormat // "FCB1", "FCB2", or "FCB3"
|
|
67
|
+
entry.data // raw PER-encoded bytes
|
|
68
|
+
|
|
69
|
+
const rt = entry.decoded; // UicRailTicketData (when dataFormat is FCBn)
|
|
45
70
|
rt.issuingDetail?.issuerNum // RICS code
|
|
46
71
|
rt.issuingDetail?.issuingYear // e.g. 2025
|
|
47
72
|
rt.issuingDetail?.issuingDay // day of year
|
|
48
73
|
rt.issuingDetail?.intercodeIssuing // Intercode 6 issuing extension
|
|
49
74
|
rt.travelerDetail?.traveler?.[0].firstName // traveler name
|
|
50
|
-
rt.transportDocument?.[0].
|
|
51
|
-
|
|
75
|
+
rt.transportDocument?.[0].ticket // { key: "openTicket", value: { ... } }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Dynamic content is in `level2Data`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const l2 = ticket.level2SignedData.level2Data;
|
|
82
|
+
|
|
83
|
+
l2.dataFormat // "FDC1" or "_3703.ID1" (Intercode)
|
|
84
|
+
l2.decoded // UicDynamicContentData (FDC1) or IntercodeDynamicData (Intercode)
|
|
52
85
|
```
|
|
53
86
|
|
|
54
87
|
## Encoding
|
|
@@ -97,6 +130,52 @@ const hex = encodeTicket({
|
|
|
97
130
|
const bytes = encodeTicketToBytes({ /* same input */ });
|
|
98
131
|
```
|
|
99
132
|
|
|
133
|
+
## Signing
|
|
134
|
+
|
|
135
|
+
Sign tickets with ECDSA using the two-pass signing flow (Level 1, then Level 2):
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { signAndEncodeTicket, generateKeyPair } from 'dosipas-ts';
|
|
139
|
+
|
|
140
|
+
const level1Key = generateKeyPair('P-256');
|
|
141
|
+
const level2Key = generateKeyPair('P-256');
|
|
142
|
+
|
|
143
|
+
const ticketBytes = signAndEncodeTicket(
|
|
144
|
+
{
|
|
145
|
+
headerVersion: 2,
|
|
146
|
+
fcbVersion: 2,
|
|
147
|
+
securityProviderNum: 3703,
|
|
148
|
+
keyId: 1,
|
|
149
|
+
railTicket: {
|
|
150
|
+
issuingDetail: {
|
|
151
|
+
issuerNum: 3703,
|
|
152
|
+
issuingYear: 2025,
|
|
153
|
+
issuingDay: 44,
|
|
154
|
+
activated: true,
|
|
155
|
+
},
|
|
156
|
+
transportDocument: [
|
|
157
|
+
{ ticketType: 'openTicket', ticket: { /* ... */ } },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
level1Key,
|
|
162
|
+
level2Key, // omit for static barcodes (Level 1 only)
|
|
163
|
+
);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
For finer control, sign each level independently:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { signLevel1, signLevel2 } from 'dosipas-ts';
|
|
170
|
+
|
|
171
|
+
const level1Sig = signLevel1(input, privateKey, 'P-256');
|
|
172
|
+
const level2Sig = signLevel2(
|
|
173
|
+
{ ...input, level1Signature: level1Sig },
|
|
174
|
+
level2PrivateKey,
|
|
175
|
+
'P-256',
|
|
176
|
+
);
|
|
177
|
+
```
|
|
178
|
+
|
|
100
179
|
## Signature verification
|
|
101
180
|
|
|
102
181
|
UIC barcodes use a two-level signature scheme:
|
|
@@ -154,6 +233,39 @@ import { verifyLevel1Signature } from 'dosipas-ts';
|
|
|
154
233
|
const result = await verifyLevel1Signature(barcodeBytes, publicKeyBytes);
|
|
155
234
|
```
|
|
156
235
|
|
|
236
|
+
## Ticket control
|
|
237
|
+
|
|
238
|
+
Perform comprehensive validation of a ticket in a single call:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { controlTicket } from 'dosipas-ts';
|
|
242
|
+
|
|
243
|
+
const result = await controlTicket(hexPayload, {
|
|
244
|
+
level1KeyProvider: provider,
|
|
245
|
+
expectedIntercodeNetworkIds: new Set(['250502']),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
result.valid // true only if all error-severity checks passed
|
|
249
|
+
result.ticket // decoded UicBarcodeTicket
|
|
250
|
+
result.checks // individual check results keyed by name
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Checks performed: decode, header format, security info, Level 1 signature, Level 2 signature, expiry, specimen flag, activated flag, issuing detail, transport document, Intercode extension (with optional network ID validation), dynamic data format, and dynamic content freshness.
|
|
254
|
+
|
|
255
|
+
## Time helpers
|
|
256
|
+
|
|
257
|
+
Compute UTC timestamps from ticket fields:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { getIssuingTime, getEndOfValidityTime, getDynamicContentTime } from 'dosipas-ts';
|
|
261
|
+
|
|
262
|
+
const ticket = decodeTicket(hex);
|
|
263
|
+
|
|
264
|
+
getIssuingTime(ticket) // Date from issuingYear + issuingDay + issuingTime
|
|
265
|
+
getEndOfValidityTime(ticket) // Date from v2 endOfValidity fields or v1 issuing + duration
|
|
266
|
+
getDynamicContentTime(ticket) // Date from FDC1 timestamp or Intercode ID1 dynamic fields
|
|
267
|
+
```
|
|
268
|
+
|
|
157
269
|
## Extracting signed data
|
|
158
270
|
|
|
159
271
|
For custom verification workflows, extract the exact signed bytes from a barcode:
|
|
@@ -193,6 +305,9 @@ import {
|
|
|
193
305
|
SOLEA_TICKET_HEX,
|
|
194
306
|
CTS_TICKET_HEX,
|
|
195
307
|
GRAND_EST_U1_FCB3_HEX,
|
|
308
|
+
BUS_ARDECHE_TICKET_HEX,
|
|
309
|
+
BUS_AIN_TICKET_HEX,
|
|
310
|
+
DROME_BUS_TICKET_HEX,
|
|
196
311
|
} from 'dosipas-ts';
|
|
197
312
|
```
|
|
198
313
|
|
|
@@ -204,8 +319,8 @@ import { SNCF_TER_SIGNATURES, SOLEA_SIGNATURES, CTS_SIGNATURES } from 'dosipas-t
|
|
|
204
319
|
|
|
205
320
|
## Supported algorithms
|
|
206
321
|
|
|
207
|
-
| Algorithm | Signing |
|
|
208
|
-
|
|
322
|
+
| Algorithm | Signing | Verification |
|
|
323
|
+
|-----------|---------|--------------|
|
|
209
324
|
| ECDSA P-256 with SHA-256 | Yes | Yes |
|
|
210
325
|
| ECDSA P-384 with SHA-384 | Yes | Yes |
|
|
211
326
|
| ECDSA P-521 with SHA-512 | Yes | Yes |
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ControlResult, ControlOptions } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Perform comprehensive validation of a dosipas ticket.
|
|
4
|
+
*
|
|
5
|
+
* Decodes the ticket from hex, then runs a series of check functions covering
|
|
6
|
+
* header format, security metadata, signatures, expiry, specimen/activated
|
|
7
|
+
* flags, issuing details, transport documents, Intercode extensions, and
|
|
8
|
+
* dynamic content freshness.
|
|
9
|
+
*
|
|
10
|
+
* @param hex - Hex-encoded barcode payload.
|
|
11
|
+
* @param options - Control options (reference time, key provider, expected networks).
|
|
12
|
+
* @returns Aggregated control result with individual check results.
|
|
13
|
+
*/
|
|
14
|
+
export declare function controlTicket(hex: string, options?: ControlOptions): Promise<ControlResult>;
|
|
15
|
+
//# sourceMappingURL=control.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAMV,aAAa,EACb,cAAc,EACf,MAAM,SAAS,CAAC;AAmajB;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,aAAa,CAAC,CAqExB"}
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ticket validity control helpers.
|
|
3
|
+
*
|
|
4
|
+
* Performs comprehensive validation of a dosipas ticket by decoding it once
|
|
5
|
+
* and running a series of focused check functions — each responsible for
|
|
6
|
+
* verifying one specific aspect of the ticket.
|
|
7
|
+
*/
|
|
8
|
+
import { decodeTicket } from './decoder';
|
|
9
|
+
import { verifyLevel1Signature, verifyLevel2Signature } from './verifier';
|
|
10
|
+
import { getEndOfValidityTime, getDynamicContentTime } from './time-helpers';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Hex helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function hexToBytes(hex) {
|
|
15
|
+
const clean = hex.replace(/\s+/g, '').replace(/h$/i, '').toLowerCase();
|
|
16
|
+
return new Uint8Array(clean.match(/.{1,2}/g).map(b => parseInt(b, 16)));
|
|
17
|
+
}
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Accessor helpers — resolve new schema hierarchy
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/** Infer header version from format string (U1→1, U2→2). */
|
|
22
|
+
function headerVersion(ticket) {
|
|
23
|
+
const m = ticket.format.match(/^U(\d)$/);
|
|
24
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
25
|
+
}
|
|
26
|
+
/** Get the first decoded UicRailTicketData from dataSequence, if any. */
|
|
27
|
+
function firstRailTicket(ticket) {
|
|
28
|
+
for (const entry of ticket.level2SignedData.level1Data.dataSequence) {
|
|
29
|
+
if (entry.decoded)
|
|
30
|
+
return entry.decoded;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Get decoded level2 dynamic content, narrowed to FDC1. */
|
|
35
|
+
function fdc1Data(ticket) {
|
|
36
|
+
const l2 = ticket.level2SignedData.level2Data;
|
|
37
|
+
if (!l2 || l2.dataFormat !== 'FDC1')
|
|
38
|
+
return undefined;
|
|
39
|
+
return l2.decoded;
|
|
40
|
+
}
|
|
41
|
+
/** Get decoded level2 dynamic content, narrowed to Intercode. */
|
|
42
|
+
function intercodeDynamic(ticket) {
|
|
43
|
+
const l2 = ticket.level2SignedData.level2Data;
|
|
44
|
+
if (!l2 || !/^_\d+\.ID1$/.test(l2.dataFormat))
|
|
45
|
+
return undefined;
|
|
46
|
+
return l2.decoded;
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Check helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
function checkHeader(ticket) {
|
|
52
|
+
const formatOk = ticket.format === 'U1' || ticket.format === 'U2';
|
|
53
|
+
return {
|
|
54
|
+
name: 'Header',
|
|
55
|
+
passed: formatOk,
|
|
56
|
+
severity: 'error',
|
|
57
|
+
message: formatOk
|
|
58
|
+
? undefined
|
|
59
|
+
: `Unrecognized header format: ${ticket.format}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function checkSecurityInfo(ticket) {
|
|
63
|
+
const l1 = ticket.level2SignedData.level1Data;
|
|
64
|
+
const issues = [];
|
|
65
|
+
// Level 1 (mandatory)
|
|
66
|
+
if (l1.securityProviderNum == null && !l1.securityProviderIA5) {
|
|
67
|
+
issues.push('missing security provider');
|
|
68
|
+
}
|
|
69
|
+
if (l1.keyId == null) {
|
|
70
|
+
issues.push('missing keyId');
|
|
71
|
+
}
|
|
72
|
+
// level1SigningAlg is only available in v2 headers; v1 headers don't include OID fields
|
|
73
|
+
if (headerVersion(ticket) >= 2 && !l1.level1SigningAlg) {
|
|
74
|
+
issues.push('missing level1SigningAlg');
|
|
75
|
+
}
|
|
76
|
+
if (!ticket.level2SignedData.level1Signature) {
|
|
77
|
+
issues.push('missing level1Signature');
|
|
78
|
+
}
|
|
79
|
+
// Level 2 (conditional)
|
|
80
|
+
if (l1.level2SigningAlg) {
|
|
81
|
+
if (!l1.level2KeyAlg)
|
|
82
|
+
issues.push('level2SigningAlg set but missing level2KeyAlg');
|
|
83
|
+
if (!l1.level2PublicKey)
|
|
84
|
+
issues.push('level2SigningAlg set but missing level2PublicKey');
|
|
85
|
+
if (!ticket.level2Signature)
|
|
86
|
+
issues.push('level2SigningAlg set but missing level2Signature');
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
name: 'Security Info',
|
|
90
|
+
passed: issues.length === 0,
|
|
91
|
+
severity: 'error',
|
|
92
|
+
message: issues.length > 0 ? issues.join('; ') : undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function checkLevel1Signature(bytes, ticket, options) {
|
|
96
|
+
if (!options.level1KeyProvider) {
|
|
97
|
+
return {
|
|
98
|
+
name: 'Level 1 Signature',
|
|
99
|
+
passed: false,
|
|
100
|
+
severity: 'error',
|
|
101
|
+
message: 'No level 1 key provider — cannot verify mandatory level 1 signature',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const l1 = ticket.level2SignedData.level1Data;
|
|
106
|
+
const pubKey = await options.level1KeyProvider.getPublicKey({ num: l1.securityProviderNum, ia5: l1.securityProviderIA5 }, l1.keyId ?? 0, l1.level1KeyAlg);
|
|
107
|
+
const result = await verifyLevel1Signature(bytes, pubKey);
|
|
108
|
+
return {
|
|
109
|
+
name: 'Level 1 Signature',
|
|
110
|
+
passed: result.valid,
|
|
111
|
+
severity: 'error',
|
|
112
|
+
message: result.valid ? undefined : (result.error ?? 'Verification failed'),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
return {
|
|
117
|
+
name: 'Level 1 Signature',
|
|
118
|
+
passed: false,
|
|
119
|
+
severity: 'error',
|
|
120
|
+
message: e instanceof Error ? e.message : 'Key provider error',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function checkLevel2Signature(bytes, ticket) {
|
|
125
|
+
if (!ticket.level2SignedData.level1Data.level2SigningAlg) {
|
|
126
|
+
return {
|
|
127
|
+
name: 'Level 2 Signature',
|
|
128
|
+
passed: true,
|
|
129
|
+
severity: 'info',
|
|
130
|
+
message: 'Level 2 signature not required — level2SigningAlg not set',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const result = await verifyLevel2Signature(bytes);
|
|
135
|
+
return {
|
|
136
|
+
name: 'Level 2 Signature',
|
|
137
|
+
passed: result.valid,
|
|
138
|
+
severity: 'error',
|
|
139
|
+
message: result.valid ? undefined : (result.error ?? 'Verification failed'),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
return {
|
|
144
|
+
name: 'Level 2 Signature',
|
|
145
|
+
passed: false,
|
|
146
|
+
severity: 'error',
|
|
147
|
+
message: e instanceof Error ? e.message : 'Verification failed',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function checkNotExpired(ticket, now) {
|
|
152
|
+
const expiry = getEndOfValidityTime(ticket);
|
|
153
|
+
if (!expiry) {
|
|
154
|
+
return {
|
|
155
|
+
name: 'Not Expired',
|
|
156
|
+
passed: true,
|
|
157
|
+
severity: 'info',
|
|
158
|
+
message: 'Cannot determine expiry — no validity duration available',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const passed = now < expiry;
|
|
162
|
+
return {
|
|
163
|
+
name: 'Not Expired',
|
|
164
|
+
passed,
|
|
165
|
+
severity: 'error',
|
|
166
|
+
message: passed ? undefined : `Ticket expired at ${expiry.toISOString()}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function checkNotSpecimen(ticket) {
|
|
170
|
+
const specimen = firstRailTicket(ticket)?.issuingDetail?.specimen;
|
|
171
|
+
return {
|
|
172
|
+
name: 'Not Specimen',
|
|
173
|
+
passed: !specimen,
|
|
174
|
+
severity: 'error',
|
|
175
|
+
message: specimen ? 'Ticket is a specimen/test ticket' : undefined,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function checkActivated(ticket) {
|
|
179
|
+
const activated = firstRailTicket(ticket)?.issuingDetail?.activated;
|
|
180
|
+
return {
|
|
181
|
+
name: 'Activated',
|
|
182
|
+
passed: !!activated,
|
|
183
|
+
severity: 'error',
|
|
184
|
+
message: activated ? undefined : 'Ticket is not activated',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function checkIssuingDetail(ticket) {
|
|
188
|
+
const issues = [];
|
|
189
|
+
const rt = firstRailTicket(ticket);
|
|
190
|
+
if (!rt) {
|
|
191
|
+
issues.push('no decoded rail ticket present');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
if (!rt.issuingDetail) {
|
|
195
|
+
issues.push('missing issuingDetail');
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const iss = rt.issuingDetail;
|
|
199
|
+
if (iss.issuingYear < 2016)
|
|
200
|
+
issues.push(`implausible issuingYear: ${iss.issuingYear}`);
|
|
201
|
+
if (iss.issuingDay < 1 || iss.issuingDay > 366)
|
|
202
|
+
issues.push(`implausible issuingDay: ${iss.issuingDay}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
name: 'Issuing Detail',
|
|
207
|
+
passed: issues.length === 0,
|
|
208
|
+
severity: 'error',
|
|
209
|
+
message: issues.length > 0 ? issues.join('; ') : undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function checkTransportDocument(ticket) {
|
|
213
|
+
const docs = firstRailTicket(ticket)?.transportDocument;
|
|
214
|
+
if (!docs || docs.length === 0) {
|
|
215
|
+
return {
|
|
216
|
+
name: 'Transport Document',
|
|
217
|
+
passed: false,
|
|
218
|
+
severity: 'error',
|
|
219
|
+
message: 'No transport document present',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// In new structure, each doc has ticket: { key, value } — check that key exists
|
|
223
|
+
const invalid = docs.filter(d => !d.ticket?.key);
|
|
224
|
+
if (invalid.length > 0) {
|
|
225
|
+
return {
|
|
226
|
+
name: 'Transport Document',
|
|
227
|
+
passed: false,
|
|
228
|
+
severity: 'error',
|
|
229
|
+
message: `${invalid.length} transport document(s) missing ticket type`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
name: 'Transport Document',
|
|
234
|
+
passed: true,
|
|
235
|
+
severity: 'error',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function checkIntercodeExtension(ticket, options) {
|
|
239
|
+
const iss = firstRailTicket(ticket)?.issuingDetail;
|
|
240
|
+
if (iss?.intercodeIssuing) {
|
|
241
|
+
// Extension decoded successfully — check network ID if expected
|
|
242
|
+
if (options.expectedIntercodeNetworkIds) {
|
|
243
|
+
const networkHex = Array.from(iss.intercodeIssuing.networkId)
|
|
244
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
245
|
+
.join('');
|
|
246
|
+
if (!options.expectedIntercodeNetworkIds.has(networkHex)) {
|
|
247
|
+
return {
|
|
248
|
+
name: 'Intercode Extension',
|
|
249
|
+
passed: false,
|
|
250
|
+
severity: 'error',
|
|
251
|
+
message: `Network ID ${networkHex} not in expected set: ${[...options.expectedIntercodeNetworkIds].join(', ')}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
name: 'Intercode Extension',
|
|
257
|
+
passed: true,
|
|
258
|
+
severity: options.expectedIntercodeNetworkIds ? 'error' : 'warning',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (iss?.extension) {
|
|
262
|
+
const extId = iss.extension.extensionId;
|
|
263
|
+
if (/^[_+](\d+|[A-Z]{2})II1$/.test(extId)) {
|
|
264
|
+
return {
|
|
265
|
+
name: 'Intercode Extension',
|
|
266
|
+
passed: false,
|
|
267
|
+
severity: options.expectedIntercodeNetworkIds ? 'error' : 'warning',
|
|
268
|
+
message: `Extension ${extId} looks like Intercode but was not decoded`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (options.expectedIntercodeNetworkIds) {
|
|
273
|
+
return {
|
|
274
|
+
name: 'Intercode Extension',
|
|
275
|
+
passed: false,
|
|
276
|
+
severity: 'error',
|
|
277
|
+
message: 'Intercode issuing data required but absent',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
name: 'Intercode Extension',
|
|
282
|
+
passed: true,
|
|
283
|
+
severity: 'info',
|
|
284
|
+
message: 'No issuing extension present',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function checkDynamicData(ticket) {
|
|
288
|
+
const l2 = ticket.level2SignedData.level2Data;
|
|
289
|
+
if (!l2) {
|
|
290
|
+
return {
|
|
291
|
+
name: 'Dynamic Data',
|
|
292
|
+
passed: true,
|
|
293
|
+
severity: 'info',
|
|
294
|
+
message: 'No level 2 data block present',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// FDC1 format
|
|
298
|
+
if (l2.dataFormat === 'FDC1') {
|
|
299
|
+
if (!l2.decoded) {
|
|
300
|
+
return {
|
|
301
|
+
name: 'Dynamic Data',
|
|
302
|
+
passed: false,
|
|
303
|
+
severity: 'warning',
|
|
304
|
+
message: 'FDC1 data block present but decoding failed',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { name: 'Dynamic Data', passed: true, severity: 'warning' };
|
|
308
|
+
}
|
|
309
|
+
// Intercode _RICS.ID1 format
|
|
310
|
+
if (/^_\d+\.ID1$/.test(l2.dataFormat)) {
|
|
311
|
+
if (!l2.decoded) {
|
|
312
|
+
return {
|
|
313
|
+
name: 'Dynamic Data',
|
|
314
|
+
passed: false,
|
|
315
|
+
severity: 'warning',
|
|
316
|
+
message: `${l2.dataFormat} data block present but decoding failed`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { name: 'Dynamic Data', passed: true, severity: 'warning' };
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
name: 'Dynamic Data',
|
|
323
|
+
passed: true,
|
|
324
|
+
severity: 'info',
|
|
325
|
+
message: `Unknown level 2 data format: ${l2.dataFormat}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function checkDynamicContentFreshness(ticket, now) {
|
|
329
|
+
const l1 = ticket.level2SignedData.level1Data;
|
|
330
|
+
const genTime = getDynamicContentTime(ticket);
|
|
331
|
+
if (!genTime) {
|
|
332
|
+
// No dynamic content or required fields missing
|
|
333
|
+
const l2 = ticket.level2SignedData.level2Data;
|
|
334
|
+
if (!l2?.decoded) {
|
|
335
|
+
return {
|
|
336
|
+
name: 'Dynamic Content Freshness',
|
|
337
|
+
passed: true,
|
|
338
|
+
severity: 'info',
|
|
339
|
+
message: 'No dynamic content present',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
name: 'Dynamic Content Freshness',
|
|
344
|
+
passed: true,
|
|
345
|
+
severity: 'info',
|
|
346
|
+
message: 'Cannot compute freshness — missing fields',
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// Determine duration: for Intercode, prefer dynamicContentDuration, then validityDuration
|
|
350
|
+
let durationMs;
|
|
351
|
+
const dd = intercodeDynamic(ticket);
|
|
352
|
+
if (dd?.dynamicContentDuration != null) {
|
|
353
|
+
durationMs = dd.dynamicContentDuration * 1000;
|
|
354
|
+
}
|
|
355
|
+
else if (l1.validityDuration != null) {
|
|
356
|
+
durationMs = l1.validityDuration * 1000;
|
|
357
|
+
}
|
|
358
|
+
if (durationMs == null) {
|
|
359
|
+
return {
|
|
360
|
+
name: 'Dynamic Content Freshness',
|
|
361
|
+
passed: true,
|
|
362
|
+
severity: 'info',
|
|
363
|
+
message: 'Cannot compute freshness — no duration available',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const expiryTime = new Date(genTime.getTime() + durationMs);
|
|
367
|
+
const passed = now < expiryTime;
|
|
368
|
+
return {
|
|
369
|
+
name: 'Dynamic Content Freshness',
|
|
370
|
+
passed,
|
|
371
|
+
severity: 'error',
|
|
372
|
+
message: passed
|
|
373
|
+
? undefined
|
|
374
|
+
: `Dynamic content expired at ${expiryTime.toISOString()}`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Main entry point
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
/**
|
|
381
|
+
* Perform comprehensive validation of a dosipas ticket.
|
|
382
|
+
*
|
|
383
|
+
* Decodes the ticket from hex, then runs a series of check functions covering
|
|
384
|
+
* header format, security metadata, signatures, expiry, specimen/activated
|
|
385
|
+
* flags, issuing details, transport documents, Intercode extensions, and
|
|
386
|
+
* dynamic content freshness.
|
|
387
|
+
*
|
|
388
|
+
* @param hex - Hex-encoded barcode payload.
|
|
389
|
+
* @param options - Control options (reference time, key provider, expected networks).
|
|
390
|
+
* @returns Aggregated control result with individual check results.
|
|
391
|
+
*/
|
|
392
|
+
export async function controlTicket(hex, options) {
|
|
393
|
+
const checks = {};
|
|
394
|
+
const opts = options ?? {};
|
|
395
|
+
const now = opts.now ?? new Date();
|
|
396
|
+
// 1. Decode
|
|
397
|
+
let ticket;
|
|
398
|
+
try {
|
|
399
|
+
ticket = decodeTicket(hex);
|
|
400
|
+
checks.decode = {
|
|
401
|
+
name: 'Decode',
|
|
402
|
+
passed: true,
|
|
403
|
+
severity: 'error',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
checks.decode = {
|
|
408
|
+
name: 'Decode',
|
|
409
|
+
passed: false,
|
|
410
|
+
severity: 'error',
|
|
411
|
+
message: e instanceof Error ? e.message : 'Decode failed',
|
|
412
|
+
};
|
|
413
|
+
return { valid: false, checks };
|
|
414
|
+
}
|
|
415
|
+
// Convert hex to bytes for signature verification
|
|
416
|
+
const bytes = hexToBytes(hex);
|
|
417
|
+
// 2. Header
|
|
418
|
+
checks.header = checkHeader(ticket);
|
|
419
|
+
// 3. Security Info
|
|
420
|
+
checks.securityInfo = checkSecurityInfo(ticket);
|
|
421
|
+
// 4. Level 1 Signature (async)
|
|
422
|
+
checks.level1Signature = await checkLevel1Signature(bytes, ticket, opts);
|
|
423
|
+
// 5. Level 2 Signature (async)
|
|
424
|
+
checks.level2Signature = await checkLevel2Signature(bytes, ticket);
|
|
425
|
+
// 6. Not Expired
|
|
426
|
+
checks.notExpired = checkNotExpired(ticket, now);
|
|
427
|
+
// 7. Not Specimen
|
|
428
|
+
checks.notSpecimen = checkNotSpecimen(ticket);
|
|
429
|
+
// 8. Activated
|
|
430
|
+
checks.activated = checkActivated(ticket);
|
|
431
|
+
// 9. Issuing Detail
|
|
432
|
+
checks.issuingDetail = checkIssuingDetail(ticket);
|
|
433
|
+
// 10. Transport Document
|
|
434
|
+
checks.transportDocument = checkTransportDocument(ticket);
|
|
435
|
+
// 11. Intercode Extension
|
|
436
|
+
checks.intercodeExtension = checkIntercodeExtension(ticket, opts);
|
|
437
|
+
// 12. Dynamic Data
|
|
438
|
+
checks.dynamicData = checkDynamicData(ticket);
|
|
439
|
+
// 13. Dynamic Content Freshness
|
|
440
|
+
checks.dynamicContentFreshness = checkDynamicContentFreshness(ticket, now);
|
|
441
|
+
// Compute overall validity: all error-severity checks must pass
|
|
442
|
+
const valid = Object.values(checks).every(c => c.severity !== 'error' || c.passed);
|
|
443
|
+
return { valid, ticket, checks };
|
|
444
|
+
}
|
|
445
|
+
//# sourceMappingURL=control.js.map
|