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 +21 -0
- package/README.md +384 -0
- package/dist/chunk-CLQSCDXC.mjs +81 -0
- package/dist/chunk-FW3YUVJC.cjs +174 -0
- package/dist/chunk-JI6NIMGK.cjs +81 -0
- package/dist/chunk-XXJAJHNP.mjs +174 -0
- package/dist/errors-BmtCXo7w.d.cts +7 -0
- package/dist/errors-BmtCXo7w.d.ts +7 -0
- package/dist/express.cjs +124 -0
- package/dist/express.d.cts +52 -0
- package/dist/express.d.ts +52 -0
- package/dist/express.mjs +124 -0
- package/dist/index.cjs +167 -0
- package/dist/index.d.cts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.mjs +167 -0
- package/dist/types-By44RNIt.d.cts +134 -0
- package/dist/types-By44RNIt.d.ts +134 -0
- package/dist/verify.cjs +14 -0
- package/dist/verify.d.cts +59 -0
- package/dist/verify.d.ts +59 -0
- package/dist/verify.mjs +14 -0
- package/package.json +90 -0
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;
|