quadqr-js 0.7.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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * QuadQR Secure Payload v1
3
+ *
4
+ * Security is intentionally layered above the QuadQR matrix/ECC codec.
5
+ * The encrypted envelope is treated as an ordinary byte payload by Spectrum ECC.
6
+ *
7
+ * v1 algorithms:
8
+ * - AES-256-GCM authenticated encryption
9
+ * - Password mode: PBKDF2-HMAC-SHA-256 -> 256-bit AES key
10
+ * - Raw-key mode: caller supplies an exact 256-bit key
11
+ */
12
+
13
+ export const SECURE_PAYLOAD_VERSION = 1;
14
+ export const DEFAULT_PBKDF2_ITERATIONS = 600_000;
15
+ export const MIN_PBKDF2_ITERATIONS = 100_000;
16
+ export const MAX_PBKDF2_ITERATIONS = 2_000_000;
17
+
18
+ export const SECURITY_MODES = Object.freeze({
19
+ PASSWORD: "password",
20
+ RAW_KEY: "raw-key"
21
+ });
22
+
23
+ export const SECURITY_ALGORITHMS = Object.freeze({
24
+ AES_256_GCM: "AES-256-GCM"
25
+ });
26
+
27
+ const MAGIC = new Uint8Array([0x51, 0x53, 0x45, 0x43]); // QSEC
28
+ const MODE_PASSWORD = 1;
29
+ const MODE_RAW_KEY = 2;
30
+ const ALGORITHM_AES_256_GCM = 1;
31
+ const KDF_NONE = 0;
32
+ const KDF_PBKDF2_SHA256 = 1;
33
+ const FLAG_KEY_ID_UTF8 = 1;
34
+ const FLAG_KEY_ID_AUTO = 1 << 1;
35
+ const FIXED_HEADER_BYTES = 24;
36
+ const SALT_BYTES = 16;
37
+ const NONCE_BYTES = 12;
38
+ const TAG_BYTES = 16;
39
+ const AUTO_KEY_ID_BYTES = 8;
40
+ const MAX_KEY_ID_BYTES = 32;
41
+
42
+ const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
43
+ const decoder = typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8", { fatal: false }) : null;
44
+
45
+ function assert(condition, message) {
46
+ if (!condition) throw new Error(message);
47
+ }
48
+
49
+ function cryptoApi() {
50
+ const api = globalThis.crypto;
51
+ assert(api?.subtle && typeof api.getRandomValues === "function", "Web Crypto API is required for QuadQR secure payloads.");
52
+ return api;
53
+ }
54
+
55
+ function concatBytes(...arrays) {
56
+ const length = arrays.reduce((sum, value) => sum + value.length, 0);
57
+ const output = new Uint8Array(length);
58
+ let offset = 0;
59
+ for (const value of arrays) {
60
+ output.set(value, offset);
61
+ offset += value.length;
62
+ }
63
+ return output;
64
+ }
65
+
66
+ function u32be(value) {
67
+ const v = value >>> 0;
68
+ return new Uint8Array([
69
+ (v >>> 24) & 0xff,
70
+ (v >>> 16) & 0xff,
71
+ (v >>> 8) & 0xff,
72
+ v & 0xff
73
+ ]);
74
+ }
75
+
76
+ function readU32be(bytes, offset) {
77
+ return (
78
+ ((bytes[offset] << 24) >>> 0) |
79
+ (bytes[offset + 1] << 16) |
80
+ (bytes[offset + 2] << 8) |
81
+ bytes[offset + 3]
82
+ ) >>> 0;
83
+ }
84
+
85
+ function equalPrefix(bytes, prefix) {
86
+ if (bytes.length < prefix.length) return false;
87
+ for (let index = 0; index < prefix.length; index++) {
88
+ if (bytes[index] !== prefix[index]) return false;
89
+ }
90
+ return true;
91
+ }
92
+
93
+ function toBytes(value) {
94
+ if (value instanceof Uint8Array) return new Uint8Array(value);
95
+ if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
96
+ if (ArrayBuffer.isView(value)) {
97
+ return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
98
+ }
99
+ return new Uint8Array(value);
100
+ }
101
+
102
+ export function bytesToHex(bytes) {
103
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
104
+ }
105
+
106
+ export function hexToBytes(hex) {
107
+ const clean = String(hex).trim().replace(/^0x/i, "").replace(/[\s:-]/g, "");
108
+ assert(clean.length % 2 === 0 && /^[0-9a-f]*$/i.test(clean), "Expected a hexadecimal byte string.");
109
+ const output = new Uint8Array(clean.length / 2);
110
+ for (let index = 0; index < output.length; index++) {
111
+ output[index] = parseInt(clean.slice(index * 2, index * 2 + 2), 16);
112
+ }
113
+ return output;
114
+ }
115
+
116
+ export function normalizeRaw256Key(key) {
117
+ const bytes = typeof key === "string" ? hexToBytes(key) : toBytes(key);
118
+ assert(bytes.length === 32, "Raw QuadQR encryption key must be exactly 32 bytes (256 bits / 64 hex characters).");
119
+ return bytes;
120
+ }
121
+
122
+ export function generateRaw256Key() {
123
+ const key = new Uint8Array(32);
124
+ cryptoApi().getRandomValues(key);
125
+ return key;
126
+ }
127
+
128
+ function randomBytes(length) {
129
+ const bytes = new Uint8Array(length);
130
+ cryptoApi().getRandomValues(bytes);
131
+ return bytes;
132
+ }
133
+
134
+ function normalizeMode(mode) {
135
+ const value = String(mode ?? "").toLowerCase();
136
+ if (["password", "passphrase"].includes(value)) return SECURITY_MODES.PASSWORD;
137
+ if (["raw-key", "raw", "key", "raw256", "raw-256"].includes(value)) return SECURITY_MODES.RAW_KEY;
138
+ throw new Error('Security mode must be "password" or "raw-key".');
139
+ }
140
+
141
+ function encodeKeyId(keyId) {
142
+ if (keyId == null || keyId === false) return { bytes: new Uint8Array(0), utf8: false, auto: false };
143
+ if (typeof keyId === "string") {
144
+ assert(encoder, "TextEncoder is required for string key IDs.");
145
+ const bytes = encoder.encode(keyId);
146
+ assert(bytes.length > 0 && bytes.length <= MAX_KEY_ID_BYTES, `Key ID must be 1..${MAX_KEY_ID_BYTES} UTF-8 bytes.`);
147
+ return { bytes, utf8: true, auto: false };
148
+ }
149
+ const bytes = toBytes(keyId);
150
+ assert(bytes.length > 0 && bytes.length <= MAX_KEY_ID_BYTES, `Key ID must be 1..${MAX_KEY_ID_BYTES} bytes.`);
151
+ return { bytes, utf8: false, auto: false };
152
+ }
153
+
154
+ async function autoKeyId(rawKey) {
155
+ const digest = new Uint8Array(await cryptoApi().subtle.digest("SHA-256", rawKey));
156
+ return digest.slice(0, AUTO_KEY_ID_BYTES);
157
+ }
158
+
159
+ async function derivePasswordKey(password, salt, iterations, usages) {
160
+ assert(typeof password === "string" && password.length > 0, "A non-empty password is required.");
161
+ assert(encoder, "TextEncoder is required for password encryption.");
162
+ assert(Number.isInteger(iterations), "PBKDF2 iteration count must be an integer.");
163
+ assert(
164
+ iterations >= MIN_PBKDF2_ITERATIONS && iterations <= MAX_PBKDF2_ITERATIONS,
165
+ `PBKDF2 iterations must be ${MIN_PBKDF2_ITERATIONS}..${MAX_PBKDF2_ITERATIONS}.`
166
+ );
167
+
168
+ const baseKey = await cryptoApi().subtle.importKey(
169
+ "raw",
170
+ encoder.encode(password),
171
+ "PBKDF2",
172
+ false,
173
+ ["deriveKey"]
174
+ );
175
+
176
+ return cryptoApi().subtle.deriveKey(
177
+ { name: "PBKDF2", hash: "SHA-256", salt, iterations },
178
+ baseKey,
179
+ { name: "AES-GCM", length: 256 },
180
+ false,
181
+ usages
182
+ );
183
+ }
184
+
185
+ async function importRawAesKey(rawKey, usages) {
186
+ return cryptoApi().subtle.importKey(
187
+ "raw",
188
+ normalizeRaw256Key(rawKey),
189
+ { name: "AES-GCM", length: 256 },
190
+ false,
191
+ usages
192
+ );
193
+ }
194
+
195
+ function makeFixedHeader({ modeId, kdfId, flags, saltLength, nonceLength, keyIdLength, iterations, plaintextLength }) {
196
+ const header = new Uint8Array(FIXED_HEADER_BYTES);
197
+ header.set(MAGIC, 0);
198
+ header[4] = SECURE_PAYLOAD_VERSION;
199
+ header[5] = modeId;
200
+ header[6] = ALGORITHM_AES_256_GCM;
201
+ header[7] = kdfId;
202
+ header[8] = flags;
203
+ header[9] = saltLength;
204
+ header[10] = nonceLength;
205
+ header[11] = TAG_BYTES;
206
+ header[12] = keyIdLength;
207
+ header[13] = 0;
208
+ header[14] = 0;
209
+ header[15] = 0;
210
+ header.set(u32be(iterations), 16);
211
+ header.set(u32be(plaintextLength), 20);
212
+ return header;
213
+ }
214
+
215
+ function parseEnvelope(envelope) {
216
+ const bytes = toBytes(envelope);
217
+ assert(bytes.length >= FIXED_HEADER_BYTES + NONCE_BYTES + TAG_BYTES, "Secure payload envelope is too short.");
218
+ assert(equalPrefix(bytes, MAGIC), "Secure payload magic mismatch.");
219
+ assert(bytes[4] === SECURE_PAYLOAD_VERSION, `Unsupported secure payload version ${bytes[4]}.`);
220
+ assert(bytes[6] === ALGORITHM_AES_256_GCM, `Unsupported secure payload algorithm id ${bytes[6]}.`);
221
+
222
+ const modeId = bytes[5];
223
+ const kdfId = bytes[7];
224
+ const flags = bytes[8];
225
+ const saltLength = bytes[9];
226
+ const nonceLength = bytes[10];
227
+ const tagLength = bytes[11];
228
+ const keyIdLength = bytes[12];
229
+ const iterations = readU32be(bytes, 16);
230
+ const plaintextLength = readU32be(bytes, 20);
231
+
232
+ assert(nonceLength === NONCE_BYTES, `Unsupported AES-GCM nonce length ${nonceLength}.`);
233
+ assert(tagLength === TAG_BYTES, `Unsupported AES-GCM tag length ${tagLength}.`);
234
+ assert(keyIdLength <= MAX_KEY_ID_BYTES, "Secure payload key ID is too long.");
235
+
236
+ let mode;
237
+ if (modeId === MODE_PASSWORD) {
238
+ mode = SECURITY_MODES.PASSWORD;
239
+ assert(kdfId === KDF_PBKDF2_SHA256, "Password payload uses an unsupported KDF.");
240
+ assert(saltLength === SALT_BYTES, `Password payload salt must be ${SALT_BYTES} bytes.`);
241
+ assert(iterations >= MIN_PBKDF2_ITERATIONS && iterations <= MAX_PBKDF2_ITERATIONS, "Password payload PBKDF2 iteration count is outside the supported safety range.");
242
+ } else if (modeId === MODE_RAW_KEY) {
243
+ mode = SECURITY_MODES.RAW_KEY;
244
+ assert(kdfId === KDF_NONE, "Raw-key payload must not declare a password KDF.");
245
+ assert(saltLength === 0, "Raw-key payload must not contain a password salt.");
246
+ assert(iterations === 0, "Raw-key payload must not declare PBKDF2 iterations.");
247
+ } else {
248
+ throw new Error(`Unsupported secure payload mode id ${modeId}.`);
249
+ }
250
+
251
+ const metadataLength = FIXED_HEADER_BYTES + keyIdLength + saltLength + nonceLength;
252
+ const expectedLength = metadataLength + plaintextLength + tagLength;
253
+ assert(bytes.length === expectedLength, `Secure payload length mismatch: expected ${expectedLength} bytes, got ${bytes.length}.`);
254
+
255
+ let cursor = FIXED_HEADER_BYTES;
256
+ const keyIdBytes = bytes.slice(cursor, cursor + keyIdLength);
257
+ cursor += keyIdLength;
258
+ const salt = bytes.slice(cursor, cursor + saltLength);
259
+ cursor += saltLength;
260
+ const nonce = bytes.slice(cursor, cursor + nonceLength);
261
+ cursor += nonceLength;
262
+ const ciphertextWithTag = bytes.slice(cursor);
263
+ const aad = bytes.slice(0, cursor);
264
+ const keyIdUtf8 = Boolean(flags & FLAG_KEY_ID_UTF8);
265
+ const keyIdAuto = Boolean(flags & FLAG_KEY_ID_AUTO);
266
+
267
+ return {
268
+ bytes,
269
+ mode,
270
+ modeId,
271
+ kdfId,
272
+ flags,
273
+ salt,
274
+ nonce,
275
+ iterations,
276
+ plaintextLength,
277
+ keyIdBytes,
278
+ keyIdUtf8,
279
+ keyIdAuto,
280
+ ciphertextWithTag,
281
+ aad
282
+ };
283
+ }
284
+
285
+ export function inspectSecureEnvelope(envelope) {
286
+ const parsed = parseEnvelope(envelope);
287
+ return {
288
+ securePayloadVersion: SECURE_PAYLOAD_VERSION,
289
+ mode: parsed.mode,
290
+ algorithm: SECURITY_ALGORITHMS.AES_256_GCM,
291
+ kdf: parsed.mode === SECURITY_MODES.PASSWORD ? "PBKDF2-HMAC-SHA-256" : null,
292
+ iterations: parsed.mode === SECURITY_MODES.PASSWORD ? parsed.iterations : null,
293
+ keyId: parsed.keyIdUtf8 && decoder ? decoder.decode(parsed.keyIdBytes) : null,
294
+ keyIdHex: parsed.keyIdBytes.length ? bytesToHex(parsed.keyIdBytes) : null,
295
+ keyIdAuto: parsed.keyIdAuto,
296
+ plaintextBytes: parsed.plaintextLength,
297
+ envelopeBytes: parsed.bytes.length,
298
+ overheadBytes: parsed.bytes.length - parsed.plaintextLength,
299
+ authenticated: true
300
+ };
301
+ }
302
+
303
+ export async function encryptSecurePayload(input, security = {}) {
304
+ const plaintext = toBytes(input);
305
+ const mode = normalizeMode(security.mode);
306
+ const nonce = randomBytes(NONCE_BYTES);
307
+ let aesKey;
308
+ let salt = new Uint8Array(0);
309
+ let iterations = 0;
310
+ let modeId;
311
+ let kdfId;
312
+ let keyId = { bytes: new Uint8Array(0), utf8: false, auto: false };
313
+
314
+ if (mode === SECURITY_MODES.PASSWORD) {
315
+ modeId = MODE_PASSWORD;
316
+ kdfId = KDF_PBKDF2_SHA256;
317
+ iterations = security.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
318
+ salt = randomBytes(SALT_BYTES);
319
+ aesKey = await derivePasswordKey(security.password, salt, iterations, ["encrypt"]);
320
+ if (security.keyId != null && security.keyId !== false) keyId = encodeKeyId(security.keyId);
321
+ } else {
322
+ modeId = MODE_RAW_KEY;
323
+ kdfId = KDF_NONE;
324
+ const rawKey = normalizeRaw256Key(security.key);
325
+ aesKey = await importRawAesKey(rawKey, ["encrypt"]);
326
+ if (security.keyId === false) {
327
+ keyId = { bytes: new Uint8Array(0), utf8: false, auto: false };
328
+ } else if (security.keyId != null) {
329
+ keyId = encodeKeyId(security.keyId);
330
+ } else {
331
+ keyId = { bytes: await autoKeyId(rawKey), utf8: false, auto: true };
332
+ }
333
+ }
334
+
335
+ const flags = (keyId.utf8 ? FLAG_KEY_ID_UTF8 : 0) | (keyId.auto ? FLAG_KEY_ID_AUTO : 0);
336
+ const fixedHeader = makeFixedHeader({
337
+ modeId,
338
+ kdfId,
339
+ flags,
340
+ saltLength: salt.length,
341
+ nonceLength: nonce.length,
342
+ keyIdLength: keyId.bytes.length,
343
+ iterations,
344
+ plaintextLength: plaintext.length
345
+ });
346
+ const aad = concatBytes(fixedHeader, keyId.bytes, salt, nonce);
347
+ const ciphertextWithTag = new Uint8Array(await cryptoApi().subtle.encrypt(
348
+ { name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: TAG_BYTES * 8 },
349
+ aesKey,
350
+ plaintext
351
+ ));
352
+ const envelope = concatBytes(aad, ciphertextWithTag);
353
+
354
+ return {
355
+ envelope,
356
+ metadata: inspectSecureEnvelope(envelope)
357
+ };
358
+ }
359
+
360
+ export async function decryptSecurePayload(envelope, security = {}) {
361
+ const parsed = parseEnvelope(envelope);
362
+ let aesKey;
363
+
364
+ if (parsed.mode === SECURITY_MODES.PASSWORD) {
365
+ aesKey = await derivePasswordKey(security.password, parsed.salt, parsed.iterations, ["decrypt"]);
366
+ } else {
367
+ aesKey = await importRawAesKey(security.key, ["decrypt"]);
368
+
369
+ // Key IDs are routing hints, not secrets. When the envelope has an auto
370
+ // fingerprint ID, verify it before doing the more expensive GCM operation.
371
+ if (parsed.keyIdBytes.length && parsed.keyIdAuto && security.verifyKeyId !== false) {
372
+ const expected = await autoKeyId(normalizeRaw256Key(security.key));
373
+ if (expected.length === parsed.keyIdBytes.length) {
374
+ let mismatch = 0;
375
+ for (let index = 0; index < expected.length; index++) mismatch |= expected[index] ^ parsed.keyIdBytes[index];
376
+ assert(mismatch === 0, "Raw encryption key does not match this QuadQR key ID.");
377
+ }
378
+ }
379
+ }
380
+
381
+ try {
382
+ const plaintext = new Uint8Array(await cryptoApi().subtle.decrypt(
383
+ { name: "AES-GCM", iv: parsed.nonce, additionalData: parsed.aad, tagLength: TAG_BYTES * 8 },
384
+ aesKey,
385
+ parsed.ciphertextWithTag
386
+ ));
387
+ assert(plaintext.length === parsed.plaintextLength, "Secure payload plaintext length mismatch after decryption.");
388
+ return plaintext;
389
+ } catch (error) {
390
+ if (/key ID/i.test(error?.message ?? "")) throw error;
391
+ throw new Error("Secure QuadQR decryption failed. The password/key is wrong or the encrypted payload was modified.");
392
+ }
393
+ }
394
+
395
+ export const securityInternals = Object.freeze({
396
+ FIXED_HEADER_BYTES,
397
+ SALT_BYTES,
398
+ NONCE_BYTES,
399
+ TAG_BYTES,
400
+ AUTO_KEY_ID_BYTES,
401
+ parseEnvelope
402
+ });