Skip to content

Commit 85a2a53

Browse files
chtituxclaude
andcommitted
Fix FCB3 encoding: default issuingTime to 0 when not provided
FCB3 schema requires issuingTime as mandatory (unlike FCB1/FCB2 where it's optional). This caused L1 signature generation to fail with "Missing mandatory field: 'issuingTime'". The encoder now defaults it to 0 for FCB3+, and the website default ticket sets it to current time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 48b8ac7 commit 85a2a53

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

src/encoder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ function encodeRailTicket(fcbVersion: number, railTicket: UicRailTicketData, sec
349349
}
350350

351351
issuingDetail = { ...issuingRest, extension };
352+
353+
// FCB3 requires issuingTime as mandatory — default to 0 if not provided
354+
if (fcbVersion >= 3 && issuingDetail.issuingTime == null) {
355+
issuingDetail.issuingTime = 0;
356+
}
352357
}
353358

354359
// Validate traveler birth-day fields match the target FCB version

tests/test-encode.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
signLevel1,
4+
signAndEncodeTicket,
5+
encodeTicketToBytes,
6+
controlTicket,
7+
getPublicKey,
8+
CURVES,
9+
} from '../src/index';
10+
import type { UicBarcodeTicket, Level1KeyProvider } from '../src/index';
11+
12+
function hexToBytes(hex: string): Uint8Array {
13+
const clean = hex.replace(/\s/g, '');
14+
const bytes = new Uint8Array(clean.length / 2);
15+
for (let i = 0; i < bytes.length; i++) {
16+
bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
17+
}
18+
return bytes;
19+
}
20+
21+
function bytesToHex(bytes: Uint8Array): string {
22+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
23+
}
24+
25+
const FIPS_KEY_1 = 'c9806898a0334916c860748880a541f093b579a9b1f32934d86c363c39800357';
26+
27+
/** Minimal FCB3 ticket with no issuingTime (tests encoder defaults it to 0). */
28+
function makeFCB3Ticket(): UicBarcodeTicket {
29+
return {
30+
format: 'U2',
31+
level2SignedData: {
32+
level1Data: {
33+
securityProviderNum: 9999,
34+
keyId: 1,
35+
dataSequence: [{
36+
dataFormat: 'FCB3',
37+
decoded: {
38+
issuingDetail: {
39+
issuerNum: 9999,
40+
issuingYear: 2026,
41+
issuingDay: 50,
42+
specimen: true,
43+
securePaperTicket: false,
44+
activated: true,
45+
},
46+
transportDocument: [
47+
{ ticket: { key: 'openTicket', value: { returnIncluded: false } } },
48+
],
49+
},
50+
}],
51+
},
52+
},
53+
};
54+
}
55+
56+
describe('encode → sign → control round-trip', () => {
57+
it('signLevel1 + encodeTicketToBytes + controlTicket verifies L1 signature', async () => {
58+
const ticket = makeFCB3Ticket();
59+
ticket.level2SignedData.level1Data.level1KeyAlg = CURVES['P-256'].keyAlgOid;
60+
ticket.level2SignedData.level1Data.level1SigningAlg = CURVES['P-256'].sigAlgOid;
61+
62+
const l1Sig = signLevel1(ticket, hexToBytes(FIPS_KEY_1), 'P-256');
63+
const withSig: UicBarcodeTicket = {
64+
...ticket,
65+
level2SignedData: {
66+
...ticket.level2SignedData,
67+
level1Signature: l1Sig,
68+
},
69+
};
70+
const hex = bytesToHex(encodeTicketToBytes(withSig));
71+
72+
const pubKey = getPublicKey(hexToBytes(FIPS_KEY_1), 'P-256');
73+
const provider: Level1KeyProvider = {
74+
async getPublicKey() { return pubKey; },
75+
};
76+
const result = await controlTicket(hex, { level1KeyProvider: provider });
77+
expect(result.checks.decode.passed).toBe(true);
78+
expect(result.checks.level1Signature.passed).toBe(true);
79+
});
80+
81+
it('signAndEncodeTicket + controlTicket verifies L1 signature', async () => {
82+
const ticket = makeFCB3Ticket();
83+
84+
const l1Key = {
85+
privateKey: hexToBytes(FIPS_KEY_1),
86+
publicKey: getPublicKey(hexToBytes(FIPS_KEY_1), 'P-256'),
87+
curve: 'P-256' as const,
88+
};
89+
90+
const hex = bytesToHex(signAndEncodeTicket(ticket, l1Key));
91+
92+
const provider: Level1KeyProvider = {
93+
async getPublicKey() { return l1Key.publicKey; },
94+
};
95+
const result = await controlTicket(hex, { level1KeyProvider: provider });
96+
expect(result.checks.decode.passed).toBe(true);
97+
expect(result.checks.level1Signature.passed).toBe(true);
98+
});
99+
});

website/src/components/EncodeTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ function getDefaultTicket(): UicBarcodeTicket {
187187
issuerNum: 9999,
188188
issuingYear: now.getFullYear(),
189189
issuingDay: dayOfYear,
190+
issuingTime: now.getHours() * 60 + now.getMinutes(),
190191
specimen: true,
191192
securePaperTicket: false,
192193
activated: true,

0 commit comments

Comments
 (0)