canary-kit 0.9.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.
Files changed (69) hide show
  1. package/CANARY.md +1065 -0
  2. package/INTEGRATION.md +351 -0
  3. package/LICENSE +21 -0
  4. package/NIP-CANARY.md +624 -0
  5. package/README.md +187 -0
  6. package/SECURITY.md +92 -0
  7. package/dist/beacon.d.ts +104 -0
  8. package/dist/beacon.d.ts.map +1 -0
  9. package/dist/beacon.js +197 -0
  10. package/dist/beacon.js.map +1 -0
  11. package/dist/counter.d.ts +37 -0
  12. package/dist/counter.d.ts.map +1 -0
  13. package/dist/counter.js +62 -0
  14. package/dist/counter.js.map +1 -0
  15. package/dist/crypto.d.ts +111 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +309 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/derive.d.ts +68 -0
  20. package/dist/derive.d.ts.map +1 -0
  21. package/dist/derive.js +85 -0
  22. package/dist/derive.js.map +1 -0
  23. package/dist/encoding.d.ts +56 -0
  24. package/dist/encoding.d.ts.map +1 -0
  25. package/dist/encoding.js +98 -0
  26. package/dist/encoding.js.map +1 -0
  27. package/dist/group.d.ts +185 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +263 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +12 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/nostr.d.ts +134 -0
  36. package/dist/nostr.d.ts.map +1 -0
  37. package/dist/nostr.js +175 -0
  38. package/dist/nostr.js.map +1 -0
  39. package/dist/presets.d.ts +26 -0
  40. package/dist/presets.d.ts.map +1 -0
  41. package/dist/presets.js +39 -0
  42. package/dist/presets.js.map +1 -0
  43. package/dist/session.d.ts +114 -0
  44. package/dist/session.d.ts.map +1 -0
  45. package/dist/session.js +173 -0
  46. package/dist/session.js.map +1 -0
  47. package/dist/sync-crypto.d.ts +66 -0
  48. package/dist/sync-crypto.d.ts.map +1 -0
  49. package/dist/sync-crypto.js +125 -0
  50. package/dist/sync-crypto.js.map +1 -0
  51. package/dist/sync.d.ts +191 -0
  52. package/dist/sync.d.ts.map +1 -0
  53. package/dist/sync.js +568 -0
  54. package/dist/sync.js.map +1 -0
  55. package/dist/token.d.ts +186 -0
  56. package/dist/token.d.ts.map +1 -0
  57. package/dist/token.js +344 -0
  58. package/dist/token.js.map +1 -0
  59. package/dist/verify.d.ts +45 -0
  60. package/dist/verify.d.ts.map +1 -0
  61. package/dist/verify.js +59 -0
  62. package/dist/verify.js.map +1 -0
  63. package/dist/wordlist.d.ts +28 -0
  64. package/dist/wordlist.d.ts.map +1 -0
  65. package/dist/wordlist.js +297 -0
  66. package/dist/wordlist.js.map +1 -0
  67. package/llms-full.txt +1461 -0
  68. package/llms.txt +180 -0
  69. package/package.json +144 -0
