@navette-js/panels 1.0.0-beta.3 → 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.
- package/THIRD-PARTY-NOTICES.md +2 -1
- package/dist/navette/navette/calendar.css +5 -5
- package/dist/navette/navette/motion.css +21 -21
- package/dist/navette/navette/motion.js +6 -6
- package/dist/navette/navette/security-keys.css +11 -0
- package/dist/navette/navette/security-keys.js +72 -0
- package/lib/auth.d.ts +17 -0
- package/lib/auth.js +70 -0
- package/lib/error-pages.d.ts +9 -0
- package/lib/error-pages.js +173 -0
- package/lib/import-export.d.ts +185 -0
- package/lib/import-export.js +543 -0
- package/lib/index.d.ts +629 -79
- package/lib/index.js +1945 -333
- package/lib/multi-factor.d.ts +348 -0
- package/lib/multi-factor.js +928 -0
- package/lib/notifications.d.ts +20 -2
- package/lib/notifications.js +34 -4
- package/lib/qr-code.d.ts +16 -0
- package/lib/qr-code.js +255 -0
- package/lib/session-store.d.ts +21 -0
- package/lib/session-store.js +35 -0
- package/lib/throttle.d.ts +10 -0
- package/lib/throttle.js +25 -0
- package/lib/topbar.d.ts +3 -1
- package/lib/topbar.js +18 -1
- package/lib/totp.d.ts +38 -0
- package/lib/totp.js +104 -0
- package/lib/webauthn.d.ts +155 -0
- package/lib/webauthn.js +287 -0
- package/lib/xlsx.d.ts +7 -0
- package/lib/xlsx.js +171 -0
- package/package.json +9 -9
- package/views/components/tenant-menu.ejs +3 -3
- package/views/components/user-menu.ejs +3 -2
- package/views/errors/page.ejs +87 -0
- package/views/layout/base.ejs +9 -1
- package/views/livewire/sidebar.ejs +35 -20
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +53 -35
- package/views/pages/simple.ejs +4 -0
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
|
+
};
|
package/lib/webauthn.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// WebAuthn (Web Authentication, level 2) relying party: registration and authentication ceremonies for security keys
|
|
2
|
+
// and passkeys. No dependency: CBOR and COSE parsing, signature checks with `node:crypto`.
|
|
3
|
+
// Attestation statements are not verified (`attestation: 'none'`): the key proves possession, not its make or model.
|
|
4
|
+
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify } from 'node:crypto';
|
|
5
|
+
export class WebAuthnError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
|
|
8
|
+
const fromB64url = (value, what) => {
|
|
9
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*={0,2}$/.test(value) || value.length > 100_000)
|
|
10
|
+
throw new WebAuthnError(`Invalid ${what}.`);
|
|
11
|
+
return Buffer.from(value, 'base64url');
|
|
12
|
+
};
|
|
13
|
+
// --- CBOR (RFC 8949): the definite-length subset that authenticators produce.
|
|
14
|
+
/** Decodes one CBOR item at `offset`; returns it with the offset after it. Maps are returned as `Map`. */
|
|
15
|
+
export function decodeCborItem(bytes, offset = 0, depth = 0) {
|
|
16
|
+
if (depth > 16)
|
|
17
|
+
throw new WebAuthnError('CBOR nesting too deep.');
|
|
18
|
+
const need = (count) => { if (offset + count > bytes.length)
|
|
19
|
+
throw new WebAuthnError('Truncated CBOR data.'); };
|
|
20
|
+
need(1);
|
|
21
|
+
const initial = bytes[offset++];
|
|
22
|
+
const major = initial >> 5, info = initial & 31;
|
|
23
|
+
let length;
|
|
24
|
+
if (info < 24)
|
|
25
|
+
length = info;
|
|
26
|
+
else if (info === 24) {
|
|
27
|
+
need(1);
|
|
28
|
+
length = bytes[offset];
|
|
29
|
+
offset += 1;
|
|
30
|
+
}
|
|
31
|
+
else if (info === 25) {
|
|
32
|
+
need(2);
|
|
33
|
+
length = (bytes[offset] << 8) | bytes[offset + 1];
|
|
34
|
+
offset += 2;
|
|
35
|
+
}
|
|
36
|
+
else if (info === 26) {
|
|
37
|
+
need(4);
|
|
38
|
+
length = Buffer.from(bytes.subarray(offset, offset + 4)).readUInt32BE(0);
|
|
39
|
+
offset += 4;
|
|
40
|
+
}
|
|
41
|
+
else if (info === 27) {
|
|
42
|
+
need(8);
|
|
43
|
+
const big = Buffer.from(bytes.subarray(offset, offset + 8)).readBigUInt64BE(0);
|
|
44
|
+
offset += 8;
|
|
45
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER))
|
|
46
|
+
throw new WebAuthnError('CBOR integer too large.');
|
|
47
|
+
length = Number(big);
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
throw new WebAuthnError('Unsupported CBOR encoding.');
|
|
51
|
+
switch (major) {
|
|
52
|
+
case 0: return [length, offset];
|
|
53
|
+
case 1: return [-1 - length, offset];
|
|
54
|
+
case 2:
|
|
55
|
+
need(length);
|
|
56
|
+
return [Buffer.from(bytes.subarray(offset, offset + length)), offset + length];
|
|
57
|
+
case 3:
|
|
58
|
+
need(length);
|
|
59
|
+
return [Buffer.from(bytes.subarray(offset, offset + length)).toString('utf8'), offset + length];
|
|
60
|
+
case 4: {
|
|
61
|
+
if (length > 10_000)
|
|
62
|
+
throw new WebAuthnError('CBOR array too long.');
|
|
63
|
+
const items = [];
|
|
64
|
+
for (let i = 0; i < length; i++) {
|
|
65
|
+
const [item, next] = decodeCborItem(bytes, offset, depth + 1);
|
|
66
|
+
items.push(item);
|
|
67
|
+
offset = next;
|
|
68
|
+
}
|
|
69
|
+
return [items, offset];
|
|
70
|
+
}
|
|
71
|
+
case 5: {
|
|
72
|
+
if (length > 10_000)
|
|
73
|
+
throw new WebAuthnError('CBOR map too long.');
|
|
74
|
+
const map = new Map();
|
|
75
|
+
for (let i = 0; i < length; i++) {
|
|
76
|
+
const [key, afterKey] = decodeCborItem(bytes, offset, depth + 1);
|
|
77
|
+
const [value, afterValue] = decodeCborItem(bytes, afterKey, depth + 1);
|
|
78
|
+
map.set(key, value);
|
|
79
|
+
offset = afterValue;
|
|
80
|
+
}
|
|
81
|
+
return [map, offset];
|
|
82
|
+
}
|
|
83
|
+
case 6: return decodeCborItem(bytes, offset, depth + 1); // tag: its content
|
|
84
|
+
default: {
|
|
85
|
+
if (info === 20)
|
|
86
|
+
return [false, offset];
|
|
87
|
+
if (info === 21)
|
|
88
|
+
return [true, offset];
|
|
89
|
+
if (info === 22 || info === 23)
|
|
90
|
+
return [null, offset];
|
|
91
|
+
if (info === 25) {
|
|
92
|
+
const half = length, exponent = (half >> 10) & 31, fraction = half & 1023;
|
|
93
|
+
const value = exponent === 0 ? fraction * 2 ** -24 : exponent === 31 ? (fraction ? NaN : Infinity) : (fraction + 1024) * 2 ** (exponent - 25);
|
|
94
|
+
return [half & 0x8000 ? -value : value, offset];
|
|
95
|
+
}
|
|
96
|
+
if (info === 26) {
|
|
97
|
+
const buffer = Buffer.alloc(4);
|
|
98
|
+
buffer.writeUInt32BE(length);
|
|
99
|
+
return [buffer.readFloatBE(0), offset];
|
|
100
|
+
}
|
|
101
|
+
if (info === 27)
|
|
102
|
+
throw new WebAuthnError('Unsupported CBOR float.');
|
|
103
|
+
return [length, offset];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export const decodeCbor = (bytes) => decodeCborItem(bytes, 0)[0];
|
|
108
|
+
// --- COSE keys (RFC 9053) and signatures.
|
|
109
|
+
/** COSE algorithms supported: ES256, EdDSA (Ed25519), ES384, ES512, RS256. */
|
|
110
|
+
export const SUPPORTED_ALGORITHMS = [-8, -7, -257, -35, -36];
|
|
111
|
+
const EC_CURVES = { 1: 'P-256', 2: 'P-384', 3: 'P-521' };
|
|
112
|
+
/** Public key of a COSE key map. */
|
|
113
|
+
export function coseToPublicKey(cose) {
|
|
114
|
+
const kty = cose.get(1), algorithm = Number(cose.get(3));
|
|
115
|
+
if (!SUPPORTED_ALGORITHMS.includes(algorithm))
|
|
116
|
+
throw new WebAuthnError(`Unsupported key algorithm: ${algorithm}.`);
|
|
117
|
+
const bytes = (label) => { const value = cose.get(label); if (!Buffer.isBuffer(value))
|
|
118
|
+
throw new WebAuthnError('Invalid public key.'); return b64url(value); };
|
|
119
|
+
let jwk;
|
|
120
|
+
if (kty === 2 && [-7, -35, -36].includes(algorithm)) {
|
|
121
|
+
const curve = EC_CURVES[Number(cose.get(-1))];
|
|
122
|
+
if (!curve || curve !== { [-7]: 'P-256', [-35]: 'P-384', [-36]: 'P-521' }[algorithm])
|
|
123
|
+
throw new WebAuthnError('Invalid elliptic curve.');
|
|
124
|
+
jwk = { kty: 'EC', crv: curve, x: bytes(-2), y: bytes(-3) };
|
|
125
|
+
}
|
|
126
|
+
else if (kty === 1 && algorithm === -8) {
|
|
127
|
+
if (cose.get(-1) !== 6)
|
|
128
|
+
throw new WebAuthnError('Only the Ed25519 curve is supported for EdDSA.');
|
|
129
|
+
jwk = { kty: 'OKP', crv: 'Ed25519', x: bytes(-2) };
|
|
130
|
+
}
|
|
131
|
+
else if (kty === 3 && algorithm === -257) {
|
|
132
|
+
jwk = { kty: 'RSA', n: bytes(-1), e: bytes(-2) };
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
throw new WebAuthnError('Invalid public key type.');
|
|
136
|
+
try {
|
|
137
|
+
return { algorithm, key: createPublicKey({ key: jwk, format: 'jwk' }) };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new WebAuthnError('Invalid public key.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Checks an assertion signature with a stored public key (SPKI DER, base64url). */
|
|
144
|
+
export function verifySignature(algorithm, publicKey, data, signature) {
|
|
145
|
+
const key = createPublicKey({ key: fromB64url(publicKey, 'public key'), format: 'der', type: 'spki' });
|
|
146
|
+
try {
|
|
147
|
+
if (algorithm === -8)
|
|
148
|
+
return verify(null, data, key, signature);
|
|
149
|
+
if (algorithm === -7)
|
|
150
|
+
return verify('sha256', data, { key, dsaEncoding: 'der' }, signature);
|
|
151
|
+
if (algorithm === -35)
|
|
152
|
+
return verify('sha384', data, { key, dsaEncoding: 'der' }, signature);
|
|
153
|
+
if (algorithm === -36)
|
|
154
|
+
return verify('sha512', data, { key, dsaEncoding: 'der' }, signature);
|
|
155
|
+
if (algorithm === -257)
|
|
156
|
+
return verify('sha256', data, key, signature);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
export function parseAuthenticatorData(data) {
|
|
164
|
+
if (data.length < 37)
|
|
165
|
+
throw new WebAuthnError('Authenticator data too short.');
|
|
166
|
+
const flags = data[32];
|
|
167
|
+
let offset = 37, attestedCredential = null;
|
|
168
|
+
if (flags & 0x40) {
|
|
169
|
+
if (data.length < offset + 18)
|
|
170
|
+
throw new WebAuthnError('Attested credential data too short.');
|
|
171
|
+
const aaguid = data.subarray(offset, offset + 16).toString('hex');
|
|
172
|
+
const idLength = data.readUInt16BE(offset + 16);
|
|
173
|
+
offset += 18;
|
|
174
|
+
if (idLength > 1023 || data.length < offset + idLength)
|
|
175
|
+
throw new WebAuthnError('Invalid credential id.');
|
|
176
|
+
const credentialId = Buffer.from(data.subarray(offset, offset + idLength));
|
|
177
|
+
offset += idLength;
|
|
178
|
+
const [publicKey, next] = decodeCborItem(data, offset);
|
|
179
|
+
if (!(publicKey instanceof Map))
|
|
180
|
+
throw new WebAuthnError('Invalid credential public key.');
|
|
181
|
+
offset = next;
|
|
182
|
+
attestedCredential = { aaguid: `${aaguid.slice(0, 8)}-${aaguid.slice(8, 12)}-${aaguid.slice(12, 16)}-${aaguid.slice(16, 20)}-${aaguid.slice(20)}`, credentialId, publicKey };
|
|
183
|
+
}
|
|
184
|
+
if (flags & 0x80)
|
|
185
|
+
offset = decodeCborItem(data, offset)[1]; // extensions
|
|
186
|
+
if (offset !== data.length)
|
|
187
|
+
throw new WebAuthnError('Unexpected data after the authenticator data.');
|
|
188
|
+
return { rpIdHash: data.subarray(0, 32), userPresent: Boolean(flags & 0x01), userVerified: Boolean(flags & 0x04), backupEligible: Boolean(flags & 0x08), backedUp: Boolean(flags & 0x10), signCount: data.readUInt32BE(33), attestedCredential };
|
|
189
|
+
}
|
|
190
|
+
const sameBytes = (a, b) => a.length === b.length && timingSafeEqual(a, b);
|
|
191
|
+
function checkClientData(raw, type, expected) {
|
|
192
|
+
let clientData;
|
|
193
|
+
try {
|
|
194
|
+
clientData = JSON.parse(raw.toString('utf8'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
throw new WebAuthnError('Invalid client data.');
|
|
198
|
+
}
|
|
199
|
+
if (clientData?.type !== type)
|
|
200
|
+
throw new WebAuthnError('Unexpected ceremony type.');
|
|
201
|
+
if (typeof clientData.challenge !== 'string' || !sameBytes(fromB64url(clientData.challenge, 'challenge'), fromB64url(expected.challenge, 'challenge')))
|
|
202
|
+
throw new WebAuthnError('The challenge does not match.');
|
|
203
|
+
const origins = [expected.origin].flat();
|
|
204
|
+
if (!origins.includes(clientData.origin))
|
|
205
|
+
throw new WebAuthnError(`Unexpected origin: ${String(clientData.origin)}.`);
|
|
206
|
+
if (clientData.crossOrigin === true)
|
|
207
|
+
throw new WebAuthnError('Cross-origin ceremonies are not allowed.');
|
|
208
|
+
}
|
|
209
|
+
function checkAuthenticatorData(data, expected) {
|
|
210
|
+
if (!sameBytes(data.rpIdHash, createHash('sha256').update(expected.rpId).digest()))
|
|
211
|
+
throw new WebAuthnError('The relying party id does not match.');
|
|
212
|
+
if (!data.userPresent)
|
|
213
|
+
throw new WebAuthnError('The user was not present.');
|
|
214
|
+
if (expected.requireUserVerification && !data.userVerified)
|
|
215
|
+
throw new WebAuthnError('The user was not verified.');
|
|
216
|
+
}
|
|
217
|
+
/** Verifies a registration (`navigator.credentials.create()`), and returns the credential to store. Throws `WebAuthnError`. */
|
|
218
|
+
export function verifyRegistration(response, expected) {
|
|
219
|
+
const body = response;
|
|
220
|
+
if (!body || typeof body !== 'object' || body.type !== 'public-key' || !body.response)
|
|
221
|
+
throw new WebAuthnError('Invalid credential.');
|
|
222
|
+
checkClientData(fromB64url(body.response.clientDataJSON, 'client data'), 'webauthn.create', expected);
|
|
223
|
+
const attestation = decodeCbor(fromB64url(body.response.attestationObject, 'attestation'));
|
|
224
|
+
if (!(attestation instanceof Map) || !Buffer.isBuffer(attestation.get('authData')))
|
|
225
|
+
throw new WebAuthnError('Invalid attestation object.');
|
|
226
|
+
const data = parseAuthenticatorData(attestation.get('authData'));
|
|
227
|
+
checkAuthenticatorData(data, expected);
|
|
228
|
+
if (!data.attestedCredential)
|
|
229
|
+
throw new WebAuthnError('No credential in the attestation.');
|
|
230
|
+
const credentialId = b64url(data.attestedCredential.credentialId);
|
|
231
|
+
if (credentialId !== b64url(fromB64url(body.rawId ?? body.id, 'credential id')))
|
|
232
|
+
throw new WebAuthnError('The credential id does not match.');
|
|
233
|
+
const { algorithm, key } = coseToPublicKey(data.attestedCredential.publicKey);
|
|
234
|
+
const transports = Array.isArray(body.response.transports) ? body.response.transports.filter((transport) => typeof transport === 'string' && /^[a-z-]{1,20}$/.test(transport)).slice(0, 10) : [];
|
|
235
|
+
return {
|
|
236
|
+
credentialId, publicKey: b64url(key.export({ type: 'spki', format: 'der' })), algorithm, signCount: data.signCount, transports,
|
|
237
|
+
aaguid: data.attestedCredential.aaguid, backedUp: data.backedUp, userVerified: data.userVerified,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Verifies an authentication (`navigator.credentials.get()`) against the stored credential whose id the response carries.
|
|
242
|
+
* Returns the new signature counter, to store. Throws `WebAuthnError`, notably when the counter went backwards (cloned key).
|
|
243
|
+
*/
|
|
244
|
+
export function verifyAuthentication(response, expected) {
|
|
245
|
+
const body = response;
|
|
246
|
+
if (!body || typeof body !== 'object' || body.type !== 'public-key' || !body.response)
|
|
247
|
+
throw new WebAuthnError('Invalid credential.');
|
|
248
|
+
if (b64url(fromB64url(body.rawId ?? body.id, 'credential id')) !== expected.credential.credentialId)
|
|
249
|
+
throw new WebAuthnError('Unknown credential.');
|
|
250
|
+
const clientDataJSON = fromB64url(body.response.clientDataJSON, 'client data');
|
|
251
|
+
checkClientData(clientDataJSON, 'webauthn.get', expected);
|
|
252
|
+
const authenticatorData = fromB64url(body.response.authenticatorData, 'authenticator data');
|
|
253
|
+
const data = parseAuthenticatorData(authenticatorData);
|
|
254
|
+
checkAuthenticatorData(data, expected);
|
|
255
|
+
const signed = Buffer.concat([authenticatorData, createHash('sha256').update(clientDataJSON).digest()]);
|
|
256
|
+
if (!verifySignature(expected.credential.algorithm, expected.credential.publicKey, signed, fromB64url(body.response.signature, 'signature')))
|
|
257
|
+
throw new WebAuthnError('Invalid signature.');
|
|
258
|
+
// A counter that does not increase means two copies of the key exist (§6.1.1); 0 means the authenticator keeps none.
|
|
259
|
+
if ((data.signCount !== 0 || expected.credential.signCount !== 0) && data.signCount <= expected.credential.signCount)
|
|
260
|
+
throw new WebAuthnError('The signature counter did not increase: this key may have been cloned.');
|
|
261
|
+
return { signCount: data.signCount, userVerified: data.userVerified };
|
|
262
|
+
}
|
|
263
|
+
/** Random challenge (base64url, 32 bytes). */
|
|
264
|
+
export const generateChallenge = () => b64url(randomBytes(32));
|
|
265
|
+
/** Options for `navigator.credentials.create()`, as JSON (`PublicKeyCredential.parseCreationOptionsFromJSON()`). */
|
|
266
|
+
export function registrationOptions(options) {
|
|
267
|
+
return {
|
|
268
|
+
rp: { id: options.rpId, name: options.rpName },
|
|
269
|
+
user: { id: b64url(Buffer.from(options.userId)), name: options.userName, displayName: options.userDisplayName ?? options.userName },
|
|
270
|
+
challenge: options.challenge,
|
|
271
|
+
pubKeyCredParams: SUPPORTED_ALGORITHMS.map((alg) => ({ type: 'public-key', alg })),
|
|
272
|
+
timeout: options.timeout ?? 120_000,
|
|
273
|
+
excludeCredentials: (options.excludeCredentials ?? []).map((credential) => ({ type: 'public-key', id: credential.credentialId, ...(credential.transports?.length ? { transports: credential.transports } : {}) })),
|
|
274
|
+
authenticatorSelection: { residentKey: options.residentKey ?? 'discouraged', requireResidentKey: options.residentKey === 'required', userVerification: options.userVerification ?? 'preferred', ...(options.authenticatorAttachment ? { authenticatorAttachment: options.authenticatorAttachment } : {}) },
|
|
275
|
+
attestation: 'none',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Options for `navigator.credentials.get()`, as JSON (`PublicKeyCredential.parseRequestOptionsFromJSON()`). */
|
|
279
|
+
export function authenticationOptions(options) {
|
|
280
|
+
return {
|
|
281
|
+
rpId: options.rpId,
|
|
282
|
+
challenge: options.challenge,
|
|
283
|
+
timeout: options.timeout ?? 120_000,
|
|
284
|
+
allowCredentials: (options.allowCredentials ?? []).map((credential) => ({ type: 'public-key', id: credential.credentialId, ...(credential.transports?.length ? { transports: credential.transports } : {}) })),
|
|
285
|
+
userVerification: options.userVerification ?? 'preferred',
|
|
286
|
+
};
|
|
287
|
+
}
|
package/lib/xlsx.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Workbook with a single sheet: the first row is bold (header). */
|
|
2
|
+
export declare function buildXlsx(rows: unknown[][], sheetName?: string): Buffer;
|
|
3
|
+
/**
|
|
4
|
+
* Rows of the first worksheet of an XLSX file, as text (numbers as written, booleans as `1`/`0`, formulas as their cached
|
|
5
|
+
* value). Stops after `maxRows` rows. Throws on a file that is not a valid workbook.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readXlsx(archive: Buffer, maxRows?: number, maxEntryBytes?: number): string[][];
|