@forgezero/access 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/authenticator.d.ts +118 -0
- package/dist/authenticator.js +186 -0
- package/dist/ceremony-modes.d.ts +71 -0
- package/dist/ceremony-modes.js +55 -0
- package/dist/client.d.ts +76 -0
- package/dist/client.js +107 -0
- package/dist/conditions.d.ts +280 -0
- package/dist/conditions.js +491 -0
- package/dist/effects.d.ts +168 -0
- package/dist/effects.js +505 -0
- package/dist/elysia.d.ts +78 -0
- package/dist/elysia.js +575 -0
- package/dist/fetch.d.ts +41 -0
- package/dist/fetch.js +587 -0
- package/dist/index.d.ts +379 -0
- package/dist/index.js +298 -0
- package/dist/pipeline.d.ts +92 -0
- package/dist/pipeline.js +528 -0
- package/dist/rate-limit.d.ts +54 -0
- package/dist/rate-limit.js +89 -0
- package/dist/security.d.ts +102 -0
- package/dist/security.js +141 -0
- package/dist/testing.d.ts +63 -0
- package/dist/testing.js +357 -0
- package/package.json +93 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ForgeZero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @forgezero/access
|
|
2
|
+
|
|
3
|
+
**Who may call what, decided from one table instead of scattered `if` statements.**
|
|
4
|
+
|
|
5
|
+
You declare every route once — the session it needs, the roles that reach it, the
|
|
6
|
+
factors it demands — and the same declaration authorises the request, renders the
|
|
7
|
+
navigation and fails the build when the two disagree.
|
|
8
|
+
|
|
9
|
+
Zero runtime dependencies. Bun, Node 18+, Deno, Cloudflare Workers, browsers —
|
|
10
|
+
anywhere `fetch` and Web Crypto exist.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bun add @forgezero/access
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## The idea in twelve lines
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { defineRoutes, authorise } from '@forgezero/access';
|
|
20
|
+
|
|
21
|
+
const ACCESS = defineRoutes({
|
|
22
|
+
'api/invoices': { group: 'session', factors: ['passkey'] },
|
|
23
|
+
'api/invoices/refund': { group: 'admin', factors: ['passkey'], actionFactors: ['passkey'] }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// A ROLE IS A SET OF ROUTE KEYS. There is no second permission vocabulary to
|
|
27
|
+
// keep in sync, and a role naming a route that no longer exists is a type error.
|
|
28
|
+
const FINANCE = ['api/invoices', 'api/invoices/refund'] as const;
|
|
29
|
+
|
|
30
|
+
authorise(ACCESS, 'api/invoices/refund', session); // → allowed | Refusal
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What it gives you that a middleware does not
|
|
34
|
+
|
|
35
|
+
**Two layers, and the second never trusts the first.** A session factor is proved
|
|
36
|
+
once and persists — it answers *who is this*. An action factor is proved per call
|
|
37
|
+
and never persists — it answers *is this a human, now, for this record*. A
|
|
38
|
+
five-minute "recently verified" timestamp looks equivalent and is not: a
|
|
39
|
+
timestamp **is** a persisted factor, so a left-open laptop replays the privileged
|
|
40
|
+
action. `fulfilledActionFactors()` returns `[]` — always, by design.
|
|
41
|
+
|
|
42
|
+
**A refusal that says what happened.** `401` no session · `403` role lacks the
|
|
43
|
+
route · `404` route exists but not in this stage · `409` a condition refused ·
|
|
44
|
+
`423` locked, a human must act · `428` a factor is missing, and the headers name
|
|
45
|
+
which. Every one is distinct because "403" for all of them is how a support
|
|
46
|
+
queue fills up.
|
|
47
|
+
|
|
48
|
+
**Twelve guards already written.** `requireBalance`, `requireApproval`,
|
|
49
|
+
`requireQuota`, `requireFreshness` and the rest — the conditions every project
|
|
50
|
+
writes by hand, each already carrying the right status code.
|
|
51
|
+
|
|
52
|
+
**Testable with no server.** `@forgezero/access/testing` builds sessions and
|
|
53
|
+
asserts the decision directly, so the truth table is a unit test rather than an
|
|
54
|
+
integration suite.
|
|
55
|
+
|
|
56
|
+
## Subpaths
|
|
57
|
+
|
|
58
|
+
| import | what it is |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `@forgezero/access` | the matrix, roles, `authorise`, `Refusal` |
|
|
61
|
+
| `/conditions` | the twelve guards, each with its own status |
|
|
62
|
+
| `/effects` | audit, emit, meter, invalidate, notify — after the decision |
|
|
63
|
+
| `/security` | constant-time compare, CSPRNG tokens, HMAC, HKDF, AES-GCM sealing |
|
|
64
|
+
| `/rate-limit` | counters over a window — memory, Redis, or a Durable Object |
|
|
65
|
+
| `/elysia` · `/fetch` | adapters |
|
|
66
|
+
| `/client` | the browser half, including the 428 replay |
|
|
67
|
+
| `/testing` | decide without a server |
|
|
68
|
+
| `/pipeline` · `/authenticator` | the resolver, and WebAuthn |
|
|
69
|
+
|
|
70
|
+
Full documentation: **https://forgezero.net/docs/access**
|
|
71
|
+
|
|
72
|
+
## Licence
|
|
73
|
+
|
|
74
|
+
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
75
|
+
deploys — and usable entirely on its own, with no ForgeZero account.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A software WebAuthn authenticator, for tests.
|
|
3
|
+
*
|
|
4
|
+
* Passkeys are the wall every integration test hits. Registration and assertion
|
|
5
|
+
* need a real authenticator, so the usual answer is to stub the verifier — which
|
|
6
|
+
* removes the one thing worth testing and leaves every route behind it unproven.
|
|
7
|
+
* ForgeZero hit exactly that: the custodian ceremony, admin routes, tenant
|
|
8
|
+
* provisioning and the vault all sit behind a passkey, so none of them had ever
|
|
9
|
+
* been exercised end to end.
|
|
10
|
+
*
|
|
11
|
+
* This produces genuine credentials: real P-256 keys, real ES256 signatures over
|
|
12
|
+
* `authenticatorData || SHA-256(clientDataJSON)`, real CBOR attestation. A real
|
|
13
|
+
* verifier accepts them, and a tampered one is rejected — which is what makes it
|
|
14
|
+
* a test rather than a bypass.
|
|
15
|
+
*
|
|
16
|
+
* ## Deliberately NOT a security boundary
|
|
17
|
+
*
|
|
18
|
+
* The private key lives in memory and never leaves the process. Nothing here
|
|
19
|
+
* belongs in a production path, and nothing here weakens one: the server code
|
|
20
|
+
* under test is unchanged and unaware.
|
|
21
|
+
*
|
|
22
|
+
* ## PRF
|
|
23
|
+
*
|
|
24
|
+
* The PRF extension is what a custodian's share envelope is sealed with, so an
|
|
25
|
+
* authenticator without it cannot test custody at all. A real authenticator
|
|
26
|
+
* computes HMAC over a per-credential secret; so does this, which makes the
|
|
27
|
+
* output stable across assertions for one credential and unrelated between
|
|
28
|
+
* credentials — the two properties the envelope scheme depends on.
|
|
29
|
+
*/
|
|
30
|
+
type CborValue = number | string | Uint8Array | Map<CborValue, CborValue>;
|
|
31
|
+
declare function cbor(value: CborValue): Uint8Array;
|
|
32
|
+
/**
|
|
33
|
+
* Web Crypto returns ECDSA as raw `r || s`; WebAuthn verifiers expect DER.
|
|
34
|
+
*
|
|
35
|
+
* Skipping this conversion produces a signature that is the right length, looks
|
|
36
|
+
* plausible, and fails verification with no useful message — which is a long
|
|
37
|
+
* afternoon the first time.
|
|
38
|
+
*/
|
|
39
|
+
declare function toDer(raw: Uint8Array): Uint8Array;
|
|
40
|
+
/** Matches @simplewebauthn's RegistrationResponseJSON closely enough to pass to it. */
|
|
41
|
+
export interface RegistrationResponse {
|
|
42
|
+
id: string;
|
|
43
|
+
rawId: string;
|
|
44
|
+
type: 'public-key';
|
|
45
|
+
clientExtensionResults: Record<string, unknown>;
|
|
46
|
+
authenticatorAttachment?: 'platform' | 'cross-platform';
|
|
47
|
+
response: {
|
|
48
|
+
clientDataJSON: string;
|
|
49
|
+
attestationObject: string;
|
|
50
|
+
transports?: string[];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export interface AuthenticationResponse {
|
|
54
|
+
id: string;
|
|
55
|
+
rawId: string;
|
|
56
|
+
type: 'public-key';
|
|
57
|
+
clientExtensionResults: Record<string, unknown>;
|
|
58
|
+
response: {
|
|
59
|
+
clientDataJSON: string;
|
|
60
|
+
authenticatorData: string;
|
|
61
|
+
signature: string;
|
|
62
|
+
userHandle?: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export interface Credential {
|
|
66
|
+
id: string;
|
|
67
|
+
userHandle?: string;
|
|
68
|
+
signCount: number;
|
|
69
|
+
}
|
|
70
|
+
export interface AuthenticatorOptions {
|
|
71
|
+
rpId: string;
|
|
72
|
+
origin: string;
|
|
73
|
+
/** Off reproduces an authenticator that does not support PRF — worth testing. */
|
|
74
|
+
prf?: boolean;
|
|
75
|
+
/** Off makes `uv` false, so a route demanding user verification refuses. */
|
|
76
|
+
userVerified?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/** Whatever `startRegistration` returned. Only these fields are read. */
|
|
79
|
+
export interface RegistrationOptions {
|
|
80
|
+
challenge: string;
|
|
81
|
+
user?: {
|
|
82
|
+
id?: string;
|
|
83
|
+
};
|
|
84
|
+
rp?: {
|
|
85
|
+
id?: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export interface AuthenticationOptions {
|
|
89
|
+
challenge: string;
|
|
90
|
+
allowCredentials?: readonly {
|
|
91
|
+
id: string;
|
|
92
|
+
}[];
|
|
93
|
+
}
|
|
94
|
+
export declare function createAuthenticator(options: AuthenticatorOptions): {
|
|
95
|
+
/** Create a credential. The response is what a browser would post back. */
|
|
96
|
+
register(registration: RegistrationOptions): Promise<RegistrationResponse>;
|
|
97
|
+
/** Assert. Picks the allowed credential, or the only one when unconstrained. */
|
|
98
|
+
authenticate(authentication: AuthenticationOptions): Promise<AuthenticationResponse>;
|
|
99
|
+
/**
|
|
100
|
+
* PRF output for a salt — the key a custodian's share envelope is sealed
|
|
101
|
+
* with. Stable for one credential, unrelated between credentials, and
|
|
102
|
+
* unavailable when the device does not support the extension.
|
|
103
|
+
*/
|
|
104
|
+
prf(salt: string | Uint8Array, credentialId?: string): Promise<Uint8Array>;
|
|
105
|
+
list: () => readonly Credential[];
|
|
106
|
+
/** Simulate a lost device, which is what the recovery path exists for. */
|
|
107
|
+
forget(credentialId?: string): void;
|
|
108
|
+
};
|
|
109
|
+
export type Authenticator = ReturnType<typeof createAuthenticator>;
|
|
110
|
+
/** Exposed for tests that assert a tampered signature is actually rejected. */
|
|
111
|
+
export declare const encoding: {
|
|
112
|
+
b64url: (bytes: Uint8Array) => string;
|
|
113
|
+
fromB64url: (value: string) => Uint8Array;
|
|
114
|
+
toDer: typeof toDer;
|
|
115
|
+
cbor: typeof cbor;
|
|
116
|
+
sha256: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
117
|
+
};
|
|
118
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/authenticator.ts
|
|
2
|
+
function cborHead(major, value) {
|
|
3
|
+
if (value < 24)
|
|
4
|
+
return new Uint8Array([major << 5 | value]);
|
|
5
|
+
if (value < 256)
|
|
6
|
+
return new Uint8Array([major << 5 | 24, value]);
|
|
7
|
+
if (value < 65536)
|
|
8
|
+
return new Uint8Array([major << 5 | 25, value >> 8, value & 255]);
|
|
9
|
+
return new Uint8Array([
|
|
10
|
+
major << 5 | 26,
|
|
11
|
+
value >>> 24 & 255,
|
|
12
|
+
value >>> 16 & 255,
|
|
13
|
+
value >>> 8 & 255,
|
|
14
|
+
value & 255
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
var concat = (...parts) => {
|
|
18
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
19
|
+
const out = new Uint8Array(total);
|
|
20
|
+
let at = 0;
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
out.set(part, at);
|
|
23
|
+
at += part.length;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
function cbor(value) {
|
|
28
|
+
if (typeof value === "number") {
|
|
29
|
+
return value < 0 ? cborHead(1, -value - 1) : cborHead(0, value);
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
const bytes = new TextEncoder().encode(value);
|
|
33
|
+
return concat(cborHead(3, bytes.length), bytes);
|
|
34
|
+
}
|
|
35
|
+
if (value instanceof Uint8Array)
|
|
36
|
+
return concat(cborHead(2, value.length), value);
|
|
37
|
+
const entries = [...value.entries()];
|
|
38
|
+
return concat(cborHead(5, entries.length), ...entries.map(([key, item]) => concat(cbor(key), cbor(item))));
|
|
39
|
+
}
|
|
40
|
+
function toDer(raw) {
|
|
41
|
+
const trim = (part) => {
|
|
42
|
+
let at = 0;
|
|
43
|
+
while (at < part.length - 1 && part[at] === 0)
|
|
44
|
+
at += 1;
|
|
45
|
+
const trimmed = part.slice(at);
|
|
46
|
+
return trimmed[0] & 128 ? concat(new Uint8Array([0]), trimmed) : trimmed;
|
|
47
|
+
};
|
|
48
|
+
const r = trim(raw.slice(0, 32));
|
|
49
|
+
const s = trim(raw.slice(32));
|
|
50
|
+
const body = concat(new Uint8Array([2, r.length]), r, new Uint8Array([2, s.length]), s);
|
|
51
|
+
return concat(new Uint8Array([48, body.length]), body);
|
|
52
|
+
}
|
|
53
|
+
var b64url = (bytes) => {
|
|
54
|
+
let binary = "";
|
|
55
|
+
for (const byte of bytes)
|
|
56
|
+
binary += String.fromCharCode(byte);
|
|
57
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
58
|
+
};
|
|
59
|
+
var fromB64url = (value) => {
|
|
60
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
61
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
62
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
63
|
+
};
|
|
64
|
+
var sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
65
|
+
var AAGUID = new Uint8Array(16);
|
|
66
|
+
function createAuthenticator(options) {
|
|
67
|
+
const { rpId, origin, prf = true, userVerified = true } = options;
|
|
68
|
+
const credentials = new Map;
|
|
69
|
+
const authData = async (credential, attested) => {
|
|
70
|
+
const rpIdHash = await sha256(new TextEncoder().encode(rpId));
|
|
71
|
+
let flags = 1;
|
|
72
|
+
if (userVerified)
|
|
73
|
+
flags |= 4;
|
|
74
|
+
if (attested)
|
|
75
|
+
flags |= 64;
|
|
76
|
+
const counter = new Uint8Array(4);
|
|
77
|
+
new DataView(counter.buffer).setUint32(0, credential?.signCount ?? 0, false);
|
|
78
|
+
if (!attested || !credential)
|
|
79
|
+
return concat(rpIdHash, new Uint8Array([flags]), counter);
|
|
80
|
+
const raw = await crypto.subtle.exportKey("raw", credential.keys.publicKey);
|
|
81
|
+
const point = new Uint8Array(raw);
|
|
82
|
+
const cose = cbor(new Map([
|
|
83
|
+
[1, 2],
|
|
84
|
+
[3, -7],
|
|
85
|
+
[-1, 1],
|
|
86
|
+
[-2, point.slice(1, 33)],
|
|
87
|
+
[-3, point.slice(33, 65)]
|
|
88
|
+
]));
|
|
89
|
+
const idLength = new Uint8Array(2);
|
|
90
|
+
new DataView(idLength.buffer).setUint16(0, credential.id.length, false);
|
|
91
|
+
return concat(rpIdHash, new Uint8Array([flags]), counter, AAGUID, idLength, credential.id, cose);
|
|
92
|
+
};
|
|
93
|
+
const clientData = (type, challenge) => new TextEncoder().encode(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
|
|
94
|
+
const sign = async (credential, data, clientDataJSON) => {
|
|
95
|
+
const signed = concat(data, await sha256(clientDataJSON));
|
|
96
|
+
const raw = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, credential.keys.privateKey, signed));
|
|
97
|
+
return toDer(raw);
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
async register(registration) {
|
|
101
|
+
const keys = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [
|
|
102
|
+
"sign",
|
|
103
|
+
"verify"
|
|
104
|
+
]);
|
|
105
|
+
const id = new Uint8Array(32);
|
|
106
|
+
crypto.getRandomValues(id);
|
|
107
|
+
const prfSecret = new Uint8Array(32);
|
|
108
|
+
crypto.getRandomValues(prfSecret);
|
|
109
|
+
const credential = {
|
|
110
|
+
id,
|
|
111
|
+
keys,
|
|
112
|
+
userHandle: registration.user?.id,
|
|
113
|
+
prfSecret,
|
|
114
|
+
signCount: 0
|
|
115
|
+
};
|
|
116
|
+
credentials.set(b64url(id), credential);
|
|
117
|
+
const clientDataJSON = clientData("webauthn.create", registration.challenge);
|
|
118
|
+
const attestation = cbor(new Map([
|
|
119
|
+
["fmt", "none"],
|
|
120
|
+
["attStmt", new Map],
|
|
121
|
+
["authData", await authData(credential, true)]
|
|
122
|
+
]));
|
|
123
|
+
return {
|
|
124
|
+
id: b64url(id),
|
|
125
|
+
rawId: b64url(id),
|
|
126
|
+
type: "public-key",
|
|
127
|
+
authenticatorAttachment: "platform",
|
|
128
|
+
clientExtensionResults: prf ? { prf: { enabled: true } } : {},
|
|
129
|
+
response: {
|
|
130
|
+
clientDataJSON: b64url(clientDataJSON),
|
|
131
|
+
attestationObject: b64url(attestation),
|
|
132
|
+
transports: ["internal", "hybrid"]
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
async authenticate(authentication) {
|
|
137
|
+
const allowed = authentication.allowCredentials?.[0]?.id;
|
|
138
|
+
const key = allowed ?? [...credentials.keys()][0];
|
|
139
|
+
const credential = key ? credentials.get(key) : undefined;
|
|
140
|
+
if (!credential)
|
|
141
|
+
throw new Error("This authenticator holds no credential for that request.");
|
|
142
|
+
credential.signCount += 1;
|
|
143
|
+
const clientDataJSON = clientData("webauthn.get", authentication.challenge);
|
|
144
|
+
const data = await authData(credential, false);
|
|
145
|
+
return {
|
|
146
|
+
id: b64url(credential.id),
|
|
147
|
+
rawId: b64url(credential.id),
|
|
148
|
+
type: "public-key",
|
|
149
|
+
clientExtensionResults: {},
|
|
150
|
+
response: {
|
|
151
|
+
clientDataJSON: b64url(clientDataJSON),
|
|
152
|
+
authenticatorData: b64url(data),
|
|
153
|
+
signature: b64url(await sign(credential, data, clientDataJSON)),
|
|
154
|
+
...credential.userHandle ? { userHandle: credential.userHandle } : {}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
async prf(salt, credentialId) {
|
|
159
|
+
if (!prf)
|
|
160
|
+
throw new Error("This authenticator does not support the PRF extension.");
|
|
161
|
+
const key = credentialId ?? [...credentials.keys()][0];
|
|
162
|
+
const credential = key ? credentials.get(key) : undefined;
|
|
163
|
+
if (!credential)
|
|
164
|
+
throw new Error("This authenticator holds no credential.");
|
|
165
|
+
const imported = await crypto.subtle.importKey("raw", credential.prfSecret, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
166
|
+
const bytes = typeof salt === "string" ? new TextEncoder().encode(salt) : salt;
|
|
167
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", imported, bytes));
|
|
168
|
+
},
|
|
169
|
+
list: () => [...credentials.entries()].map(([id, credential]) => ({
|
|
170
|
+
id,
|
|
171
|
+
userHandle: credential.userHandle,
|
|
172
|
+
signCount: credential.signCount
|
|
173
|
+
})),
|
|
174
|
+
forget(credentialId) {
|
|
175
|
+
if (credentialId)
|
|
176
|
+
credentials.delete(credentialId);
|
|
177
|
+
else
|
|
178
|
+
credentials.clear();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
var encoding = { b64url, fromB64url, toDer, cbor, sha256 };
|
|
183
|
+
export {
|
|
184
|
+
encoding,
|
|
185
|
+
createAuthenticator
|
|
186
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custodian threshold modes.
|
|
3
|
+
*
|
|
4
|
+
* A closed set chosen at ceremony time rather than free-form M and N. Two
|
|
5
|
+
* reasons: a typo like 7-of-3 is unrepresentable, and every mode here has been
|
|
6
|
+
* thought about — an operator picking numbers in a form has not.
|
|
7
|
+
*/
|
|
8
|
+
export interface ThresholdMode {
|
|
9
|
+
id: string;
|
|
10
|
+
/** M — how many custodians must convene to unlock. */
|
|
11
|
+
threshold: number;
|
|
12
|
+
/** N — how many shares exist in total. */
|
|
13
|
+
total: number;
|
|
14
|
+
label: string;
|
|
15
|
+
description: string;
|
|
16
|
+
/**
|
|
17
|
+
* True when a single custodian can unlock alone. Shamir needs threshold >= 2,
|
|
18
|
+
* so these modes REPLICATE the seed instead of splitting it: every custodian
|
|
19
|
+
* holds the whole thing. That is a real weakening — one compromised custodian
|
|
20
|
+
* is a compromised vault — and it is why these are marked rather than mixed
|
|
21
|
+
* in silently.
|
|
22
|
+
*/
|
|
23
|
+
replicated: boolean;
|
|
24
|
+
/** Stated on EVERY mode, not just the recommended one: an optional field
|
|
25
|
+
* disappears from the literal type where it is absent, and a consumer then
|
|
26
|
+
* cannot read it uniformly across the set. */
|
|
27
|
+
recommended: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare const THRESHOLD_MODES: readonly [{
|
|
30
|
+
readonly id: "1-of-2";
|
|
31
|
+
readonly threshold: 1;
|
|
32
|
+
readonly total: 2;
|
|
33
|
+
readonly label: "1 of 2";
|
|
34
|
+
readonly description: "Either custodian can unlock alone. Survives losing one person, but a single compromised custodian is enough to open the vault.";
|
|
35
|
+
readonly replicated: true;
|
|
36
|
+
readonly recommended: false;
|
|
37
|
+
}, {
|
|
38
|
+
readonly id: "1-of-3";
|
|
39
|
+
readonly threshold: 1;
|
|
40
|
+
readonly total: 3;
|
|
41
|
+
readonly label: "1 of 3";
|
|
42
|
+
readonly description: "Any custodian can unlock alone. Maximum availability, minimum protection against an insider.";
|
|
43
|
+
readonly replicated: true;
|
|
44
|
+
readonly recommended: false;
|
|
45
|
+
}, {
|
|
46
|
+
readonly id: "2-of-3";
|
|
47
|
+
readonly threshold: 2;
|
|
48
|
+
readonly total: 3;
|
|
49
|
+
readonly label: "2 of 3";
|
|
50
|
+
readonly description: "Two of three must convene. Survives losing one custodian, and no single person can unlock. The smallest genuinely split arrangement.";
|
|
51
|
+
readonly replicated: false;
|
|
52
|
+
readonly recommended: true;
|
|
53
|
+
}, {
|
|
54
|
+
readonly id: "3-of-5";
|
|
55
|
+
readonly threshold: 3;
|
|
56
|
+
readonly total: 5;
|
|
57
|
+
readonly label: "3 of 5";
|
|
58
|
+
readonly description: "Three of five must convene. Survives losing two custodians.";
|
|
59
|
+
readonly replicated: false;
|
|
60
|
+
readonly recommended: false;
|
|
61
|
+
}, {
|
|
62
|
+
readonly id: "4-of-7";
|
|
63
|
+
readonly threshold: 4;
|
|
64
|
+
readonly total: 7;
|
|
65
|
+
readonly label: "4 of 7";
|
|
66
|
+
readonly description: "Four of seven must convene. For larger governance groups.";
|
|
67
|
+
readonly replicated: false;
|
|
68
|
+
readonly recommended: false;
|
|
69
|
+
}];
|
|
70
|
+
export type ThresholdModeId = (typeof THRESHOLD_MODES)[number]['id'];
|
|
71
|
+
export declare function thresholdMode(id: string): ThresholdMode | undefined;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/ceremony-modes.ts
|
|
2
|
+
var THRESHOLD_MODES = [
|
|
3
|
+
{
|
|
4
|
+
id: "1-of-2",
|
|
5
|
+
threshold: 1,
|
|
6
|
+
total: 2,
|
|
7
|
+
label: "1 of 2",
|
|
8
|
+
description: "Either custodian can unlock alone. Survives losing one person, but a single compromised custodian is enough to open the vault.",
|
|
9
|
+
replicated: true,
|
|
10
|
+
recommended: false
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "1-of-3",
|
|
14
|
+
threshold: 1,
|
|
15
|
+
total: 3,
|
|
16
|
+
label: "1 of 3",
|
|
17
|
+
description: "Any custodian can unlock alone. Maximum availability, minimum protection against an insider.",
|
|
18
|
+
replicated: true,
|
|
19
|
+
recommended: false
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "2-of-3",
|
|
23
|
+
threshold: 2,
|
|
24
|
+
total: 3,
|
|
25
|
+
label: "2 of 3",
|
|
26
|
+
description: "Two of three must convene. Survives losing one custodian, and no single person can unlock. The smallest genuinely split arrangement.",
|
|
27
|
+
replicated: false,
|
|
28
|
+
recommended: true
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "3-of-5",
|
|
32
|
+
threshold: 3,
|
|
33
|
+
total: 5,
|
|
34
|
+
label: "3 of 5",
|
|
35
|
+
description: "Three of five must convene. Survives losing two custodians.",
|
|
36
|
+
replicated: false,
|
|
37
|
+
recommended: false
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "4-of-7",
|
|
41
|
+
threshold: 4,
|
|
42
|
+
total: 7,
|
|
43
|
+
label: "4 of 7",
|
|
44
|
+
description: "Four of seven must convene. For larger governance groups.",
|
|
45
|
+
replicated: false,
|
|
46
|
+
recommended: false
|
|
47
|
+
}
|
|
48
|
+
];
|
|
49
|
+
function thresholdMode(id) {
|
|
50
|
+
return THRESHOLD_MODES.find((mode) => mode.id === id);
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
thresholdMode,
|
|
54
|
+
THRESHOLD_MODES
|
|
55
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RouteRegistry } from './index';
|
|
2
|
+
/**
|
|
3
|
+
* The typed client, and the other half of the 428 handshake.
|
|
4
|
+
*
|
|
5
|
+
* Generated from the same declarations the server enforces, so a route that
|
|
6
|
+
* changes its body shape changes the client's types in the same commit. There
|
|
7
|
+
* is no second source to drift from.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the handshake lives here
|
|
10
|
+
*
|
|
11
|
+
* The server answers 428 with a one-time key and the factors it will accept.
|
|
12
|
+
* Something has to mint a proof and replay the original request with that key
|
|
13
|
+
* attached — and without it `stepUp` is a wall rather than a challenge. That
|
|
14
|
+
* was specified server-side for a long time with nobody implementing the other
|
|
15
|
+
* end.
|
|
16
|
+
*
|
|
17
|
+
* The replay MUST surface the action's own failure. Security passing while the
|
|
18
|
+
* write fails is otherwise completely silent: the caller sees a resolved
|
|
19
|
+
* promise, the record is unchanged, and nothing anywhere says why.
|
|
20
|
+
*/
|
|
21
|
+
export interface Problem {
|
|
22
|
+
code: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export type ClientResult<T> = {
|
|
26
|
+
ok: true;
|
|
27
|
+
status: number;
|
|
28
|
+
data: T;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
status: number;
|
|
32
|
+
error: Problem;
|
|
33
|
+
errors?: readonly {
|
|
34
|
+
path: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}[];
|
|
37
|
+
};
|
|
38
|
+
export interface SecurityChallenge {
|
|
39
|
+
scope: 'session' | 'action';
|
|
40
|
+
route: string;
|
|
41
|
+
factors: readonly string[];
|
|
42
|
+
required: number;
|
|
43
|
+
requestKey: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Turns a challenge into proofs. Supplied by the host, because how a passkey or
|
|
47
|
+
* a TOTP code is collected is a UI decision the client cannot make.
|
|
48
|
+
*
|
|
49
|
+
* Returning `undefined` means the human declined — a cancel, not an error, and
|
|
50
|
+
* the original 428 is surfaced unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export interface ChallengeHandler {
|
|
53
|
+
(challenge: SecurityChallenge): Promise<{
|
|
54
|
+
factor: string;
|
|
55
|
+
proof: unknown;
|
|
56
|
+
}[] | undefined>;
|
|
57
|
+
}
|
|
58
|
+
export interface ClientOptions {
|
|
59
|
+
baseUrl: string;
|
|
60
|
+
onChallenge?: ChallengeHandler;
|
|
61
|
+
fetch?: typeof globalThis.fetch;
|
|
62
|
+
headers?: Record<string, string>;
|
|
63
|
+
/** Path that accepts a proof against a request key. */
|
|
64
|
+
fulfilPath?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface RequestArgs {
|
|
67
|
+
params?: Record<string, string>;
|
|
68
|
+
query?: Record<string, unknown>;
|
|
69
|
+
body?: unknown;
|
|
70
|
+
headers?: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
/** `api/orders/[id]` + `{id:'7'}` → `/api/orders/7` */
|
|
73
|
+
export declare function buildPath(routeKey: string, params?: Record<string, string>): string;
|
|
74
|
+
export declare function createClient<R extends RouteRegistry>(routes: R, options: ClientOptions): { [K in keyof R as R[K] extends {
|
|
75
|
+
kind: "action";
|
|
76
|
+
} ? K : never]: (args?: RequestArgs) => Promise<ClientResult<unknown>>; };
|