|
| 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 type — new `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 bytes — see dedicated section below.** |
| 99 | + |
| 100 | +### 4. `encodeUicBarcodeHeader(input): Uint8Array` |
| 101 | + |
| 102 | +**Purpose:** Encode the outermost `UicBarcodeHeader` SEQUENCE. |
| 103 | + |
| 104 | +**Input type — new `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 decoding — the 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:** Fragile — tightly 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