quadqr-js 1.0.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +40 -3
- package/README.md +199 -20
- package/SPECIFICATION.md +337 -0
- package/bin/quadqr.js +105 -14
- package/dist/esm/benchmark.js +113 -8
- package/dist/esm/node.js +13 -1
- package/dist/esm/quadqr.js +2312 -299
- package/dist/esm/vision.js +452 -14
- package/dist/quadqr.js +2746 -299
- package/dist/quadqr.min.js +2736 -297
- package/docs/API.md +230 -8
- package/docs/BROWSER_CDN.md +51 -6
- package/docs/CLI.md +156 -76
- package/docs/GETTING_STARTED.md +80 -7
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/NODE.md +137 -123
- package/docs/README.md +60 -41
- package/docs/TRIANGLE16.md +108 -0
- package/docs/WASM.md +2 -2
- package/package.json +10 -3
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +144 -2
- package/types/node.d.ts +2 -0
package/SPECIFICATION.md
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# QuadQR Technical Specification
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
QuadQR is an experimental RGBW matrix symbology. Normal mode uses 4-state RGBW cells, while the optional **High Density Mode** is experimental and uses the 16-state Triangle16 physical layout. It is not ISO/IEC QR Code and is not intended to be decoded by standard QR readers.
|
|
6
|
+
|
|
7
|
+
The physical matrix format remains **QuadQR Format v5**. Normal application data is always treated simply as UTF-8 text or arbitrary bytes. Compression, signatures, encryption, rendering, and diagnostics are optional features layered around that stable matrix codec.
|
|
8
|
+
|
|
9
|
+
For exact matrix geometry and Reed-Solomon framing, see [`FORMAT.md`](./FORMAT.md).
|
|
10
|
+
|
|
11
|
+
## 1. Layer model
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
Application bytes / UTF-8 text
|
|
15
|
+
│
|
|
16
|
+
├─ optional compression metadata (internal)
|
|
17
|
+
├─ optional Ed25519 signature metadata (internal)
|
|
18
|
+
├─ optional Secure Payload v1 (AES-256-GCM)
|
|
19
|
+
│
|
|
20
|
+
└─ QuadQR Format v5
|
|
21
|
+
├─ protected header
|
|
22
|
+
├─ CRC-32
|
|
23
|
+
├─ GF(256) Reed-Solomon Spectrum ECC
|
|
24
|
+
├─ spectral-spatial interleaving
|
|
25
|
+
└─ normal RGBW or High Density Triangle16 matrix
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
There is deliberately **no public payload-type registry**. Applications do not select URL, JSON, contact, Wi-Fi, or other semantic types. They encode text or bytes and interpret that data themselves.
|
|
29
|
+
|
|
30
|
+
## 2. Physical cell alphabet
|
|
31
|
+
|
|
32
|
+
Data modules use exactly four states:
|
|
33
|
+
|
|
34
|
+
| State | Bits | Internal value |
|
|
35
|
+
|---|---|---:|
|
|
36
|
+
| Red | `00` | 0 |
|
|
37
|
+
| Green | `01` | 1 |
|
|
38
|
+
| Blue | `10` | 2 |
|
|
39
|
+
| White | `11` | 3 |
|
|
40
|
+
|
|
41
|
+
Structural black is separate from the data alphabet. One encoded byte maps to exactly four RGBW data cells.
|
|
42
|
+
|
|
43
|
+
### High Density Mode
|
|
44
|
+
|
|
45
|
+
When `highDensity: true` is selected, each payload module has a fixed `/` diagonal and two independently classified RGBW regions. This creates 16 states and 4 raw bits per body cell. The protected header stays solid-color at 2 bits per cell for robust bootstrap recovery, then header flag bit 6 tells the decoder that the ECC/body stream uses Triangle16 packing. Scanner sampling uses two points well inside the triangles and excludes the diagonal boundary.
|
|
46
|
+
|
|
47
|
+
## 3. Matrix sizing
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
size = 21 + 4 × (version - 1)
|
|
51
|
+
version = 1..40
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Three 7×7 finder patterns are always present. Version 1 has the legacy QuadQR 5×5 bottom-right alignment marker. Versions 2–40 use the distributed alignment schedule defined in `FORMAT.md`.
|
|
55
|
+
|
|
56
|
+
## 4. Format v5 header flags
|
|
57
|
+
|
|
58
|
+
The protected Format v5 header uses:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
bit 0 UTF-8 text flag
|
|
62
|
+
bits 1..2 ECC profile id
|
|
63
|
+
bit 3 Secure Payload v1 envelope
|
|
64
|
+
bit 4 internal payload-extension metadata present
|
|
65
|
+
bit 5 signed-payload hint
|
|
66
|
+
bit 6 High Density Mode flag
|
|
67
|
+
bit 7 reserved
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Bit 4 is an implementation/interoperability hint used only when compression or signing requires metadata. It is not a user-selectable payload mode.
|
|
71
|
+
|
|
72
|
+
## 5. Spectrum ECC
|
|
73
|
+
|
|
74
|
+
The field remains:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
GF(2^8) = GF(256)
|
|
78
|
+
primitive polynomial = 0x11d
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Version 2+ parity profiles:
|
|
82
|
+
|
|
83
|
+
| Profile | Parity bytes/block | Correctable unknown byte errors/block |
|
|
84
|
+
|---|---:|---:|
|
|
85
|
+
| L | 12 | 6 |
|
|
86
|
+
| M | 24 | 12 |
|
|
87
|
+
| Q | 36 | 18 |
|
|
88
|
+
| H | 48 | 24 |
|
|
89
|
+
|
|
90
|
+
The decoder retains per-cell color confidence. Low-confidence bytes can be promoted to known erasures, allowing Reed-Solomon recovery to use the existing parity budget more efficiently.
|
|
91
|
+
|
|
92
|
+
## 6. Internal payload extension envelope
|
|
93
|
+
|
|
94
|
+
Compression and signing require a small amount of metadata. QuadQR stores that metadata in an **internal extension envelope** only when needed. Applications should normally use `encodeText()`, `encodeBytes()`, `encodeSignedText()`, or `encodeSignedBytes()` and never construct this envelope themselves.
|
|
95
|
+
|
|
96
|
+
Current envelope magic:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
QPX1
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Fixed header size: **16 bytes**.
|
|
103
|
+
|
|
104
|
+
| Offset | Size | Field |
|
|
105
|
+
|---:|---:|---|
|
|
106
|
+
| 0 | 4 | ASCII magic `QPX1` |
|
|
107
|
+
| 4 | 1 | Extension version (`2`; decoder also accepts legacy `1`) |
|
|
108
|
+
| 5 | 1 | Flags |
|
|
109
|
+
| 6 | 1 | Compression ID |
|
|
110
|
+
| 7 | 1 | Signature algorithm ID |
|
|
111
|
+
| 8 | 4 | Original application payload length, big-endian |
|
|
112
|
+
| 12 | 1 | Signing key-ID length (v2); legacy signer-label length in v1 |
|
|
113
|
+
| 13 | 1 | Optional embedded public-key length |
|
|
114
|
+
| 14 | 1 | Signature length |
|
|
115
|
+
| 15 | 1 | Reserved (`0`) |
|
|
116
|
+
|
|
117
|
+
Variable bytes follow as:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
keyId || optionalPublicKey || signature || storedPayload
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Envelope flags currently use:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
bit 0 signed
|
|
127
|
+
bit 1 compressed
|
|
128
|
+
bit 2 public key embedded
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The envelope contains no semantic content type. The Format v5 text flag still determines whether the recovered application payload should be decoded as UTF-8 text.
|
|
132
|
+
|
|
133
|
+
## 7. Compression
|
|
134
|
+
|
|
135
|
+
Compression IDs:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
0 = none
|
|
139
|
+
1 = QuadQR portable LZ
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The portable LZ stream is an LZSS-style format with groups of up to eight tokens. Each group begins with one flag byte. A flag bit of `0` means a one-byte literal. A flag bit of `1` means a two-byte back-reference:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
12-bit offset: 1..4095 bytes
|
|
146
|
+
4-bit length: stored value + 3, therefore 3..18 bytes
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Public compression modes are:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
none
|
|
153
|
+
auto
|
|
154
|
+
lz
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`compression: "auto"` uses compression only when it meaningfully reduces payload size. When it does not help and the payload is not signed, QuadQR stores the original payload directly with **no extension-envelope overhead**.
|
|
158
|
+
|
|
159
|
+
Compression occurs before signing, encryption, and Format v5 ECC.
|
|
160
|
+
|
|
161
|
+
## 8. Signed QuadQR
|
|
162
|
+
|
|
163
|
+
Signature algorithm ID `1` is **Ed25519**.
|
|
164
|
+
|
|
165
|
+
Signed payloads in extension v2 store:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
optional compact key ID
|
|
169
|
+
64-byte Ed25519 signature
|
|
170
|
+
optional 32-byte raw Ed25519 public key only when explicitly requested
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The normal production profile does **not** embed the public key. The signature covers:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
16-byte extension header
|
|
177
|
+
|| key ID
|
|
178
|
+
|| optional embedded public key
|
|
179
|
+
|| stored payload bytes
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The signature field itself is excluded from the signed message.
|
|
183
|
+
|
|
184
|
+
The private Ed25519 key is used only by the issuer to create signatures and must never be embedded in a QuadQR. Verification uses a trusted public key supplied externally by the application, server, certificate, or trusted-key registry. The optional key ID is only an identifier that helps the verifier select the correct trusted public key.
|
|
185
|
+
|
|
186
|
+
For compatibility or self-contained integrity checks, implementations may set `embedPublicKey: true`. A signature verified only against a key embedded in the same QuadQR proves integrity and key possession, but does not establish a trusted signer identity. Such verification should be reported separately from verification against an external trust anchor.
|
|
187
|
+
|
|
188
|
+
Legacy extension v1 symbols used a signer label and embedded public key. Decoders may continue to read and verify those symbols for backward compatibility.
|
|
189
|
+
|
|
190
|
+
## 9. Secure + signed composition
|
|
191
|
+
|
|
192
|
+
When compression, signing, and encryption are combined, QuadQR uses:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
application payload
|
|
196
|
+
→ optional compression
|
|
197
|
+
→ optional Ed25519 signature metadata
|
|
198
|
+
→ AES-256-GCM Secure Payload v1
|
|
199
|
+
→ Format v5 ECC/matrix
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
After scanning:
|
|
203
|
+
|
|
204
|
+
```text
|
|
205
|
+
Format v5 decode
|
|
206
|
+
→ AES-GCM authentication/decryption
|
|
207
|
+
→ internal compression/signature metadata processing
|
|
208
|
+
→ application payload
|
|
209
|
+
→ optional Ed25519 signature verification
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This keeps signature metadata confidential when encryption is enabled while preserving offline verification after decryption.
|
|
213
|
+
|
|
214
|
+
## 10. Text and binary APIs
|
|
215
|
+
|
|
216
|
+
Text and bytes are first-class without semantic payload types:
|
|
217
|
+
|
|
218
|
+
- `encodeText()` / `decodeMatrix().text` for UTF-8 text;
|
|
219
|
+
- `encodeBytes()` for arbitrary bytes;
|
|
220
|
+
- `encodeUint8Array()` / `decodeUint8Array()` as explicit byte-oriented convenience APIs.
|
|
221
|
+
|
|
222
|
+
Compression works on both text and byte payloads through the same `compression` option.
|
|
223
|
+
|
|
224
|
+
## 11. Rendering profiles
|
|
225
|
+
|
|
226
|
+
Rendering does not modify the encoded matrix.
|
|
227
|
+
|
|
228
|
+
### Screen mode
|
|
229
|
+
|
|
230
|
+
Uses the normal RGBW palette and permits:
|
|
231
|
+
|
|
232
|
+
```text
|
|
233
|
+
classic
|
|
234
|
+
soft
|
|
235
|
+
depth
|
|
236
|
+
inset
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Print mode
|
|
240
|
+
|
|
241
|
+
`mode: "print"` applies conservative defaults:
|
|
242
|
+
|
|
243
|
+
- minimum quiet zone of 4 modules unless explicitly overridden;
|
|
244
|
+
- print-safe darker RGB primaries;
|
|
245
|
+
- Classic solid-module rendering by default;
|
|
246
|
+
- physical-size guidance through `getPrintGuidance()`.
|
|
247
|
+
|
|
248
|
+
Recommended general-purpose starting module size is **0.40 mm/module**. Real printer, paper, ink/toner, lamination, lighting, and camera validation remains necessary for production deployments.
|
|
249
|
+
|
|
250
|
+
## 12. Logo safety
|
|
251
|
+
|
|
252
|
+
Logos are rendering overlays and never modify Format v5 data structures.
|
|
253
|
+
|
|
254
|
+
`size: "auto"` estimates a conservative logo ratio from:
|
|
255
|
+
|
|
256
|
+
- ECC profile;
|
|
257
|
+
- encoded utilization;
|
|
258
|
+
- version 1 compact-profile penalty;
|
|
259
|
+
- clear-background usage;
|
|
260
|
+
- screen vs print mode.
|
|
261
|
+
|
|
262
|
+
`findMaxSafeLogoSize()` can empirically search the largest decodable logo size when an ImageData-like logo is available. Generated symbols should still be verified after final rendering.
|
|
263
|
+
|
|
264
|
+
## 13. Scanner diagnostics
|
|
265
|
+
|
|
266
|
+
Successful image scans expose normalized diagnostic fields including:
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
confidence
|
|
270
|
+
geometryConfidence
|
|
271
|
+
calibrationConfidence
|
|
272
|
+
structureConfidence
|
|
273
|
+
eccUtilization
|
|
274
|
+
correctedErrors
|
|
275
|
+
erasureSymbols
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
With `debug: true`, the scanner can also report stage state, geometry candidates, finder/vision passes, latest sampled matrix, confidence matrix, color-normalization method, and the failed stage when decoding does not complete.
|
|
279
|
+
|
|
280
|
+
Debug output is diagnostic evidence, not a cryptographic assurance score.
|
|
281
|
+
|
|
282
|
+
## 14. Scanability score and torture testing
|
|
283
|
+
|
|
284
|
+
`runImageStressTest()` and `assessScanability()` use deterministic synthetic distortions to estimate robustness against:
|
|
285
|
+
|
|
286
|
+
- blur;
|
|
287
|
+
- low brightness;
|
|
288
|
+
- high exposure;
|
|
289
|
+
- uneven shadow;
|
|
290
|
+
- contrast loss;
|
|
291
|
+
- perspective distortion;
|
|
292
|
+
- JPEG-like quantization/block artifacts;
|
|
293
|
+
- downscaling.
|
|
294
|
+
|
|
295
|
+
Current rating bands:
|
|
296
|
+
|
|
297
|
+
```text
|
|
298
|
+
90–100 Excellent
|
|
299
|
+
75–89 Good
|
|
300
|
+
50–74 Risky
|
|
301
|
+
0–49 Likely unscannable
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
These scores are regression/testing aids. They do not replace validation with real phone cameras, printers, displays, paper stocks, lighting conditions, and physical damage.
|
|
305
|
+
|
|
306
|
+
## 15. Capacity planning
|
|
307
|
+
|
|
308
|
+
Capacity is determined from the actual Format v5 layout, protected header, CRC, and Spectrum ECC plan. Compression can reduce stored payload bytes, while signatures and encryption add metadata bytes before the matrix codec.
|
|
309
|
+
|
|
310
|
+
The benchmark helper can report:
|
|
311
|
+
|
|
312
|
+
- minimum QuadQR version;
|
|
313
|
+
- encoded bytes;
|
|
314
|
+
- remaining capacity;
|
|
315
|
+
- utilization;
|
|
316
|
+
- approximate same-letter standard QR byte-mode version.
|
|
317
|
+
|
|
318
|
+
When only a payload byte count is known, `compression: "auto"` cannot predict the gain because compressibility depends on the actual bytes.
|
|
319
|
+
|
|
320
|
+
Standard QR comparisons use the same **nominal ECC letter only**. QuadQR and ISO QR recovery strengths are not equivalent and should not be presented as such.
|
|
321
|
+
|
|
322
|
+
## 16. Compatibility principles
|
|
323
|
+
|
|
324
|
+
Implementations should follow these rules:
|
|
325
|
+
|
|
326
|
+
1. Keep RGBW mapping exactly `R=00, G=01, B=10, W=11`.
|
|
327
|
+
2. Keep GF(256) Spectrum ECC and its errors+erasures behavior.
|
|
328
|
+
3. Preserve Format v5 decoding for normal and Secure Payload symbols.
|
|
329
|
+
4. Keep application semantics outside the QuadQR codec. Do not require a growing content-type registry.
|
|
330
|
+
5. Treat compression/signature metadata as internal transport metadata, not a separate user payload mode.
|
|
331
|
+
6. Treat `keyId` only as an identifier. Signer trust comes from an external trusted public-key binding.
|
|
332
|
+
7. Keep rendering effects out of structural finder/timing/alignment/calibration modules.
|
|
333
|
+
8. Validate public format changes with both matrix and rendered-image scanner tests.
|
|
334
|
+
|
|
335
|
+
## 17. Reference implementation
|
|
336
|
+
|
|
337
|
+
The JavaScript implementation in this repository is the current reference implementation. Public entry points are documented under `docs/` and exercised by `tests/self-test.js` and `tests/package-test.js`.
|
package/bin/quadqr.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import {
|
|
6
6
|
bytesToHex,
|
|
7
7
|
decryptDecoded,
|
|
8
8
|
encodeSecureText,
|
|
9
|
+
encodeSignedText,
|
|
9
10
|
encodeText,
|
|
10
|
-
generateRaw256Key
|
|
11
|
+
generateRaw256Key,
|
|
12
|
+
generateSigningKeyPair,
|
|
13
|
+
verifyDecodedSignature
|
|
11
14
|
} from "../dist/index.js";
|
|
12
|
-
import { savePNG, scanFile } from "../dist/node.js";
|
|
15
|
+
import { savePNG, saveSVG, scanFile } from "../dist/node.js";
|
|
13
16
|
|
|
14
17
|
function help() {
|
|
15
|
-
console.log(`QuadQR CLI\n\nUsage:\n quadqr encode <text> [-o file.png] [--ecc M] [--version auto|1..40]\n quadqr encode <text> --
|
|
18
|
+
console.log(`QuadQR CLI\n\nUsage:\n quadqr encode <text> [-o file.png|file.svg] [--ecc M] [--version auto|1..40] [--high-density]\n quadqr encode <text> [--compression auto]\n quadqr encode <text> --sign-key signing-key.json [--key-id issuer-main]\n quadqr encode <text> --password <password> [-o file.png|file.svg]\n quadqr decode <file.png> [--password <password> | --key <64-hex-key>] [--verify-key signing-key.json] [--debug]\n quadqr keygen\n quadqr signkeygen [-o signing-key.json]\n\nOptions:\n -o, --output <file> Output PNG/SVG path, or signing-key JSON for signkeygen\n --ecc <L|M|Q|H> ECC profile (default: M)\n --version <auto|1..40> Symbol version (default: auto)\n --compression <mode> none|auto|lz (default: auto)\n --high-density Enable experimental Triangle16 High Density Mode\n --sign-key <file> Sign using a signkeygen JSON bundle\n --key-id <id> Override the signing key ID stored in the QuadQR\n --embed-public-key Also embed the public key for untrusted/self-contained checks\n --verify-key <file> Verify a signed QuadQR with a trusted key bundle\n --password <text> Encrypt/decrypt with password mode\n --key <hex> Encrypt/decrypt with raw 256-bit key mode\n --print Use the print-safe render profile\n --image-size <px> Exact square output size (default: 720)\n --module-size <px> Legacy pixels-per-module sizing\n --quiet-zone <modules> Quiet zone in modules (default: 4)\n --debug Emit scanner diagnostics to stderr on decode\n -h, --help Show help\n`);
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function parse(argv) {
|
|
@@ -24,8 +27,17 @@ function parse(argv) {
|
|
|
24
27
|
else if (token === "-o" || token === "--output") flags.output = argv[++i];
|
|
25
28
|
else if (token === "--ecc") flags.ecc = argv[++i];
|
|
26
29
|
else if (token === "--version") flags.version = argv[++i];
|
|
30
|
+
else if (token === "--high-density") flags.highDensity = true;
|
|
27
31
|
else if (token === "--password") flags.password = argv[++i];
|
|
28
32
|
else if (token === "--key") flags.key = argv[++i];
|
|
33
|
+
else if (token === "--compression") flags.compression = argv[++i];
|
|
34
|
+
else if (token === "--sign-key") flags.signKey = argv[++i];
|
|
35
|
+
else if (token === "--key-id") flags.keyId = argv[++i];
|
|
36
|
+
else if (token === "--embed-public-key") flags.embedPublicKey = true;
|
|
37
|
+
else if (token === "--verify-key") flags.verifyKey = argv[++i];
|
|
38
|
+
else if (token === "--print") flags.print = true;
|
|
39
|
+
else if (token === "--debug") flags.debug = true;
|
|
40
|
+
else if (token === "--image-size") flags.imageSize = Number(argv[++i]);
|
|
29
41
|
else if (token === "--module-size") flags.moduleSize = Number(argv[++i]);
|
|
30
42
|
else if (token === "--quiet-zone") flags.quietZone = Number(argv[++i]);
|
|
31
43
|
else args.push(token);
|
|
@@ -33,6 +45,25 @@ function parse(argv) {
|
|
|
33
45
|
return { args, flags };
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
function bytesToBase64(bytes) {
|
|
49
|
+
return Buffer.from(bytes).toString("base64");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function base64ToBytes(value, label) {
|
|
53
|
+
if (typeof value !== "string" || !value) throw new Error(`${label} is missing from signing key bundle.`);
|
|
54
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readSigningBundle(filename) {
|
|
58
|
+
const parsed = JSON.parse(await readFile(filename, "utf8"));
|
|
59
|
+
if (parsed.algorithm && parsed.algorithm !== "Ed25519") throw new Error(`Unsupported signing algorithm ${parsed.algorithm}.`);
|
|
60
|
+
return {
|
|
61
|
+
privateKey: parsed.privateKeyPkcs8 ? base64ToBytes(parsed.privateKeyPkcs8, "privateKeyPkcs8") : null,
|
|
62
|
+
publicKey: base64ToBytes(parsed.publicKeyRaw || parsed.publicKeyBytes, "publicKeyRaw"),
|
|
63
|
+
keyId: parsed.keyId || null
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
async function main() {
|
|
37
68
|
const { args, flags } = parse(process.argv.slice(2));
|
|
38
69
|
if (flags.help || !args.length) {
|
|
@@ -46,6 +77,24 @@ async function main() {
|
|
|
46
77
|
return;
|
|
47
78
|
}
|
|
48
79
|
|
|
80
|
+
if (command === "signkeygen") {
|
|
81
|
+
const pair = await generateSigningKeyPair();
|
|
82
|
+
const bundle = JSON.stringify({
|
|
83
|
+
format: "quadqr-ed25519-key-v1",
|
|
84
|
+
algorithm: pair.algorithm,
|
|
85
|
+
privateKeyPkcs8: bytesToBase64(pair.privateKeyPkcs8),
|
|
86
|
+
publicKeyRaw: bytesToBase64(pair.publicKeyBytes),
|
|
87
|
+
keyId: pair.keyId
|
|
88
|
+
}, null, 2) + "\n";
|
|
89
|
+
if (flags.output) {
|
|
90
|
+
await writeFile(flags.output, bundle, { mode: 0o600 });
|
|
91
|
+
console.log(`Saved ${flags.output}. Keep the private key file secret.`);
|
|
92
|
+
} else {
|
|
93
|
+
process.stdout.write(bundle);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
49
98
|
if (command === "encode") {
|
|
50
99
|
const text = args.join(" ");
|
|
51
100
|
if (!text) throw new Error("encode requires text.");
|
|
@@ -53,27 +102,52 @@ async function main() {
|
|
|
53
102
|
|
|
54
103
|
const options = {
|
|
55
104
|
ecc: flags.ecc || "M",
|
|
105
|
+
highDensity: Boolean(flags.highDensity),
|
|
106
|
+
compression: flags.compression || "auto",
|
|
56
107
|
...(flags.version && flags.version !== "auto" ? { version: Number(flags.version) } : {})
|
|
57
108
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
109
|
+
const signingBundle = flags.signKey ? await readSigningBundle(flags.signKey) : null;
|
|
110
|
+
const signing = signingBundle ? {
|
|
111
|
+
privateKey: signingBundle.privateKey,
|
|
112
|
+
keyId: flags.keyId || signingBundle.keyId || undefined,
|
|
113
|
+
...(flags.embedPublicKey ? { publicKey: signingBundle.publicKey, embedPublicKey: true } : {})
|
|
114
|
+
} : null;
|
|
115
|
+
const security = flags.password
|
|
116
|
+
? { mode: "password", password: flags.password }
|
|
60
117
|
: flags.key
|
|
61
|
-
?
|
|
62
|
-
:
|
|
118
|
+
? { mode: "raw-key", key: flags.key }
|
|
119
|
+
: null;
|
|
120
|
+
|
|
121
|
+
let code;
|
|
122
|
+
if (security) {
|
|
123
|
+
code = await encodeSecureText(text, { ...options, security, ...(signing ? { signing } : {}) });
|
|
124
|
+
} else if (signing) {
|
|
125
|
+
code = await encodeSignedText(text, { ...options, ...signing });
|
|
126
|
+
} else {
|
|
127
|
+
code = encodeText(text, options);
|
|
128
|
+
}
|
|
63
129
|
|
|
64
130
|
const output = flags.output || "quadqr.png";
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
131
|
+
const renderOptions = {
|
|
132
|
+
quietZone: Number.isFinite(flags.quietZone) ? flags.quietZone : 4,
|
|
133
|
+
...(flags.print ? { mode: "print" } : {}),
|
|
134
|
+
...(Number.isFinite(flags.imageSize)
|
|
135
|
+
? { imageSize: flags.imageSize }
|
|
136
|
+
: Number.isFinite(flags.moduleSize)
|
|
137
|
+
? { moduleSize: flags.moduleSize }
|
|
138
|
+
: { imageSize: 720 })
|
|
139
|
+
};
|
|
140
|
+
const saved = output.toLowerCase().endsWith(".svg")
|
|
141
|
+
? await saveSVG(code, output, renderOptions)
|
|
142
|
+
: await savePNG(code, output, renderOptions);
|
|
143
|
+
console.log(`Saved ${output} (${saved.bytes} bytes, v${code.version}, ${code.size}x${code.size}, ${code.highDensity ? "High Density experimental" : "Normal RGBW"}, ECC ${code.eccLevel}).`);
|
|
70
144
|
return;
|
|
71
145
|
}
|
|
72
146
|
|
|
73
147
|
if (command === "decode") {
|
|
74
148
|
const filename = args[0];
|
|
75
149
|
if (!filename) throw new Error("decode requires an image filename.");
|
|
76
|
-
let result = await scanFile(filename);
|
|
150
|
+
let result = await scanFile(filename, flags.debug ? { debug: true } : {});
|
|
77
151
|
if (result.secure) {
|
|
78
152
|
if (!flags.password && !flags.key) {
|
|
79
153
|
console.log(JSON.stringify({
|
|
@@ -88,6 +162,23 @@ async function main() {
|
|
|
88
162
|
}
|
|
89
163
|
result = await decryptDecoded(result, flags.password ? { password: flags.password } : { key: flags.key });
|
|
90
164
|
}
|
|
165
|
+
if (result.signed && flags.verifyKey) {
|
|
166
|
+
const verifier = await readSigningBundle(flags.verifyKey);
|
|
167
|
+
result = await verifyDecodedSignature(result, { publicKey: verifier.publicKey });
|
|
168
|
+
}
|
|
169
|
+
if (flags.debug) {
|
|
170
|
+
console.error(JSON.stringify({
|
|
171
|
+
confidence: result.confidence ?? null,
|
|
172
|
+
geometryConfidence: result.geometryConfidence ?? null,
|
|
173
|
+
calibrationConfidence: result.calibrationConfidence ?? null,
|
|
174
|
+
eccUtilization: result.eccUtilization ?? null,
|
|
175
|
+
signed: Boolean(result.signed),
|
|
176
|
+
signatureVerified: result.signatureVerified ?? null,
|
|
177
|
+
signatureTrusted: result.signatureTrusted ?? null,
|
|
178
|
+
signingKeyId: result.signingKeyId ?? null,
|
|
179
|
+
diagnostics: result.diagnostics ?? null
|
|
180
|
+
}, null, 2));
|
|
181
|
+
}
|
|
91
182
|
if (result.text != null) console.log(result.text);
|
|
92
183
|
else process.stdout.write(Buffer.from(result.payload));
|
|
93
184
|
return;
|