Skip to content

Commit 09a68b8

Browse files
committed
Add optional experimental modes: encrypted QR, video QR
1 parent ebc0998 commit 09a68b8

4 files changed

Lines changed: 1268 additions & 0 deletions

File tree

src/encrypted.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*!
2+
Copyright (c) 2023 Paul Miller (paulmillr.com)
3+
The library paulmillr-qr is dual-licensed under the Apache 2.0 OR MIT license.
4+
You can select a license of your choice.
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
/**
18+
* Authenticated-encryption envelope helpers for QR payloads.
19+
* @module
20+
*/
21+
22+
import type { Image, Output, QrOpts, SvgQrOpts, TRet } from './index.ts';
23+
import { encodeQR, validateObject } from './index.ts';
24+
import type { DecodeOpts } from './decode.ts';
25+
import { decodeQR } from './decode.ts';
26+
27+
// Global symbols in both browsers and Node.js since v11.
28+
// See https://github.com/microsoft/TypeScript/issues/31535
29+
declare const TextEncoder: any;
30+
declare const TextDecoder: any;
31+
32+
const MAGIC_0 = 0x51; // Q
33+
const MAGIC_1 = 0x65; // e
34+
const VERSION = 1;
35+
const HEADER = /* @__PURE__ */ Uint8Array.from([MAGIC_0, MAGIC_1, VERSION]);
36+
const HEADER_LENGTH = HEADER.length;
37+
const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
38+
const BASE64URL_VALUES = /* @__PURE__ */ (() => {
39+
const values = new Int16Array(128);
40+
values.fill(-1);
41+
for (let i = 0; i < BASE64URL.length; i++) values[BASE64URL.charCodeAt(i)] = i;
42+
return values;
43+
})();
44+
const noCipherMsg = 'encrypted QR: no cipher configured — pass opts.encryption or call setCipher()';
45+
46+
export type AeadInstance = {
47+
encrypt(plaintext: Uint8Array): Uint8Array;
48+
decrypt(ciphertext: Uint8Array): Uint8Array;
49+
};
50+
export type AeadCipher = {
51+
(key: Uint8Array, nonce: Uint8Array, aad?: Uint8Array): AeadInstance;
52+
nonceLength: number;
53+
keyLength?: number;
54+
};
55+
export type QREncryption = { cipher: AeadCipher; key: Uint8Array };
56+
export type EncryptedQrOpts = QrOpts & { encryption?: QREncryption };
57+
export type DecryptedResult = { bytes: Uint8Array; text: () => string };
58+
59+
let defaultEncryption: QREncryption | undefined;
60+
61+
function validateBytes(bytes: unknown, title: string): Uint8Array {
62+
if (!(bytes instanceof Uint8Array))
63+
throw new TypeError(`"${title}" expected Uint8Array, got type=${typeof bytes}`);
64+
return bytes;
65+
}
66+
67+
function validateEncryption(encryption: QREncryption): QREncryption {
68+
const opts = validateObject(encryption, 'encryption') as QREncryption;
69+
if (typeof opts.cipher !== 'function')
70+
throw new TypeError(`"encryption.cipher" expected function, got type=${typeof opts.cipher}`);
71+
const nonceLength = opts.cipher.nonceLength;
72+
if (!Number.isSafeInteger(nonceLength) || nonceLength < 8 || nonceLength > 32)
73+
throw new TypeError(
74+
`"encryption.cipher.nonceLength" expected integer in [8..32], got ${nonceLength}`
75+
);
76+
validateBytes(opts.key, 'encryption.key');
77+
const keyLength = opts.cipher.keyLength;
78+
if (keyLength !== undefined) {
79+
if (!Number.isSafeInteger(keyLength))
80+
throw new TypeError(`"encryption.cipher.keyLength" expected safe integer, got ${keyLength}`);
81+
if (opts.key.length !== keyLength)
82+
throw new TypeError(`"encryption.key" expected length=${keyLength}, got ${opts.key.length}`);
83+
}
84+
return opts;
85+
}
86+
87+
function resolveEncryption(encryption?: QREncryption): QREncryption {
88+
const resolved = encryption !== undefined ? encryption : defaultEncryption;
89+
if (resolved === undefined) throw new Error(noCipherMsg);
90+
return validateEncryption(resolved);
91+
}
92+
93+
function toBytes(data: string | Uint8Array): Uint8Array {
94+
if (typeof data === 'string') return new Uint8Array(new TextEncoder().encode(data));
95+
if (data instanceof Uint8Array) return data;
96+
throw new TypeError(`"data" expected string or Uint8Array, got type=${typeof data}`);
97+
}
98+
99+
function randomBytes(length: number): Uint8Array {
100+
const crypto = (
101+
globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } }
102+
).crypto;
103+
if (crypto === undefined || typeof crypto.getRandomValues !== 'function')
104+
throw new Error('encrypted QR: crypto.getRandomValues is unavailable');
105+
const bytes = new Uint8Array(length);
106+
crypto.getRandomValues(bytes);
107+
return bytes;
108+
}
109+
110+
function aeadSeal(encryption: QREncryption, nonce: Uint8Array, plaintext: Uint8Array): Uint8Array {
111+
const aead = encryption.cipher(encryption.key, nonce, HEADER);
112+
if (aead === null || typeof aead !== 'object' || typeof aead.encrypt !== 'function')
113+
throw new TypeError(`"encryption.cipher(...).encrypt" expected function`);
114+
const ciphertext = aead.encrypt(plaintext);
115+
return validateBytes(ciphertext, 'encryption.cipher(...).encrypt result');
116+
}
117+
118+
function aeadOpen(encryption: QREncryption, nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array {
119+
const aead = encryption.cipher(encryption.key, nonce, HEADER);
120+
if (aead === null || typeof aead !== 'object' || typeof aead.decrypt !== 'function')
121+
throw new TypeError(`"encryption.cipher(...).decrypt" expected function`);
122+
let plaintext;
123+
try {
124+
plaintext = aead.decrypt(ciphertext);
125+
} catch (e) {
126+
throw new Error('encrypted QR: decryption failed');
127+
}
128+
return validateBytes(plaintext, 'encryption.cipher(...).decrypt result');
129+
}
130+
131+
const base64url = {
132+
encode(bytes: Uint8Array): string {
133+
let out = '';
134+
let i = 0;
135+
for (; i + 2 < bytes.length; i += 3) {
136+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
137+
out +=
138+
BASE64URL[n >>> 18] +
139+
BASE64URL[(n >>> 12) & 63] +
140+
BASE64URL[(n >>> 6) & 63] +
141+
BASE64URL[n & 63];
142+
}
143+
const left = bytes.length - i;
144+
if (left === 1) {
145+
const n = bytes[i] << 16;
146+
out += BASE64URL[n >>> 18] + BASE64URL[(n >>> 12) & 63];
147+
} else if (left === 2) {
148+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
149+
out += BASE64URL[n >>> 18] + BASE64URL[(n >>> 12) & 63] + BASE64URL[(n >>> 6) & 63];
150+
}
151+
return out;
152+
},
153+
154+
decode(text: string): Uint8Array {
155+
if (typeof text !== 'string')
156+
throw new TypeError(`"envelope" expected string, got type=${typeof text}`);
157+
if (text.length % 4 === 1) throw new Error('encrypted QR: bad base64url envelope');
158+
const out = new Uint8Array((text.length * 3) >>> 2);
159+
let buffer = 0;
160+
let bits = 0;
161+
let pos = 0;
162+
for (let i = 0; i < text.length; i++) {
163+
const c = text.charCodeAt(i);
164+
const value = c < BASE64URL_VALUES.length ? BASE64URL_VALUES[c] : -1;
165+
if (value === -1) throw new Error('encrypted QR: bad base64url envelope');
166+
buffer = (buffer << 6) | value;
167+
bits += 6;
168+
if (bits >= 8) {
169+
bits -= 8;
170+
out[pos++] = (buffer >>> bits) & 0xff;
171+
}
172+
}
173+
if (pos !== out.length || base64url.encode(out) !== text)
174+
throw new Error('encrypted QR: bad base64url envelope');
175+
return out;
176+
},
177+
};
178+
179+
function result(bytes: Uint8Array): DecryptedResult {
180+
let text: string | undefined;
181+
return {
182+
bytes,
183+
text: () => {
184+
if (text !== undefined) return text;
185+
const decoded = new TextDecoder().decode(bytes) as string;
186+
text = decoded;
187+
return decoded;
188+
},
189+
};
190+
}
191+
192+
/** Set a module-level default encryption used when opts.encryption is omitted. */
193+
export function setCipher(encryption: QREncryption | null): void {
194+
if (encryption === null) {
195+
defaultEncryption = undefined;
196+
return;
197+
}
198+
defaultEncryption = validateEncryption(encryption);
199+
}
200+
201+
/** Seal data into a binary AEAD envelope and return base64url without padding. */
202+
export function sealEnvelope(data: string | Uint8Array, encryption?: QREncryption): string {
203+
const resolved = resolveEncryption(encryption);
204+
const nonce = randomBytes(resolved.cipher.nonceLength);
205+
const ciphertext = aeadSeal(resolved, nonce, toBytes(data));
206+
const envelope = new Uint8Array(HEADER_LENGTH + nonce.length + ciphertext.length);
207+
envelope.set(HEADER);
208+
envelope.set(nonce, HEADER_LENGTH);
209+
envelope.set(ciphertext, HEADER_LENGTH + nonce.length);
210+
return base64url.encode(envelope);
211+
}
212+
213+
/** Open a base64url AEAD envelope and return decrypted bytes plus lazy UTF-8 text. */
214+
export function openEnvelope(envelope: string, encryption?: QREncryption): DecryptedResult {
215+
const resolved = resolveEncryption(encryption);
216+
const bytes = base64url.decode(envelope);
217+
if (bytes.length < HEADER_LENGTH) throw new Error('encrypted QR: envelope too short');
218+
if (bytes[0] !== MAGIC_0 || bytes[1] !== MAGIC_1) throw new Error('encrypted QR: bad magic');
219+
if (bytes[2] !== VERSION) throw new Error(`encrypted QR: unsupported version ${bytes[2]}`);
220+
const start = HEADER_LENGTH;
221+
const end = start + resolved.cipher.nonceLength;
222+
if (bytes.length <= end) throw new Error('encrypted QR: envelope too short');
223+
const nonce = bytes.subarray(start, end);
224+
const ciphertext = bytes.subarray(end);
225+
return result(aeadOpen(resolved, nonce, ciphertext));
226+
}
227+
228+
export function encodeEncryptedQR(
229+
data: string | Uint8Array,
230+
output: 'raw',
231+
opts?: EncryptedQrOpts
232+
): boolean[][];
233+
export function encodeEncryptedQR(
234+
data: string | Uint8Array,
235+
output: 'ascii' | 'term',
236+
opts?: EncryptedQrOpts
237+
): string;
238+
export function encodeEncryptedQR(
239+
data: string | Uint8Array,
240+
output: 'svg',
241+
opts?: EncryptedQrOpts & SvgQrOpts
242+
): string;
243+
export function encodeEncryptedQR(
244+
data: string | Uint8Array,
245+
output: 'gif',
246+
opts?: EncryptedQrOpts
247+
): TRet<Uint8Array>;
248+
export function encodeEncryptedQR(
249+
data: string | Uint8Array,
250+
output: Output = 'raw',
251+
opts: EncryptedQrOpts & SvgQrOpts = {}
252+
) {
253+
const options = validateObject(opts, 'opts') as EncryptedQrOpts & SvgQrOpts;
254+
const envelope = sealEnvelope(data, options.encryption);
255+
if (output === 'raw') return encodeQR(envelope, output, options);
256+
if (output === 'ascii' || output === 'term') return encodeQR(envelope, output, options);
257+
if (output === 'svg') return encodeQR(envelope, output, options);
258+
if (output === 'gif') return encodeQR(envelope, output, options);
259+
return encodeQR(envelope, output, options);
260+
}
261+
262+
/** Decode an encrypted QR image and return decrypted bytes plus lazy UTF-8 text. */
263+
export function decodeEncryptedQR(
264+
img: Image,
265+
opts: DecodeOpts & { encryption?: QREncryption } = {}
266+
): DecryptedResult {
267+
const options = validateObject(opts, 'opts') as DecodeOpts & { encryption?: QREncryption };
268+
return openEnvelope(decodeQR(img, options), options.encryption);
269+
}

0 commit comments

Comments
 (0)