@navette-js/panels 1.0.0-beta.4 → 1.0.0-beta.5

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,16 @@
1
+ export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H';
2
+ /** Module matrix of a QR Code: `true` for a dark module. Raises an error when the data does not fit (about 2.3 KB at level M). */
3
+ export declare function qrCodeMatrix(text: string | Uint8Array, errorCorrection?: QrErrorCorrection): boolean[][];
4
+ export type QrCodeSvgOptions = {
5
+ errorCorrection?: QrErrorCorrection;
6
+ /** Quiet zone around the code, in modules (4 by default, as the standard requires). */
7
+ margin?: number;
8
+ dark?: string;
9
+ light?: string;
10
+ /** Accessible name of the image (`<title>`). */
11
+ title?: string | null;
12
+ };
13
+ /** QR Code as an SVG document (scalable: give it a size with CSS). */
14
+ export declare function qrCodeSvg(text: string, options?: QrCodeSvgOptions): string;
15
+ /** QR Code as a `data:` URI, for an `<img src>`. */
16
+ export declare const qrCodeDataUri: (text: string, options?: QrCodeSvgOptions) => string;
package/lib/qr-code.js ADDED
@@ -0,0 +1,255 @@
1
+ // QR Code generator (ISO/IEC 18004), byte mode, versions 1 to 40, rendered as SVG. No dependency.
2
+ // Follows the structure of Project Nayuki's QR Code generator (https://www.nayuki.io/page/qr-code-generator-library):
3
+ // Copyright (c) Project Nayuki. MIT License: permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ // this software and associated documentation files, to deal in the Software without restriction, subject to including this
5
+ // notice; the Software is provided "as is", without warranty of any kind.
6
+ const LEVELS = { L: { ordinal: 0, formatBits: 1 }, M: { ordinal: 1, formatBits: 0 }, Q: { ordinal: 2, formatBits: 3 }, H: { ordinal: 3, formatBits: 2 } };
7
+ const ECC_CODEWORDS_PER_BLOCK = [
8
+ [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
9
+ [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
10
+ [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
11
+ [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
12
+ ];
13
+ const NUM_ERROR_CORRECTION_BLOCKS = [
14
+ [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
15
+ [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
16
+ [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
17
+ [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
18
+ ];
19
+ const bit = (value, index) => ((value >>> index) & 1) !== 0;
20
+ function rawDataModules(version) {
21
+ let result = (16 * version + 128) * version + 64;
22
+ if (version >= 2) {
23
+ const alignments = Math.floor(version / 7) + 2;
24
+ result -= (25 * alignments - 10) * alignments - 55;
25
+ if (version >= 7)
26
+ result -= 36;
27
+ }
28
+ return result;
29
+ }
30
+ const dataCodewords = (version, level) => Math.floor(rawDataModules(version) / 8) - ECC_CODEWORDS_PER_BLOCK[level][version] * NUM_ERROR_CORRECTION_BLOCKS[level][version];
31
+ // Reed-Solomon over GF(2^8), polynomial 0x11D.
32
+ function multiply(x, y) {
33
+ let z = 0;
34
+ for (let i = 7; i >= 0; i--) {
35
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
36
+ z ^= ((y >>> i) & 1) * x;
37
+ }
38
+ return z & 0xff;
39
+ }
40
+ function divisor(degree) {
41
+ const result = new Array(degree - 1).fill(0).concat([1]);
42
+ let root = 1;
43
+ for (let i = 0; i < degree; i++) {
44
+ for (let j = 0; j < result.length; j++) {
45
+ result[j] = multiply(result[j], root);
46
+ if (j + 1 < result.length)
47
+ result[j] ^= result[j + 1];
48
+ }
49
+ root = multiply(root, 0x02);
50
+ }
51
+ return result;
52
+ }
53
+ function remainder(data, generator) {
54
+ const result = generator.map(() => 0);
55
+ for (const byte of data) {
56
+ const factor = byte ^ result.shift();
57
+ result.push(0);
58
+ generator.forEach((coefficient, i) => { result[i] ^= multiply(coefficient, factor); });
59
+ }
60
+ return result;
61
+ }
62
+ /** Module matrix of a QR Code: `true` for a dark module. Raises an error when the data does not fit (about 2.3 KB at level M). */
63
+ export function qrCodeMatrix(text, errorCorrection = 'M') {
64
+ const level = LEVELS[errorCorrection];
65
+ if (!level)
66
+ throw new Error(`Unknown error correction level: ${errorCorrection}`);
67
+ const bytes = typeof text === 'string' ? new TextEncoder().encode(text) : text;
68
+ let version = 1;
69
+ for (;; version++) {
70
+ if (version > 40)
71
+ throw new Error('Data too long for a QR Code.');
72
+ const countBits = version <= 9 ? 8 : 16;
73
+ if (4 + countBits + bytes.length * 8 <= dataCodewords(version, level.ordinal) * 8)
74
+ break;
75
+ }
76
+ // Data bits: byte mode, character count, bytes, terminator, padding.
77
+ const bits = [];
78
+ const append = (value, length) => { for (let i = length - 1; i >= 0; i--)
79
+ bits.push((value >>> i) & 1); };
80
+ append(0b0100, 4);
81
+ append(bytes.length, version <= 9 ? 8 : 16);
82
+ for (const byte of bytes)
83
+ append(byte, 8);
84
+ const capacity = dataCodewords(version, level.ordinal) * 8;
85
+ append(0, Math.min(4, capacity - bits.length));
86
+ append(0, (8 - (bits.length % 8)) % 8);
87
+ for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11)
88
+ append(pad, 8);
89
+ const data = [];
90
+ for (let i = 0; i < bits.length; i += 8)
91
+ data.push(bits.slice(i, i + 8).reduce((byte, b) => (byte << 1) | b, 0));
92
+ // Error correction blocks, interleaved.
93
+ const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[level.ordinal][version];
94
+ const blockEccLength = ECC_CODEWORDS_PER_BLOCK[level.ordinal][version];
95
+ const rawCodewords = Math.floor(rawDataModules(version) / 8);
96
+ const numShortBlocks = numBlocks - (rawCodewords % numBlocks);
97
+ const shortBlockLength = Math.floor(rawCodewords / numBlocks);
98
+ const generator = divisor(blockEccLength);
99
+ const blocks = [];
100
+ for (let i = 0, k = 0; i < numBlocks; i++) {
101
+ const block = data.slice(k, k + shortBlockLength - blockEccLength + (i < numShortBlocks ? 0 : 1));
102
+ k += block.length;
103
+ const ecc = remainder(block, generator);
104
+ if (i < numShortBlocks)
105
+ block.push(0);
106
+ blocks.push(block.concat(ecc));
107
+ }
108
+ const codewords = [];
109
+ for (let i = 0; i < blocks[0].length; i++)
110
+ blocks.forEach((block, j) => { if (i !== shortBlockLength - blockEccLength || j >= numShortBlocks)
111
+ codewords.push(block[i]); });
112
+ // Function patterns.
113
+ const size = version * 4 + 17;
114
+ const modules = Array.from({ length: size }, () => new Array(size).fill(false));
115
+ const isFunction = Array.from({ length: size }, () => new Array(size).fill(false));
116
+ const set = (x, y, dark) => { modules[y][x] = dark; isFunction[y][x] = true; };
117
+ for (let i = 0; i < size; i++) {
118
+ set(6, i, i % 2 === 0);
119
+ set(i, 6, i % 2 === 0);
120
+ }
121
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
122
+ for (let dy = -4; dy <= 4; dy++)
123
+ for (let dx = -4; dx <= 4; dx++) {
124
+ const distance = Math.max(Math.abs(dx), Math.abs(dy)), x = cx + dx, y = cy + dy;
125
+ if (x >= 0 && x < size && y >= 0 && y < size)
126
+ set(x, y, distance !== 2 && distance !== 4);
127
+ }
128
+ }
129
+ const alignments = [];
130
+ if (version > 1) {
131
+ const count = Math.floor(version / 7) + 2;
132
+ const step = Math.floor((version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2;
133
+ alignments.push(6);
134
+ for (let position = size - 7; alignments.length < count; position -= step)
135
+ alignments.splice(1, 0, position);
136
+ }
137
+ alignments.forEach((cx, i) => alignments.forEach((cy, j) => {
138
+ if ((i === 0 && j === 0) || (i === 0 && j === alignments.length - 1) || (i === alignments.length - 1 && j === 0))
139
+ return;
140
+ for (let dy = -2; dy <= 2; dy++)
141
+ for (let dx = -2; dx <= 2; dx++)
142
+ set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
143
+ }));
144
+ const drawFormat = (mask) => {
145
+ const formatData = (level.formatBits << 3) | mask;
146
+ let rem = formatData;
147
+ for (let i = 0; i < 10; i++)
148
+ rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
149
+ const format = ((formatData << 10) | rem) ^ 0x5412;
150
+ for (let i = 0; i <= 5; i++)
151
+ set(8, i, bit(format, i));
152
+ set(8, 7, bit(format, 6));
153
+ set(8, 8, bit(format, 7));
154
+ set(7, 8, bit(format, 8));
155
+ for (let i = 9; i < 15; i++)
156
+ set(14 - i, 8, bit(format, i));
157
+ for (let i = 0; i < 8; i++)
158
+ set(size - 1 - i, 8, bit(format, i));
159
+ for (let i = 8; i < 15; i++)
160
+ set(8, size - 15 + i, bit(format, i));
161
+ set(8, size - 8, true);
162
+ };
163
+ drawFormat(0); // reserves the format areas before the data is placed
164
+ if (version >= 7) {
165
+ let rem = version;
166
+ for (let i = 0; i < 12; i++)
167
+ rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
168
+ const versionBits = (version << 12) | rem;
169
+ for (let i = 0; i < 18; i++) {
170
+ const a = size - 11 + (i % 3), b = Math.floor(i / 3);
171
+ set(a, b, bit(versionBits, i));
172
+ set(b, a, bit(versionBits, i));
173
+ }
174
+ }
175
+ // Codewords, in the zigzag order.
176
+ let index = 0;
177
+ for (let right = size - 1; right >= 1; right -= 2) {
178
+ if (right === 6)
179
+ right = 5;
180
+ for (let vertical = 0; vertical < size; vertical++)
181
+ for (let j = 0; j < 2; j++) {
182
+ const x = right - j, upward = ((right + 1) & 2) === 0, y = upward ? size - 1 - vertical : vertical;
183
+ if (!isFunction[y][x] && index < codewords.length * 8) {
184
+ modules[y][x] = bit(codewords[index >>> 3], 7 - (index & 7));
185
+ index++;
186
+ }
187
+ }
188
+ }
189
+ // Mask: the one with the lowest penalty.
190
+ const masks = [
191
+ (x, y) => (x + y) % 2 === 0, (_x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
192
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
193
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
194
+ ];
195
+ const applyMask = (mask) => { for (let y = 0; y < size; y++)
196
+ for (let x = 0; x < size; x++)
197
+ if (!isFunction[y][x] && masks[mask](x, y))
198
+ modules[y][x] = !modules[y][x]; };
199
+ let best = 0, bestPenalty = Infinity;
200
+ for (let mask = 0; mask < 8; mask++) {
201
+ applyMask(mask);
202
+ drawFormat(mask);
203
+ const score = penalty(modules);
204
+ if (score < bestPenalty) {
205
+ best = mask;
206
+ bestPenalty = score;
207
+ }
208
+ applyMask(mask); // XOR again: undoes the mask
209
+ }
210
+ applyMask(best);
211
+ drawFormat(best);
212
+ return modules;
213
+ }
214
+ function penalty(modules) {
215
+ const size = modules.length;
216
+ let score = 0;
217
+ const lines = [...modules, ...modules[0].map((_, x) => modules.map((row) => row[x]))];
218
+ const finderLike = [[true, false, true, true, true, false, true, false, false, false, false], [false, false, false, false, true, false, true, true, true, false, true]];
219
+ for (const line of lines) {
220
+ for (let i = 0, run = 1; i < size; i++, run++) {
221
+ if (i === size - 1 || line[i] !== line[i + 1]) {
222
+ if (run >= 5)
223
+ score += 3 + run - 5;
224
+ run = 0;
225
+ }
226
+ }
227
+ for (let i = 0; i + 11 <= size; i++)
228
+ for (const pattern of finderLike)
229
+ if (pattern.every((dark, k) => line[i + k] === dark))
230
+ score += 40;
231
+ }
232
+ for (let y = 0; y + 1 < size; y++)
233
+ for (let x = 0; x + 1 < size; x++) {
234
+ const color = modules[y][x];
235
+ if (color === modules[y][x + 1] && color === modules[y + 1][x] && color === modules[y + 1][x + 1])
236
+ score += 3;
237
+ }
238
+ const dark = modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0), total = size * size;
239
+ score += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10;
240
+ return score;
241
+ }
242
+ const escapeXml = (value) => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
243
+ /** QR Code as an SVG document (scalable: give it a size with CSS). */
244
+ export function qrCodeSvg(text, options = {}) {
245
+ const modules = qrCodeMatrix(text, options.errorCorrection ?? 'M');
246
+ const margin = Math.max(0, Math.floor(options.margin ?? 4));
247
+ const size = modules.length + margin * 2;
248
+ let path = '';
249
+ modules.forEach((row, y) => row.forEach((dark, x) => { if (dark)
250
+ path += `M${x + margin},${y + margin}h1v1h-1z`; }));
251
+ const color = (value, fallback) => escapeXml(value ?? fallback);
252
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges">${options.title ? `<title>${escapeXml(options.title)}</title>` : ''}<rect width="100%" height="100%" fill="${color(options.light, '#ffffff')}"/><path d="${path}" fill="${color(options.dark, '#000000')}"/></svg>`;
253
+ }
254
+ /** QR Code as a `data:` URI, for an `<img src>`. */
255
+ export const qrCodeDataUri = (text, options = {}) => `data:image/svg+xml;base64,${Buffer.from(qrCodeSvg(text, options)).toString('base64')}`;
@@ -0,0 +1,10 @@
1
+ /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
2
+ export declare class Throttle {
3
+ private hits;
4
+ private max;
5
+ private windowMs;
6
+ constructor(max?: number, windowMs?: number);
7
+ /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
8
+ hit(key: string, now?: number): number;
9
+ clear(key: string): void;
10
+ }
@@ -0,0 +1,25 @@
1
+ // Rate limiting of sensitive endpoints (login, password reset, multi-factor challenges).
2
+ /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
3
+ export class Throttle {
4
+ hits = new Map();
5
+ max;
6
+ windowMs;
7
+ constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
8
+ /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
9
+ hit(key, now = Date.now()) {
10
+ const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
11
+ if (recent.length >= this.max) {
12
+ this.hits.set(key, recent);
13
+ return Math.max(1, Math.ceil((recent[0] + this.windowMs - now) / 1000));
14
+ }
15
+ recent.push(now);
16
+ this.hits.set(key, recent);
17
+ // Keep the store from growing forever.
18
+ if (this.hits.size > 10_000)
19
+ for (const [k, v] of this.hits)
20
+ if (!v.some((at) => at > now - this.windowMs))
21
+ this.hits.delete(k);
22
+ return 0;
23
+ }
24
+ clear(key) { this.hits.delete(key); }
25
+ }
package/lib/topbar.d.ts CHANGED
@@ -5,7 +5,9 @@ export declare const notificationBodyHtml: (body: string) => string;
5
5
  export type GlobalSearchResult = {
6
6
  title: string;
7
7
  url: string;
8
- details: Record<string, string>;
8
+ details: Record<string, string>; /** Buttons under the result: links (`url()`) or Livewire events (`dispatch()`). */
9
+ actions?: any[];
10
+ record?: unknown;
9
11
  };
10
12
  export type GlobalSearchCategory = {
11
13
  label: string;
package/lib/topbar.js CHANGED
@@ -54,6 +54,22 @@ export class GlobalSearch extends Component {
54
54
  }).class(['fi-input', 'fi-input-has-inline-prefix']).toHtml()} />`;
55
55
  const focus = "if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')";
56
56
  const field = `<div class="fi-input-wrp"><div x-on:click="${e(focus)}" class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content fi-inline">${icon('m-magnifying-glass', { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': 'search' }) })}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': 'search' }))}</div><div class="fi-input-wrp-content-ctn">${input}</div>${suffix ? `<div x-on:click="${e(focus)}" class="fi-input-wrp-suffix fi-inline fi-input-wrp-suffix-has-label"><span class="fi-input-wrp-label">${e(suffix)}</span></div>` : ''}</div>`;
57
+ // Actions of each result: rendered first (async), then placed under the result's link.
58
+ const actionsHtml = new Map();
59
+ for (const [groupIndex, category] of (categories ?? []).entries()) {
60
+ for (const [index, result] of category.results.entries()) {
61
+ const rendered = [];
62
+ for (const action of result.actions ?? []) {
63
+ const ctx = { livewire: this, component: null, operation: null, record: result.record ?? null, get: () => undefined, state: undefined };
64
+ if (!(await action.isVisible(ctx)))
65
+ continue;
66
+ const describedBy = `${id}-group-${groupIndex} ${id}-group-${groupIndex}-result-${index}`;
67
+ rendered.push(String(await action.toHtml(ctx)).replace(/^<(a|button)\s/, (_m, tag) => `<${tag} aria-describedby="${e(describedBy)}" data-global-search-action="1" `));
68
+ }
69
+ if (rendered.length)
70
+ actionsHtml.set(result, rendered);
71
+ }
72
+ }
57
73
  let results = '';
58
74
  if (categories !== null) {
59
75
  results = categories.length === 0
@@ -62,7 +78,8 @@ export class GlobalSearch extends Component {
62
78
  const groupId = `${id}-group-${groupIndex}`;
63
79
  return `<li class="fi-global-search-result-group"><h3 id="${e(groupId)}" class="fi-global-search-result-group-header">${e(category.label)}</h3><ul class="fi-global-search-result-group-results">${category.results.map((result, index) => {
64
80
  const details = Object.entries(result.details);
65
- return `<li class="fi-global-search-result"><a href="${e(result.url)}" wire:navigate class="fi-global-search-result-link"><h4 id="${e(`${groupId}-result-${index}`)}" class="fi-global-search-result-heading">${e(result.title)}</h4>${details.length ? `<dl class="fi-global-search-result-details">${details.map(([label, value]) => `<div class="fi-global-search-result-detail"><dt class="fi-global-search-result-detail-label">${e(label)}:</dt><dd class="fi-global-search-result-detail-value">${e(value)}</dd></div>`).join('')}</dl>` : ''}</a></li>`;
81
+ const actions = actionsHtml.get(result);
82
+ return `<li class="${actions ? 'fi-global-search-result fi-global-search-result-has-actions' : 'fi-global-search-result'}"><a href="${e(result.url)}" wire:navigate class="fi-global-search-result-link"><h4 id="${e(`${groupId}-result-${index}`)}" class="fi-global-search-result-heading">${e(result.title)}</h4>${details.length ? `<dl class="fi-global-search-result-details">${details.map(([label, value]) => `<div class="fi-global-search-result-detail"><dt class="fi-global-search-result-detail-label">${e(label)}:</dt><dd class="fi-global-search-result-detail-value">${e(value)}</dd></div>`).join('')}</dl>` : ''}</a>${actions ? `<div class="fi-global-search-result-actions">${actions.join('')}</div>` : ''}</li>`;
66
83
  }).join('')}</ul></li>`;
67
84
  }).join('')}</ul>`;
68
85
  }
package/lib/totp.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /** RFC 4648 base32, without padding (the format of authenticator app secrets). */
2
+ export declare function base32Encode(bytes: Uint8Array): string;
3
+ /** Decodes base32 (case, spaces and padding ignored). Throws on any other character. */
4
+ export declare function base32Decode(text: string): Buffer;
5
+ export type TotpAlgorithm = 'sha1' | 'sha256' | 'sha512';
6
+ export type TotpOptions = {
7
+ /** Seconds per code (30 by default, what every app expects). */
8
+ period?: number;
9
+ /** Code length (6 by default). */
10
+ digits?: number;
11
+ /** HMAC algorithm (`sha1` by default: most apps ignore any other value). */
12
+ algorithm?: TotpAlgorithm;
13
+ };
14
+ /** Random base32 secret of `bytes` bytes (20 by default: 160 bits, as RFC 4226 recommends). */
15
+ export declare function generateTotpSecret(bytes?: number): string;
16
+ /** HOTP value for a counter. */
17
+ export declare function hotp(secret: string | Uint8Array, counter: number, options?: Pick<TotpOptions, 'digits' | 'algorithm'>): string;
18
+ /** Time step (counter) of a moment: `floor(unix seconds / period)`. */
19
+ export declare const totpTimestep: (at?: number, period?: number) => number;
20
+ /** Current code of a secret (or the code at `at`, in milliseconds). */
21
+ export declare function totpCode(secret: string, options?: TotpOptions & {
22
+ at?: number;
23
+ }): string;
24
+ /**
25
+ * Checks a code against the secret, accepting `window` time steps before and after the current one (clock drift).
26
+ * Returns the matched time step, or `null`. Pass the last accepted step as `after` to refuse a code that was already
27
+ * used, or any older one (RFC 6238 §5.2): store the returned step and pass it next time.
28
+ */
29
+ export declare function verifyTotp(secret: string, code: unknown, options?: TotpOptions & {
30
+ window?: number;
31
+ at?: number;
32
+ after?: number | null;
33
+ }): number | null;
34
+ /**
35
+ * `otpauth://` URI to show as a QR code: `issuer` is the application name, `account` the user's (their email).
36
+ * See https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
37
+ */
38
+ export declare function totpUri(secret: string, account: string, issuer: string, options?: TotpOptions): string;
package/lib/totp.js ADDED
@@ -0,0 +1,104 @@
1
+ // Time-based one-time passwords (RFC 6238, on HOTP: RFC 4226), as used by authenticator apps
2
+ // (Google Authenticator, Microsoft Authenticator, 1Password, Authy…). No dependency: HMAC from `node:crypto`.
3
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
4
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
5
+ /** RFC 4648 base32, without padding (the format of authenticator app secrets). */
6
+ export function base32Encode(bytes) {
7
+ let bits = 0, value = 0, output = '';
8
+ for (const byte of bytes) {
9
+ value = (value << 8) | byte;
10
+ bits += 8;
11
+ while (bits >= 5) {
12
+ output += ALPHABET[(value >>> (bits - 5)) & 31];
13
+ bits -= 5;
14
+ }
15
+ }
16
+ if (bits > 0)
17
+ output += ALPHABET[(value << (5 - bits)) & 31];
18
+ return output;
19
+ }
20
+ /** Decodes base32 (case, spaces and padding ignored). Throws on any other character. */
21
+ export function base32Decode(text) {
22
+ const clean = text.toUpperCase().replace(/[\s=-]/g, '');
23
+ const bytes = [];
24
+ let bits = 0, value = 0;
25
+ for (const char of clean) {
26
+ const index = ALPHABET.indexOf(char);
27
+ if (index < 0)
28
+ throw new Error('Invalid base32 character.');
29
+ value = (value << 5) | index;
30
+ bits += 5;
31
+ if (bits >= 8) {
32
+ bytes.push((value >>> (bits - 8)) & 255);
33
+ bits -= 8;
34
+ }
35
+ }
36
+ return Buffer.from(bytes);
37
+ }
38
+ const defaults = (options = {}) => ({ period: options.period ?? 30, digits: options.digits ?? 6, algorithm: options.algorithm ?? 'sha1' });
39
+ /** Random base32 secret of `bytes` bytes (20 by default: 160 bits, as RFC 4226 recommends). */
40
+ export function generateTotpSecret(bytes = 20) { return base32Encode(randomBytes(bytes)); }
41
+ /** HOTP value for a counter. */
42
+ export function hotp(secret, counter, options = {}) {
43
+ const { digits, algorithm } = defaults(options);
44
+ const key = typeof secret === 'string' ? base32Decode(secret) : Buffer.from(secret);
45
+ const message = Buffer.alloc(8);
46
+ message.writeBigUInt64BE(BigInt(counter));
47
+ const hash = createHmac(algorithm, key).update(message).digest();
48
+ const offset = hash[hash.length - 1] & 15;
49
+ const binary = ((hash[offset] & 127) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3];
50
+ return String(binary % 10 ** digits).padStart(digits, '0');
51
+ }
52
+ /** Time step (counter) of a moment: `floor(unix seconds / period)`. */
53
+ export const totpTimestep = (at = Date.now(), period = 30) => Math.floor(at / 1000 / period);
54
+ /** Current code of a secret (or the code at `at`, in milliseconds). */
55
+ export function totpCode(secret, options = {}) {
56
+ const { period } = defaults(options);
57
+ return hotp(secret, totpTimestep(options.at, period), options);
58
+ }
59
+ /**
60
+ * Checks a code against the secret, accepting `window` time steps before and after the current one (clock drift).
61
+ * Returns the matched time step, or `null`. Pass the last accepted step as `after` to refuse a code that was already
62
+ * used, or any older one (RFC 6238 §5.2): store the returned step and pass it next time.
63
+ */
64
+ export function verifyTotp(secret, code, options = {}) {
65
+ const { period, digits } = defaults(options);
66
+ const value = String(code ?? '').replace(/\s/g, '');
67
+ if (!new RegExp(`^\\d{${digits}}$`).test(value))
68
+ return null;
69
+ let key;
70
+ try {
71
+ key = base32Decode(secret);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ if (!key.length)
77
+ return null;
78
+ const current = totpTimestep(options.at, period);
79
+ const window = Math.max(0, Math.min(10, Math.floor(options.window ?? 1)));
80
+ let matched = null;
81
+ // Every candidate is computed and compared in constant time, so the response time does not reveal which step matched.
82
+ for (let step = current - window; step <= current + window; step++) {
83
+ const candidate = Buffer.from(hotp(key, step, options));
84
+ if (timingSafeEqual(candidate, Buffer.from(value)) && matched === null && (options.after === null || options.after === undefined || step > options.after))
85
+ matched = step;
86
+ }
87
+ return matched;
88
+ }
89
+ /**
90
+ * `otpauth://` URI to show as a QR code: `issuer` is the application name, `account` the user's (their email).
91
+ * See https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
92
+ */
93
+ export function totpUri(secret, account, issuer, options = {}) {
94
+ const { period, digits, algorithm } = defaults(options);
95
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
96
+ const params = new URLSearchParams({ secret, issuer });
97
+ if (algorithm !== 'sha1')
98
+ params.set('algorithm', algorithm.toUpperCase());
99
+ if (digits !== 6)
100
+ params.set('digits', String(digits));
101
+ if (period !== 30)
102
+ params.set('period', String(period));
103
+ return `otpauth://totp/${label}?${params.toString().replace(/\+/g, '%20')}`;
104
+ }
@@ -0,0 +1,155 @@
1
+ import { type KeyObject } from 'node:crypto';
2
+ export declare class WebAuthnError extends Error {
3
+ }
4
+ /** Decodes one CBOR item at `offset`; returns it with the offset after it. Maps are returned as `Map`. */
5
+ export declare function decodeCborItem(bytes: Uint8Array, offset?: number, depth?: number): [unknown, number];
6
+ export declare const decodeCbor: (bytes: Uint8Array) => unknown;
7
+ /** COSE algorithms supported: ES256, EdDSA (Ed25519), ES384, ES512, RS256. */
8
+ export declare const SUPPORTED_ALGORITHMS: readonly [-8, -7, -257, -35, -36];
9
+ /** Public key of a COSE key map. */
10
+ export declare function coseToPublicKey(cose: Map<unknown, unknown>): {
11
+ algorithm: number;
12
+ key: KeyObject;
13
+ };
14
+ /** Checks an assertion signature with a stored public key (SPKI DER, base64url). */
15
+ export declare function verifySignature(algorithm: number, publicKey: string, data: Buffer, signature: Buffer): boolean;
16
+ export type AuthenticatorData = {
17
+ rpIdHash: Buffer;
18
+ userPresent: boolean;
19
+ userVerified: boolean;
20
+ backupEligible: boolean;
21
+ backedUp: boolean;
22
+ signCount: number;
23
+ attestedCredential: {
24
+ aaguid: string;
25
+ credentialId: Buffer;
26
+ publicKey: Map<unknown, unknown>;
27
+ } | null;
28
+ };
29
+ export declare function parseAuthenticatorData(data: Buffer): AuthenticatorData;
30
+ export type UserVerification = 'required' | 'preferred' | 'discouraged';
31
+ /** What the browser sends after `navigator.credentials.create()` (the credential's `toJSON()`). */
32
+ export type RegistrationResponseJSON = {
33
+ id: string;
34
+ rawId: string;
35
+ type: string;
36
+ response: {
37
+ clientDataJSON: string;
38
+ attestationObject: string;
39
+ transports?: string[];
40
+ };
41
+ };
42
+ /** What the browser sends after `navigator.credentials.get()`. */
43
+ export type AuthenticationResponseJSON = {
44
+ id: string;
45
+ rawId: string;
46
+ type: string;
47
+ response: {
48
+ clientDataJSON: string;
49
+ authenticatorData: string;
50
+ signature: string;
51
+ userHandle?: string | null;
52
+ };
53
+ };
54
+ export type ExpectedCeremony = {
55
+ /** Challenge sent to the browser (base64url). */
56
+ challenge: string;
57
+ /** Origins allowed (`https://example.com`, with a port if not standard). */
58
+ origin: string | string[];
59
+ /** Relying party id: the domain (`example.com`), without scheme or port. */
60
+ rpId: string;
61
+ /** Require the authenticator to verify the user (PIN, biometrics): `true` for passkeys used without a password. */
62
+ requireUserVerification?: boolean;
63
+ };
64
+ /** A registered credential, as stored. */
65
+ export type StoredCredential = {
66
+ credentialId: string;
67
+ publicKey: string;
68
+ algorithm: number;
69
+ signCount: number;
70
+ transports?: string[];
71
+ };
72
+ /** Verifies a registration (`navigator.credentials.create()`), and returns the credential to store. Throws `WebAuthnError`. */
73
+ export declare function verifyRegistration(response: unknown, expected: ExpectedCeremony): StoredCredential & {
74
+ aaguid: string;
75
+ backedUp: boolean;
76
+ userVerified: boolean;
77
+ };
78
+ /**
79
+ * Verifies an authentication (`navigator.credentials.get()`) against the stored credential whose id the response carries.
80
+ * Returns the new signature counter, to store. Throws `WebAuthnError`, notably when the counter went backwards (cloned key).
81
+ */
82
+ export declare function verifyAuthentication(response: unknown, expected: ExpectedCeremony & {
83
+ credential: StoredCredential;
84
+ }): {
85
+ signCount: number;
86
+ userVerified: boolean;
87
+ };
88
+ /** Random challenge (base64url, 32 bytes). */
89
+ export declare const generateChallenge: () => string;
90
+ /** Options for `navigator.credentials.create()`, as JSON (`PublicKeyCredential.parseCreationOptionsFromJSON()`). */
91
+ export declare function registrationOptions(options: {
92
+ rpId: string;
93
+ rpName: string;
94
+ userId: string;
95
+ userName: string;
96
+ userDisplayName?: string;
97
+ challenge: string;
98
+ excludeCredentials?: {
99
+ credentialId: string;
100
+ transports?: string[];
101
+ }[];
102
+ userVerification?: UserVerification;
103
+ residentKey?: 'required' | 'preferred' | 'discouraged';
104
+ authenticatorAttachment?: 'platform' | 'cross-platform' | null;
105
+ timeout?: number;
106
+ }): {
107
+ rp: {
108
+ id: string;
109
+ name: string;
110
+ };
111
+ user: {
112
+ id: string;
113
+ name: string;
114
+ displayName: string;
115
+ };
116
+ challenge: string;
117
+ pubKeyCredParams: {
118
+ type: string;
119
+ alg: -8 | -7 | -257 | -35 | -36;
120
+ }[];
121
+ timeout: number;
122
+ excludeCredentials: {
123
+ transports?: string[];
124
+ type: string;
125
+ id: string;
126
+ }[];
127
+ authenticatorSelection: {
128
+ authenticatorAttachment?: "platform" | "cross-platform";
129
+ residentKey: "required" | "preferred" | "discouraged";
130
+ requireResidentKey: boolean;
131
+ userVerification: UserVerification;
132
+ };
133
+ attestation: string;
134
+ };
135
+ /** Options for `navigator.credentials.get()`, as JSON (`PublicKeyCredential.parseRequestOptionsFromJSON()`). */
136
+ export declare function authenticationOptions(options: {
137
+ rpId: string;
138
+ challenge: string;
139
+ allowCredentials?: {
140
+ credentialId: string;
141
+ transports?: string[];
142
+ }[];
143
+ userVerification?: UserVerification;
144
+ timeout?: number;
145
+ }): {
146
+ rpId: string;
147
+ challenge: string;
148
+ timeout: number;
149
+ allowCredentials: {
150
+ transports?: string[];
151
+ type: string;
152
+ id: string;
153
+ }[];
154
+ userVerification: UserVerification;
155
+ };