Skip to content

Commit 587076d

Browse files
committed
Add plan for decomposing signing into composable encoding + signing primitives
https://claude.ai/code/session_01S8KYgakd78tszsuWTScECb
1 parent 254de08 commit 587076d

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

plans/REFACTOR_SIGNING.md

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Plan: Decompose signing into composable encoding + signing primitives
2+
3+
## Motivation
4+
5+
The current `signLevel1` and `signAndEncodeTicket` couple encoding and signing into monolithic functions. The goal is to split them into independent, composable steps that mirror the actual UIC barcode wire format:
6+
7+
```
8+
UicBarcodeHeader
9+
├── format ← string like "U2"
10+
├── level2SignedData ← SIGNED BY level2Signature
11+
│ ├── level1Data ← SIGNED BY level1Signature
12+
│ │ ├── securityProviderNum, keyId, dataSequence, OIDs, publicKey, validity...
13+
│ ├── level1Signature
14+
│ └── level2Data (optional)
15+
└── level2Signature
16+
```
17+
18+
## New functions
19+
20+
### 1. `encodeLevel1Structure(input): Uint8Array`
21+
22+
**Purpose:** Encode only the `level1Data` SEQUENCE to bytes. Since `level1Signature` sits *outside* `level1Data` (it's a sibling in `level2SignedData`), no key or signature is needed here.
23+
24+
**Input type — new `Level1Input`:**
25+
```ts
26+
interface Level1Input {
27+
headerVersion?: number; // 1 or 2 (default 2)
28+
fcbVersion?: number; // 1, 2, or 3 (default 2)
29+
securityProviderNum?: number;
30+
keyId?: number;
31+
level1KeyAlg?: string; // OID
32+
level2KeyAlg?: string; // OID
33+
level1SigningAlg?: string; // OID
34+
level2SigningAlg?: string; // OID
35+
level2PublicKey?: Uint8Array;
36+
endOfValidityYear?: number; // v2 only
37+
endOfValidityDay?: number;
38+
endOfValidityTime?: number;
39+
validityDuration?: number;
40+
railTicket: RailTicketInput; // reuse existing type
41+
}
42+
```
43+
44+
**Implementation approach:**
45+
- Reuse the existing `encodeRailTicket()` helper to produce the `dataSequence` entry bytes.
46+
- Build the `level1Data` object matching the ASN.1 schema structure.
47+
- Encode *just* `level1Data` using the header schema's `level1Data` sub-codec.
48+
- Return the raw PER-encoded bytes of `level1Data`.
49+
50+
**Key challenge — encoding a sub-structure in isolation:**
51+
The current `SchemaCodec` encodes the full `UicBarcodeHeader` from root. To encode just `level1Data`, we need one of:
52+
53+
- **Option A (recommended): Build a standalone codec from the `level1Data` schema node.** Extract the `level1Data` field definition from the header schema JSON and pass it to `SchemaBuilder.build()` or `new SchemaCodec()`. This is clean but requires the schema node to be accessible (it's a nested field inside the header schema).
54+
- **Option B: Encode the full header with dummy wrappers, then extract `level1Data` bytes.** Encode a complete `UicBarcodeHeader` with placeholder signatures, then use `extractSignedData()` to get `level1DataBytes`. This is what the current `signLevel1` already does — it works but defeats the purpose of decomposition.
55+
- **Option C: Walk the header schema JSON at runtime to extract the nested `level1Data` type definition.** Parse the schema to find the field named `level1Data` inside `level2SignedData`, get its `schema` node, and build a codec from it. This is clean and doesn't require schema file changes.
56+
57+
**Recommendation:** Option C — walk the existing header schema JSON to extract `level1Data`'s schema node, then `new SchemaCodec(level1DataSchemaNode)`. This keeps schemas as the single source of truth and requires no schema file modifications. A small helper like `extractFieldSchema(headerSchema, 'level2SignedData.level1Data')` would make this reusable for `level2SignedData` as well.
58+
59+
### 2. `signPayload(data, privateKey, curve): Uint8Array`
60+
61+
**Purpose:** Pure signing function. Takes arbitrary bytes, signs them with ECDSA, returns DER signature.
62+
63+
**Signature:**
64+
```ts
65+
function signPayload(
66+
data: Uint8Array,
67+
privateKey: Uint8Array,
68+
curve: CurveName,
69+
): Uint8Array
70+
```
71+
72+
**Implementation:** Essentially the existing `ecSign()` function made public. Uses `@noble/curves` with `prehash: true, lowS: false`, then `rawToDer()`.
73+
74+
**Usable for both levels:**
75+
- Level 1: `signPayload(level1Bytes, l1PrivateKey, 'P-256')`
76+
- Level 2: `signPayload(level2Bytes, l2PrivateKey, 'P-256')`
77+
78+
### 3. `encodeLevel2Structure(input): Uint8Array`
79+
80+
**Purpose:** Encode the `level2SignedData` SEQUENCE. This contains `level1Data`, `level1Signature`, and optionally `level2Data`. The `level2Signature` sits *outside* this structure (it's a sibling at the header root), so no L2 key/signature needed.
81+
82+
**Input typenew `Level2Input`:**
83+
```ts
84+
interface Level2Input {
85+
level1Data: Uint8Array; // pre-encoded level1Data bytes (from encodeLevel1Structure)
86+
level1Signature: Uint8Array; // DER signature bytes (from signPayload)
87+
level2Data?: { // optional dynamic content
88+
dataFormat: string; // e.g. "FDC1" or "_3703.ID1"
89+
data: Uint8Array; // pre-encoded payload bytes
90+
};
91+
}
92+
```
93+
94+
**Implementation approach:**
95+
- Extract the `level2SignedData` schema node from the header schema (same technique as for `level1Data`).
96+
- The tricky part: `level1Data` is provided as **pre-encoded raw bytes**, but the ASN.1 codec expects a structured object to encode.
97+
98+
**Integrating raw payload bytessee dedicated section below.**
99+
100+
### 4. `encodeUicBarcodeHeader(input): Uint8Array`
101+
102+
**Purpose:** Encode the outermost `UicBarcodeHeader` SEQUENCE.
103+
104+
**Input typenew `HeaderInput`:**
105+
```ts
106+
interface HeaderInput {
107+
format: string; // "U1" or "U2"
108+
level2SignedData: Uint8Array; // pre-encoded (from encodeLevel2Structure)
109+
level2Signature?: Uint8Array; // DER signature bytes (from signPayload)
110+
}
111+
```
112+
113+
**Same raw-bytes integration challenge as `encodeLevel2Structure`.**
114+
115+
---
116+
117+
## Integrating raw payload bytes into ASN.1 PER encoding
118+
119+
This is the core design challenge. `encodeLevel2Structure` receives `level1Data` as pre-encoded `Uint8Array`, but the ASN.1 PER codec expects structured objects. Three approaches:
120+
121+
### Approach A: Raw-bytes passthrough in the codec (recommended)
122+
123+
Add support in `asn1-per-ts` for a sentinel/marker that tells the encoder "write these bytes verbatim instead of encoding this field."
124+
125+
```ts
126+
// In asn1-per-ts, add a marker type:
127+
const RAW = Symbol('raw');
128+
129+
// Usage:
130+
const level2Obj = {
131+
level1Data: { [RAW]: level1DataBytes }, // passthrough
132+
level1Signature: signatureBytes,
133+
level2Data: level2DataObj,
134+
};
135+
codec.encode(level2Obj);
136+
```
137+
138+
**Pros:** Clean API, no schema modifications, bytes are bit-exact.
139+
**Cons:** Requires a change to `asn1-per-ts`.
140+
141+
### Approach B: OCTET STRING wrapping with schema modification
142+
143+
Change the schema so that `level1Data` inside `level2SignedData` is declared as `OCTET STRING` rather than a `SEQUENCE`. Then the codec treats it as opaque bytes.
144+
145+
But this fundamentally breaks decodingthe decoder needs the full structure to parse `level1Data`.
146+
147+
**Verdict:** Not viable without maintaining two parallel schemas (one for encoding, one for decoding).
148+
149+
### Approach C: Manual byte concatenation (no codec for outer layers)
150+
151+
Build `level2SignedData` bytes manually by concatenating PER-encoded fragments:
152+
```ts
153+
function encodeLevel2Structure(input: Level2Input): Uint8Array {
154+
// Manually write the PER encoding:
155+
// - optional bitmap for level2SignedData fields
156+
// - level1Data raw bytes (verbatim)
157+
// - level1Signature as OCTET STRING
158+
// - level2Data (if present) as sub-structure
159+
}
160+
```
161+
162+
**Pros:** No changes to `asn1-per-ts`, full control over byte layout.
163+
**Cons:** Fragiletightly coupled to the PER encoding rules and schema layout. Any schema change requires manual update. Error-prone for optional fields / extension markers.
164+
165+
### Approach D: Decode-then-reencode with override
166+
167+
Decode the raw `level1Data` bytes back into a structured object, then pass it normally to the codec as part of `level2SignedData`. This guarantees the bytes are structurally valid but does NOT guarantee bit-exact reproduction (the codec may encode differently than the original).
168+
169+
**Pros:** Uses the codec as intended, no changes needed.
170+
**Cons:** Not bit-exact. The re-encoded `level1Data` might differ from the input bytes, which would invalidate the Level 2 signature (since it signs the `level2SignedData` bytes, which include `level1Data`).
171+
172+
### Recommendation
173+
174+
**Approach A** is the cleanest long-term solution. It requires adding a "raw bytes passthrough" feature to `asn1-per-ts`, which is a generally useful capability (e.g., for any "embed pre-encoded sub-structure" use case in ASN.1).
175+
176+
The API in `asn1-per-ts` could look like:
177+
```ts
178+
import { RawBytes } from 'asn1-per-ts';
179+
180+
// When encoding, any field value wrapped in RawBytes is written verbatim
181+
const obj = {
182+
level1Data: new RawBytes(preEncodedBytes),
183+
level1Signature: sigBytes,
184+
};
185+
```
186+
187+
The encoder, when it encounters a `RawBytes` instance for a SEQUENCE/SET field, writes the bytes directly to the `BitBuffer` instead of recursing into the field's schema.
188+
189+
**Fallback:** If modifying `asn1-per-ts` is not desirable, Approach C (manual concatenation) works but should be accompanied by thorough tests to ensure the manual PER encoding matches what the codec produces.
190+
191+
---
192+
193+
## Composed usage (end-to-end flow)
194+
195+
```ts
196+
// 1. Encode level1Data
197+
const level1Bytes = encodeLevel1Structure({
198+
securityProviderNum: 1080,
199+
keyId: 1,
200+
level1KeyAlg: '1.2.840.10045.3.1.7',
201+
level1SigningAlg: '1.2.840.10045.4.3.2',
202+
level2KeyAlg: '...',
203+
level2SigningAlg: '...',
204+
level2PublicKey: l2PublicKeyBytes,
205+
railTicket: { ... },
206+
});
207+
208+
// 2. Sign level1Data
209+
const level1Sig = signPayload(level1Bytes, l1PrivateKey, 'P-256');
210+
211+
// 3. Encode level2SignedData (contains level1Data + signature + optional dynamic data)
212+
const level2Bytes = encodeLevel2Structure({
213+
level1Data: level1Bytes,
214+
level1Signature: level1Sig,
215+
level2Data: { dataFormat: 'FDC1', data: fdc1Bytes },
216+
});
217+
218+
// 4. Sign level2SignedData
219+
const level2Sig = signPayload(level2Bytes, l2PrivateKey, 'P-256');
220+
221+
// 5. Encode final barcode header
222+
const barcode = encodeUicBarcodeHeader({
223+
format: 'U2',
224+
level2SignedData: level2Bytes,
225+
level2Signature: level2Sig,
226+
});
227+
```
228+
229+
## Migration strategy
230+
231+
1. Add the new functions alongside the existing ones (non-breaking).
232+
2. Reimplement `signAndEncodeTicket` on top of the new primitives (to validate equivalence).
233+
3. Deprecate `signLevel1`, `signLevel2`, `signAndEncodeTicket` once callers migrate.
234+
4. Update tests: add unit tests for each new function, keep existing round-trip tests passing.
235+
236+
## Files to modify
237+
238+
| File | Changes |
239+
|------|---------|
240+
| `src/encoder.ts` | Add `encodeLevel1Structure`, `encodeLevel2Structure`, `encodeUicBarcodeHeader`. Extract `encodeRailTicket` reuse. Add schema-node extraction helper. |
241+
| `src/signer.ts` | Add `signPayload` (public). Optionally reimplement existing functions on top of new primitives. |
242+
| `src/types.ts` | Add `Level1Input`, `Level2Input`, `HeaderInput` types. |
243+
| `src/index.ts` | Export new functions and types. |
244+
| `asn1-per-ts` (upstream) | If Approach A: add `RawBytes` passthrough support. |
245+
| `tests/signer.test.ts` | Add tests for new primitives, verify round-trip equivalence. |

0 commit comments

Comments
 (0)