humankey 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 humankey contributors
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,384 @@
1
+ # humankey
2
+
3
+ Per-action hardware key (YubiKey/FIDO2) verification SDK with built-in confirmation step.
4
+
5
+ Proves a human physically tapped a security key **and** confirmed they understood what they were approving — for every action, not just login.
6
+
7
+ ## Tech Stack
8
+
9
+ - **Language**: TypeScript (strict mode)
10
+ - **Build**: tsup (dual ESM/CJS output)
11
+ - **Test**: vitest
12
+ - **Core dependency**: [@simplewebauthn/browser](https://simplewebauthn.dev/) (peer) + [@simplewebauthn/server](https://simplewebauthn.dev/) (verify)
13
+ - **Target runtimes**: Browser (client SDK), Node/Deno/Bun/Edge (verify utility)
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ humankey/
19
+ ├── src/
20
+ │ ├── index.ts # Browser exports: createConfirmation, requestTap, registerKey, isHumanKeySupported
21
+ │ ├── confirm.ts # Confirmation code generation + validation
22
+ │ ├── tap.ts # WebAuthn assertion with action binding
23
+ │ ├── register.ts # One-time hardware key registration
24
+ │ ├── support.ts # Feature detection
25
+ │ ├── verify.ts # Server-side proof verification (humankey/verify)
26
+ │ ├── registration-verify.ts # Server-side registration verification
27
+ │ ├── challenge.ts # Server-side challenge generation
28
+ │ ├── express.ts # Express framework adapter (humankey/express)
29
+ │ ├── hash.ts # SHA-256 canonical JSON hashing (isomorphic)
30
+ │ ├── types.ts # All type definitions
31
+ │ └── errors.ts # Typed error classes
32
+ ├── tests/ # vitest test suite
33
+ │ ├── helpers/
34
+ │ │ └── soft-authenticator.ts # Software FIDO2 authenticator for integration tests
35
+ │ └── integration.test.ts # End-to-end tests against real @simplewebauthn/server
36
+ └── examples/basic/ # Working Express + HTML example
37
+ ```
38
+
39
+ **Three entry points:**
40
+ - `humankey` — browser SDK (confirm + tap + register)
41
+ - `humankey/verify` — server-side verification, registration, and challenge generation (any JS runtime)
42
+ - `humankey/express` — Express router with built-in challenge lifecycle, registration, and verification
43
+
44
+ ## How It Works
45
+
46
+ ```
47
+ 1. Your server: createChallenge() → send to client
48
+ 2. humankey: createConfirmation(action) → show code to user, user types it back
49
+ 3. humankey: requestTap(challenge, action, confirmation) → user taps YubiKey → TapProof
50
+ 4. Your client: send TapProof to your server
51
+ 5. humankey/verify: verifyTapProof(proof, ...) → { verified, confirmationValid }
52
+ ```
53
+
54
+ The confirmation code is derived from the action hash — a compromised client can't predict the code for a different action. The server re-derives everything independently.
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ npm install humankey @simplewebauthn/browser
60
+ ```
61
+
62
+ `@simplewebauthn/browser` is a peer dependency (only needed in the browser).
63
+
64
+ For the Express adapter:
65
+
66
+ ```bash
67
+ npm install humankey express
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ### Express Adapter (recommended)
73
+
74
+ The fastest way to add humankey to an Express app. Handles challenge lifecycle, registration, and verification automatically.
75
+
76
+ ```ts
77
+ import express from 'express';
78
+ import { createHumanKeyRouter } from 'humankey/express';
79
+ import type { TapCredential } from 'humankey/verify';
80
+
81
+ const app = express();
82
+ app.use(express.json());
83
+
84
+ const credentials = new Map<string, TapCredential>();
85
+
86
+ app.use('/api', createHumanKeyRouter({
87
+ rpID: 'example.com',
88
+ rpName: 'My App',
89
+ origin: 'https://example.com',
90
+ getCredential: async (id) => credentials.get(id) ?? null,
91
+ onRegister: async (credential) => {
92
+ credentials.set(credential.id, credential);
93
+ },
94
+ onVerify: async (result, action) => {
95
+ console.log('Verified action:', action, result);
96
+ },
97
+ }));
98
+
99
+ app.listen(3000);
100
+ ```
101
+
102
+ This creates three routes:
103
+ - `POST /api/challenge` — generates and stores a challenge, returns `{ challengeId, challenge }`
104
+ - `POST /api/register` — verifies registration, calls `onRegister`, returns `{ ok, credentialId }`
105
+ - `POST /api/verify` — verifies tap proof, calls `onVerify`, returns `{ verified, confirmationValid, newCounter }`
106
+
107
+ #### Configuration
108
+
109
+ ```ts
110
+ createHumanKeyRouter({
111
+ rpID: 'example.com', // Required: relying party ID
112
+ rpName: 'My App', // Required: relying party name
113
+ origin: 'https://example.com', // Required: expected origin(s)
114
+ getCredential: async (id) => ..., // Required: credential lookup
115
+ onRegister: async (cred) => ..., // Required: store new credentials
116
+ onVerify: async (result, action) => ..., // Optional: post-verification hook
117
+ challengeTTL: 60_000, // Optional: challenge TTL in ms (default: 60s)
118
+ challengeStore: customStore, // Optional: custom ChallengeStore (default: in-memory)
119
+ requireUserVerification: true, // Optional: require PIN/biometric (default: true)
120
+ allowedAAGUIDs: ['...'], // Optional: restrict authenticator models
121
+ });
122
+ ```
123
+
124
+ #### Custom Challenge Store
125
+
126
+ The default `MemoryChallengeStore` works for single-process deployments. For multi-server setups, implement the `ChallengeStore` interface:
127
+
128
+ ```ts
129
+ import type { ChallengeStore } from 'humankey/express';
130
+
131
+ class RedisChallengeStore implements ChallengeStore {
132
+ constructor(private redis: RedisClient) {}
133
+
134
+ async set(id: string, challenge: string, ttlMs: number): Promise<void> {
135
+ await this.redis.set(`hk:${id}`, challenge, 'PX', ttlMs);
136
+ }
137
+
138
+ async get(id: string): Promise<string | null> {
139
+ const challenge = await this.redis.get(`hk:${id}`);
140
+ if (challenge) await this.redis.del(`hk:${id}`); // single-use
141
+ return challenge;
142
+ }
143
+ }
144
+ ```
145
+
146
+ ### Server (manual — challenge + registration + verify)
147
+
148
+ ```ts
149
+ import {
150
+ verifyTapProof,
151
+ verifyRegistration,
152
+ createChallenge,
153
+ } from 'humankey/verify';
154
+
155
+ // Generate a challenge (base64url, 256-bit)
156
+ const challenge = createChallenge();
157
+
158
+ // After client registers a key, verify the registration
159
+ const { credential } = await verifyRegistration({
160
+ response: registrationResponseFromClient,
161
+ expectedChallenge: challenge,
162
+ expectedOrigin: 'https://example.com',
163
+ expectedRPID: 'example.com',
164
+ });
165
+ // → store credential server-side
166
+
167
+ // After client sends a TapProof, verify it
168
+ const result = await verifyTapProof({
169
+ proof,
170
+ credential, // stored TapCredential
171
+ expectedChallenge, // the challenge you generated
172
+ expectedAction: action, // your server's copy of the action
173
+ expectedOrigin: 'https://example.com',
174
+ expectedRPID: 'example.com',
175
+ requireUserVerification: true,
176
+ requireConfirmation: true, // default: throws if code is wrong
177
+ });
178
+ // result.verified → signature is valid
179
+ // result.confirmationValid → user typed the correct code
180
+ // result.userVerified → biometric/PIN was used
181
+ // result.newCounter → update stored counter
182
+ ```
183
+
184
+ ### Browser (register + confirm + tap)
185
+
186
+ ```ts
187
+ import { createConfirmation, requestTap, registerKey, isHumanKeySupported } from 'humankey';
188
+
189
+ // Check support
190
+ if (!isHumanKeySupported()) {
191
+ throw new Error('WebAuthn not supported in this browser');
192
+ }
193
+
194
+ // One-time: register a hardware key
195
+ const registration = await registerKey({
196
+ challenge, // from your server
197
+ rpID: 'example.com',
198
+ rpName: 'My App',
199
+ userName: 'alice',
200
+ });
201
+ // → send registration.response to your server for verifyRegistration()
202
+
203
+ // Per-action: confirm + tap
204
+ const action = { action: 'send-message', data: { to: 'bob', body: 'hello' } };
205
+ const confirmation = createConfirmation(action);
206
+ // confirmation.code → "A7X3"
207
+ // Show in your UI: "You're sending a message to bob. Type A7X3 to confirm."
208
+
209
+ // After user types the code:
210
+ const proof = await requestTap({
211
+ challenge, // from your server (unique per action)
212
+ action,
213
+ confirmation,
214
+ userInput: 'A7X3', // what the user typed
215
+ allowCredentials: [{ id: registration.credentialId }],
216
+ rpID: 'example.com',
217
+ });
218
+ // → send proof to your server for verifyTapProof()
219
+ ```
220
+
221
+ ## Attestation Allowlist (AAGUIDs)
222
+
223
+ For high-security deployments, restrict which authenticator models are accepted during registration. Each FIDO2 authenticator has an AAGUID — a UUID identifying its make and model.
224
+
225
+ ```ts
226
+ // Only allow YubiKey 5 series (example AAGUIDs)
227
+ const result = await verifyRegistration({
228
+ response: registrationResponse,
229
+ expectedChallenge: challenge,
230
+ expectedOrigin: 'https://example.com',
231
+ expectedRPID: 'example.com',
232
+ allowedAAGUIDs: [
233
+ 'cb69481e-8ff7-4039-93ec-0a2729a154a8', // YubiKey 5 NFC
234
+ 'ee882879-721c-4913-9775-3dfcce97072a', // YubiKey 5Ci
235
+ ],
236
+ });
237
+ ```
238
+
239
+ If the authenticator's AAGUID is not in the list, registration throws `AAGUID_NOT_ALLOWED`. When `allowedAAGUIDs` is omitted or empty, any authenticator is accepted.
240
+
241
+ The AAGUID is also stored on the `TapCredential` for auditing:
242
+
243
+ ```ts
244
+ console.log(credential.aaguid); // "cb69481e-8ff7-4039-93ec-0a2729a154a8"
245
+ ```
246
+
247
+ Find AAGUIDs for specific hardware keys in the [FIDO Alliance Metadata Service](https://fidoalliance.org/metadata/).
248
+
249
+ ## Rate-Limiting Guide
250
+
251
+ The 4-character confirmation code has ~20.68 bits of entropy (~1.7 million combinations). Without rate limiting, an attacker with a stolen key could brute-force the code.
252
+
253
+ **You must rate-limit the verification endpoint.** Example with `express-rate-limit`:
254
+
255
+ ```ts
256
+ import rateLimit from 'express-rate-limit';
257
+
258
+ const verifyLimiter = rateLimit({
259
+ windowMs: 60_000, // 1 minute
260
+ max: 5, // 5 attempts per window
261
+ keyGenerator: (req) => req.ip ?? 'unknown',
262
+ message: { error: 'Too many verification attempts' },
263
+ });
264
+
265
+ app.use('/api/verify', verifyLimiter);
266
+ ```
267
+
268
+ For production, consider:
269
+ - Per-credential rate limiting (not just per-IP)
270
+ - Exponential backoff after consecutive failures
271
+ - Alerting on repeated failures (possible stolen key)
272
+
273
+ ## Security Model
274
+
275
+ ### What humankey proves
276
+
277
+ - A human with physical access to a registered hardware key approved the action
278
+ - The human confirmed they understood the action (typed the correct confirmation code)
279
+ - The approval is cryptographically bound to the specific action payload
280
+ - The approval is one-time use (challenge nonce prevents replay)
281
+ - The hardware key is genuine (attestation verification by default)
282
+
283
+ ### Known limitations and mitigations
284
+
285
+ | Limitation | Status | Future Solution |
286
+ |---|---|---|
287
+ | **Blind tap** — key has no display | **Mitigated** — confirmation code proves the user read the action details | `txAuthSimple` FIDO2 extension when display-equipped keys become mainstream |
288
+ | **Compromised client (XSS)** — could show wrong action | **Mitigated** — server re-derives action hash + confirmation code independently | CSP hardening guide |
289
+ | **Software authenticator spoofing** | **Mitigated** — `allowedAAGUIDs` restricts to known hardware models | Attestation certificate chain validation |
290
+ | **Safari UV flag in clamshell mode** | **Mitigated** — independent UV flag check in `verifyTapProof()` | N/A, already handled |
291
+ | **Single-key single-factor** | Configurable — `userVerification: 'required'` adds PIN/biometric | Multi-key quorum (2-of-3) in future version |
292
+ | **Confirmation code entropy** | ~20.68 bits (36^4) — rate-limit attempts | Longer codes or richer character sets in future version |
293
+
294
+ ### Security recommendations
295
+
296
+ 1. Generate challenges server-side with `createChallenge()` from `humankey/verify`
297
+ 2. Enforce short TTLs on challenges (60s or less)
298
+ 3. Delete challenges after single use (prevent replay)
299
+ 4. **Rate-limit confirmation code attempts** — the 4-character code has ~20.68 bits of entropy (~1.7M combinations)
300
+ 5. Pass `expectedAction` from your server's copy — never trust client-provided action data
301
+ 6. Store credential public keys securely
302
+ 7. Monitor signature counters for anomalies (counter going backwards = cloned key)
303
+ 8. Use `allowedAAGUIDs` to restrict authenticator models in high-security environments
304
+
305
+ ## API Reference
306
+
307
+ ### Entry Points
308
+
309
+ | Import | Environment | Contents |
310
+ |---|---|---|
311
+ | `humankey` | Browser | `createConfirmation`, `requestTap`, `registerKey`, `isHumanKeySupported`, `hashAction`, `HumanKeyError` |
312
+ | `humankey/verify` | Server (any JS runtime) | `verifyTapProof`, `verifyRegistration`, `createChallenge`, `HumanKeyError` |
313
+ | `humankey/express` | Server (Express) | `createHumanKeyRouter`, `MemoryChallengeStore`, `ChallengeStore`, `HumanKeyExpressConfig` |
314
+
315
+ ### Error Codes
316
+
317
+ | Code | Thrown by | Meaning |
318
+ |---|---|---|
319
+ | `CONFIRMATION_MISMATCH` | `verifyTapProof` | User typed wrong confirmation code |
320
+ | `ACTION_HASH_MISMATCH` | `verifyTapProof` | Client signed a different action than expected |
321
+ | `VERIFICATION_FAILED` | `verifyTapProof` | WebAuthn signature invalid |
322
+ | `COUNTER_REPLAY` | `verifyTapProof` | Counter didn't increase (possible cloned key) |
323
+ | `USER_VERIFICATION_MISSING` | `verifyTapProof` | UV required but authenticator didn't verify user |
324
+ | `AAGUID_NOT_ALLOWED` | `verifyRegistration` | Authenticator model not in allowlist |
325
+ | `REGISTRATION_FAILED` | `verifyRegistration` | WebAuthn registration verification failed |
326
+ | `WEBAUTHN_NOT_SUPPORTED` | `registerKey`, `requestTap` | Browser doesn't support WebAuthn |
327
+ | `USER_CANCELLED` | `registerKey`, `requestTap` | User cancelled the WebAuthn prompt |
328
+
329
+ ### `verifyTapProof(request)` options
330
+
331
+ | Option | Default | Description |
332
+ |---|---|---|
333
+ | `requireUserVerification` | `true` | Throw if the authenticator didn't verify the user (PIN/biometric) |
334
+ | `requireConfirmation` | `true` | Throw `CONFIRMATION_MISMATCH` if the user typed the wrong code. Set `false` to check `result.confirmationValid` manually. |
335
+
336
+ ### `verifyRegistration(request)` options
337
+
338
+ | Option | Default | Description |
339
+ |---|---|---|
340
+ | `requireUserVerification` | `true` | Throw if UV flag is not set |
341
+ | `allowedAAGUIDs` | `undefined` | Array of allowed authenticator AAGUIDs. If set and non-empty, throws `AAGUID_NOT_ALLOWED` for unlisted models. |
342
+
343
+ ## Development
344
+
345
+ ```bash
346
+ npm install # install dependencies
347
+ npm run build # build ESM + CJS
348
+ npm run test # run tests
349
+ npm run typecheck # type check
350
+ npm run dev # watch mode
351
+ ```
352
+
353
+ ### Running the example
354
+
355
+ ```bash
356
+ cd examples/basic
357
+ npm install
358
+ npm start
359
+ # → open http://localhost:3000
360
+ ```
361
+
362
+ ## Changelog
363
+
364
+ ### v0.2.0
365
+
366
+ **New features:**
367
+ - **Express adapter** (`humankey/express`) — `createHumanKeyRouter()` provides a complete Express router with challenge lifecycle, registration, and verification. Includes `MemoryChallengeStore` (in-memory, single-use, TTL-based) and a `ChallengeStore` interface for custom backends (Redis, etc.).
368
+ - **Attestation allowlist** — `allowedAAGUIDs` option on `verifyRegistration()` restricts accepted authenticator models by AAGUID. `TapCredential` now includes `aaguid` field.
369
+ - **Integration tests** — end-to-end tests using a software FIDO2 authenticator against real `@simplewebauthn/server` (no mocks). Covers full registration, tap flow, confirmation mismatch, action tampering, and counter replay.
370
+ - **Counter replay detection** — `verifyTapProof()` now correctly returns `COUNTER_REPLAY` error code when the authenticator counter doesn't increase.
371
+
372
+ **Breaking changes:**
373
+ - `TapCredential` now includes a required `aaguid: string` field. Existing stored credentials need this field added (use `'00000000-0000-0000-0000-000000000000'` as a default for credentials registered before this version).
374
+
375
+ ### v0.1.0
376
+
377
+ - `registerKey()` now returns `RegistrationResult` (with `credentialId`, `response`, `transports`) instead of `{ credential, response }`. Use `verifyRegistration()` server-side to get the full `TapCredential`.
378
+ - `verifyTapProof()` now throws `CONFIRMATION_MISMATCH` by default when the confirmation code is wrong. Pass `requireConfirmation: false` for the old behavior.
379
+ - Confirmation code derivation uses 16-bit values instead of single bytes, eliminating modulo bias. Codes for the same action will differ from previous versions.
380
+ - Error messages no longer leak expected/actual confirmation code values.
381
+
382
+ ## License
383
+
384
+ MIT
@@ -0,0 +1,81 @@
1
+ // src/hash.ts
2
+ async function hashAction(action) {
3
+ const canonical = canonicalize(action);
4
+ const encoded = new TextEncoder().encode(canonical);
5
+ return crypto.subtle.digest("SHA-256", encoded);
6
+ }
7
+ async function combineAndHash(...buffers) {
8
+ let totalLength = 0;
9
+ for (const buf of buffers) {
10
+ totalLength += buf.byteLength;
11
+ }
12
+ const combined = new Uint8Array(totalLength);
13
+ let offset = 0;
14
+ for (const buf of buffers) {
15
+ combined.set(new Uint8Array(buf), offset);
16
+ offset += buf.byteLength;
17
+ }
18
+ return crypto.subtle.digest("SHA-256", combined);
19
+ }
20
+ function deriveConfirmationCode(hashBuffer) {
21
+ const bytes = new Uint8Array(hashBuffer);
22
+ const CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
23
+ let code = "";
24
+ for (let i = 0; i < 4; i++) {
25
+ const value = bytes[i * 2] << 8 | bytes[i * 2 + 1];
26
+ code += CHARSET[value % CHARSET.length];
27
+ }
28
+ return code;
29
+ }
30
+ function bufferToBase64url(buffer) {
31
+ const bytes = new Uint8Array(buffer);
32
+ let binary = "";
33
+ for (let i = 0; i < bytes.length; i++) {
34
+ binary += String.fromCharCode(bytes[i]);
35
+ }
36
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
37
+ }
38
+ function base64urlToBuffer(base64url) {
39
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
40
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
41
+ const binary = atob(padded);
42
+ const bytes = new Uint8Array(binary.length);
43
+ for (let i = 0; i < binary.length; i++) {
44
+ bytes[i] = binary.charCodeAt(i);
45
+ }
46
+ return bytes.buffer;
47
+ }
48
+ function canonicalize(value) {
49
+ if (value === null || typeof value !== "object") {
50
+ return JSON.stringify(value);
51
+ }
52
+ if (Array.isArray(value)) {
53
+ return "[" + value.map(canonicalize).join(",") + "]";
54
+ }
55
+ const obj = value;
56
+ const keys = Object.keys(obj).sort();
57
+ const pairs = keys.map((key) => JSON.stringify(key) + ":" + canonicalize(obj[key]));
58
+ return "{" + pairs.join(",") + "}";
59
+ }
60
+
61
+ // src/errors.ts
62
+ var HumanKeyError = class extends Error {
63
+ code;
64
+ constructor(message, code, cause) {
65
+ super(message);
66
+ this.name = "HumanKeyError";
67
+ this.code = code;
68
+ if (cause !== void 0) {
69
+ this.cause = cause;
70
+ }
71
+ }
72
+ };
73
+
74
+ export {
75
+ hashAction,
76
+ combineAndHash,
77
+ deriveConfirmationCode,
78
+ bufferToBase64url,
79
+ base64urlToBuffer,
80
+ HumanKeyError
81
+ };
@@ -0,0 +1,174 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }
2
+
3
+
4
+
5
+
6
+
7
+
8
+ var _chunkJI6NIMGKcjs = require('./chunk-JI6NIMGK.cjs');
9
+
10
+ // src/verify.ts
11
+ var _server = require('@simplewebauthn/server');
12
+
13
+ // src/challenge.ts
14
+ function createChallenge(byteLength = 32) {
15
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
16
+ return _chunkJI6NIMGKcjs.bufferToBase64url.call(void 0, bytes.buffer);
17
+ }
18
+
19
+ // src/registration-verify.ts
20
+
21
+ async function verifyRegistration(request) {
22
+ const {
23
+ response,
24
+ expectedChallenge,
25
+ expectedOrigin,
26
+ expectedRPID,
27
+ requireUserVerification = true,
28
+ allowedAAGUIDs
29
+ } = request;
30
+ let verification;
31
+ try {
32
+ verification = await _server.verifyRegistrationResponse.call(void 0, {
33
+ response,
34
+ expectedChallenge,
35
+ expectedOrigin,
36
+ expectedRPID,
37
+ requireUserVerification
38
+ });
39
+ } catch (error) {
40
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
41
+ `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
42
+ "REGISTRATION_FAILED",
43
+ error
44
+ );
45
+ }
46
+ if (!verification.verified || !verification.registrationInfo) {
47
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
48
+ "Registration verification failed",
49
+ "REGISTRATION_FAILED"
50
+ );
51
+ }
52
+ const { registrationInfo } = verification;
53
+ if (allowedAAGUIDs && allowedAAGUIDs.length > 0) {
54
+ if (!allowedAAGUIDs.includes(registrationInfo.aaguid)) {
55
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
56
+ `Authenticator model (AAGUID ${registrationInfo.aaguid}) is not in the allowed list`,
57
+ "AAGUID_NOT_ALLOWED"
58
+ );
59
+ }
60
+ }
61
+ const credential = {
62
+ id: registrationInfo.credential.id,
63
+ publicKey: registrationInfo.credential.publicKey,
64
+ counter: registrationInfo.credential.counter,
65
+ transports: registrationInfo.credential.transports,
66
+ deviceType: registrationInfo.credentialDeviceType,
67
+ backedUp: registrationInfo.credentialBackedUp,
68
+ aaguid: registrationInfo.aaguid
69
+ };
70
+ return { credential, verified: true };
71
+ }
72
+
73
+ // src/verify.ts
74
+ async function verifyTapProof(request) {
75
+ const {
76
+ proof,
77
+ credential,
78
+ expectedChallenge,
79
+ expectedAction,
80
+ expectedOrigin,
81
+ expectedRPID,
82
+ requireUserVerification = true
83
+ } = request;
84
+ const actionHashBuffer = await _chunkJI6NIMGKcjs.hashAction.call(void 0, expectedAction);
85
+ const expectedActionHash = _chunkJI6NIMGKcjs.bufferToBase64url.call(void 0, actionHashBuffer);
86
+ if (proof.actionHash !== expectedActionHash) {
87
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
88
+ "Action hash mismatch \u2014 the client may have signed a different action than expected",
89
+ "ACTION_HASH_MISMATCH"
90
+ );
91
+ }
92
+ const expectedCode = _chunkJI6NIMGKcjs.deriveConfirmationCode.call(void 0, actionHashBuffer);
93
+ const confirmationValid = proof.userInput.toUpperCase().trim() === expectedCode.toUpperCase();
94
+ const confirmationInput = `${expectedCode}:${proof.userInput.toUpperCase().trim()}`;
95
+ const confirmationHashBuffer = await crypto.subtle.digest(
96
+ "SHA-256",
97
+ new TextEncoder().encode(confirmationInput)
98
+ );
99
+ const challengeBuffer = _chunkJI6NIMGKcjs.base64urlToBuffer.call(void 0, expectedChallenge);
100
+ const expectedFinalChallenge = await _chunkJI6NIMGKcjs.combineAndHash.call(void 0,
101
+ challengeBuffer,
102
+ actionHashBuffer,
103
+ confirmationHashBuffer
104
+ );
105
+ const expectedFinalChallengeB64 = _chunkJI6NIMGKcjs.bufferToBase64url.call(void 0, expectedFinalChallenge);
106
+ let verification;
107
+ try {
108
+ verification = await _server.verifyAuthenticationResponse.call(void 0, {
109
+ response: proof.response,
110
+ expectedChallenge: expectedFinalChallengeB64,
111
+ expectedOrigin,
112
+ expectedRPID,
113
+ credential: {
114
+ id: credential.id,
115
+ publicKey: credential.publicKey,
116
+ counter: credential.counter,
117
+ transports: credential.transports
118
+ },
119
+ requireUserVerification
120
+ });
121
+ } catch (error) {
122
+ const message = error instanceof Error ? error.message : "Unknown error";
123
+ if (message.includes("counter") && message.includes("lower than expected")) {
124
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
125
+ `Signature counter replay detected \u2014 ${message}`,
126
+ "COUNTER_REPLAY",
127
+ error
128
+ );
129
+ }
130
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
131
+ `WebAuthn verification failed: ${message}`,
132
+ "VERIFICATION_FAILED",
133
+ error
134
+ );
135
+ }
136
+ if (!verification.verified) {
137
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
138
+ "WebAuthn signature verification failed",
139
+ "VERIFICATION_FAILED"
140
+ );
141
+ }
142
+ const { authenticationInfo } = verification;
143
+ if (requireUserVerification && !authenticationInfo.userVerified) {
144
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
145
+ "User verification was required but not performed \u2014 the authenticator did not verify the user (possible Safari clamshell mode issue)",
146
+ "USER_VERIFICATION_MISSING"
147
+ );
148
+ }
149
+ const requireConfirmation = _nullishCoalesce(request.requireConfirmation, () => ( true));
150
+ if (requireConfirmation && !confirmationValid) {
151
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
152
+ "Confirmation code mismatch",
153
+ "CONFIRMATION_MISMATCH"
154
+ );
155
+ }
156
+ if (authenticationInfo.newCounter <= credential.counter && credential.counter !== 0) {
157
+ throw new (0, _chunkJI6NIMGKcjs.HumanKeyError)(
158
+ `Signature counter did not increase (got ${authenticationInfo.newCounter}, expected > ${credential.counter}) \u2014 possible cloned key`,
159
+ "COUNTER_REPLAY"
160
+ );
161
+ }
162
+ return {
163
+ verified: true,
164
+ userVerified: authenticationInfo.userVerified,
165
+ confirmationValid,
166
+ newCounter: authenticationInfo.newCounter
167
+ };
168
+ }
169
+
170
+
171
+
172
+
173
+
174
+ exports.createChallenge = createChallenge; exports.verifyRegistration = verifyRegistration; exports.verifyTapProof = verifyTapProof;