Skip to content

Commit b9fa536

Browse files
committed
Add CLI tool to extract ECDSA public keys from ticket signatures
Uses ECDSA signature recovery to derive Level 1 public key candidates from UIC barcode tickets. A single signature yields two candidates; intersecting results across multiple tickets signed with the same key narrows it to the unique key. Also adds ardeche, ain, and drome fixtures to the decode-ticket CLI. Verified: provider 5152 key ID 3 (P-256) = 02549c01282fb85da8b901dc... confirmed across all three bus tickets (Ardèche, Ain, Drôme). https://claude.ai/code/session_01Gm1PzLC2s7xn78iQ9BvEGk
1 parent 5e08585 commit b9fa536

2 files changed

Lines changed: 337 additions & 0 deletions

File tree

cli/decode-ticket.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import {
2929
SOLEA_TICKET_HEX,
3030
CTS_TICKET_HEX,
3131
GRAND_EST_U1_FCB3_HEX,
32+
BUS_ARDECHE_TICKET_HEX,
33+
BUS_AIN_TICKET_HEX,
34+
DROME_BUS_TICKET_HEX,
3235
} from '../src/index';
3336
import { getSigningAlgorithm, getKeyAlgorithm } from '../src/oids';
3437
import type { UicBarcodeTicket, SecurityInfo, RailTicketData } from '../src/types';
@@ -44,6 +47,9 @@ const FIXTURES: Record<string, string> = {
4447
solea: SOLEA_TICKET_HEX,
4548
cts: CTS_TICKET_HEX,
4649
grand_est: GRAND_EST_U1_FCB3_HEX,
50+
ardeche: BUS_ARDECHE_TICKET_HEX,
51+
ain: BUS_AIN_TICKET_HEX,
52+
drome: DROME_BUS_TICKET_HEX,
4753
};
4854

4955
// ---------------------------------------------------------------------------

