Skip to content

Commit cdaeb75

Browse files
authored
Merge pull request #15 from sysdevrun/claude/check-fdc-parsing-AGxiS
Add FDC1 encoding support and website UI for decode/encode
2 parents 5e08585 + 69f211c commit cdaeb75

7 files changed

Lines changed: 237 additions & 61 deletions

File tree

src/encoder.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type SchemaNode,
1212
} from 'asn1-per-ts';
1313
import type { Codec } from 'asn1-per-ts';
14-
import { HEADER_SCHEMAS, RAIL_TICKET_SCHEMAS, INTERCODE_SCHEMAS } from './schemas';
14+
import { HEADER_SCHEMAS, RAIL_TICKET_SCHEMAS, INTERCODE_SCHEMAS, DYNAMIC_CONTENT_SCHEMAS } from './schemas';
1515
import type {
1616
UicBarcodeTicketInput,
1717
IssuingDetailInput,
@@ -28,6 +28,7 @@ const headerCodecCache = new Map<number, SchemaCodec>();
2828
const ticketCodecCache = new Map<number, Record<string, Codec<unknown>>>();
2929
let intercodeIssuingCodec: SchemaCodec | undefined;
3030
let intercodeDynamicCodec: SchemaCodec | undefined;
31+
let fdc1Codec: SchemaCodec | undefined;
3132

3233
function getHeaderCodec(version: number): SchemaCodec {
3334
let codec = headerCodecCache.get(version);
@@ -62,6 +63,12 @@ function getIntercodeDynamicCodec(): SchemaCodec {
6263
return intercodeDynamicCodec!;
6364
}
6465

66+
function getFdc1Codec(): SchemaCodec {
67+
if (fdc1Codec) return fdc1Codec;
68+
fdc1Codec = new SchemaCodec(DYNAMIC_CONTENT_SCHEMAS.UicDynamicContentData as SchemaNode);
69+
return fdc1Codec;
70+
}
71+
6572
function toHex(bytes: Uint8Array): string {
6673
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
6774
}
@@ -88,9 +95,12 @@ export function encodeTicket(input: UicBarcodeTicketInput): string {
8895
{ dataFormat: `FCB${fcbVersion}`, data: railTicketBytes },
8996
];
9097

91-
// Step 3: Build Level 2 data (Intercode dynamic)
98+
// Step 3: Build Level 2 data (Intercode dynamic or FDC1)
9299
let level2Data: { dataFormat: string; data: Uint8Array } | undefined;
93-
if (input.dynamicData) {
100+
if (input.dynamicContentData) {
101+
const fdc1Bytes = getFdc1Codec().encode(input.dynamicContentData);
102+
level2Data = { dataFormat: 'FDC1', data: fdc1Bytes };
103+
} else if (input.dynamicData) {
94104
const dynamicBytes = getIntercodeDynamicCodec().encode({
95105
dynamicContentDay: input.dynamicData.dynamicContentDay ?? 0,
96106
dynamicContentTime: input.dynamicData.dynamicContentTime,

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type {
3434
IssuingDetailInput,
3535
IntercodeIssuingDataInput,
3636
IntercodeDynamicDataInput,
37+
UicDynamicContentDataInput,
3738
TravelerDetailInput,
3839
TransportDocumentInput,
3940
SignatureVerificationResult,

src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,10 @@ export interface UicBarcodeTicketInput {
331331
fcbVersion?: number;
332332
/** The rail ticket data to encode. */
333333
railTicket: RailTicketInput;
334-
/** Intercode 6 dynamic data for Level 2. */
334+
/** Intercode 6 dynamic data for Level 2 (encoded as _RICS.ID1). */
335335
dynamicData?: IntercodeDynamicDataInput;
336+
/** UIC Dynamic Content Data for Level 2 (encoded as FDC1). */
337+
dynamicContentData?: UicDynamicContentDataInput;
336338
}
337339

338340
export interface RailTicketInput {
@@ -375,6 +377,15 @@ export interface IntercodeDynamicDataInput {
375377
dynamicContentDuration?: number;
376378
}
377379

380+
/** Input for encoding UIC Dynamic Content Data (FDC1 format). */
381+
export interface UicDynamicContentDataInput {
382+
dynamicContentMobileAppId?: string;
383+
dynamicContentTimeStamp?: TimeStampData;
384+
dynamicContentGeoCoordinate?: DynamicContentGeoCoordinate;
385+
dynamicContentResponseToChallenge?: DynamicContentExtensionData[];
386+
dynamicContentExtension?: DynamicContentExtensionData;
387+
}
388+
378389
export interface TravelerDetailInput {
379390
traveler?: Partial<TravelerInfo>[];
380391
preferredLanguage?: string;

tests/signer.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ function decodedToInput(hex: string): UicBarcodeTicketInput {
8282
},
8383
};
8484

85-
// Add dynamic data if present
86-
if (ticket.dynamicData && ticket.level2DataBlock) {
85+
// Add dynamic data if present (FDC1 or Intercode)
86+
if (ticket.dynamicContentData) {
87+
input.dynamicContentData = ticket.dynamicContentData;
88+
} else if (ticket.dynamicData && ticket.level2DataBlock) {
8789
const ricsMatch = ticket.level2DataBlock.dataFormat.match(/^_(\d+)\.ID1$/);
8890
const rics = ricsMatch ? parseInt(ricsMatch[1], 10) : ticket.security.securityProviderNum ?? 0;
8991
input.dynamicData = {
@@ -157,6 +159,7 @@ describe('signAndEncodeTicket', () => {
157159
const input = decodedToInput(SOLEA_TICKET_HEX);
158160
// Remove dynamic data for static mode
159161
delete (input as any).dynamicData;
162+
delete (input as any).dynamicContentData;
160163
const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256');
161164

162165
const bytes = signAndEncodeTicket(input, l1Key);
@@ -190,11 +193,11 @@ describe('decode → re-encode → decode round-trip', () => {
190193
expect(rt.issuingDetail!.intercodeIssuing).toBeDefined();
191194
expect(rt.issuingDetail!.intercodeIssuing!.intercodeVersion).toBe(1);
192195

193-
// Solea uses FDC1 format — the encoder currently only supports Intercode
194-
// dynamic data (_RICS.ID1), so FDC1 data is not re-encoded in round-trips.
195-
// Verify the re-encoded ticket decodes without dynamic data.
196+
// Verify FDC1 dynamic content data survived the round-trip
197+
expect(decoded.dynamicContentData).toBeDefined();
198+
expect(decoded.level2DataBlock!.dataFormat).toBe('FDC1');
199+
// Intercode dynamic data should not be set for FDC1
196200
expect(decoded.dynamicData).toBeUndefined();
197-
expect(decoded.dynamicContentData).toBeUndefined();
198201
});
199202

200203
it('CTS ticket round-trips through encode/decode', () => {
@@ -267,6 +270,7 @@ describe('signature verification on re-encoded tickets', () => {
267270
it('Level 1 only (static) verifies on re-encoded ticket', async () => {
268271
const input = decodedToInput(SOLEA_TICKET_HEX);
269272
delete (input as any).dynamicData;
273+
delete (input as any).dynamicContentData;
270274
const l1Key = makeKeyPair(FIPS_L1_PRIV, 'P-256');
271275

272276
const encoded = signAndEncodeTicket(input, l1Key);

website/src/components/EncodeTab.tsx

Lines changed: 147 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
2-
import type { UicBarcodeTicketInput, CurveName } from 'dosipas-ts';
2+
import type { UicBarcodeTicketInput, UicDynamicContentDataInput, CurveName } from 'dosipas-ts';
33
import TicketForm, { ToggleSection, NumberField, OptionalNumberField } from './TicketForm';
44
import KeyPairInput from './KeyPairInput';
55
import AztecBarcode from './AztecBarcode';
@@ -192,7 +192,7 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
192192
if (prefillInput) {
193193
setInput(prefillInput);
194194
// Enable L2 if prefill has dynamic data
195-
if (prefillInput.dynamicData) {
195+
if (prefillInput.dynamicData || prefillInput.dynamicContentData) {
196196
setL2Enabled(true);
197197
}
198198
// Populate signatures from prefilled input if present
@@ -292,7 +292,7 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
292292
setEncodeError(null);
293293

294294
// Start dynamic refresh countdown if enabled
295-
if (l2Enabled && dynamicRefreshEnabled && input.dynamicData && l1PrivKey && l2PrivKey) {
295+
if (l2Enabled && dynamicRefreshEnabled && (input.dynamicData || input.dynamicContentData) && l1PrivKey && l2PrivKey) {
296296
regenRef.current = { input, l1PrivKey, l1Curve, l2PrivKey, l2Curve };
297297
setCountdown(dynamicRefreshInterval);
298298
} else {
@@ -328,14 +328,28 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
328328
const secondsSinceMidnight =
329329
now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
330330

331-
const updatedInput: UicBarcodeTicketInput = {
332-
...params.input,
333-
dynamicData: {
334-
...params.input.dynamicData!,
335-
dynamicContentDay: dayOfYear,
336-
dynamicContentTime: secondsSinceMidnight,
337-
},
338-
};
331+
let updatedInput: UicBarcodeTicketInput;
332+
if (params.input.dynamicContentData) {
333+
updatedInput = {
334+
...params.input,
335+
dynamicContentData: {
336+
...params.input.dynamicContentData,
337+
dynamicContentTimeStamp: {
338+
day: dayOfYear,
339+
time: secondsSinceMidnight,
340+
},
341+
},
342+
};
343+
} else {
344+
updatedInput = {
345+
...params.input,
346+
dynamicData: {
347+
...params.input.dynamicData!,
348+
dynamicContentDay: dayOfYear,
349+
dynamicContentTime: secondsSinceMidnight,
350+
},
351+
};
352+
}
339353
regenRef.current = { ...params, input: updatedInput };
340354
setInput(updatedInput);
341355

@@ -397,11 +411,11 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
397411

398412
// Stop regeneration when L2 is disabled or dynamic refresh is turned off
399413
useEffect(() => {
400-
if (!l2Enabled || !dynamicRefreshEnabled || !input.dynamicData) {
414+
if (!l2Enabled || !dynamicRefreshEnabled || (!input.dynamicData && !input.dynamicContentData)) {
401415
regenRef.current = null;
402416
setCountdown(null);
403417
}
404-
}, [l2Enabled, dynamicRefreshEnabled, input.dynamicData]);
418+
}, [l2Enabled, dynamicRefreshEnabled, input.dynamicData, input.dynamicContentData]);
405419

406420
// -------------------------------------------------------------------------
407421
// Dynamic data update helper
@@ -431,7 +445,8 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
431445
navigator.clipboard.writeText(jsonPreview);
432446
};
433447

434-
const hasDynamic = !!input.dynamicData;
448+
const hasDynamic = !!input.dynamicData || !!input.dynamicContentData;
449+
const dynamicFormat: 'intercode' | 'fdc1' = input.dynamicContentData ? 'fdc1' : 'intercode';
435450
const l2KeyPresent = l2Enabled && !!l2PrivKey;
436451

437452
return (
@@ -511,43 +526,127 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
511526

512527
{/* Level 2 Dynamic Data */}
513528
<ToggleSection
514-
title="Intercode 6 Dynamic Data"
529+
title="Level 2 Dynamic Data"
515530
enabled={hasDynamic}
516531
onToggle={(v) => {
517-
handleInputChange({
518-
...input,
519-
dynamicData: v
520-
? { rics: input.securityProviderNum ?? 0, dynamicContentDay: 0 }
521-
: undefined,
522-
});
532+
if (v) {
533+
handleInputChange({
534+
...input,
535+
dynamicContentData: {},
536+
dynamicData: undefined,
537+
});
538+
} else {
539+
handleInputChange({
540+
...input,
541+
dynamicData: undefined,
542+
dynamicContentData: undefined,
543+
});
544+
}
523545
}}
524546
>
525-
<NumberField
526-
label="RICS"
527-
value={input.dynamicData?.rics}
528-
onChange={(v) => updateDynamicData({ rics: v ?? 0 })}
529-
placeholder="e.g. 3703"
530-
/>
531-
<NumberField
532-
label="Day"
533-
value={input.dynamicData?.dynamicContentDay}
534-
onChange={(v) => updateDynamicData({ dynamicContentDay: v })}
535-
/>
536-
<OptionalNumberField
537-
label="Time"
538-
value={input.dynamicData?.dynamicContentTime}
539-
onChange={(v) => updateDynamicData({ dynamicContentTime: v })}
540-
/>
541-
<OptionalNumberField
542-
label="UTC Offset"
543-
value={input.dynamicData?.dynamicContentUTCOffset}
544-
onChange={(v) => updateDynamicData({ dynamicContentUTCOffset: v })}
545-
/>
546-
<OptionalNumberField
547-
label="Duration"
548-
value={input.dynamicData?.dynamicContentDuration}
549-
onChange={(v) => updateDynamicData({ dynamicContentDuration: v })}
550-
/>
547+
{/* Format selector */}
548+
<div className="col-span-2 flex items-center gap-3 mb-1">
549+
<span className="text-xs text-gray-500">Format</span>
550+
<label className="flex items-center gap-1">
551+
<input
552+
type="radio"
553+
name="dynamicFormat"
554+
checked={dynamicFormat === 'fdc1'}
555+
onChange={() => {
556+
handleInputChange({
557+
...input,
558+
dynamicContentData: {},
559+
dynamicData: undefined,
560+
});
561+
}}
562+
className="text-blue-600"
563+
/>
564+
<span className="text-xs text-gray-700">FDC1</span>
565+
</label>
566+
<label className="flex items-center gap-1">
567+
<input
568+
type="radio"
569+
name="dynamicFormat"
570+
checked={dynamicFormat === 'intercode'}
571+
onChange={() => {
572+
handleInputChange({
573+
...input,
574+
dynamicData: { rics: input.securityProviderNum ?? 0, dynamicContentDay: 0 },
575+
dynamicContentData: undefined,
576+
});
577+
}}
578+
className="text-blue-600"
579+
/>
580+
<span className="text-xs text-gray-700">Intercode 6 (_RICS.ID1)</span>
581+
</label>
582+
</div>
583+
584+
{dynamicFormat === 'fdc1' && (
585+
<>
586+
<NumberField
587+
label="Timestamp Day"
588+
value={input.dynamicContentData?.dynamicContentTimeStamp?.day}
589+
onChange={(v) => {
590+
const ts = input.dynamicContentData?.dynamicContentTimeStamp;
591+
handleInputChange({
592+
...input,
593+
dynamicContentData: {
594+
...input.dynamicContentData,
595+
dynamicContentTimeStamp: v != null ? { day: v, time: ts?.time ?? 0 } : undefined,
596+
},
597+
});
598+
}}
599+
placeholder="Day of year (1-366)"
600+
/>
601+
<NumberField
602+
label="Timestamp Time"
603+
value={input.dynamicContentData?.dynamicContentTimeStamp?.time}
604+
onChange={(v) => {
605+
const ts = input.dynamicContentData?.dynamicContentTimeStamp;
606+
handleInputChange({
607+
...input,
608+
dynamicContentData: {
609+
...input.dynamicContentData,
610+
dynamicContentTimeStamp: v != null ? { day: ts?.day ?? 1, time: v } : undefined,
611+
},
612+
});
613+
}}
614+
placeholder="Seconds since midnight (0-86399)"
615+
/>
616+
</>
617+
)}
618+
619+
{dynamicFormat === 'intercode' && (
620+
<>
621+
<NumberField
622+
label="RICS"
623+
value={input.dynamicData?.rics}
624+
onChange={(v) => updateDynamicData({ rics: v ?? 0 })}
625+
placeholder="e.g. 3703"
626+
/>
627+
<NumberField
628+
label="Day"
629+
value={input.dynamicData?.dynamicContentDay}
630+
onChange={(v) => updateDynamicData({ dynamicContentDay: v })}
631+
/>
632+
<OptionalNumberField
633+
label="Time"
634+
value={input.dynamicData?.dynamicContentTime}
635+
onChange={(v) => updateDynamicData({ dynamicContentTime: v })}
636+
/>
637+
<OptionalNumberField
638+
label="UTC Offset"
639+
value={input.dynamicData?.dynamicContentUTCOffset}
640+
onChange={(v) => updateDynamicData({ dynamicContentUTCOffset: v })}
641+
/>
642+
<OptionalNumberField
643+
label="Duration"
644+
value={input.dynamicData?.dynamicContentDuration}
645+
onChange={(v) => updateDynamicData({ dynamicContentDuration: v })}
646+
/>
647+
</>
648+
)}
649+
551650
{l2KeyPresent && (
552651
<div className="col-span-2 space-y-2 border-t border-gray-100 pt-2 mt-1">
553652
<label className="flex items-center gap-2">
@@ -558,7 +657,7 @@ export default function EncodeTab({ onDecode, prefillInput, onPrefillConsumed }:
558657
className="rounded border-gray-300"
559658
/>
560659
<span className="text-xs text-gray-600">
561-
Refresh dynamicContentDate and dynamicContentTime
660+
Refresh timestamp automatically
562661
</span>
563662
</label>
564663
{dynamicRefreshEnabled && (

0 commit comments

Comments
 (0)