package/llms-full.txt ADDED
@@ -0,0 +1,1461 @@
1
+ # canary-kit — Full API Reference
2
+
3
+ > Deepfake-proof spoken-word identity verification. Open protocol, minimal dependencies.
4
+
5
+ Minimal dependencies. ESM-only. TypeScript native.
6
+
7
+ Repository: https://github.com/TheCryptoDonkey/canary-kit
8
+ Interactive demo: https://thecryptodonkey.github.io/canary-kit/
9
+ Licence: MIT
10
+
11
+ ## Install
12
+
13
+ ```
14
+ npm install canary-kit
15
+ ```
16
+
17
+ ## Imports
18
+
19
+ ```typescript
20
+ // Barrel (main API — group management, token protocol, beacon, wordlist)
21
+ import {
22
+ createGroup, getCurrentWord, getCurrentDuressWord, advanceCounter, reseed,
23
+ addMember, removeMember, syncCounter,
24
+ deriveToken, deriveDuressToken, verifyToken, deriveLivenessToken,
25
+ encodeAsWords, encodeAsPin, encodeAsHex, encodeToken,
26
+ deriveVerificationWord, deriveVerificationPhrase,
27
+ deriveDuressWord, deriveDuressPhrase,
28
+ verifyWord,
29
+ PRESETS,
30
+ deriveBeaconKey, encryptBeacon, decryptBeacon,
31
+ buildDuressAlert, encryptDuressAlert, decryptDuressAlert,
32
+ getCounter, counterToBytes,
33
+ WORDLIST, WORDLIST_SIZE, getWord, indexOf,
34
+ } from 'canary-kit'
35
+
36
+ // Subpath imports (tree-shakeable)
37
+ import { deriveToken, deriveDuressToken, verifyToken, deriveLivenessToken, deriveDirectionalPair } from 'canary-kit/token'
38
+ import { encodeAsWords, encodeAsPin, encodeAsHex, encodeToken } from 'canary-kit/encoding'
39
+ import { createSession, generateSeed, deriveSeed, SESSION_PRESETS } from 'canary-kit/session'
40
+ import { WORDLIST, WORDLIST_SIZE, getWord, indexOf } from 'canary-kit/wordlist'
41
+ import { KINDS, buildGroupEvent, buildSeedDistributionEvent, buildMemberUpdateEvent, buildReseedEvent, buildWordUsedEvent, buildBeaconEvent } from 'canary-kit/nostr'
42
+ import { deriveBeaconKey, encryptBeacon, decryptBeacon, buildDuressAlert, encryptDuressAlert, decryptDuressAlert } from 'canary-kit/beacon'
43
+ import { applySyncMessage, decodeSyncMessage, encodeSyncMessage, deriveGroupKey, deriveGroupSigningKey, hashGroupTag, encryptEnvelope, decryptEnvelope } from 'canary-kit/sync'
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Types
49
+
50
+ ```typescript
51
+ // ── Encoding ─────────────────────────────────────────────────────────────────
52
+
53
+ /** Controls how a token is encoded for spoken/displayed output. */
54
+ type TokenEncoding =
55
+ | { format: 'words'; count?: number; wordlist?: readonly string[] }
56
+ | { format: 'pin'; digits?: number }
57
+ | { format: 'hex'; length?: number }
58
+
59
+ /** Default encoding: a single word from the en-v1 wordlist. */
60
+ const DEFAULT_ENCODING: TokenEncoding // { format: 'words', count: 1 }
61
+
62
+ /** Maximum allowed tolerance/maxTolerance value. Prevents pathological iteration. */
63
+ const MAX_TOLERANCE = 10
64
+
65
+ // ── Token Protocol ────────────────────────────────────────────────────────────
66
+
67
+ /** Result of verifying a token against a group's secret and identities. */
68
+ interface TokenVerifyResult {
69
+ /** 'valid' = matches normal token, 'duress' = matches a duress token, 'invalid' = no match. */
70
+ status: 'valid' | 'duress' | 'invalid'
71
+ /** Identities of duress signallers (only present when status = 'duress'). */
72
+ identities?: string[]
73
+ }
74
+
75
+ /** Options for token verification. */
76
+ interface VerifyOptions {
77
+ /** Output encoding to use for comparison (default: single word). */
78
+ encoding?: TokenEncoding
79
+ /** Counter tolerance window: accept tokens within ±tolerance counter values (default: 0). */
80
+ tolerance?: number
81
+ }
82
+
83
+ /** A pair of directional tokens keyed by role name. */
84
+ interface DirectionalPair {
85
+ [role: string]: string
86
+ }
87
+
88
+ // ── Group Verification ────────────────────────────────────────────────────────
89
+
90
+ /** Outcome of group word verification. */
91
+ type VerifyStatus = 'verified' | 'duress' | 'stale' | 'failed'
92
+
93
+ /** Result of verifying a spoken word against a group. */
94
+ interface VerifyResult {
95
+ status: VerifyStatus
96
+ /** Pubkeys of members whose duress word matched (only when status = 'duress'). */
97
+ members?: string[]
98
+ }
99
+
100
+ // ── Group Management ──────────────────────────────────────────────────────────
101
+
102
+ /** Configuration for creating a new group. */
103
+ interface GroupConfig {
104
+ name: string
105
+ members: string[] // Nostr pubkeys (64-char hex)
106
+ preset?: PresetName // Named threat-profile preset
107
+ rotationInterval?: number // Seconds; overrides preset value
108
+ wordCount?: 1 | 2 | 3 // Words per challenge; overrides preset value
109
+ wordlist?: string // Wordlist identifier (default: 'en-v1')
110
+ beaconInterval?: number // Beacon broadcast interval in seconds (default: 300)
111
+ beaconPrecision?: number // Geohash precision for normal beacons 1–11 (default: 6)
112
+ }
113
+
114
+ /** Serialisable persistent state for a canary group. */
115
+ interface GroupState {
116
+ name: string
117
+ seed: string // 64-char hex (256-bit shared secret)
118
+ members: string[] // Nostr pubkeys (64-char hex)
119
+ rotationInterval: number // Seconds
120
+ wordCount: 1 | 2 | 3
121
+ wordlist: string // e.g. 'en-v1'
122
+ counter: number // Time-based counter at last sync
123
+ usageOffset: number // Burn-after-use offset on top of counter
124
+ createdAt: number // Unix timestamp
125
+ beaconInterval: number // Seconds
126
+ beaconPrecision: number // 1–11
127
+ }
128
+
129
+ // ── Group Presets ─────────────────────────────────────────────────────────────
130
+
131
+ /** Named threat-profile preset identifier. */
132
+ type PresetName = 'family' | 'field-ops' | 'enterprise'
133
+
134
+ /** A threat-profile preset for group creation. */
135
+ interface GroupPreset {
136
+ wordCount: 1 | 2 | 3
137
+ rotationInterval: number
138
+ description: string
139
+ }
140
+
141
+ // ── Session (two-party directional verification) ──────────────────────────────
142
+
143
+ /** Named session preset identifier. */
144
+ type SessionPresetName = 'call' | 'handoff'
145
+
146
+ /** A session preset for directional two-party verification. */
147
+ interface SessionPreset {
148
+ wordCount: number
149
+ rotationSeconds: number // 0 = fixed counter (single-use)
150
+ tolerance: number
151
+ directional: boolean
152
+ description: string
153
+ }
154
+
155
+ /** Configuration for creating a directional verification session. */
156
+ interface SessionConfig {
157
+ secret: Uint8Array | string // Shared secret (hex string or raw bytes)
158
+ namespace: string // Context namespace (e.g. 'aviva', 'dispatch')
159
+ roles: [string, string] // The two roles (e.g. ['caller', 'agent'])
160
+ myRole: string // Which role I am
161
+ rotationSeconds?: number // Default: 30
162
+ encoding?: TokenEncoding
163
+ tolerance?: number // Default: from preset or 0
164
+ theirIdentity?: string // Their identity string for duress detection
165
+ preset?: SessionPresetName
166
+ counter?: number // Fixed counter for single-use mode (rotationSeconds=0)
167
+ }
168
+
169
+ /** A role-aware, time-managed session for two-party verification. */
170
+ interface Session {
171
+ counter(nowSec?: number): number
172
+ myToken(nowSec?: number): string
173
+ theirToken(nowSec?: number): string
174
+ verify(spoken: string, nowSec?: number): TokenVerifyResult
175
+ pair(nowSec?: number): DirectionalPair
176
+ }
177
+
178
+ // ── Beacon ────────────────────────────────────────────────────────────────────
179
+
180
+ /** Decrypted content of a kind 20800 location beacon event. */
181
+ interface BeaconPayload {
182
+ geohash: string
183
+ precision: number
184
+ timestamp: number
185
+ }
186
+
187
+ /** Decrypted content of a duress alert beacon. */
188
+ interface DuressAlert {
189
+ type: 'duress'
190
+ member: string // Signalling member's pubkey
191
+ geohash: string
192
+ precision: number
193
+ locationSource: 'beacon' | 'verifier' | 'none'
194
+ timestamp: number
195
+ }
196
+
197
+ /** Location info supplied when building a duress alert. Null means no location. */
198
+ interface DuressLocation {
199
+ geohash: string
200
+ precision: number
201
+ locationSource: 'beacon' | 'verifier'
202
+ }
203
+
204
+ // ── Nostr ─────────────────────────────────────────────────────────────────────
205
+
206
+ /** Unsigned Nostr event (consumer signs with their own library). */
207
+ interface UnsignedEvent {
208
+ kind: number
209
+ content: string
210
+ tags: string[][]
211
+ created_at: number
212
+ }
213
+
214
+ interface GroupEventParams {
215
+ groupId: string
216
+ name: string
217
+ members: string[]
218
+ rotationInterval: number
219
+ wordCount: 1 | 2 | 3
220
+ wordlist: string
221
+ encryptedContent: string
222
+ expiration?: number
223
+ }
224
+
225
+ interface SeedDistributionParams {
226
+ recipientPubkey: string
227
+ groupEventId: string
228
+ encryptedContent: string
229
+ }
230
+
231
+ interface MemberUpdateParams {
232
+ groupId: string
233
+ action: 'add' | 'remove'
234
+ memberPubkey: string
235
+ reseed: boolean
236
+ encryptedContent: string
237
+ }
238
+
239
+ interface ReseedParams {
240
+ groupEventId: string
241
+ reason: 'member_removed' | 'compromise' | 'scheduled' | 'duress'
242
+ encryptedContent: string
243
+ }
244
+
245
+ interface WordUsedParams {
246
+ groupEventId: string
247
+ encryptedContent: string
248
+ }
249
+
250
+ interface BeaconEventParams {
251
+ groupId: string
252
+ encryptedContent: string
253
+ expiration?: number
254
+ }
255
+ ```
256
+
257
+ ---
258
+
259
+ ## canary-kit (main)
260
+
261
+ The barrel export. Covers group management, the CANARY protocol token API, encoding, wordlist, counter, and beacon functions. Omits session-specific exports (`createSession`, `generateSeed`, `deriveSeed`, `SESSION_PRESETS`) — import those from `canary-kit/session`.
262
+
263
+ ### deriveVerificationWord(seedHex, counter)
264
+
265
+ Derive the single verification word shared by all group members for a given counter.
266
+
267
+ ```typescript
268
+ deriveVerificationWord(seedHex: string, counter: number): string
269
+
270
+ deriveVerificationWord(
271
+ 'a'.repeat(64), // 64-char hex seed
272
+ 42, // current counter
273
+ )
274
+ // e.g. 'marble'
275
+ ```
276
+
277
+ Algorithm: `HMAC-SHA256(seed, counterToBytes(counter))`. First two bytes of digest `% 2048` → wordlist index.
278
+
279
+ ### deriveVerificationPhrase(seedHex, counter, wordCount)
280
+
281
+ Derive a multi-word verification phrase. Each word is derived from a consecutive 2-byte slice of the HMAC-SHA256 digest.
282
+
283
+ ```typescript
284
+ deriveVerificationPhrase(
285
+ seedHex: string,
286
+ counter: number,
287
+ wordCount: 1 | 2 | 3,
288
+ ): string[]
289
+
290
+ deriveVerificationPhrase('a'.repeat(64), 42, 2)
291
+ // e.g. ['marble', 'lantern']
292
+ ```
293
+
294
+ ### deriveDuressWord(seedHex, memberPubkeyHex, counter)
295
+
296
+ Derive a member's duress word — unique per member, derivable by all group members, guaranteed distinct from the current verification word (and adjacent counters). Collision avoidance retries up to 255 times with incrementing suffix bytes.
297
+
298
+ ```typescript
299
+ deriveDuressWord(
300
+ seedHex: string,
301
+ memberPubkeyHex: string, // 64-char hex Nostr pubkey
302
+ counter: number,
303
+ ): string
304
+
305
+ deriveDuressWord('a'.repeat(64), 'b'.repeat(64), 42)
306
+ // e.g. 'crystal' (always ≠ current verification word)
307
+ ```
308
+
309
+ ### deriveDuressPhrase(seedHex, memberPubkeyHex, counter, wordCount)
310
+
311
+ Derive a multi-word duress phrase for a given member. The entire phrase is guaranteed distinct from the verification phrase within the ±1 tolerance window.
312
+
313
+ ```typescript
314
+ deriveDuressPhrase(
315
+ seedHex: string,
316
+ memberPubkeyHex: string,
317
+ counter: number,
318
+ wordCount: 1 | 2 | 3,
319
+ ): string[]
320
+
321
+ deriveDuressPhrase('a'.repeat(64), 'b'.repeat(64), 42, 2)
322
+ // e.g. ['crystal', 'anchor']
323
+ ```
324
+
325
+ ### verifyWord(spokenWord, seedHex, memberPubkeys, counter, wordCount?)
326
+
327
+ Verify a spoken word (or phrase) against the group's current state. Returns a structured result indicating whether verification succeeded, duress was signalled, the token was stale, or no match was found.
328
+
329
+ Priority order:
330
+ 1. Current verification word → `verified`
331
+ 2. ALL members' duress words at current counter → `duress` (all matching members collected)
332
+ 3. ALL members' duress words at previous counter → `duress` (stale but still duress)
333
+ 4. Previous window's verification word → `stale`
334
+ 5. No match → `failed`
335
+
336
+ ```typescript
337
+ verifyWord(
338
+ spokenWord: string,
339
+ seedHex: string,
340
+ memberPubkeys: string[],
341
+ counter: number,
342
+ wordCount: 1 | 2 | 3 = 1,
343
+ ): VerifyResult
344
+
345
+ verifyWord('marble', seed, [alicePubkey, bobPubkey], 42)
346
+ // { status: 'verified' }
347
+
348
+ verifyWord('crystal', seed, [alicePubkey, bobPubkey], 42)
349
+ // { status: 'duress', members: ['<alice-pubkey>'] }
350
+
351
+ verifyWord('unknown', seed, [alicePubkey], 42)
352
+ // { status: 'failed' }
353
+ ```
354
+
355
+ ### createGroup(config)
356
+
357
+ Create a new group with a freshly generated seed and time-based counter.
358
+
359
+ ```typescript
360
+ createGroup(config: GroupConfig): GroupState
361
+
362
+ const state = createGroup({
363
+ name: 'Alpine Team',
364
+ members: ['<alice-64hex-pubkey>', '<bob-64hex-pubkey>'],
365
+ preset: 'field-ops',
366
+ })
367
+
368
+ // With explicit options
369
+ const state = createGroup({
370
+ name: 'Family',
371
+ members: ['<alice-pubkey>'],
372
+ rotationInterval: 604_800, // 7 days
373
+ wordCount: 1,
374
+ })
375
+ ```
376
+
377
+ ### getCurrentWord(state)
378
+
379
+ Return the current verification word (or space-joined phrase) for all group members.
380
+
381
+ ```typescript
382
+ getCurrentWord(state: GroupState): string
383
+
384
+ getCurrentWord(state) // e.g. 'marble' or 'marble lantern' (if wordCount=2)
385
+ ```
386
+
387
+ ### getCurrentDuressWord(state, memberPubkey)
388
+
389
+ Return the duress word (or phrase) for a specific member at the current counter.
390
+
391
+ ```typescript
392
+ getCurrentDuressWord(state: GroupState, memberPubkey: string): string
393
+
394
+ getCurrentDuressWord(state, '<alice-pubkey>') // e.g. 'crystal'
395
+ ```
396
+
397
+ ### advanceCounter(state)
398
+
399
+ Advance the usage offset by one, rotating the current word (burn-after-use). Returns new state — does not mutate.
400
+
401
+ ```typescript
402
+ advanceCounter(state: GroupState): GroupState
403
+
404
+ const next = advanceCounter(state)
405
+ getCurrentWord(next) // different word from getCurrentWord(state)
406
+ ```
407
+
408
+ ### reseed(state)
409
+
410
+ Generate a fresh seed and reset the usage offset. Call after a suspected security event. Returns new state — does not mutate.
411
+
412
+ ```typescript
413
+ reseed(state: GroupState): GroupState
414
+
415
+ const safe = reseed(state)
416
+ // safe.seed is a new random 64-char hex string
417
+ // safe.usageOffset === 0
418
+ ```
419
+
420
+ ### addMember(state, pubkey)
421
+
422
+ Add a member to the group. Idempotent — returns existing state if pubkey already present. Returns new state — does not mutate.
423
+
424
+ ```typescript
425
+ addMember(state: GroupState, pubkey: string): GroupState
426
+
427
+ const updated = addMember(state, '<charlie-pubkey>')
428
+ ```
429
+
430
+ ### removeMember(state, pubkey)
431
+
432
+ Remove a member from the group and immediately reseed to invalidate the old shared secret. Returns new state — does not mutate.
433
+
434
+ ```typescript
435
+ removeMember(state: GroupState, pubkey: string): GroupState
436
+
437
+ const updated = removeMember(state, '<alice-pubkey>')
438
+ // updated.seed !== state.seed (reseeded)
439
+ // updated.members does not include alice
440
+ ```
441
+
442
+ ### syncCounter(state, nowSec?)
443
+
444
+ Refresh the counter to the current time window. Call after loading persisted state. Returns new state — does not mutate.
445
+
446
+ ```typescript
447
+ syncCounter(
448
+ state: GroupState,
449
+ nowSec: number = Math.floor(Date.now() / 1000),
450
+ ): GroupState
451
+
452
+ const current = syncCounter(state)
453
+ // current.counter is up to date with the current time window
454
+ ```
455
+
456
+ ### deriveToken(secret, context, counter, encoding?)
457
+
458
+ CANARY-DERIVE: Derive an encoded token string from a shared secret, context string, and counter.
459
+
460
+ Algorithm: `HMAC-SHA256(secret, utf8(context) || counter_be32)`, then encoded per the `encoding` option.
461
+
462
+ ```typescript
463
+ deriveToken(
464
+ secret: Uint8Array | string, // hex string or raw bytes
465
+ context: string, // application-specific context string
466
+ counter: number, // 32-bit unsigned integer
467
+ encoding: TokenEncoding = DEFAULT_ENCODING,
468
+ ): string
469
+
470
+ deriveToken(secret, 'aviva:caller', 42)
471
+ // e.g. 'marble' (single word, default encoding)
472
+
473
+ deriveToken(secret, 'aviva:caller', 42, { format: 'pin', digits: 6 })
474
+ // e.g. '047291'
475
+
476
+ deriveToken(secret, 'aviva:caller', 42, { format: 'hex', length: 16 })
477
+ // e.g. 'a3f2c1d4e5b60789'
478
+ ```
479
+
480
+ ### deriveTokenBytes(secret, context, counter)
481
+
482
+ CANARY-DERIVE: Derive raw 32-byte token bytes without encoding.
483
+
484
+ ```typescript
485
+ deriveTokenBytes(
486
+ secret: Uint8Array | string,
487
+ context: string,
488
+ counter: number,
489
+ ): Uint8Array
490
+
491
+ const bytes = deriveTokenBytes(secret, 'aviva:caller', 42)
492
+ // Uint8Array(32) [...]
493
+ ```
494
+
495
+ ### deriveDuressToken(secret, context, identity, counter, encoding, maxTolerance)
496
+
497
+ CANARY-DURESS: Derive an encoded duress token for a specific identity, with collision avoidance against normal tokens within ±(2 × maxTolerance) counter values. **maxTolerance is required** — it must match the tolerance used by verifiers. Both maxTolerance and tolerance are capped at MAX_TOLERANCE (10).
498
+
499
+ Algorithm: `HMAC-SHA256(secret, utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32)`. Retries with suffix bytes 0x01–0xFF until the encoded token is distinct from any normal token in the tolerance window.
500
+
501
+ ```typescript
502
+ deriveDuressToken(
503
+ secret: Uint8Array | string,
504
+ context: string,
505
+ identity: string, // e.g. customer ID, Nostr pubkey
506
+ counter: number,
507
+ encoding: TokenEncoding, // use DEFAULT_ENCODING for single word
508
+ maxTolerance: number, // REQUIRED — must match verifier's tolerance
509
+ ): string
510
+
511
+ deriveDuressToken(secret, 'aviva', 'customer-123', 42, DEFAULT_ENCODING, 1)
512
+ // e.g. 'anchor' (guaranteed ≠ any normal token in ±2 window)
513
+ ```
514
+
515
+ ### deriveDuressTokenBytes(secret, context, identity, counter)
516
+
517
+ CANARY-DURESS: Derive raw duress token bytes without encoding or collision avoidance.
518
+
519
+ ```typescript
520
+ deriveDuressTokenBytes(
521
+ secret: Uint8Array | string,
522
+ context: string,
523
+ identity: string,
524
+ counter: number,
525
+ ): Uint8Array
526
+ ```
527
+
528
+ ### verifyToken(secret, context, counter, input, identities, options?)
529
+
530
+ CANARY-DURESS: Verify a spoken/entered token against a group. Checks in priority order: normal (exact counter) → duress (all identities, full tolerance window) → normal (remaining tolerance window) → invalid.
531
+
532
+ ```typescript
533
+ verifyToken(
534
+ secret: Uint8Array | string,
535
+ context: string,
536
+ counter: number,
537
+ input: string,
538
+ identities: string[], // identity strings for duress detection
539
+ options?: VerifyOptions,
540
+ ): TokenVerifyResult
541
+
542
+ verifyToken(secret, 'aviva', 42, 'marble', ['customer-123'])
543
+ // { status: 'valid' }
544
+
545
+ verifyToken(secret, 'aviva', 42, 'anchor', ['customer-123'])
546
+ // { status: 'duress', identities: ['customer-123'] }
547
+
548
+ verifyToken(secret, 'aviva', 42, 'unknown', ['customer-123'])
549
+ // { status: 'invalid' }
550
+
551
+ // With tolerance window
552
+ verifyToken(secret, 'aviva', 42, 'marble', [], { tolerance: 1 })
553
+ // { status: 'valid' } — accepts tokens from counter 41, 42, or 43
554
+ ```
555
+
556
+ ### deriveLivenessToken(secret, context, identity, counter)
557
+
558
+ CANARY-DURESS: Derive a liveness heartbeat token for a dead man's switch. If heartbeats stop arriving, the implementation triggers its DMS response.
559
+
560
+ Algorithm: `HMAC-SHA256(secret, utf8(context + ":alive") || 0x00 || utf8(identity) || counter_be32)`
561
+
562
+ ```typescript
563
+ deriveLivenessToken(
564
+ secret: Uint8Array | string,
565
+ context: string,
566
+ identity: string,
567
+ counter: number,
568
+ ): Uint8Array
569
+
570
+ const heartbeat = deriveLivenessToken(secret, 'my-app', 'alice', currentCounter)
571
+ // Uint8Array(32) [...] — publish periodically; absence triggers DMS
572
+ ```
573
+
574
+ ### PRESETS
575
+
576
+ Built-in threat-profile presets for group creation. See Preset Tables section.
577
+
578
+ ```typescript
579
+ const PRESETS: Readonly<Record<PresetName, Readonly<GroupPreset>>>
580
+
581
+ PRESETS['family'] // { wordCount: 1, rotationInterval: 604800, description: '...' }
582
+ PRESETS['field-ops'] // { wordCount: 2, rotationInterval: 86400, description: '...' }
583
+ PRESETS['enterprise'] // { wordCount: 2, rotationInterval: 172800, description: '...' }
584
+ ```
585
+
586
+ ### deriveBeaconKey(seedHex)
587
+
588
+ Derive a 256-bit AES key from the group seed for beacon and duress encryption. Deterministic: same seed always yields the same key.
589
+
590
+ ```typescript
591
+ deriveBeaconKey(seedHex: string): Uint8Array
592
+
593
+ const key = deriveBeaconKey(state.seed)
594
+ // Uint8Array(32) [...]
595
+ ```
596
+
597
+ ### encryptBeacon(key, geohash, precision)
598
+
599
+ Encrypt a location beacon payload with the group's beacon key. Returns a base64 string for a Nostr event's `content` field. (async, AES-256-GCM)
600
+
601
+ ```typescript
602
+ encryptBeacon(
603
+ key: Uint8Array,
604
+ geohash: string,
605
+ precision: number,
606
+ ): Promise<string>
607
+
608
+ const key = deriveBeaconKey(state.seed)
609
+ const content = await encryptBeacon(key, 'gcpvjb', 6)
610
+ // 'base64-encoded-ciphertext...'
611
+ ```
612
+
613
+ ### decryptBeacon(key, content)
614
+
615
+ Decrypt a location beacon event's content. Throws if the key is wrong or the ciphertext is tampered with.
616
+
617
+ ```typescript
618
+ decryptBeacon(key: Uint8Array, content: string): Promise<BeaconPayload>
619
+
620
+ const payload = await decryptBeacon(key, content)
621
+ // { geohash: 'gcpvjb', precision: 6, timestamp: 1709461234 }
622
+ ```
623
+
624
+ ### buildDuressAlert(memberPubkey, location)
625
+
626
+ Construct a duress alert payload. Pass `null` for location when no location is available.
627
+
628
+ ```typescript
629
+ buildDuressAlert(
630
+ memberPubkey: string,
631
+ location: DuressLocation | null,
632
+ ): DuressAlert
633
+
634
+ buildDuressAlert('<alice-pubkey>', { geohash: 'gcpvjbsm5', precision: 9, locationSource: 'verifier' })
635
+ // { type: 'duress', member: '<alice-pubkey>', geohash: 'gcpvjbsm5', precision: 9, locationSource: 'verifier', timestamp: ... }
636
+
637
+ buildDuressAlert('<alice-pubkey>', null)
638
+ // { type: 'duress', member: '<alice-pubkey>', geohash: '', precision: 0, locationSource: 'none', timestamp: ... }
639
+ ```
640
+
641
+ ### encryptDuressAlert(key, alert)
642
+
643
+ Encrypt a duress alert with the group's beacon key. (async, AES-256-GCM)
644
+
645
+ ```typescript
646
+ encryptDuressAlert(key: Uint8Array, alert: DuressAlert): Promise<string>
647
+
648
+ const content = await encryptDuressAlert(key, alert)
649
+ // 'base64-encoded-ciphertext...'
650
+ ```
651
+
652
+ ### decryptDuressAlert(key, content)
653
+
654
+ Decrypt a duress alert event's content.
655
+
656
+ ```typescript
657
+ decryptDuressAlert(key: Uint8Array, content: string): Promise<DuressAlert>
658
+
659
+ const alert = await decryptDuressAlert(key, content)
660
+ // { type: 'duress', member: '...', geohash: '...', ... }
661
+ ```
662
+
663
+ ### getCounter(timestampSec, rotationIntervalSec?)
664
+
665
+ Derive the current counter from a Unix timestamp and rotation interval.
666
+
667
+ ```typescript
668
+ getCounter(
669
+ timestampSec: number,
670
+ rotationIntervalSec: number = 604_800, // 7 days
671
+ ): number
672
+
673
+ getCounter(Math.floor(Date.now() / 1000)) // current weekly counter
674
+ getCounter(Math.floor(Date.now() / 1000), 86_400) // current daily counter
675
+ getCounter(1_000_000, 604_800) // 1
676
+ ```
677
+
678
+ ### counterToBytes(counter)
679
+
680
+ Serialise a counter to an 8-byte big-endian Uint8Array (same encoding as TOTP, RFC 6238).
681
+
682
+ ```typescript
683
+ counterToBytes(counter: number): Uint8Array
684
+
685
+ counterToBytes(42) // Uint8Array(8) [0, 0, 0, 0, 0, 0, 0, 42]
686
+ ```
687
+
688
+ ### DEFAULT_ROTATION_INTERVAL
689
+
690
+ Seven days in seconds.
691
+
692
+ ```typescript
693
+ const DEFAULT_ROTATION_INTERVAL: number // 604_800
694
+ ```
695
+
696
+ ### WORDLIST
697
+
698
+ The complete 2048-word en-v1 English wordlist for spoken-word encoding.
699
+
700
+ ```typescript
701
+ const WORDLIST: readonly string[] // 2048 entries
702
+ ```
703
+
704
+ ### WORDLIST_SIZE
705
+
706
+ ```typescript
707
+ const WORDLIST_SIZE: number // 2048
708
+ ```
709
+
710
+ ### getWord(index)
711
+
712
+ Return the word at the given index. Throws `RangeError` if index is outside 0–2047.
713
+
714
+ ```typescript
715
+ getWord(index: number): string
716
+
717
+ getWord(0) // 'ability'
718
+ getWord(2047) // 'zoo'
719
+ getWord(2048) // throws RangeError
720
+ ```
721
+
722
+ ### indexOf(word)
723
+
724
+ Return the index of a word, or -1 if not in the wordlist.
725
+
726
+ ```typescript
727
+ indexOf(word: string): number
728
+
729
+ indexOf('marble') // e.g. 1103
730
+ indexOf('unknown') // -1
731
+ ```
732
+
733
+ ---
734
+
735
+ ## canary-kit/token
736
+
737
+ The universal CANARY protocol — context-string-based derivation, duress, and liveness. Use this directly when you need fine-grained control (custom context strings, arbitrary identity sets, raw bytes).
738
+
739
+ All functions from the main barrel (`deriveToken`, `deriveTokenBytes`, `deriveDuressToken`, `deriveDuressTokenBytes`, `verifyToken`, `deriveLivenessToken`) are in this module, plus:
740
+
741
+ ### deriveDirectionalPair(secret, namespace, roles, counter, encoding?)
742
+
743
+ Derive two distinct tokens from the same secret — one per role. Neither token can be derived from the other without the shared secret, preventing the "echo problem" where the second speaker parrots the first.
744
+
745
+ Each token uses `context = ${namespace}:${role}`.
746
+
747
+ ```typescript
748
+ deriveDirectionalPair(
749
+ secret: Uint8Array | string,
750
+ namespace: string,
751
+ roles: [string, string],
752
+ counter: number,
753
+ encoding: TokenEncoding = DEFAULT_ENCODING,
754
+ ): DirectionalPair
755
+
756
+ deriveDirectionalPair(secret, 'aviva', ['caller', 'agent'], 42)
757
+ // { caller: 'marble', agent: 'lantern' }
758
+ // caller speaks 'marble', agent speaks 'lantern'
759
+ // neither can be derived from the other
760
+ ```
761
+
762
+ ---
763
+
764
+ ## canary-kit/encoding
765
+
766
+ Output encoding for CANARY tokens. All functions accept raw `Uint8Array` bytes and return human-readable strings.
767
+
768
+ ### encodeAsWords(bytes, count?, wordlist?)
769
+
770
+ Encode raw bytes as words using 11-bit indices into the wordlist. Each word uses 2 consecutive bytes: `readUint16BE(bytes, i*2) % 2048`.
771
+
772
+ ```typescript
773
+ encodeAsWords(
774
+ bytes: Uint8Array,
775
+ count: number = 1,
776
+ wordlist: readonly string[] = WORDLIST,
777
+ ): string[]
778
+
779
+ encodeAsWords(someBytes, 1) // ['marble']
780
+ encodeAsWords(someBytes, 3) // ['marble', 'lantern', 'copper']
781
+
782
+ // Custom wordlist (must be exactly 2048 entries)
783
+ encodeAsWords(someBytes, 1, myCustomWordlist)
784
+ ```
785
+
786
+ Throws `RangeError` if `wordlist.length !== 2048`, `count` is outside 1–16, or `bytes.length < count * 2`.
787
+
788
+ ### encodeAsPin(bytes, digits?)
789
+
790
+ Encode raw bytes as a zero-padded numeric PIN.
791
+
792
+ ```typescript
793
+ encodeAsPin(bytes: Uint8Array, digits: number = 4): string
794
+
795
+ encodeAsPin(someBytes, 4) // '0472'
796
+ encodeAsPin(someBytes, 6) // '047291'
797
+ encodeAsPin(someBytes, 10) // '0472918345'
798
+ ```
799
+
800
+ Throws `RangeError` if `digits` is outside 1–10.
801
+
802
+ ### encodeAsHex(bytes, length?)
803
+
804
+ Encode raw bytes as a lowercase hex string.
805
+
806
+ ```typescript
807
+ encodeAsHex(bytes: Uint8Array, length: number = 8): string
808
+
809
+ encodeAsHex(someBytes, 8) // 'a3f2c1d4'
810
+ encodeAsHex(someBytes, 16) // 'a3f2c1d4e5b60789'
811
+ encodeAsHex(someBytes, 64) // full 32-byte hex
812
+ ```
813
+
814
+ Throws `RangeError` if `length` is outside 1–64.
815
+
816
+ ### encodeToken(bytes, encoding?)
817
+
818
+ Encode raw bytes using the specified encoding format. Words are space-joined.
819
+
820
+ ```typescript
821
+ encodeToken(bytes: Uint8Array, encoding: TokenEncoding = DEFAULT_ENCODING): string
822
+
823
+ encodeToken(bytes)
824
+ // 'marble' (default: single word)
825
+
826
+ encodeToken(bytes, { format: 'words', count: 2 })
827
+ // 'marble lantern'
828
+
829
+ encodeToken(bytes, { format: 'pin', digits: 6 })
830
+ // '047291'
831
+
832
+ encodeToken(bytes, { format: 'hex', length: 16 })
833
+ // 'a3f2c1d4e5b60789'
834
+
835
+ encodeToken(bytes, { format: 'words', count: 1, wordlist: myWordlist })
836
+ // 'custom-word'
837
+ ```
838
+
839
+ ---
840
+
841
+ ## canary-kit/session
842
+
843
+ High-level two-party directional verification sessions. Wraps the low-level token API with role awareness, time-based counter management, and optional duress detection.
844
+
845
+ ### generateSeed()
846
+
847
+ Generate a cryptographically secure 256-bit session seed using `crypto.getRandomValues`.
848
+
849
+ ```typescript
850
+ generateSeed(): Uint8Array
851
+
852
+ const seed = generateSeed()
853
+ // Uint8Array(32) [random bytes...]
854
+ ```
855
+
856
+ ### deriveSeed(masterKey, ...components)
857
+
858
+ Derive a seed deterministically from a master key and string components. Null-byte separators prevent concatenation ambiguity.
859
+
860
+ Algorithm: `HMAC-SHA256(masterKey, utf8(components[0]) || 0x00 || utf8(components[1]) || ...)`
861
+
862
+ ```typescript
863
+ deriveSeed(
864
+ masterKey: Uint8Array | string,
865
+ ...components: string[]
866
+ ): Uint8Array
867
+
868
+ deriveSeed(masterKey, 'session', 'customer-123')
869
+ // Uint8Array(32) [deterministic bytes...]
870
+
871
+ // Different components always produce different seeds
872
+ deriveSeed(masterKey, 'session', 'customer-456')
873
+ // Uint8Array(32) [different deterministic bytes...]
874
+ ```
875
+
876
+ ### createSession(config)
877
+
878
+ Create a directional verification session with role awareness and time management.
879
+
880
+ ```typescript
881
+ createSession(config: SessionConfig): Session
882
+
883
+ // Phone call verification (using preset)
884
+ const callerSession = createSession({
885
+ secret: sharedSecret,
886
+ namespace: 'aviva',
887
+ roles: ['caller', 'agent'],
888
+ myRole: 'caller',
889
+ preset: 'call',
890
+ theirIdentity: 'customer-123', // for duress detection
891
+ })
892
+
893
+ // Physical handoff (single-use)
894
+ const handoffSession = createSession({
895
+ secret: taskSecret,
896
+ namespace: 'dispatch',
897
+ roles: ['driver', 'recipient'],
898
+ myRole: 'driver',
899
+ preset: 'handoff',
900
+ counter: taskId,
901
+ })
902
+
903
+ // Manual configuration
904
+ const session = createSession({
905
+ secret: sharedSecret,
906
+ namespace: 'my-app',
907
+ roles: ['alice', 'bob'],
908
+ myRole: 'alice',
909
+ rotationSeconds: 30,
910
+ tolerance: 1,
911
+ encoding: { format: 'words', count: 2 },
912
+ })
913
+ ```
914
+
915
+ Throws if `roles[0] === roles[1]` or if `myRole` is not one of the two roles.
916
+
917
+ #### session.counter(nowSec?)
918
+
919
+ Get the current counter (time-based or fixed).
920
+
921
+ ```typescript
922
+ session.counter(): number
923
+ session.counter(nowSec) // pass explicit time for testing
924
+ ```
925
+
926
+ #### session.myToken(nowSec?)
927
+
928
+ Get the token I speak to prove my identity.
929
+
930
+ ```typescript
931
+ session.myToken(): string // e.g. 'marble'
932
+ ```
933
+
934
+ #### session.theirToken(nowSec?)
935
+
936
+ Get the token I expect to hear from the other party.
937
+
938
+ ```typescript
939
+ session.theirToken(): string // e.g. 'lantern'
940
+ ```
941
+
942
+ #### session.verify(spoken, nowSec?)
943
+
944
+ Verify a word spoken to me against the other party's context. Applies the configured tolerance window and duress detection.
945
+
946
+ ```typescript
947
+ session.verify(spoken: string, nowSec?: number): TokenVerifyResult
948
+
949
+ session.verify('lantern')
950
+ // { status: 'valid' }
951
+
952
+ session.verify('anchor') // if duress token for customer-123
953
+ // { status: 'duress', identities: ['customer-123'] }
954
+
955
+ session.verify('unknown')
956
+ // { status: 'invalid' }
957
+ ```
958
+
959
+ #### session.pair(nowSec?)
960
+
961
+ Get both tokens at once, keyed by role name.
962
+
963
+ ```typescript
964
+ session.pair(): DirectionalPair
965
+
966
+ session.pair()
967
+ // { caller: 'marble', agent: 'lantern' }
968
+ ```
969
+
970
+ ### SESSION_PRESETS
971
+
972
+ Built-in session presets. See Session Preset Table section.
973
+
974
+ ```typescript
975
+ const SESSION_PRESETS: Readonly<Record<SessionPresetName, Readonly<SessionPreset>>>
976
+
977
+ SESSION_PRESETS.call
978
+ // { wordCount: 1, rotationSeconds: 30, tolerance: 1, directional: true, description: '...' }
979
+
980
+ SESSION_PRESETS.handoff
981
+ // { wordCount: 1, rotationSeconds: 0, tolerance: 0, directional: true, description: '...' }
982
+ ```
983
+
984
+ ---
985
+
986
+ ## canary-kit/wordlist
987
+
988
+ Direct access to the en-v1 wordlist and lookup utilities.
989
+
990
+ All four exports (`WORDLIST`, `WORDLIST_SIZE`, `getWord`, `indexOf`) are documented in the main barrel section above.
991
+
992
+ ### Wordlist properties
993
+
994
+ - 2048 words
995
+ - Based on BIP-39 English, filtered for verbal verification
996
+ - No homophones, no phonetic near-collisions, no emotionally charged words
997
+ - Backfilled with concrete, unambiguous supplementary words
998
+ - All words are 3–8 characters, lowercase alphabetic only
999
+
1000
+ ```typescript
1001
+ WORDLIST[0] // 'ability'
1002
+ WORDLIST[1023] // 'merge'
1003
+ WORDLIST[2047] // 'zoo'
1004
+ ```
1005
+
1006
+ ---
1007
+
1008
+ ## canary-kit/nostr
1009
+
1010
+ Nostr event builders for the CANARY NIP. All builders return unsigned events (`UnsignedEvent`) — sign with your own Nostr library (e.g. `nostr-tools`).
1011
+
1012
+ ### KINDS
1013
+
1014
+ ```typescript
1015
+ const KINDS = {
1016
+ group: 38_800, // replaceable — group announcement
1017
+ seedDistribution: 28_800, // ephemeral — seed delivery to member
1018
+ memberUpdate: 38_801, // replaceable — add/remove member
1019
+ reseed: 28_801, // ephemeral — new seed broadcast
1020
+ wordUsed: 28_802, // ephemeral — burn-after-use notification
1021
+ beacon: 20_800, // ephemeral — encrypted location beacon
1022
+ } as const
1023
+ ```
1024
+
1025
+ ### buildGroupEvent(params)
1026
+
1027
+ Build a kind 38800 group announcement event.
1028
+
1029
+ ```typescript
1030
+ buildGroupEvent(params: GroupEventParams): UnsignedEvent
1031
+
1032
+ buildGroupEvent({
1033
+ groupId: 'alpine-team-1',
1034
+ name: 'Alpine Team',
1035
+ members: ['<alice-pubkey>', '<bob-pubkey>'],
1036
+ rotationInterval: 86_400,
1037
+ wordCount: 2,
1038
+ wordlist: 'en-v1',
1039
+ encryptedContent: '<NIP-44 encrypted group metadata>',
1040
+ expiration: Math.floor(Date.now() / 1000) + 30 * 86_400, // 30 days
1041
+ })
1042
+ // { kind: 38800, content: '...', tags: [['d','alpine-team-1'],['name','Alpine Team'],['p','<alice>'],['p','<bob>'],['rotation','86400'],['words','2'],['wordlist','en-v1'],['expiration','...']], created_at: ... }
1043
+ ```
1044
+
1045
+ ### buildSeedDistributionEvent(params)
1046
+
1047
+ Build a kind 28800 seed distribution event, sent privately to each member.
1048
+
1049
+ ```typescript
1050
+ buildSeedDistributionEvent(params: SeedDistributionParams): UnsignedEvent
1051
+
1052
+ buildSeedDistributionEvent({
1053
+ recipientPubkey: '<alice-pubkey>',
1054
+ groupEventId: '<kind-38800-event-id>',
1055
+ encryptedContent: '<NIP-44 encrypted seed for alice>',
1056
+ })
1057
+ // { kind: 28800, content: '...', tags: [['p','<alice>'],['e','<group-id>']], created_at: ... }
1058
+ ```
1059
+
1060
+ ### buildMemberUpdateEvent(params)
1061
+
1062
+ Build a kind 38801 member update event.
1063
+
1064
+ ```typescript
1065
+ buildMemberUpdateEvent(params: MemberUpdateParams): UnsignedEvent
1066
+
1067
+ buildMemberUpdateEvent({
1068
+ groupId: 'alpine-team-1',
1069
+ action: 'add',
1070
+ memberPubkey: '<charlie-pubkey>',
1071
+ reseed: true,
1072
+ encryptedContent: '<encrypted update content>',
1073
+ })
1074
+ // { kind: 38801, ... tags: [['d','alpine-team-1'],['action','add'],['p','<charlie>'],['reseed','true']], ... }
1075
+ ```
1076
+
1077
+ ### buildReseedEvent(params)
1078
+
1079
+ Build a kind 28801 reseed event.
1080
+
1081
+ ```typescript
1082
+ buildReseedEvent(params: ReseedParams): UnsignedEvent
1083
+
1084
+ buildReseedEvent({
1085
+ groupEventId: '<kind-38800-event-id>',
1086
+ reason: 'member_removed',
1087
+ encryptedContent: '<NIP-44 encrypted new seed>',
1088
+ })
1089
+ // { kind: 28801, ... tags: [['e','<group-id>'],['reason','member_removed']], ... }
1090
+ ```
1091
+
1092
+ Valid reason values: `'member_removed'` | `'compromise'` | `'scheduled'` | `'duress'`
1093
+
1094
+ ### buildWordUsedEvent(params)
1095
+
1096
+ Build a kind 28802 word-used (burn-after-use) notification event.
1097
+
1098
+ ```typescript
1099
+ buildWordUsedEvent(params: WordUsedParams): UnsignedEvent
1100
+
1101
+ buildWordUsedEvent({
1102
+ groupEventId: '<kind-38800-event-id>',
1103
+ encryptedContent: '<encrypted burn notification>',
1104
+ })
1105
+ // { kind: 28802, ... tags: [['e','<group-id>']], ... }
1106
+ ```
1107
+
1108
+ ### buildBeaconEvent(params)
1109
+
1110
+ Build a kind 20800 encrypted location beacon event.
1111
+
1112
+ ```typescript
1113
+ buildBeaconEvent(params: BeaconEventParams): UnsignedEvent
1114
+
1115
+ buildBeaconEvent({
1116
+ groupId: 'alpine-team-1',
1117
+ encryptedContent: '<AES-256-GCM encrypted beacon payload>',
1118
+ expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour
1119
+ })
1120
+ // { kind: 20800, ... tags: [['h','alpine-team-1'],['expiration','...']], ... }
1121
+ ```
1122
+
1123
+ ---
1124
+
1125
+ ## canary-kit/beacon
1126
+
1127
+ Encrypted location beacons and duress alerts for canary groups.
1128
+
1129
+ Key derivation is synchronous (HMAC-SHA256). Encryption is asynchronous (AES-256-GCM via `crypto.subtle`). The IV is a random 12-byte prefix prepended to the ciphertext; the combined value is base64-encoded.
1130
+
1131
+ All functions from the main barrel (`deriveBeaconKey`, `encryptBeacon`, `decryptBeacon`, `buildDuressAlert`, `encryptDuressAlert`, `decryptDuressAlert`) are documented in the main section above.
1132
+
1133
+ ---
1134
+
1135
+ ## canary-kit/sync
1136
+
1137
+ Transport-agnostic group state synchronisation with authority model and replay protection.
1138
+
1139
+ ### Types
1140
+
1141
+ ```typescript
1142
+ /** A typed, serialisable description of a group state change. */
1143
+ type SyncMessage =
1144
+ | { type: 'member-join'; pubkey: string; displayName?: string; timestamp: number; epoch: number; opId: string }
1145
+ | { type: 'member-leave'; pubkey: string; timestamp: number; epoch: number; opId: string }
1146
+ | { type: 'counter-advance'; counter: number; usageOffset: number; timestamp: number }
1147
+ | { type: 'reseed'; seed: Uint8Array; counter: number; timestamp: number; epoch: number; opId: string; admins: string[]; members: string[] }
1148
+ | { type: 'beacon'; lat: number; lon: number; accuracy: number; timestamp: number; opId: string }
1149
+ | { type: 'duress-alert'; lat: number; lon: number; timestamp: number; opId: string; subject?: string }
1150
+ | { type: 'liveness-checkin'; pubkey: string; timestamp: number; opId: string }
1151
+ | { type: 'state-snapshot'; seed: string; counter: number; usageOffset: number; members: string[]; admins: string[]; epoch: number; opId: string; timestamp: number; prevEpochSeed?: string }
1152
+
1153
+ interface SyncResult {
1154
+ state: GroupState
1155
+ applied: boolean
1156
+ reason?: string
1157
+ }
1158
+ ```
1159
+
1160
+ ### Functions
1161
+
1162
+ ```typescript
1163
+ decodeSyncMessage(json: string): SyncMessage // Parse and validate a JSON sync message
1164
+ encodeSyncMessage(msg: SyncMessage): string // Serialise a sync message to JSON
1165
+ applySyncMessage(state: GroupState, msg: SyncMessage, sender?: string): SyncResult // Apply a validated message to group state
1166
+ ```
1167
+
1168
+ ### Envelope Encryption
1169
+
1170
+ ```typescript
1171
+ deriveGroupKey(seed: string): Uint8Array // Derive AES-256-GCM group key from seed
1172
+ deriveGroupSigningKey(seed: string): Uint8Array // Derive HMAC signing key from seed
1173
+ hashGroupTag(groupId: string): string // Privacy-preserving group tag hash
1174
+ encryptEnvelope(key: Uint8Array, plaintext: string): Promise<string> // AES-256-GCM encrypt
1175
+ decryptEnvelope(key: Uint8Array, ciphertext: string): Promise<string> // AES-256-GCM decrypt
1176
+ ```
1177
+
1178
+ ### Authority Invariants
1179
+
1180
+ | ID | Rule |
1181
+ |---|---|
1182
+ | I1 | Sender must be admin for privileged actions (member-join, member-leave of others) |
1183
+ | I2 | opId replay guard within epoch (consumedOps set) |
1184
+ | I3 | Non-reseed privileged ops: epoch must match local epoch |
1185
+ | I4 | Reseed: epoch must equal local epoch + 1 |
1186
+ | I5 | Snapshot: epoch >= local epoch, anti-rollback for same-epoch |
1187
+ | I6 | Stale epoch rejection for all privileged ops |
1188
+
1189
+ ### Constants
1190
+
1191
+ ```typescript
1192
+ FIRE_AND_FORGET_FRESHNESS_SEC = 300 // Max age for fire-and-forget messages
1193
+ MAX_FUTURE_SKEW_SEC = 60 // Max future timestamp skew
1194
+ PROTOCOL_VERSION = 2 // Current sync protocol version
1195
+ ```
1196
+
1197
+ ---
1198
+
1199
+ ## Preset Tables
1200
+
1201
+ ### Group Presets
1202
+
1203
+ Used with `createGroup({ preset: '...' })`.
1204
+
1205
+ | Preset | Words | Rotation | Entropy | Use case |
1206
+ |---|---|---|---|---|
1207
+ | `family` | 1 | 7 days (604,800 s) | ~11 bits | Casual family/friend verification. Single word, weekly rotation. Adequate for live voice calls. |
1208
+ | `field-ops` | 2 | 24 hours (86,400 s) | ~22 bits | Journalism, activism, field operations. Two-word phrases with daily rotation. Use burn-after-use for maximum protection. |
1209
+ | `enterprise` | 2 | 48 hours (172,800 s) | ~22 bits | Corporate incident response. Two-word phrases with 48-hour rotation. Balances security with operational convenience. |
1210
+
1211
+ Explicit `rotationInterval` or `wordCount` fields override the preset value.
1212
+
1213
+ ### Session Presets
1214
+
1215
+ Used with `createSession({ preset: '...' })`.
1216
+
1217
+ | Preset | Words | Rotation | Tolerance | Directional | Use case |
1218
+ |---|---|---|---|---|---|
1219
+ | `call` | 1 | 30 seconds | 1 | Yes | Phone verification for insurance, banking, and call centres. Deepfake-proof — cloning a voice does not help derive the current word. |
1220
+ | `handoff` | 1 | single-use | 0 | Yes | Physical handoff for rideshare, delivery, and task completion. Counter is the task/event ID — no time dependency. |
1221
+
1222
+ ---
1223
+
1224
+ ## Nostr Event Kinds
1225
+
1226
+ | Kind | Type | Name | Description |
1227
+ |---|---|---|---|
1228
+ | 38800 | Replaceable | `group` | Group announcement with member list and configuration |
1229
+ | 28800 | Ephemeral | `seedDistribution` | Encrypted seed delivery to a specific member |
1230
+ | 38801 | Replaceable | `memberUpdate` | Add or remove a group member (with optional reseed) |
1231
+ | 28801 | Ephemeral | `reseed` | New seed broadcast after compromise or member removal |
1232
+ | 28802 | Ephemeral | `wordUsed` | Burn-after-use notification — advance counter for all members |
1233
+ | 20800 | Ephemeral | `beacon` | AES-256-GCM encrypted location beacon |
1234
+
1235
+ Replaceable events (38xxx) use the `d` tag for addressability. Ephemeral events (28xxx, 20xxx) are not stored by relays.
1236
+
1237
+ ---
1238
+
1239
+ ## Usage Patterns
1240
+
1241
+ ### Phone Verification (Bank / Insurance)
1242
+
1243
+ The bank's system and the customer both derive words from a shared per-customer secret. The customer speaks their word; the agent speaks theirs. Neither word can be derived from the other without the secret — voice cloning is defeated.
1244
+
1245
+ ```typescript
1246
+ import { createSession, deriveSeed } from 'canary-kit/session'
1247
+
1248
+ // Bank side: derive a per-customer secret from a master key
1249
+ const masterKey = process.env.CANARY_MASTER_KEY // 64-char hex, stored securely
1250
+
1251
+ const customerSecret = deriveSeed(masterKey, 'aviva-verify', customerId)
1252
+
1253
+ // Create session (bank agent's perspective)
1254
+ const agentSession = createSession({
1255
+ secret: customerSecret,
1256
+ namespace: 'aviva',
1257
+ roles: ['caller', 'agent'],
1258
+ myRole: 'agent',
1259
+ preset: 'call',
1260
+ theirIdentity: customerId, // for duress detection
1261
+ })
1262
+
1263
+ // What the agent says to prove they are the real bank:
1264
+ const agentWord = agentSession.myToken()
1265
+ console.log(`Agent says: "${agentWord}"`)
1266
+
1267
+ // What the agent expects to hear from the caller:
1268
+ const expectedCallerWord = agentSession.theirToken()
1269
+
1270
+ // Verify what the caller actually said:
1271
+ const spokenByCustomer = 'marble' // received from phone
1272
+ const result = agentSession.verify(spokenByCustomer)
1273
+
1274
+ if (result.status === 'valid') {
1275
+ console.log('Customer verified — proceed with call')
1276
+ } else if (result.status === 'duress') {
1277
+ console.log('DURESS SIGNAL — alert security, do not reveal to caller')
1278
+ console.log('Signallers:', result.identities)
1279
+ } else {
1280
+ console.log('Verification failed — possible impersonation attempt')
1281
+ }
1282
+
1283
+ // Customer side (mobile app)
1284
+ const customerSession = createSession({
1285
+ secret: customerSecret,
1286
+ namespace: 'aviva',
1287
+ roles: ['caller', 'agent'],
1288
+ myRole: 'caller',
1289
+ preset: 'call',
1290
+ })
1291
+
1292
+ const customerWord = customerSession.myToken() // 'marble' — customer speaks this
1293
+ const bankWord = customerSession.theirToken() // 'lantern' — customer expects this from bank
1294
+ ```
1295
+
1296
+ ### Family Group Verification
1297
+
1298
+ All members derive the same word from a shared seed. On a video or voice call, everyone says the word. If someone is under duress, they say their personal duress word instead — silent distress signal.
1299
+
1300
+ ```typescript
1301
+ import { createGroup, getCurrentWord, getCurrentDuressWord, verifyWord, syncCounter } from 'canary-kit'
1302
+
1303
+ // Create group (group admin)
1304
+ let state = createGroup({
1305
+ name: 'Family',
1306
+ members: ['<alice-pubkey>', '<bob-pubkey>', '<charlie-pubkey>'],
1307
+ preset: 'family',
1308
+ })
1309
+
1310
+ // Share state.seed with all members via secure channel (e.g. Nostr DM, NIP-44 encrypted)
1311
+
1312
+ // On a call — all members derive the same word
1313
+ const syncedState = syncCounter(state) // refresh after loading from storage
1314
+ const todaysWord = getCurrentWord(syncedState)
1315
+ console.log(`Say to confirm identity: "${todaysWord}"`)
1316
+
1317
+ // Alice's personal duress word (if she's coerced):
1318
+ const aliceDuressWord = getCurrentDuressWord(syncedState, '<alice-pubkey>')
1319
+
1320
+ // Verifying what someone said on the call:
1321
+ const spoken = 'marble' // heard on the call
1322
+ const result = verifyWord(spoken, syncedState.seed, syncedState.members, syncedState.counter)
1323
+
1324
+ switch (result.status) {
1325
+ case 'verified':
1326
+ console.log('Identity confirmed')
1327
+ break
1328
+ case 'duress':
1329
+ console.log('DURESS — member under coercion:', result.members)
1330
+ break
1331
+ case 'stale':
1332
+ console.log('Token is one window behind — sync clocks')
1333
+ break
1334
+ case 'failed':
1335
+ console.log('Unknown word — possible impersonation')
1336
+ break
1337
+ }
1338
+
1339
+ // Burn-after-use (field-ops scenario)
1340
+ state = advanceCounter(state) // next call will use a different word
1341
+
1342
+ // Add a new member
1343
+ state = addMember(state, '<diana-pubkey>')
1344
+ // Distribute new seed to diana via encrypted Nostr DM
1345
+
1346
+ // Remove a compromised member (auto-reseeds)
1347
+ state = removeMember(state, '<charlie-pubkey>')
1348
+ // Distribute new state.seed to remaining members
1349
+ ```
1350
+
1351
+ ### Nostr Group Integration
1352
+
1353
+ Publish group state and encrypted seeds to Nostr relays so all members can synchronise.
1354
+
1355
+ ```typescript
1356
+ import { createGroup, syncCounter, deriveBeaconKey, encryptBeacon } from 'canary-kit'
1357
+ import {
1358
+ KINDS,
1359
+ buildGroupEvent,
1360
+ buildSeedDistributionEvent,
1361
+ buildBeaconEvent,
1362
+ } from 'canary-kit/nostr'
1363
+
1364
+ // 1. Create the group
1365
+ let state = createGroup({
1366
+ name: 'Alpine Team',
1367
+ members: ['<alice-pubkey>', '<bob-pubkey>'],
1368
+ preset: 'field-ops',
1369
+ })
1370
+
1371
+ // 2. Publish a kind 38800 group event (encrypted content via NIP-44)
1372
+ const groupEvent = buildGroupEvent({
1373
+ groupId: 'alpine-team-1',
1374
+ name: state.name,
1375
+ members: state.members,
1376
+ rotationInterval: state.rotationInterval,
1377
+ wordCount: state.wordCount,
1378
+ wordlist: state.wordlist,
1379
+ encryptedContent: '<NIP-44 encrypted group config>',
1380
+ expiration: Math.floor(Date.now() / 1000) + 30 * 86_400,
1381
+ })
1382
+ // sign and publish groupEvent to relays
1383
+
1384
+ // 3. Send seed to each member via kind 28800 (one event per member)
1385
+ for (const memberPubkey of state.members) {
1386
+ const seedEvent = buildSeedDistributionEvent({
1387
+ recipientPubkey: memberPubkey,
1388
+ groupEventId: '<signed-group-event-id>',
1389
+ encryptedContent: '<NIP-44 encrypted seed for this member>',
1390
+ })
1391
+ // sign and publish seedEvent
1392
+ }
1393
+
1394
+ // 4. Publish periodic location beacons (kind 20800)
1395
+ const beaconKey = deriveBeaconKey(state.seed)
1396
+ const encryptedBeacon = await encryptBeacon(beaconKey, 'gcpvjb', 6)
1397
+
1398
+ const beaconEvent = buildBeaconEvent({
1399
+ groupId: 'alpine-team-1',
1400
+ encryptedContent: encryptedBeacon,
1401
+ expiration: Math.floor(Date.now() / 1000) + state.beaconInterval * 2,
1402
+ })
1403
+ // sign and publish beaconEvent
1404
+
1405
+ // 5. On receiving a beacon event from a member
1406
+ const payload = await decryptBeacon(beaconKey, receivedBeaconEvent.content)
1407
+ console.log(`Member at geohash ${payload.geohash} (precision ${payload.precision})`)
1408
+ console.log(`Last seen: ${new Date(payload.timestamp * 1000).toISOString()}`)
1409
+ ```
1410
+
1411
+ ### Universal Token API (Non-Group Use)
1412
+
1413
+ Use the low-level token API directly for any HMAC-based verification scheme — TROTT ride verification, API authentication, one-time codes.
1414
+
1415
+ ```typescript
1416
+ import { deriveToken, deriveDuressToken, verifyToken } from 'canary-kit/token'
1417
+ import { encodeToken } from 'canary-kit/encoding'
1418
+
1419
+ // Generate a shared secret (store securely, e.g. in a database)
1420
+ const secret = crypto.getRandomValues(new Uint8Array(32))
1421
+
1422
+ // TROTT ride handshake: driver proves to rider they are the right driver
1423
+ const rideId = 42_001 // use ride ID as counter for single-use
1424
+ const driverToken = deriveToken(secret, 'trott:driver', rideId, { format: 'words', count: 1 })
1425
+ const riderToken = deriveToken(secret, 'trott:rider', rideId, { format: 'words', count: 1 })
1426
+
1427
+ // Rider's app shows: "Ask driver to say: 'marble'"
1428
+ // Driver's app shows: "Say 'lantern' to rider after they say 'marble' to you"
1429
+
1430
+ // Time-based TOTP-style with PIN encoding
1431
+ const counter = Math.floor(Date.now() / 30_000) // 30-second window
1432
+ const pin = deriveToken(secret, 'my-app:auth', counter, { format: 'pin', digits: 6 })
1433
+ // e.g. '047291'
1434
+
1435
+ // Verify with tolerance (accept ±1 window for clock skew)
1436
+ const result = verifyToken(secret, 'my-app:auth', counter, enteredPin, [], {
1437
+ encoding: { format: 'pin', digits: 6 },
1438
+ tolerance: 1,
1439
+ })
1440
+ // { status: 'valid' } or { status: 'invalid' }
1441
+ ```
1442
+
1443
+ ### Dead Man's Switch (Liveness Heartbeat)
1444
+
1445
+ ```typescript
1446
+ import { deriveLivenessToken } from 'canary-kit/token'
1447
+ import { getCounter } from 'canary-kit'
1448
+
1449
+ const HEARTBEAT_INTERVAL = 3600 // 1 hour
1450
+
1451
+ // Publisher: send a liveness heartbeat every hour
1452
+ setInterval(async () => {
1453
+ const counter = getCounter(Math.floor(Date.now() / 1000), HEARTBEAT_INTERVAL)
1454
+ const heartbeat = deriveLivenessToken(secret, 'dms:alice', 'alice', counter)
1455
+ await publish(heartbeat) // send to monitoring server
1456
+ }, HEARTBEAT_INTERVAL * 1000)
1457
+
1458
+ // Monitor: if heartbeats stop arriving, trigger DMS response
1459
+ // The monitor verifies each heartbeat matches the expected derivation
1460
+ // for the current counter (within ±1 tolerance for timing drift)
1461
+ ```