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.
Files changed (44) hide show
  1. package/README.md +133 -18
  2. package/dist/control.d.ts +15 -0
  3. package/dist/control.d.ts.map +1 -0
  4. package/dist/control.js +445 -0
  5. package/dist/control.js.map +1 -0
  6. package/dist/decoder.d.ts +2 -2
  7. package/dist/decoder.d.ts.map +1 -1
  8. package/dist/decoder.js +85 -77
  9. package/dist/decoder.js.map +1 -1
  10. package/dist/encoder.d.ts +38 -1
  11. package/dist/encoder.d.ts.map +1 -1
  12. package/dist/encoder.js +155 -15
  13. package/dist/encoder.js.map +1 -1
  14. package/dist/fixtures.d.ts +6 -0
  15. package/dist/fixtures.d.ts.map +1 -1
  16. package/dist/fixtures.js +36 -0
  17. package/dist/fixtures.js.map +1 -1
  18. package/dist/index.d.ts +8 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/schemas.d.ts +1 -0
  23. package/dist/schemas.d.ts.map +1 -1
  24. package/dist/schemas.js +2 -0
  25. package/dist/schemas.js.map +1 -1
  26. package/dist/signature-utils.d.ts +12 -2
  27. package/dist/signature-utils.d.ts.map +1 -1
  28. package/dist/signature-utils.js +47 -2
  29. package/dist/signature-utils.js.map +1 -1
  30. package/dist/signer.d.ts +89 -0
  31. package/dist/signer.d.ts.map +1 -0
  32. package/dist/signer.js +239 -0
  33. package/dist/signer.js.map +1 -0
  34. package/dist/time-helpers.d.ts +40 -0
  35. package/dist/time-helpers.d.ts.map +1 -0
  36. package/dist/time-helpers.js +93 -0
  37. package/dist/time-helpers.js.map +1 -0
  38. package/dist/types.d.ts +223 -59
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js +3 -2
  41. package/dist/types.js.map +1 -1
  42. package/package.json +11 -3
  43. package/schemas/uic-barcode/uicDynamicContentData_v1.asn +152 -0
  44. 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
- Decode, encode, and verify UIC barcode tickets with Intercode 6 extensions in TypeScript.
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
- 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, and two-level ECDSA signature verification.
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
- Requires Node.js 18+. ESM-only.
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` object contains:
34
+ The returned `UicBarcodeTicket` follows the UIC barcode ASN.1 schema hierarchy:
28
35
 
29
36
  ```ts
30
- ticket.format // "U1" or "U2"
31
- ticket.headerVersion // 1 or 2
32
- ticket.security // SecurityInfo (provider, key IDs, algorithms, signatures)
33
- ticket.railTickets // RailTicketData[] (FCB-decoded ticket data)
34
- ticket.otherDataBlocks // DataBlock[] (non-FCB data blocks)
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
- Each rail ticket includes typed fields for issuing details, traveler info, transport documents, and control details:
44
+ Security metadata and algorithm OIDs live on `level1Data`:
40
45
 
41
46
  ```ts
42
- const rt = ticket.railTickets[0];
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
- rt.fcbVersion // 1, 2, or 3
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].ticketType // e.g. "openTicket", "reservation"
51
- rt.raw // full decoded object for fields not covered above
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 | Key verification |
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"}
@@ -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