@renoxium/sdk 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +177 -2
- package/dist/authz.d.ts +31 -0
- package/dist/authz.js +39 -0
- package/dist/crypto.d.ts +6 -0
- package/dist/crypto.js +26 -0
- package/dist/ctx.d.ts +486 -0
- package/dist/ctx.js +13 -0
- package/dist/db.d.ts +36 -0
- package/dist/db.js +3 -0
- package/dist/drizzle.d.ts +8 -0
- package/dist/drizzle.js +42 -0
- package/dist/errors.d.ts +261 -0
- package/dist/errors.js +232 -0
- package/dist/fakes.d.ts +35 -0
- package/dist/fakes.js +144 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +15 -0
- package/dist/jobs.d.ts +16 -0
- package/dist/jobs.js +72 -0
- package/dist/kysely.d.ts +11 -0
- package/dist/kysely.js +126 -0
- package/dist/modules.d.ts +72 -0
- package/dist/modules.js +30 -0
- package/dist/pages.d.ts +53 -0
- package/dist/pages.js +14 -0
- package/dist/realtime.d.ts +15 -0
- package/dist/realtime.js +40 -0
- package/dist/redirect.d.ts +19 -0
- package/dist/redirect.js +57 -0
- package/dist/test.d.ts +169 -0
- package/dist/test.js +381 -0
- package/dist/tools.d.ts +24 -0
- package/dist/tools.js +105 -0
- package/dist/totp.d.ts +40 -0
- package/dist/totp.js +111 -0
- package/dist/validate.d.ts +76 -0
- package/dist/validate.js +105 -0
- package/dist/webauthn.d.ts +63 -0
- package/dist/webauthn.js +283 -0
- package/package.json +83 -4
package/dist/totp.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type TotpAlgorithm = "SHA-1" | "SHA-256" | "SHA-512";
|
|
2
|
+
export interface TotpOptions {
|
|
3
|
+
/** Seconds per step (RFC 6238 X); 30. */
|
|
4
|
+
period?: number;
|
|
5
|
+
/** Code length; 6 (8 for the RFC test vectors). */
|
|
6
|
+
digits?: number;
|
|
7
|
+
/** HMAC hash; SHA-1, what every authenticator app implements. */
|
|
8
|
+
algorithm?: TotpAlgorithm;
|
|
9
|
+
}
|
|
10
|
+
export interface VerifyOptions extends TotpOptions {
|
|
11
|
+
/** Unix seconds; now. */
|
|
12
|
+
time?: number;
|
|
13
|
+
/** Steps accepted before and after the current one; 1 (±30 s of clock drift). */
|
|
14
|
+
window?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The step of the last code accepted for this secret: a code of that step
|
|
17
|
+
* or an earlier one is refused, so a code works once (RFC 6238 section 5.2).
|
|
18
|
+
*/
|
|
19
|
+
lastStep?: number | null;
|
|
20
|
+
}
|
|
21
|
+
/** Base32 (RFC 4648) without padding. */
|
|
22
|
+
export declare function base32Encode(bytes: Uint8Array): string;
|
|
23
|
+
/** Base32 to bytes; spaces, dashes and lowercase are accepted, padding ignored. */
|
|
24
|
+
export declare function base32Decode(text: string): Uint8Array;
|
|
25
|
+
/** A random secret: `bytes` (20, 160 bits as RFC 4226 recommends) in base32. */
|
|
26
|
+
export declare function generateTotpSecret(bytes?: number): string;
|
|
27
|
+
/** The `otpauth://totp/...` URI an authenticator app scans (as a QR code). */
|
|
28
|
+
export declare function totpUri(o: {
|
|
29
|
+
secret: string;
|
|
30
|
+
issuer: string;
|
|
31
|
+
account: string;
|
|
32
|
+
} & TotpOptions): string;
|
|
33
|
+
/** The code of `secret` (base32) at Unix second `time`, or of the raw key bytes. */
|
|
34
|
+
export declare function totp(secret: string | Uint8Array, time?: number, o?: TotpOptions): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Checks `code` against the steps around `time`. Resolves with the matched
|
|
37
|
+
* step (store it as the next `lastStep`), or null when no step matches or
|
|
38
|
+
* the matched step is not after `lastStep` (a replay).
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyTotp(secret: string | Uint8Array, code: string, o?: VerifyOptions): Promise<number | null>;
|
package/dist/totp.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// TOTP (RFC 6238, over HOTP RFC 4226) for the `2fa` module (#287), the
|
|
2
|
+
// `auth` module's step-up (#291) and control-plane MFA (#293); one
|
|
3
|
+
// implementation, on Web Crypto (crypto.subtle), so it runs in an isolate,
|
|
4
|
+
// in Bun and in a browser alike (#284).
|
|
5
|
+
//
|
|
6
|
+
// const secret = generateTotpSecret(); // base32, 160 bits
|
|
7
|
+
// const uri = totpUri({ secret, issuer: "Shop", account: "alice@example.com" });
|
|
8
|
+
// const step = await verifyTotp(secret, "123456", { lastStep }); // null, or the step to store
|
|
9
|
+
import { timingSafeEqual } from "./crypto.js";
|
|
10
|
+
/** Web Crypto takes ArrayBuffer-backed views; ours always are. */
|
|
11
|
+
const buf = (u) => u;
|
|
12
|
+
/** RFC 4648 base32 alphabet (no padding in the secret). */
|
|
13
|
+
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
14
|
+
/** Base32 (RFC 4648) without padding. */
|
|
15
|
+
export function base32Encode(bytes) {
|
|
16
|
+
let out = "";
|
|
17
|
+
let bits = 0;
|
|
18
|
+
let value = 0;
|
|
19
|
+
for (const b of bytes) {
|
|
20
|
+
value = (value << 8) | b;
|
|
21
|
+
bits += 8;
|
|
22
|
+
while (bits >= 5) {
|
|
23
|
+
out += B32[(value >>> (bits - 5)) & 31];
|
|
24
|
+
bits -= 5;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (bits > 0)
|
|
28
|
+
out += B32[(value << (5 - bits)) & 31];
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** Base32 to bytes; spaces, dashes and lowercase are accepted, padding ignored. */
|
|
32
|
+
export function base32Decode(text) {
|
|
33
|
+
const clean = text.toUpperCase().replace(/[\s=-]/gu, "");
|
|
34
|
+
const out = [];
|
|
35
|
+
let bits = 0;
|
|
36
|
+
let value = 0;
|
|
37
|
+
for (const c of clean) {
|
|
38
|
+
const v = B32.indexOf(c);
|
|
39
|
+
if (v < 0)
|
|
40
|
+
throw new TypeError(`base32: invalid character ${JSON.stringify(c)}`);
|
|
41
|
+
value = (value << 5) | v;
|
|
42
|
+
bits += 5;
|
|
43
|
+
if (bits >= 8) {
|
|
44
|
+
out.push((value >>> (bits - 8)) & 255);
|
|
45
|
+
bits -= 8;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return new Uint8Array(out);
|
|
49
|
+
}
|
|
50
|
+
/** A random secret: `bytes` (20, 160 bits as RFC 4226 recommends) in base32. */
|
|
51
|
+
export function generateTotpSecret(bytes = 20) {
|
|
52
|
+
return base32Encode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
53
|
+
}
|
|
54
|
+
/** The `otpauth://totp/...` URI an authenticator app scans (as a QR code). */
|
|
55
|
+
export function totpUri(o) {
|
|
56
|
+
const label = `${encodeURIComponent(o.issuer)}:${encodeURIComponent(o.account)}`;
|
|
57
|
+
const q = new URLSearchParams({ secret: o.secret, issuer: o.issuer });
|
|
58
|
+
if (o.algorithm && o.algorithm !== "SHA-1")
|
|
59
|
+
q.set("algorithm", o.algorithm.replace("-", ""));
|
|
60
|
+
if (o.digits && o.digits !== 6)
|
|
61
|
+
q.set("digits", String(o.digits));
|
|
62
|
+
if (o.period && o.period !== 30)
|
|
63
|
+
q.set("period", String(o.period));
|
|
64
|
+
return `otpauth://totp/${label}?${q.toString()}`;
|
|
65
|
+
}
|
|
66
|
+
/** HOTP (RFC 4226) of `counter` with the raw key. */
|
|
67
|
+
async function hotp(key, counter, digits, algorithm) {
|
|
68
|
+
const msg = new Uint8Array(8);
|
|
69
|
+
const view = new DataView(msg.buffer);
|
|
70
|
+
view.setUint32(0, Math.floor(counter / 2 ** 32));
|
|
71
|
+
view.setUint32(4, counter >>> 0);
|
|
72
|
+
const k = await crypto.subtle.importKey("raw", buf(key), { name: "HMAC", hash: algorithm }, false, ["sign"]);
|
|
73
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, buf(msg)));
|
|
74
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
75
|
+
const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
|
|
76
|
+
return String(bin % 10 ** digits).padStart(digits, "0");
|
|
77
|
+
}
|
|
78
|
+
/** The code of `secret` (base32) at Unix second `time`, or of the raw key bytes. */
|
|
79
|
+
export async function totp(secret, time = Date.now() / 1000, o = {}) {
|
|
80
|
+
const key = typeof secret === "string" ? base32Decode(secret) : secret;
|
|
81
|
+
const step = Math.floor(time / (o.period ?? 30));
|
|
82
|
+
return hotp(key, step, o.digits ?? 6, o.algorithm ?? "SHA-1");
|
|
83
|
+
}
|
|
84
|
+
/** Constant-time comparison of two digit strings (crypto.ts). */
|
|
85
|
+
const same = (a, b) => timingSafeEqual(a, b);
|
|
86
|
+
/**
|
|
87
|
+
* Checks `code` against the steps around `time`. Resolves with the matched
|
|
88
|
+
* step (store it as the next `lastStep`), or null when no step matches or
|
|
89
|
+
* the matched step is not after `lastStep` (a replay).
|
|
90
|
+
*/
|
|
91
|
+
export async function verifyTotp(secret, code, o = {}) {
|
|
92
|
+
const digits = o.digits ?? 6;
|
|
93
|
+
if (typeof code !== "string" || !new RegExp(`^[0-9]{${digits}}$`, "u").test(code.trim()))
|
|
94
|
+
return null;
|
|
95
|
+
const key = typeof secret === "string" ? base32Decode(secret) : secret;
|
|
96
|
+
const period = o.period ?? 30;
|
|
97
|
+
const now = Math.floor((o.time ?? Date.now() / 1000) / period);
|
|
98
|
+
const window = Math.max(0, Math.min(o.window ?? 1, 10));
|
|
99
|
+
let found = null;
|
|
100
|
+
// Every candidate is computed, so the time taken does not say which matched.
|
|
101
|
+
for (let s = now - window; s <= now + window; s++) {
|
|
102
|
+
const ok = same(await hotp(key, s, digits, o.algorithm ?? "SHA-1"), code.trim());
|
|
103
|
+
if (ok && found === null)
|
|
104
|
+
found = s;
|
|
105
|
+
}
|
|
106
|
+
if (found === null)
|
|
107
|
+
return null;
|
|
108
|
+
if (o.lastStep !== undefined && o.lastStep !== null && found <= o.lastStep)
|
|
109
|
+
return null;
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** The Standard Schema v1 interface (`@standard-schema/spec` 1.0.0). */
|
|
2
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
3
|
+
readonly "~standard": StandardSchemaV1.Props<Input, Output>;
|
|
4
|
+
}
|
|
5
|
+
export declare namespace StandardSchemaV1 {
|
|
6
|
+
interface Props<Input = unknown, Output = Input> {
|
|
7
|
+
readonly version: 1;
|
|
8
|
+
readonly vendor: string;
|
|
9
|
+
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
|
10
|
+
readonly types?: Types<Input, Output> | undefined;
|
|
11
|
+
}
|
|
12
|
+
type Result<Output> = SuccessResult<Output> | FailureResult;
|
|
13
|
+
interface SuccessResult<Output> {
|
|
14
|
+
readonly value: Output;
|
|
15
|
+
readonly issues?: undefined;
|
|
16
|
+
}
|
|
17
|
+
interface FailureResult {
|
|
18
|
+
readonly issues: ReadonlyArray<Issue>;
|
|
19
|
+
}
|
|
20
|
+
interface Issue {
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
|
23
|
+
}
|
|
24
|
+
interface PathSegment {
|
|
25
|
+
readonly key: PropertyKey;
|
|
26
|
+
}
|
|
27
|
+
interface Types<Input = unknown, Output = Input> {
|
|
28
|
+
readonly input: Input;
|
|
29
|
+
readonly output: Output;
|
|
30
|
+
}
|
|
31
|
+
type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
|
|
32
|
+
type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
|
|
33
|
+
}
|
|
34
|
+
/** One validation failure: the schema's message and the path to the value, `[]` for the root. */
|
|
35
|
+
export interface ValidationIssue {
|
|
36
|
+
readonly message: string;
|
|
37
|
+
readonly path: readonly (string | number)[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Thrown by `validate()` when the input does not match the schema, or when a
|
|
41
|
+
* `Request` body cannot be read as JSON or a form. Not a `RenoxError`: no
|
|
42
|
+
* platform code, it is the tenant's own 400. `toResponse()` builds one.
|
|
43
|
+
*/
|
|
44
|
+
export declare class ValidationError extends Error {
|
|
45
|
+
readonly name = "ValidationError";
|
|
46
|
+
readonly issues: readonly ValidationIssue[];
|
|
47
|
+
constructor(issues: readonly ValidationIssue[]);
|
|
48
|
+
/** `400` with `{ "issues": [{ "message", "path" }] }`. */
|
|
49
|
+
toResponse(init?: ResponseInit): Response;
|
|
50
|
+
}
|
|
51
|
+
/** What `validate()` reads: a request body, a parsed form, query parameters, or a plain value. */
|
|
52
|
+
export type ValidateInput = Request | FormData | URLSearchParams | unknown;
|
|
53
|
+
/**
|
|
54
|
+
* A form or query string as an object: a key seen once maps to its value
|
|
55
|
+
* (`string`, or `File` for an upload), a repeated key to the array of its
|
|
56
|
+
* values in order.
|
|
57
|
+
*/
|
|
58
|
+
export declare function formToObject(form: FormData | URLSearchParams): Record<string, FormDataEntryValue | FormDataEntryValue[]>;
|
|
59
|
+
/**
|
|
60
|
+
* Validates `input` against a Standard Schema and returns the schema's
|
|
61
|
+
* output (with its transforms and defaults applied).
|
|
62
|
+
*
|
|
63
|
+
* - `Request`: the body, read once as JSON (`application/json`, `+json`) or
|
|
64
|
+
* as a form (urlencoded, multipart); any other content type fails.
|
|
65
|
+
* - `FormData`, `URLSearchParams` (`ctx.form` in an action,
|
|
66
|
+
* `ctx.url.searchParams`): converted with `formToObject`.
|
|
67
|
+
* - Anything else is validated as is.
|
|
68
|
+
*
|
|
69
|
+
* Throws `ValidationError` on failure.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* const order = await validate(req, OrderSchema); // POST handler
|
|
73
|
+
* const input = await validate(form, AddToCartSchema); // action
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export declare function validate<S extends StandardSchemaV1>(input: ValidateInput, schema: S): Promise<StandardSchemaV1.InferOutput<S>>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `validate(input, Schema)`: input validation before the handler body
|
|
2
|
+
// (docs/04-isolation-security.md, "Input validation"). Any schema library
|
|
3
|
+
// that implements Standard Schema v1 (https://standardschema.dev) works:
|
|
4
|
+
// zod (3.24+), valibot (1.0+), arktype, effect. The SDK depends on none of
|
|
5
|
+
// them: the interface below is the spec's, copied as the spec recommends.
|
|
6
|
+
/**
|
|
7
|
+
* Thrown by `validate()` when the input does not match the schema, or when a
|
|
8
|
+
* `Request` body cannot be read as JSON or a form. Not a `RenoxError`: no
|
|
9
|
+
* platform code, it is the tenant's own 400. `toResponse()` builds one.
|
|
10
|
+
*/
|
|
11
|
+
export class ValidationError extends Error {
|
|
12
|
+
name = "ValidationError";
|
|
13
|
+
issues;
|
|
14
|
+
constructor(issues) {
|
|
15
|
+
const first = issues[0];
|
|
16
|
+
super(first
|
|
17
|
+
? `validation failed: ${first.path.length ? `${first.path.join(".")}: ` : ""}${first.message}${issues.length > 1 ? ` (and ${issues.length - 1} more)` : ""}`
|
|
18
|
+
: "validation failed");
|
|
19
|
+
this.issues = Object.freeze([...issues]);
|
|
20
|
+
}
|
|
21
|
+
/** `400` with `{ "issues": [{ "message", "path" }] }`. */
|
|
22
|
+
toResponse(init) {
|
|
23
|
+
return Response.json({ issues: this.issues }, { status: 400, ...init });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A form or query string as an object: a key seen once maps to its value
|
|
28
|
+
* (`string`, or `File` for an upload), a repeated key to the array of its
|
|
29
|
+
* values in order.
|
|
30
|
+
*/
|
|
31
|
+
export function formToObject(form) {
|
|
32
|
+
const out = Object.create(null);
|
|
33
|
+
for (const [key, value] of form) {
|
|
34
|
+
const prev = out[key];
|
|
35
|
+
if (prev === undefined)
|
|
36
|
+
out[key] = value;
|
|
37
|
+
else if (Array.isArray(prev))
|
|
38
|
+
prev.push(value);
|
|
39
|
+
else
|
|
40
|
+
out[key] = [prev, value];
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
const fail = (message) => new ValidationError([{ message, path: [] }]);
|
|
45
|
+
/** The value a `Request` body stands for: JSON, or a form as `formToObject` builds it. */
|
|
46
|
+
async function readBody(req) {
|
|
47
|
+
const type = (req.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
48
|
+
if (type === "application/json" || type.endsWith("+json")) {
|
|
49
|
+
const text = await req.text();
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(text);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw fail("request body is not valid JSON");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (type === "application/x-www-form-urlencoded" || type === "multipart/form-data") {
|
|
58
|
+
let form;
|
|
59
|
+
try {
|
|
60
|
+
form = await req.formData();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw fail("request body is not a valid form");
|
|
64
|
+
}
|
|
65
|
+
return formToObject(form);
|
|
66
|
+
}
|
|
67
|
+
throw fail(`unsupported content type ${type ? JSON.stringify(type) : "(none)"}: expected JSON or a form`);
|
|
68
|
+
}
|
|
69
|
+
function pathOf(path) {
|
|
70
|
+
return (path ?? []).map((seg) => {
|
|
71
|
+
const key = typeof seg === "object" && seg !== null ? seg.key : seg;
|
|
72
|
+
return typeof key === "number" ? key : String(key);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Validates `input` against a Standard Schema and returns the schema's
|
|
77
|
+
* output (with its transforms and defaults applied).
|
|
78
|
+
*
|
|
79
|
+
* - `Request`: the body, read once as JSON (`application/json`, `+json`) or
|
|
80
|
+
* as a form (urlencoded, multipart); any other content type fails.
|
|
81
|
+
* - `FormData`, `URLSearchParams` (`ctx.form` in an action,
|
|
82
|
+
* `ctx.url.searchParams`): converted with `formToObject`.
|
|
83
|
+
* - Anything else is validated as is.
|
|
84
|
+
*
|
|
85
|
+
* Throws `ValidationError` on failure.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* const order = await validate(req, OrderSchema); // POST handler
|
|
89
|
+
* const input = await validate(form, AddToCartSchema); // action
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export async function validate(input, schema) {
|
|
93
|
+
const std = schema?.["~standard"];
|
|
94
|
+
if (!std || std.version !== 1 || typeof std.validate !== "function")
|
|
95
|
+
throw new TypeError("validate(): the schema does not implement Standard Schema v1 (zod 3.24+, valibot 1.0+)");
|
|
96
|
+
let value = input;
|
|
97
|
+
if (input instanceof Request)
|
|
98
|
+
value = await readBody(input);
|
|
99
|
+
else if (input instanceof FormData || input instanceof URLSearchParams)
|
|
100
|
+
value = formToObject(input);
|
|
101
|
+
const result = await std.validate(value);
|
|
102
|
+
if (result.issues)
|
|
103
|
+
throw new ValidationError(result.issues.map((i) => ({ message: i.message, path: pathOf(i.path) })));
|
|
104
|
+
return result.value;
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Why a registration or an assertion was refused. */
|
|
2
|
+
export declare class WebAuthnError extends Error {
|
|
3
|
+
readonly reason: "client_data" | "type" | "challenge" | "origin" | "rp_id" | "user_presence" | "user_verification" | "credential" | "attestation" | "algorithm" | "signature" | "counter";
|
|
4
|
+
readonly name = "WebAuthnError";
|
|
5
|
+
constructor(reason: "client_data" | "type" | "challenge" | "origin" | "rp_id" | "user_presence" | "user_verification" | "credential" | "attestation" | "algorithm" | "signature" | "counter", message: string);
|
|
6
|
+
}
|
|
7
|
+
export type Binary = string | ArrayBuffer | Uint8Array;
|
|
8
|
+
/** base64url without padding. */
|
|
9
|
+
export declare function base64url(bytes: Uint8Array): string;
|
|
10
|
+
/** base64url (or base64) to bytes. */
|
|
11
|
+
export declare function fromBase64url(text: string): Uint8Array;
|
|
12
|
+
type Cbor = number | bigint | string | Uint8Array | boolean | null | undefined | Cbor[] | Map<Cbor, Cbor>;
|
|
13
|
+
/** Decodes one CBOR item at `at`; returns it and the offset after it. */
|
|
14
|
+
export declare function decodeCbor(b: Uint8Array, at?: number): [Cbor, number];
|
|
15
|
+
export declare const COSE_ES256 = -7;
|
|
16
|
+
export declare const COSE_RS256 = -257;
|
|
17
|
+
interface Expected {
|
|
18
|
+
/** The challenge the server issued, base64url. */
|
|
19
|
+
expectedChallenge: string;
|
|
20
|
+
/** The page origin(s) the ceremony may come from (`https://shop.example`). */
|
|
21
|
+
expectedOrigin: string | readonly string[];
|
|
22
|
+
/** The relying party id (the host, or a registrable suffix of it). */
|
|
23
|
+
expectedRpId: string;
|
|
24
|
+
/** Require the UV flag (a PIN or biometric, not only presence). */
|
|
25
|
+
requireUserVerification?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** What to store for a new credential. */
|
|
28
|
+
export interface RegisteredCredential {
|
|
29
|
+
/** base64url; the `id` the browser sends back in assertions. */
|
|
30
|
+
credentialId: string;
|
|
31
|
+
/** The COSE public key, base64url. */
|
|
32
|
+
publicKey: string;
|
|
33
|
+
/** -7 (ES256) or -257 (RS256). */
|
|
34
|
+
algorithm: number;
|
|
35
|
+
counter: number;
|
|
36
|
+
userVerified: boolean;
|
|
37
|
+
attestationFormat: string;
|
|
38
|
+
}
|
|
39
|
+
/** Verifies `navigator.credentials.create()`'s response. */
|
|
40
|
+
export declare function verifyRegistration(o: Expected & {
|
|
41
|
+
response: {
|
|
42
|
+
clientDataJSON: Binary;
|
|
43
|
+
attestationObject: Binary;
|
|
44
|
+
};
|
|
45
|
+
}): Promise<RegisteredCredential>;
|
|
46
|
+
/** Verifies `navigator.credentials.get()`'s response against a stored credential; resolves with the counter to store. */
|
|
47
|
+
export declare function verifyAssertion(o: Expected & {
|
|
48
|
+
response: {
|
|
49
|
+
clientDataJSON: Binary;
|
|
50
|
+
authenticatorData: Binary;
|
|
51
|
+
signature: Binary;
|
|
52
|
+
};
|
|
53
|
+
credential: {
|
|
54
|
+
publicKey: string;
|
|
55
|
+
counter: number;
|
|
56
|
+
};
|
|
57
|
+
}): Promise<{
|
|
58
|
+
counter: number;
|
|
59
|
+
userVerified: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
/** A random challenge (32 bytes, base64url) for a ceremony; store it with the session. */
|
|
62
|
+
export declare function webauthnChallenge(): string;
|
|
63
|
+
export {};
|
package/dist/webauthn.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// WebAuthn verification (W3C Web Authentication Level 2) for passkeys: the
|
|
2
|
+
// `2fa` module (#287), the `auth` passkey sign-in (#291) and control-plane
|
|
3
|
+
// MFA (#293). One implementation on Web Crypto (#284).
|
|
4
|
+
//
|
|
5
|
+
// Registration (`navigator.credentials.create`): the client data's type,
|
|
6
|
+
// challenge and origin, the RP id hash, the user-present (and, when asked,
|
|
7
|
+
// user-verified) flags, the attested credential, and the attestation
|
|
8
|
+
// statement of the `none` and `packed` self-attestation formats (formats
|
|
9
|
+
// with a certificate chain are refused: there is no trust store here).
|
|
10
|
+
// Assertion (`navigator.credentials.get`): the same checks, the signature
|
|
11
|
+
// over authenticatorData || SHA-256(clientDataJSON) with the stored COSE
|
|
12
|
+
// key (ES256 or RS256), and the signature counter, which must grow unless
|
|
13
|
+
// both are 0 (a counter that does not grow means a cloned authenticator).
|
|
14
|
+
//
|
|
15
|
+
// Binary values cross as base64url strings, as the browser JSON
|
|
16
|
+
// serialisation (PublicKeyCredential.toJSON) gives them.
|
|
17
|
+
import { timingSafeEqual } from "./crypto.js";
|
|
18
|
+
const enc = new TextEncoder();
|
|
19
|
+
/** Web Crypto takes ArrayBuffer-backed views; ours always are. */
|
|
20
|
+
const buf = (u) => u;
|
|
21
|
+
const dec = new TextDecoder();
|
|
22
|
+
/** Why a registration or an assertion was refused. */
|
|
23
|
+
export class WebAuthnError extends Error {
|
|
24
|
+
reason;
|
|
25
|
+
name = "WebAuthnError";
|
|
26
|
+
constructor(reason, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.reason = reason;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** base64url without padding. */
|
|
32
|
+
export function base64url(bytes) {
|
|
33
|
+
let s = "";
|
|
34
|
+
for (const b of bytes)
|
|
35
|
+
s += String.fromCharCode(b);
|
|
36
|
+
return btoa(s).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "");
|
|
37
|
+
}
|
|
38
|
+
/** base64url (or base64) to bytes. */
|
|
39
|
+
export function fromBase64url(text) {
|
|
40
|
+
const b64 = text.replace(/-/gu, "+").replace(/_/gu, "/");
|
|
41
|
+
const bin = atob(b64 + "===".slice((b64.length + 3) % 4));
|
|
42
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
43
|
+
}
|
|
44
|
+
const bytesOf = (v) => (typeof v === "string" ? fromBase64url(v) : v instanceof Uint8Array ? v : new Uint8Array(v));
|
|
45
|
+
async function sha256(b) {
|
|
46
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", buf(b)));
|
|
47
|
+
}
|
|
48
|
+
/** Constant time (crypto.ts): challenges and RP id hashes are secrets of the ceremony. */
|
|
49
|
+
const equal = (a, b) => timingSafeEqual(a, b);
|
|
50
|
+
/** Decodes one CBOR item at `at`; returns it and the offset after it. */
|
|
51
|
+
export function decodeCbor(b, at = 0) {
|
|
52
|
+
const head = b[at];
|
|
53
|
+
if (head === undefined)
|
|
54
|
+
throw new WebAuthnError("credential", "CBOR: truncated");
|
|
55
|
+
const major = head >> 5;
|
|
56
|
+
const info = head & 31;
|
|
57
|
+
let i = at + 1;
|
|
58
|
+
let n;
|
|
59
|
+
if (info < 24)
|
|
60
|
+
n = info;
|
|
61
|
+
else if (info === 24)
|
|
62
|
+
n = b[i++];
|
|
63
|
+
else if (info === 25) {
|
|
64
|
+
n = (b[i] << 8) | b[i + 1];
|
|
65
|
+
i += 2;
|
|
66
|
+
}
|
|
67
|
+
else if (info === 26) {
|
|
68
|
+
n = new DataView(b.buffer, b.byteOffset + i, 4).getUint32(0);
|
|
69
|
+
i += 4;
|
|
70
|
+
}
|
|
71
|
+
else if (info === 27) {
|
|
72
|
+
const big = new DataView(b.buffer, b.byteOffset + i, 8).getBigUint64(0);
|
|
73
|
+
n = Number(big);
|
|
74
|
+
i += 8;
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
throw new WebAuthnError("credential", "CBOR: indefinite lengths are not supported");
|
|
78
|
+
switch (major) {
|
|
79
|
+
case 0:
|
|
80
|
+
return [n, i];
|
|
81
|
+
case 1:
|
|
82
|
+
return [-1 - n, i];
|
|
83
|
+
case 2:
|
|
84
|
+
if (i + n > b.length)
|
|
85
|
+
throw new WebAuthnError("credential", "CBOR: truncated byte string");
|
|
86
|
+
return [b.slice(i, i + n), i + n];
|
|
87
|
+
case 3:
|
|
88
|
+
if (i + n > b.length)
|
|
89
|
+
throw new WebAuthnError("credential", "CBOR: truncated text");
|
|
90
|
+
return [dec.decode(b.subarray(i, i + n)), i + n];
|
|
91
|
+
case 4: {
|
|
92
|
+
const arr = [];
|
|
93
|
+
for (let k = 0; k < n; k++) {
|
|
94
|
+
const [v, next] = decodeCbor(b, i);
|
|
95
|
+
arr.push(v);
|
|
96
|
+
i = next;
|
|
97
|
+
}
|
|
98
|
+
return [arr, i];
|
|
99
|
+
}
|
|
100
|
+
case 5: {
|
|
101
|
+
const map = new Map();
|
|
102
|
+
for (let k = 0; k < n; k++) {
|
|
103
|
+
const [key, a] = decodeCbor(b, i);
|
|
104
|
+
const [v, next] = decodeCbor(b, a);
|
|
105
|
+
map.set(key, v);
|
|
106
|
+
i = next;
|
|
107
|
+
}
|
|
108
|
+
return [map, i];
|
|
109
|
+
}
|
|
110
|
+
case 7:
|
|
111
|
+
if (info === 20)
|
|
112
|
+
return [false, i];
|
|
113
|
+
if (info === 21)
|
|
114
|
+
return [true, i];
|
|
115
|
+
if (info === 22)
|
|
116
|
+
return [null, i];
|
|
117
|
+
return [undefined, i];
|
|
118
|
+
default:
|
|
119
|
+
throw new WebAuthnError("credential", `CBOR: major type ${major} is not supported`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// --- COSE keys (RFC 9053): ES256 and RS256 ------------------------------------
|
|
123
|
+
export const COSE_ES256 = -7;
|
|
124
|
+
export const COSE_RS256 = -257;
|
|
125
|
+
async function importCose(cose) {
|
|
126
|
+
const [m] = decodeCbor(cose);
|
|
127
|
+
if (!(m instanceof Map))
|
|
128
|
+
throw new WebAuthnError("credential", "the credential public key is not a COSE map");
|
|
129
|
+
const kty = m.get(1);
|
|
130
|
+
const alg = m.get(3);
|
|
131
|
+
if (kty === 2 && alg === COSE_ES256 && m.get(-1) === 1) {
|
|
132
|
+
const x = m.get(-2);
|
|
133
|
+
const y = m.get(-3);
|
|
134
|
+
if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32)
|
|
135
|
+
throw new WebAuthnError("credential", "ES256 key: bad coordinates");
|
|
136
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "EC", crv: "P-256", x: base64url(x), y: base64url(y), ext: true }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
137
|
+
return { key, alg };
|
|
138
|
+
}
|
|
139
|
+
if (kty === 3 && alg === COSE_RS256) {
|
|
140
|
+
const n = m.get(-1);
|
|
141
|
+
const e = m.get(-2);
|
|
142
|
+
if (!(n instanceof Uint8Array) || !(e instanceof Uint8Array) || n.length < 256)
|
|
143
|
+
throw new WebAuthnError("credential", "RS256 key: bad modulus or exponent");
|
|
144
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: base64url(n), e: base64url(e), alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
|
|
145
|
+
return { key, alg };
|
|
146
|
+
}
|
|
147
|
+
throw new WebAuthnError("algorithm", `unsupported COSE key (kty ${String(kty)}, alg ${String(alg)}); ES256 and RS256 are accepted`);
|
|
148
|
+
}
|
|
149
|
+
/** An ECDSA DER signature (WebAuthn's) to the raw r || s Web Crypto takes. */
|
|
150
|
+
function derToRaw(der, size = 32) {
|
|
151
|
+
if (der[0] !== 0x30)
|
|
152
|
+
throw new WebAuthnError("signature", "ES256 signature is not DER");
|
|
153
|
+
let i = 2;
|
|
154
|
+
const int = () => {
|
|
155
|
+
if (der[i] !== 0x02)
|
|
156
|
+
throw new WebAuthnError("signature", "ES256 signature is not DER");
|
|
157
|
+
const len = der[i + 1];
|
|
158
|
+
let v = der.subarray(i + 2, i + 2 + len);
|
|
159
|
+
i += 2 + len;
|
|
160
|
+
while (v.length > size && v[0] === 0)
|
|
161
|
+
v = v.subarray(1);
|
|
162
|
+
if (v.length > size)
|
|
163
|
+
throw new WebAuthnError("signature", "ES256 signature integer too long");
|
|
164
|
+
const out = new Uint8Array(size);
|
|
165
|
+
out.set(v, size - v.length);
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
const r = int();
|
|
169
|
+
const s = int();
|
|
170
|
+
const raw = new Uint8Array(size * 2);
|
|
171
|
+
raw.set(r);
|
|
172
|
+
raw.set(s, size);
|
|
173
|
+
return raw;
|
|
174
|
+
}
|
|
175
|
+
async function verifySignature(cose, signature, data) {
|
|
176
|
+
const { key, alg } = await importCose(cose);
|
|
177
|
+
if (alg === COSE_ES256)
|
|
178
|
+
return crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, buf(derToRaw(signature)), buf(data));
|
|
179
|
+
return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, buf(signature), buf(data));
|
|
180
|
+
}
|
|
181
|
+
function parseAuthData(b) {
|
|
182
|
+
if (b.length < 37)
|
|
183
|
+
throw new WebAuthnError("credential", "authenticator data is too short");
|
|
184
|
+
const flags = b[32];
|
|
185
|
+
const out = {
|
|
186
|
+
rpIdHash: b.slice(0, 32),
|
|
187
|
+
userPresent: (flags & 0x01) !== 0,
|
|
188
|
+
userVerified: (flags & 0x04) !== 0,
|
|
189
|
+
counter: new DataView(b.buffer, b.byteOffset + 33, 4).getUint32(0),
|
|
190
|
+
};
|
|
191
|
+
if (flags & 0x40) {
|
|
192
|
+
// Attested credential data: aaguid (16), credentialIdLength (2), id, COSE key.
|
|
193
|
+
const len = (b[53] << 8) | b[54];
|
|
194
|
+
out.credentialId = b.slice(55, 55 + len);
|
|
195
|
+
const [, end] = decodeCbor(b, 55 + len);
|
|
196
|
+
out.publicKey = b.slice(55 + len, end);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
async function checkClientData(raw, type, o) {
|
|
201
|
+
let c;
|
|
202
|
+
try {
|
|
203
|
+
c = JSON.parse(dec.decode(raw));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new WebAuthnError("client_data", "clientDataJSON is not JSON");
|
|
207
|
+
}
|
|
208
|
+
if (c.type !== type)
|
|
209
|
+
throw new WebAuthnError("type", `clientData.type is ${String(c.type)}, expected ${type}`);
|
|
210
|
+
if (typeof c.challenge !== "string" || !equal(fromBase64url(c.challenge), fromBase64url(o.expectedChallenge))) {
|
|
211
|
+
throw new WebAuthnError("challenge", "the challenge does not match");
|
|
212
|
+
}
|
|
213
|
+
const origins = typeof o.expectedOrigin === "string" ? [o.expectedOrigin] : o.expectedOrigin;
|
|
214
|
+
if (typeof c.origin !== "string" || !origins.includes(c.origin))
|
|
215
|
+
throw new WebAuthnError("origin", `origin ${String(c.origin)} is not expected`);
|
|
216
|
+
}
|
|
217
|
+
async function checkAuthData(a, o) {
|
|
218
|
+
if (!equal(a.rpIdHash, await sha256(enc.encode(o.expectedRpId))))
|
|
219
|
+
throw new WebAuthnError("rp_id", "the RP id hash does not match");
|
|
220
|
+
if (!a.userPresent)
|
|
221
|
+
throw new WebAuthnError("user_presence", "the user was not present");
|
|
222
|
+
if (o.requireUserVerification && !a.userVerified)
|
|
223
|
+
throw new WebAuthnError("user_verification", "the user was not verified");
|
|
224
|
+
}
|
|
225
|
+
/** Verifies `navigator.credentials.create()`'s response. */
|
|
226
|
+
export async function verifyRegistration(o) {
|
|
227
|
+
const clientData = bytesOf(o.response.clientDataJSON);
|
|
228
|
+
await checkClientData(clientData, "webauthn.create", o);
|
|
229
|
+
const [att] = decodeCbor(bytesOf(o.response.attestationObject));
|
|
230
|
+
if (!(att instanceof Map))
|
|
231
|
+
throw new WebAuthnError("attestation", "attestationObject is not a CBOR map");
|
|
232
|
+
const fmt = att.get("fmt");
|
|
233
|
+
const authBytes = att.get("authData");
|
|
234
|
+
const stmt = att.get("attStmt");
|
|
235
|
+
if (typeof fmt !== "string" || !(authBytes instanceof Uint8Array) || !(stmt instanceof Map))
|
|
236
|
+
throw new WebAuthnError("attestation", "attestationObject lacks fmt, authData or attStmt");
|
|
237
|
+
const auth = parseAuthData(authBytes);
|
|
238
|
+
await checkAuthData(auth, o);
|
|
239
|
+
if (!auth.credentialId || !auth.publicKey)
|
|
240
|
+
throw new WebAuthnError("credential", "no attested credential data");
|
|
241
|
+
const { alg } = await importCose(auth.publicKey);
|
|
242
|
+
if (fmt === "packed") {
|
|
243
|
+
if (stmt.has("x5c"))
|
|
244
|
+
throw new WebAuthnError("attestation", "packed attestation with a certificate chain is not supported");
|
|
245
|
+
const sig = stmt.get("sig");
|
|
246
|
+
if (stmt.get("alg") !== alg || !(sig instanceof Uint8Array))
|
|
247
|
+
throw new WebAuthnError("attestation", "packed self attestation: alg or sig");
|
|
248
|
+
const data = new Uint8Array([...authBytes, ...(await sha256(clientData))]);
|
|
249
|
+
if (!(await verifySignature(auth.publicKey, sig, data)))
|
|
250
|
+
throw new WebAuthnError("signature", "the attestation signature does not verify");
|
|
251
|
+
}
|
|
252
|
+
else if (fmt !== "none") {
|
|
253
|
+
throw new WebAuthnError("attestation", `attestation format ${fmt} is not supported (none and packed self attestation are)`);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
credentialId: base64url(auth.credentialId),
|
|
257
|
+
publicKey: base64url(auth.publicKey),
|
|
258
|
+
algorithm: alg,
|
|
259
|
+
counter: auth.counter,
|
|
260
|
+
userVerified: auth.userVerified,
|
|
261
|
+
attestationFormat: fmt,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** Verifies `navigator.credentials.get()`'s response against a stored credential; resolves with the counter to store. */
|
|
265
|
+
export async function verifyAssertion(o) {
|
|
266
|
+
const clientData = bytesOf(o.response.clientDataJSON);
|
|
267
|
+
await checkClientData(clientData, "webauthn.get", o);
|
|
268
|
+
const authBytes = bytesOf(o.response.authenticatorData);
|
|
269
|
+
const auth = parseAuthData(authBytes);
|
|
270
|
+
await checkAuthData(auth, o);
|
|
271
|
+
const data = new Uint8Array([...authBytes, ...(await sha256(clientData))]);
|
|
272
|
+
if (!(await verifySignature(fromBase64url(o.credential.publicKey), bytesOf(o.response.signature), data))) {
|
|
273
|
+
throw new WebAuthnError("signature", "the assertion signature does not verify");
|
|
274
|
+
}
|
|
275
|
+
if ((auth.counter !== 0 || o.credential.counter !== 0) && auth.counter <= o.credential.counter) {
|
|
276
|
+
throw new WebAuthnError("counter", `the signature counter did not grow (${auth.counter} after ${o.credential.counter}): a replay or a cloned authenticator`);
|
|
277
|
+
}
|
|
278
|
+
return { counter: auth.counter, userVerified: auth.userVerified };
|
|
279
|
+
}
|
|
280
|
+
/** A random challenge (32 bytes, base64url) for a ceremony; store it with the session. */
|
|
281
|
+
export function webauthnChallenge() {
|
|
282
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
283
|
+
}
|