cli/extract-ecdsa-key.ts

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* CLI tool to extract ECDSA public keys from UIC barcode ticket signatures.
4+
*
5+
* Given one or more tickets signed with the same Level 1 key, recovers the
6+
* public key using ECDSA signature recovery. A single signature yields two
7+
* candidate keys; using multiple tickets narrows it down to the real one.
8+
*
9+
* Usage:
10+
* npx tsx cli/extract-ecdsa-key.ts <ticket1> <ticket2> [...]
11+
* npx tsx cli/extract-ecdsa-key.ts ardeche ain drome
12+
* npx tsx cli/extract-ecdsa-key.ts path/to/ticket1.hex path/to/ticket2.hex
13+
*
14+
* Each argument can be a built-in fixture name, a path to a .hex file,
15+
* or inline hex data.
16+
*
17+
* Built-in fixtures: sample, sncf, solea, cts, grand_est, ardeche, ain, drome
18+
*/
19+
20+
import * as fs from 'fs';
21+
import { p256, p384, p521 } from '@noble/curves/nist.js';
22+
import { sha256, sha384, sha512 } from '@noble/hashes/sha2.js';
23+
import { extractSignedData } from '../src/signed-data';
24+
import { derToRaw } from '../src/signature-utils';
25+
import { getSigningAlgorithm, getKeyAlgorithm, curveComponentLength } from '../src/oids';
26+
import {
27+
SAMPLE_TICKET_HEX,
28+
SNCF_TER_TICKET_HEX,
29+
SOLEA_TICKET_HEX,
30+
CTS_TICKET_HEX,
31+
GRAND_EST_U1_FCB3_HEX,
32+
BUS_ARDECHE_TICKET_HEX,
33+
BUS_AIN_TICKET_HEX,
34+
DROME_BUS_TICKET_HEX,
35+
} from '../src/fixtures';
36+
37+
// ---------------------------------------------------------------------------
38+
// Named fixtures
39+
// ---------------------------------------------------------------------------
40+
41+
const FIXTURES: Record<string, string> = {
42+
sample: SAMPLE_TICKET_HEX,
43+
sncf: SNCF_TER_TICKET_HEX,
44+
sncf_ter: SNCF_TER_TICKET_HEX,
45+
solea: SOLEA_TICKET_HEX,
46+
cts: CTS_TICKET_HEX,
47+
grand_est: GRAND_EST_U1_FCB3_HEX,
48+
ardeche: BUS_ARDECHE_TICKET_HEX,
49+
ain: BUS_AIN_TICKET_HEX,
50+
drome: DROME_BUS_TICKET_HEX,
51+
};
52+
53+
// ---------------------------------------------------------------------------
54+
// Formatting helpers
55+
// ---------------------------------------------------------------------------
56+
57+
const GREEN = '\x1b[32m';
58+
const RED = '\x1b[31m';
59+
const YELLOW = '\x1b[33m';
60+
const CYAN = '\x1b[36m';
61+
const DIM = '\x1b[2m';
62+
const BOLD = '\x1b[1m';
63+
const RESET = '\x1b[0m';
64+
65+
function ok(msg: string) { console.log(` ${GREEN}${RESET} ${msg}`); }
66+
function fail(msg: string) { console.log(` ${RED}${RESET} ${msg}`); }
67+
function warn(msg: string) { console.log(` ${YELLOW}${RESET} ${msg}`); }
68+
function heading(msg: string) { console.log(`\n${BOLD}${CYAN}${msg}${RESET}`); }
69+
70+
function toHex(bytes: Uint8Array): string {
71+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
72+
}
73+
74+
function hexToBytes(hex: string): Uint8Array {
75+
const clean = hex.replace(/[\s\n\r]/g, '');
76+
return new Uint8Array(clean.match(/.{1,2}/g)!.map(b => parseInt(b, 16)));
77+
}
78+
79+
// ---------------------------------------------------------------------------
80+
// Curve dispatch
81+
// ---------------------------------------------------------------------------
82+
83+
interface CurveOps {
84+
name: string;
85+
componentLength: number;
86+
hash: (data: Uint8Array) => Uint8Array;
87+
signatureFromBytes: (raw: Uint8Array) => { addRecoveryBit(bit: number): { recoverPublicKey(msgHash: Uint8Array): { toBytes(): Uint8Array } } };
88+
verify: (sig: Uint8Array, msg: Uint8Array, pk: Uint8Array) => boolean;
89+
}
90+
91+
function getCurveOps(curve: string): CurveOps {
92+
const VERIFY_OPTS = { lowS: false } as const;
93+
94+
switch (curve) {
95+
case 'P-256':
96+
return {
97+
name: 'P-256',
98+
componentLength: 32,
99+
hash: sha256,
100+
signatureFromBytes: (raw) => p256.Signature.fromBytes(raw),
101+
verify: (sig, msg, pk) => p256.verify(sig, msg, pk, VERIFY_OPTS),
102+
};
103+
case 'P-384':
104+
return {
105+
name: 'P-384',
106+
componentLength: 48,
107+
hash: sha384,
108+
signatureFromBytes: (raw) => p384.Signature.fromBytes(raw),
109+
verify: (sig, msg, pk) => p384.verify(sig, msg, pk, VERIFY_OPTS),
110+
};
111+
case 'P-521':
112+
return {
113+
name: 'P-521',
114+
componentLength: 66,
115+
hash: sha512,
116+
signatureFromBytes: (raw) => p521.Signature.fromBytes(raw),
117+
verify: (sig, msg, pk) => p521.verify(sig, msg, pk, VERIFY_OPTS),
118+
};
119+
default:
120+
throw new Error(`Unsupported curve: ${curve}`);
121+
}
122+
}
123+
124+
// ---------------------------------------------------------------------------
125+
// Recovery logic
126+
// ---------------------------------------------------------------------------
127+
128+
interface TicketInfo {
129+
label: string;
130+
provider: number;
131+
keyId: number;
132+
curve: string;
133+
sigAlg: string;
134+
level1DataBytes: Uint8Array;
135+
rawSig: Uint8Array;
136+
msgHash: Uint8Array;
137+
}
138+
139+
function extractTicketInfo(label: string, hex: string): TicketInfo {
140+
const bytes = hexToBytes(hex);
141+
const extracted = extractSignedData(bytes);
142+
const { security } = extracted;
143+
144+
if (!security.level1Signature) {
145+
throw new Error(`${label}: no Level 1 signature`);
146+
}
147+
148+
const sigAlg = security.level1SigningAlg
149+
? getSigningAlgorithm(security.level1SigningAlg)
150+
: undefined;
151+
152+
if (!sigAlg || sigAlg.type !== 'ECDSA') {
153+
throw new Error(`${label}: Level 1 is not ECDSA (${sigAlg?.type ?? 'unknown'})`);
154+
}
155+
156+
const keyAlg = security.level1KeyAlg
157+
? getKeyAlgorithm(security.level1KeyAlg)
158+
: undefined;
159+
160+
const curve = keyAlg?.curve;
161+
if (!curve) {
162+
throw new Error(`${label}: cannot determine curve from key algorithm ${security.level1KeyAlg}`);
163+
}
164+
165+
const componentLength = curveComponentLength(curve);
166+
const rawSig = derToRaw(security.level1Signature, componentLength);
167+
const ops = getCurveOps(curve);
168+
const msgHash = ops.hash(extracted.level1DataBytes);
169+
170+
return {
171+
label,
172+
provider: security.securityProviderNum ?? 0,
173+
keyId: security.keyId ?? 0,
174+
curve,
175+
sigAlg: `ECDSA ${curve} with ${sigAlg.hash}`,
176+
level1DataBytes: extracted.level1DataBytes,
177+
rawSig,
178+
msgHash,
179+
};
180+
}
181+
182+
function recoverCandidates(ticket: TicketInfo): Uint8Array[] {
183+
const ops = getCurveOps(ticket.curve);
184+
const sigObj = ops.signatureFromBytes(ticket.rawSig);
185+
const candidates: Uint8Array[] = [];
186+
187+
for (const recovery of [0, 1]) {
188+
try {
189+
const recovered = sigObj.addRecoveryBit(recovery).recoverPublicKey(ticket.msgHash);
190+
const pkBytes = recovered.toBytes();
191+
// Verify the candidate actually works
192+
if (ops.verify(ticket.rawSig, ticket.level1DataBytes, pkBytes)) {
193+
candidates.push(pkBytes);
194+
}
195+
} catch {
196+
// Recovery bit may not yield a valid point — skip
197+
}
198+
}
199+
200+
return candidates;
201+
}
202+
203+
// ---------------------------------------------------------------------------
204+
// Main
205+
// ---------------------------------------------------------------------------
206+
207+
function main() {
208+
const args = process.argv.slice(2);
209+
210+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
211+
console.log('Usage: npx tsx cli/extract-ecdsa-key.ts <ticket1> [ticket2] [...]');
212+
console.log();
213+
console.log('Extract ECDSA Level 1 public keys from UIC barcode ticket signatures.');
214+
console.log('A single ticket yields two candidate keys; additional tickets signed');
215+
console.log('with the same key narrow the result to the real key.');
216+
console.log();
217+
console.log('Each argument can be:');
218+
console.log(' A fixture name: sample, sncf, solea, cts, grand_est, ardeche, ain, drome');
219+
console.log(' A path to a .hex file');
220+
console.log(' Inline hex data');
221+
process.exit(0);
222+
}
223+
224+
// Resolve inputs
225+
const tickets: TicketInfo[] = [];
226+
for (const input of args) {
227+
let hex: string;
228+
let label: string;
229+
230+
if (FIXTURES[input.toLowerCase()]) {
231+
hex = FIXTURES[input.toLowerCase()];
232+
label = input.toLowerCase();
233+
} else if (fs.existsSync(input)) {
234+
hex = fs.readFileSync(input, 'utf-8').trim();
235+
label = input;
236+
} else if (/^[0-9a-fA-F\s]+h?$/.test(input.trim())) {
237+
hex = input;
238+
label = `inline(${input.substring(0, 16)}...)`;
239+
} else {
240+
fail(`Unknown input: ${input}`);
241+
process.exit(1);
242+
}
243+
244+
try {
245+
tickets.push(extractTicketInfo(label, hex));
246+
} catch (e: unknown) {
247+
fail(e instanceof Error ? e.message : `Failed to process ${label}`);
248+
process.exit(1);
249+
}
250+
}
251+
252+
// Display ticket info
253+
heading('Tickets');
254+
for (const t of tickets) {
255+
console.log(` ${BOLD}${t.label}${RESET}`);
256+
console.log(` ${DIM}provider:${RESET} ${t.provider} ${DIM}keyId:${RESET} ${t.keyId} ${DIM}algorithm:${RESET} ${t.sigAlg}`);
257+
}
258+
259+
// Check all tickets share the same provider/keyId/curve
260+
const ref = tickets[0];
261+
const mismatch = tickets.find(t =>
262+
t.provider !== ref.provider || t.keyId !== ref.keyId || t.curve !== ref.curve
263+
);
264+
if (mismatch) {
265+
warn(`Ticket "${mismatch.label}" has different provider/keyId/curve than "${ref.label}".`);
266+
warn(` ${ref.label}: provider=${ref.provider} keyId=${ref.keyId} curve=${ref.curve}`);
267+
warn(` ${mismatch.label}: provider=${mismatch.provider} keyId=${mismatch.keyId} curve=${mismatch.curve}`);
268+
warn('Proceeding anyway — intersection may be empty.');
269+
}
270+
271+
// Recover candidates from each ticket
272+
heading('Recovery');
273+
const candidateSets: string[][] = [];
274+
275+
for (const t of tickets) {
276+
const candidates = recoverCandidates(t);
277+
const hexCandidates = candidates.map(c => toHex(c));
278+
candidateSets.push(hexCandidates);
279+
280+
ok(`${t.label}: ${candidates.length} candidate(s)`);
281+
for (let i = 0; i < candidates.length; i++) {
282+
console.log(` ${DIM}[${i}]${RESET} ${hexCandidates[i]}`);
283+
}
284+
}
285+
286+
// Intersect candidate sets
287+
heading('Result');
288+
289+
let commonKeys = new Set(candidateSets[0]);
290+
for (let i = 1; i < candidateSets.length; i++) {
291+
const nextSet = new Set(candidateSets[i]);
292+
commonKeys = new Set([...commonKeys].filter(k => nextSet.has(k)));
293+
}
294+
295+
if (commonKeys.size === 0) {
296+
fail('No common public key found across all tickets.');
297+
process.exit(1);
298+
}
299+
300+
const keys = [...commonKeys];
301+
if (keys.length === 1) {
302+
ok(`Found unique public key for provider ${ref.provider}, key ID ${ref.keyId} (${ref.curve}):`);
303+
} else {
304+
warn(`Found ${keys.length} candidate key(s) — provide more tickets to disambiguate:`);
305+
}
306+
307+
for (const key of keys) {
308+
console.log(`\n ${GREEN}${key}${RESET}`);
309+
310+
// Verify against all tickets
311+
const keyBytes = hexToBytes(key);
312+
const ops = getCurveOps(ref.curve);
313+
let allValid = true;
314+
for (const t of tickets) {
315+
const valid = ops.verify(t.rawSig, t.level1DataBytes, keyBytes);
316+
if (valid) {
317+
ok(`Verified against ${t.label}`);
318+
} else {
319+
fail(`FAILED against ${t.label}`);
320+
allValid = false;
321+
}
322+
}
323+
if (allValid && tickets.length > 1) {
324+
ok(`Key verified against all ${tickets.length} tickets`);
325+
}
326+
}
327+
328+
console.log();
329+
}
330+
331+
main();

0 commit comments

Comments
 (0)