canary-kit 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COOKBOOK.md ADDED
@@ -0,0 +1,763 @@
1
+ # Cookbook -- canary-kit
2
+
3
+ > Complete workflow examples for common integration patterns. For API reference, see [API.md](API.md). For protocol specs, see [CANARY.md](CANARY.md).
4
+
5
+ ## Duress Detection: Detecting and Handling Coercion
6
+
7
+ Canary-kit's coercion resistance works by giving each member a personal duress word that is always distinct from the normal verification word. When a coerced member speaks their duress word instead of the normal word, the verifier silently detects the coercion without the coercer knowing.
8
+
9
+ ### Session-Based Duress Detection (Phone Calls)
10
+
11
+ ```typescript
12
+ import { createSession } from 'canary-kit/session'
13
+
14
+ // Both parties share a secret (established during onboarding)
15
+ const session = createSession({
16
+ secret: sharedSecretHex,
17
+ namespace: 'acme-insurance',
18
+ roles: ['agent', 'customer'],
19
+ myRole: 'agent',
20
+ preset: 'call',
21
+ // CRITICAL: theirIdentity enables duress detection.
22
+ // Without it, duress tokens return 'invalid' instead of 'duress'.
23
+ theirIdentity: customerId,
24
+ })
25
+
26
+ // Agent speaks their token to prove identity to the customer
27
+ const agentSaysToCustomer = session.myToken()
28
+
29
+ // Customer speaks back -- agent verifies
30
+ const spokenByCustomer = 'granite' // what the agent heard
31
+ const result = session.verify(spokenByCustomer)
32
+
33
+ switch (result.status) {
34
+ case 'valid':
35
+ // Normal verification -- proceed with the call
36
+ console.log('Customer verified successfully')
37
+ break
38
+
39
+ case 'duress':
40
+ // SILENT ALERT: customer is under coercion.
41
+ // result.identities contains the identity strings of coerced parties.
42
+ // DO NOT reveal this to the caller -- the coercer may be listening.
43
+ console.log('Duress detected for:', result.identities)
44
+ // Trigger silent alert workflow:
45
+ // 1. Continue the call as if verification succeeded
46
+ // 2. Flag the interaction in your backend
47
+ // 3. Notify security/compliance team via back-channel
48
+ // 4. Follow your organisation's duress response protocol
49
+ await triggerSilentDuressAlert(result.identities, session)
50
+ break
51
+
52
+ case 'invalid':
53
+ // Word does not match -- could be wrong word, expired token, or attacker
54
+ console.log('Verification failed')
55
+ // Ask the customer to repeat, or escalate
56
+ break
57
+ }
58
+ ```
59
+
60
+ ### Group-Based Duress Detection
61
+
62
+ ```typescript
63
+ import {
64
+ createGroup, getCurrentWord, getCurrentDuressWord,
65
+ verifyWord, getCounter,
66
+ } from 'canary-kit'
67
+
68
+ const group = createGroup({
69
+ name: 'Family Safety',
70
+ members: [alicePubkey, bobPubkey, carolPubkey],
71
+ preset: 'family',
72
+ creator: alicePubkey,
73
+ })
74
+
75
+ // The normal verification word is shared by all members
76
+ const normalWord = getCurrentWord(group) // e.g. "falcon"
77
+
78
+ // Each member has a unique duress word
79
+ const aliceDuress = getCurrentDuressWord(group, alicePubkey) // e.g. "marble"
80
+ const bobDuress = getCurrentDuressWord(group, bobPubkey) // e.g. "timber"
81
+
82
+ // Verify a word spoken by any group member
83
+ const counter = getCounter(Math.floor(Date.now() / 1000), group.rotationInterval)
84
+ const result = verifyWord(spokenWord, group.seed, group.members, counter, group.wordCount)
85
+
86
+ switch (result.status) {
87
+ case 'verified':
88
+ console.log('Identity confirmed')
89
+ break
90
+
91
+ case 'duress':
92
+ // result.members contains the pubkeys of the coerced member(s)
93
+ console.log('DURESS: coerced members:', result.members)
94
+ // The specific member's duress word matched -- you know WHO is under duress
95
+ break
96
+
97
+ case 'stale':
98
+ // Word matches a previous time window -- likely clock skew or late call
99
+ console.log('Stale token -- ask them to repeat')
100
+ break
101
+
102
+ case 'failed':
103
+ console.log('Verification failed -- word does not match')
104
+ break
105
+ }
106
+ ```
107
+
108
+ ### Key Design Points
109
+
110
+ - **Duress words are always distinct from verification words.** The library enforces collision avoidance with suffix retry. A duress word can never accidentally equal the normal word.
111
+ - **Duress detection requires identity context.** For sessions, pass `theirIdentity`. For groups, pass all `members` to `verifyWord`. Without identities, duress cannot be detected.
112
+ - **Duress wins over valid.** If a word matches both (impossible by design, but as a safety property), duress takes priority.
113
+ - **Silent by design.** The coercer hears a normal-sounding word. The verifier sees `duress` status. Nothing in the protocol reveals that duress was detected.
114
+
115
+ ---
116
+
117
+ ## Replacing SMS OTP with Spoken-Word Verification
118
+
119
+ SMS OTP is vulnerable to SIM-swap, SS7 interception, and social engineering. Canary-kit replaces it with time-rotating spoken words derived from a shared secret, meeting FCA Strong Customer Authentication (SCA) requirements: possession (the shared secret on the device) + inherence (the human speaking the word).
120
+
121
+ ### Step-by-Step Migration
122
+
123
+ ```typescript
124
+ import { createSession, deriveSeed } from 'canary-kit/session'
125
+
126
+ // ── Step 1: Onboarding (replaces "register phone number") ─────────────────
127
+
128
+ // During account creation or migration, derive a per-customer seed
129
+ // from your platform's master key + customer ID.
130
+ const customerSeed = deriveSeed(platformMasterKey, 'customer', customerId)
131
+ // Store customerSeed securely server-side. Provision to customer's app via
132
+ // secure channel (QR code in branch, NFC tap, or authenticated app download).
133
+
134
+ // ── Step 2: Authentication (replaces "send SMS OTP") ──────────────────────
135
+
136
+ // Server creates a session for this authentication attempt
137
+ const serverSession = createSession({
138
+ secret: customerSeed,
139
+ namespace: 'mybank',
140
+ roles: ['server', 'customer'],
141
+ myRole: 'server',
142
+ preset: 'call', // 30-second rotation, +-1 tolerance
143
+ theirIdentity: customerId, // enables duress detection
144
+ })
145
+
146
+ // Server displays or reads: "Please say the word: {serverSession.myToken()}"
147
+ // Customer's app shows: serverSession.theirToken() from their local session
148
+ // Customer speaks the word shown in their app
149
+
150
+ // ── Step 3: Verification (replaces "check OTP code") ──────────────────────
151
+
152
+ const result = serverSession.verify(spokenWord)
153
+
154
+ if (result.status === 'valid') {
155
+ // Authentication successful
156
+ grantAccess(customerId)
157
+ } else if (result.status === 'duress') {
158
+ // Customer is under coercion -- grant apparent access but trigger silent alert
159
+ grantApparentAccess(customerId)
160
+ await alertSecurityTeam(customerId, result.identities)
161
+ } else {
162
+ // Authentication failed
163
+ denyAccess(customerId)
164
+ }
165
+ ```
166
+
167
+ ### Why This Works as an OTP Replacement
168
+
169
+ - **No phone number dependency.** SIM-swap and SS7 interception do not apply.
170
+ - **Works offline.** Tokens derived locally from shared secret + time. No network needed at verification time.
171
+ - **Bidirectional.** Both parties prove identity, not just the user to the server.
172
+ - **Duress detection.** Coerced users can signal silently -- impossible with SMS OTP.
173
+
174
+ For regulatory compliance mapping (FCA SCA, EU AI Act, UAE, RBI), see [INTEGRATION.md](INTEGRATION.md).
175
+
176
+ ---
177
+
178
+ ## Group Verification: Multi-Member Verification Workflows
179
+
180
+ ### Basic Group Verification
181
+
182
+ ```typescript
183
+ import {
184
+ createGroup, getCurrentWord, getCurrentDuressWord,
185
+ verifyWord, getCounter, advanceCounter,
186
+ } from 'canary-kit'
187
+
188
+ // Create a group with an admin who can manage membership
189
+ const group = createGroup({
190
+ name: 'Field Team Alpha',
191
+ members: [leaderPubkey, agent1Pubkey, agent2Pubkey, agent3Pubkey],
192
+ preset: 'field-ops', // 2-word phrases, 24-hour rotation
193
+ creator: leaderPubkey, // leader is admin
194
+ })
195
+
196
+ // All members derive the same verification phrase
197
+ const phrase = getCurrentWord(group) // e.g. "falcon granite"
198
+
199
+ // Any member can verify any other member's spoken phrase
200
+ const counter = getCounter(Math.floor(Date.now() / 1000), group.rotationInterval)
201
+ const result = verifyWord(
202
+ spokenPhrase,
203
+ group.seed,
204
+ group.members,
205
+ counter,
206
+ group.wordCount,
207
+ )
208
+
209
+ // After using the word (e.g. for a check-in), burn it to prevent replay
210
+ const updated = advanceCounter(group)
211
+ // The next getCurrentWord(updated) returns a different phrase
212
+ ```
213
+
214
+ ### M-of-N Threshold Verification
215
+
216
+ Canary-kit groups use a shared secret, so all members derive the same verification word. M-of-N threshold checks are implemented at the application layer by collecting individual verifications:
217
+
218
+ ```typescript
219
+ import {
220
+ createGroup, getCurrentWord, verifyWord, getCounter,
221
+ } from 'canary-kit'
222
+
223
+ interface MemberVerification {
224
+ pubkey: string
225
+ spokenWord: string
226
+ timestamp: number
227
+ }
228
+
229
+ /**
230
+ * Verify M-of-N group members have provided correct tokens.
231
+ * Each member must independently speak the current word.
232
+ * Returns verified/duress member lists for threshold decisions.
233
+ */
234
+ function verifyThreshold(
235
+ group: GroupState,
236
+ verifications: MemberVerification[],
237
+ requiredCount: number,
238
+ ): { verified: string[]; duress: string[]; failed: string[]; met: boolean } {
239
+ const counter = getCounter(Math.floor(Date.now() / 1000), group.rotationInterval)
240
+ const verified: string[] = []
241
+ const duress: string[] = []
242
+ const failed: string[] = []
243
+
244
+ for (const v of verifications) {
245
+ // Each verification is checked independently
246
+ const result = verifyWord(v.spokenWord, group.seed, group.members, counter, group.wordCount)
247
+
248
+ switch (result.status) {
249
+ case 'verified':
250
+ verified.push(v.pubkey)
251
+ break
252
+ case 'duress':
253
+ // Even in M-of-N, duress is flagged silently
254
+ duress.push(v.pubkey)
255
+ break
256
+ case 'stale':
257
+ case 'failed':
258
+ failed.push(v.pubkey)
259
+ break
260
+ }
261
+ }
262
+
263
+ return {
264
+ verified,
265
+ duress,
266
+ failed,
267
+ // Threshold met only when enough members verify AND no duress detected
268
+ met: verified.length >= requiredCount && duress.length === 0,
269
+ }
270
+ }
271
+
272
+ // Usage: require 2-of-3 members to verify before authorising an action
273
+ const result = verifyThreshold(group, collectedVerifications, 2)
274
+
275
+ if (result.duress.length > 0) {
276
+ // At least one member signalled duress -- abort and alert
277
+ await triggerGroupDuressProtocol(result.duress)
278
+ } else if (result.met) {
279
+ // Threshold met -- proceed
280
+ authoriseAction()
281
+ } else {
282
+ // Not enough verified members
283
+ requestMoreVerifications(result.failed)
284
+ }
285
+ ```
286
+
287
+ ### Group Membership Management with Sync
288
+
289
+ ```typescript
290
+ import {
291
+ createGroup, addMember, removeMember, advanceCounter,
292
+ } from 'canary-kit'
293
+ import {
294
+ applySyncMessage, encodeSyncMessage, decodeSyncMessage,
295
+ deriveGroupKey, encryptEnvelope, decryptEnvelope,
296
+ type SyncMessage,
297
+ } from 'canary-kit/sync'
298
+
299
+ // ── Adding a member (admin-only) ──────────────────────────────────────────
300
+
301
+ const joinMsg: SyncMessage = {
302
+ type: 'member-join',
303
+ pubkey: newMemberPubkey,
304
+ displayName: 'Carol',
305
+ timestamp: Math.floor(Date.now() / 1000),
306
+ epoch: group.epoch,
307
+ opId: crypto.randomUUID(),
308
+ protocolVersion: 2,
309
+ }
310
+
311
+ // Encrypt for transport (AES-256-GCM with group key)
312
+ const groupKey = deriveGroupKey(group.seed)
313
+ const encrypted = await encryptEnvelope(groupKey, encodeSyncMessage(joinMsg))
314
+
315
+ // On the receiving side: decrypt and apply
316
+ const decrypted = await decryptEnvelope(groupKey, encrypted)
317
+ const decoded = decodeSyncMessage(decrypted)
318
+ const nowSec = Math.floor(Date.now() / 1000)
319
+
320
+ // applySyncMessage returns a new GroupState (unchanged if rejected)
321
+ const newState = applySyncMessage(group, decoded, nowSec, adminPubkey)
322
+
323
+ // Check if it was applied by comparing references
324
+ if (newState !== group) {
325
+ console.log('Member added:', newMemberPubkey)
326
+ console.log('Members:', newState.members)
327
+ } else {
328
+ console.log('Message rejected (not admin, wrong epoch, or replay)')
329
+ }
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Family Safety Application
335
+
336
+ ### Setting Up a Family Verification Group
337
+
338
+ ```typescript
339
+ import {
340
+ createGroup, getCurrentWord, getCurrentDuressWord,
341
+ verifyWord, getCounter,
342
+ } from 'canary-kit'
343
+ import { deriveSeed } from 'canary-kit/session'
344
+
345
+ // ── Master Key Management ─────────────────────────────────────────────────
346
+ // The master key should be stored in the device's secure enclave/keychain.
347
+ // On iOS: Keychain Services. On Android: Android Keystore.
348
+ // On web: IndexedDB with non-extractable CryptoKey (WebCrypto API).
349
+ //
350
+ // NEVER store the master key in localStorage, plain files, or app preferences.
351
+ // If the master key is compromised, all derived group seeds are compromised.
352
+
353
+ // Option A: Generate a fresh master key during family setup
354
+ import { generateSeed } from 'canary-kit/session'
355
+ const masterKey = generateSeed() // 32 random bytes
356
+
357
+ // Option B: Derive from a mnemonic (BIP-39) for recovery
358
+ // import { mnemonicToSeedSync } from '@scure/bip39'
359
+ // const masterKey = mnemonicToSeedSync(mnemonic).slice(0, 32)
360
+
361
+ // ── Create Family Group ───────────────────────────────────────────────────
362
+ const family = createGroup({
363
+ name: 'Smith Family',
364
+ members: [mumPubkey, dadPubkey, teenPubkey, granPubkey],
365
+ preset: 'family', // 1 word, 7-day rotation
366
+ creator: mumPubkey, // mum is admin (can add/remove members)
367
+ })
368
+
369
+ // ── Weekly Family Verification ────────────────────────────────────────────
370
+ // Each week, the word rotates automatically. Family members can verify
371
+ // each other during phone calls or video calls.
372
+ const currentWord = getCurrentWord(family) // e.g. "falcon"
373
+
374
+ // ── Elderly Parent Protection ─────────────────────────────────────────────
375
+ // Scenario: gran receives a call from someone claiming to be her grandchild.
376
+ // Instead of relying on voice recognition (defeated by AI cloning), she asks
377
+ // for the family word.
378
+ //
379
+ // Legitimate grandchild knows the word (their app shows it).
380
+ // Voice-cloning scammer does NOT know the word (they don't have the seed).
381
+
382
+ // Gran verifies the word spoken to her:
383
+ const counter = getCounter(Math.floor(Date.now() / 1000), family.rotationInterval)
384
+ const result = verifyWord(spokenWord, family.seed, family.members, counter, family.wordCount)
385
+
386
+ if (result.status === 'verified') {
387
+ // Caller knows the family word -- identity confirmed
388
+ console.log('This is really a family member')
389
+ } else if (result.status === 'duress') {
390
+ // Family member is being coerced -- the correct response depends on context:
391
+ // - Notify other family members via the app
392
+ // - Contact authorities
393
+ // - Do NOT confront the coercer
394
+ console.log('Family member under duress:', result.members)
395
+ } else {
396
+ // Caller does not know the word -- likely a scam
397
+ console.log('Scam call detected -- hang up')
398
+ }
399
+ ```
400
+
401
+ ### Error Handling for Family Apps
402
+
403
+ ```typescript
404
+ import { createGroup, syncCounter, type GroupState } from 'canary-kit'
405
+
406
+ // ── Loading persisted state ───────────────────────────────────────────────
407
+ // After app restart or device reboot, refresh the time-based counter.
408
+ // syncCounter enforces monotonicity -- the counter never goes backwards,
409
+ // even if the device clock was rolled back.
410
+
411
+ function loadGroupState(persisted: GroupState): GroupState {
412
+ // Refresh counter to current time window
413
+ const refreshed = syncCounter(persisted)
414
+
415
+ // Validate the loaded state
416
+ if (refreshed.members.length === 0) {
417
+ throw new Error('Group has no members -- may have been dissolved')
418
+ }
419
+ if (refreshed.seed === '0'.repeat(64)) {
420
+ throw new Error('Group seed is zeroed -- group was dissolved')
421
+ }
422
+
423
+ return refreshed
424
+ }
425
+
426
+ // ── Handling network delays ───────────────────────────────────────────────
427
+ // Family members may be in different time zones or have clock drift.
428
+ // The tolerance window (default: 1 for family preset) accepts tokens
429
+ // from adjacent time windows: current, previous, and next.
430
+ //
431
+ // For families spread across time zones, consider increasing tolerance:
432
+ const family = createGroup({
433
+ name: 'Global Family',
434
+ members: [londonPubkey, sydneyPubkey, nyPubkey],
435
+ preset: 'family',
436
+ tolerance: 2, // Accept tokens from +-2 windows (wider margin)
437
+ creator: londonPubkey,
438
+ })
439
+ ```
440
+
441
+ ---
442
+
443
+ ## Multi-Channel Verification Architecture
444
+
445
+ Canary-kit is transport-agnostic. The same shared secret can derive verification tokens across phone calls, video calls, messaging apps, and in-person encounters. This section covers how to maintain verification integrity across multiple channels.
446
+
447
+ ### Architecture Overview
448
+
449
+ ```
450
+ +------------------+
451
+ | Shared Secret |
452
+ | (per pair or |
453
+ | per group) |
454
+ +--------+---------+
455
+ |
456
+ +--------------+--------------+
457
+ | | |
458
+ +-----+-----+ +-----+-----+ +-----+-----+
459
+ | Phone | | Video | | In-App |
460
+ | Call | | Call | | Message |
461
+ +-----+-----+ +-----+-----+ +-----+-----+
462
+ | | |
463
+ session:call session:call group:verify
464
+ 30s rotation 30s rotation 7d rotation
465
+ ```
466
+
467
+ ### Same Secret, Different Channels
468
+
469
+ ```typescript
470
+ import { createSession, deriveSeed } from 'canary-kit/session'
471
+
472
+ // Derive channel-specific sessions from the same master secret.
473
+ // Each channel gets its own namespace, producing different tokens.
474
+ // This prevents a token captured on one channel from being replayed
475
+ // on another channel (cross-channel replay prevention).
476
+
477
+ const masterSecret = deriveSeed(platformKey, 'customer', customerId)
478
+
479
+ // Phone call session
480
+ const phoneSession = createSession({
481
+ secret: masterSecret,
482
+ namespace: 'acme/phone', // unique namespace per channel
483
+ roles: ['agent', 'customer'],
484
+ myRole: 'agent',
485
+ preset: 'call',
486
+ theirIdentity: customerId,
487
+ })
488
+
489
+ // Video call session (different tokens due to different namespace)
490
+ const videoSession = createSession({
491
+ secret: masterSecret,
492
+ namespace: 'acme/video',
493
+ roles: ['agent', 'customer'],
494
+ myRole: 'agent',
495
+ preset: 'call',
496
+ theirIdentity: customerId,
497
+ })
498
+
499
+ // The phone token and video token are DIFFERENT at the same instant,
500
+ // even though they derive from the same master secret.
501
+ // This prevents cross-channel replay attacks.
502
+ console.log(phoneSession.myToken()) // e.g. "falcon"
503
+ console.log(videoSession.myToken()) // e.g. "marble" (different)
504
+ ```
505
+
506
+ ### Replay Prevention Mechanisms
507
+
508
+ Canary-kit prevents replay attacks through multiple layers:
509
+
510
+ 1. **Time-based rotation.** Tokens rotate every 30 seconds (for `call` preset). A captured token expires within the tolerance window.
511
+
512
+ 2. **Namespace isolation.** Each channel uses a different namespace, producing different tokens. A token from the phone channel is invalid on the video channel.
513
+
514
+ 3. **Burn-after-use (groups).** `advanceCounter()` immediately rotates the token after use, preventing replay within the same time window.
515
+
516
+ 4. **Epoch-based replay protection (sync).** Every state-mutating sync message carries an `opId` tracked in `consumedOps`. Replayed messages are silently dropped. The `epoch` field ensures messages from old group states (before a reseed) are rejected.
517
+
518
+ 5. **Freshness gates (fire-and-forget).** Beacon and liveness messages are dropped if older than 5 minutes (`FIRE_AND_FORGET_FRESHNESS_SEC`) or more than 60 seconds in the future (`MAX_FUTURE_SKEW_SEC`).
519
+
520
+ ```typescript
521
+ import { advanceCounter } from 'canary-kit'
522
+
523
+ // After verifying a group member's word, burn it immediately
524
+ let group = loadGroupState()
525
+ const result = verifyWord(spoken, group.seed, group.members, counter, group.wordCount)
526
+ if (result.status === 'verified') {
527
+ // Advance the counter so this word cannot be replayed
528
+ group = advanceCounter(group)
529
+ persistGroupState(group)
530
+ }
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Distributed Sync Architecture
536
+
537
+ Canary-kit's sync protocol is designed for distributed environments where multiple devices hold copies of group state and must converge without a central server. This section covers the architectural considerations for deploying sync across multiple nodes.
538
+
539
+ ### Consistency Model: Convergent State via Operation Ordering
540
+
541
+ Canary-kit uses an **operation-based consistency model** where the sync protocol guarantees convergence through invariant enforcement rather than consensus:
542
+
543
+ - **No central coordinator.** Any node can generate and broadcast sync messages. There is no leader election or distributed lock.
544
+ - **Idempotent operations.** Every sync message carries an `opId`. The `consumedOps` set ensures that applying the same message twice is a no-op (Invariant I2).
545
+ - **Monotonic counters.** Counter-advance messages enforce `incomingEffective > currentEffective`, so out-of-order delivery of counter advances converges to the highest value.
546
+ - **Epoch boundaries.** Reseeds atomically replace `{seed, members, admins, epoch}` and clear `consumedOps`. Messages from old epochs are rejected (Invariant I6). This provides a clean cut-over point.
547
+
548
+ ```typescript
549
+ import {
550
+ applySyncMessage, decodeSyncMessage,
551
+ deriveGroupKey, decryptEnvelope,
552
+ type SyncMessage,
553
+ } from 'canary-kit/sync'
554
+ import type { GroupState } from 'canary-kit'
555
+
556
+ /**
557
+ * Process an incoming sync message on any node.
558
+ * The invariants ensure convergence regardless of message ordering.
559
+ */
560
+ async function processIncomingSyncMessage(
561
+ currentState: GroupState,
562
+ encryptedPayload: string,
563
+ senderPubkey: string,
564
+ ): Promise<GroupState> {
565
+ const groupKey = deriveGroupKey(currentState.seed)
566
+
567
+ // Decrypt the envelope (AES-256-GCM, keyed to current seed)
568
+ // Fails if the message was encrypted with an old seed (post-reseed)
569
+ let plaintext: string
570
+ try {
571
+ plaintext = await decryptEnvelope(groupKey, encryptedPayload)
572
+ } catch {
573
+ // Decryption failure: message from a different epoch or corrupted
574
+ // This is expected during reseed transitions -- not an error
575
+ return currentState
576
+ }
577
+
578
+ // Parse and validate the message structure
579
+ let msg: SyncMessage
580
+ try {
581
+ msg = decodeSyncMessage(plaintext)
582
+ } catch (err) {
583
+ // Invalid message: wrong protocol version, missing fields, etc.
584
+ // Log for debugging but do not apply
585
+ console.warn('Invalid sync message:', (err as Error).message)
586
+ return currentState
587
+ }
588
+
589
+ // Apply with full invariant checking
590
+ const nowSec = Math.floor(Date.now() / 1000)
591
+ const newState = applySyncMessage(currentState, msg, nowSec, senderPubkey)
592
+
593
+ // applySyncMessage returns the same reference if rejected
594
+ if (newState === currentState) {
595
+ // Message was rejected by an invariant (stale epoch, replay, etc.)
596
+ // This is normal in distributed systems -- not an error
597
+ return currentState
598
+ }
599
+
600
+ // Persist the updated state
601
+ await persistState(newState)
602
+ return newState
603
+ }
604
+ ```
605
+
606
+ ### Message Ordering and Conflict Resolution
607
+
608
+ **Out-of-order delivery is handled by the invariants, not by the application.** You do not need to implement message ordering, vector clocks, or CRDTs. The sync protocol handles it:
609
+
610
+ | Scenario | How it converges |
611
+ |---|---|
612
+ | Two `counter-advance` messages arrive out of order | Monotonic check: only the higher effective counter wins. Both orderings produce the same final state. |
613
+ | `member-join` arrives after `reseed` | Epoch check (I3/I6): the join is rejected because its epoch doesn't match. The admin must re-issue the join in the new epoch. |
614
+ | Two nodes send `counter-advance` simultaneously | Both are applied if they advance the counter. The higher value wins on both nodes (monotonic). |
615
+ | `member-leave` replayed after re-join | `opId` replay guard (I2): the duplicate `opId` is in `consumedOps` and is silently dropped. |
616
+ | Network partition heals with diverged state | Nodes exchange their pending messages. Invariants ensure only valid, non-replayed messages are applied. Counter advances converge to the max. |
617
+
618
+ ### Preserving Deepfake-Proof Guarantees in Distributed Deployments
619
+
620
+ The deepfake-proof property depends on the shared secret remaining secret. In a distributed environment:
621
+
622
+ 1. **Envelope encryption.** All sync messages travel inside AES-256-GCM envelopes derived from the group seed (`deriveGroupKey(seed)`). Only nodes that possess the current seed can decrypt messages.
623
+
624
+ 2. **Reseed isolates epochs.** When a member is removed or a compromise is suspected, `reseed` generates a new seed and bumps the epoch. Old nodes (or attackers with the old seed) cannot decrypt messages in the new epoch. The key derivation changes completely.
625
+
626
+ 3. **No seed in transit (except during provisioning).** Normal sync messages (counter-advance, beacon, member-join) do not contain the seed. The seed is only transmitted during initial group creation (via NIP-17 gift wrap or QR code) and during reseed (encrypted within the new epoch's envelope).
627
+
628
+ 4. **Offline-first derivation.** Each node derives tokens locally from its copy of the seed and the current counter. No network request is needed to verify a spoken word. This means a compromised relay or transport layer cannot inject false tokens.
629
+
630
+ ```typescript
631
+ import {
632
+ applySyncMessage, encodeSyncMessage,
633
+ deriveGroupKey, encryptEnvelope,
634
+ type SyncMessage,
635
+ } from 'canary-kit/sync'
636
+ import { removeMember, type GroupState } from 'canary-kit'
637
+
638
+ /**
639
+ * Handle suspected compromise: remove the compromised member and reseed.
640
+ * This atomically invalidates the old seed across all nodes.
641
+ */
642
+ function handleCompromise(
643
+ group: GroupState,
644
+ compromisedPubkey: string,
645
+ adminPubkey: string,
646
+ ): { newState: GroupState; reseedMessage: SyncMessage } {
647
+ // Build the reseed message with the compromised member removed
648
+ const remainingMembers = group.members.filter(m => m !== compromisedPubkey)
649
+ const remainingAdmins = group.admins.filter(a => a !== compromisedPubkey)
650
+
651
+ // Generate a new seed (32 random bytes, hex-encoded)
652
+ const newSeed = crypto.getRandomValues(new Uint8Array(32))
653
+
654
+ const reseedMsg: SyncMessage = {
655
+ type: 'reseed',
656
+ seed: newSeed,
657
+ counter: group.counter,
658
+ timestamp: Math.floor(Date.now() / 1000),
659
+ epoch: group.epoch + 1, // I4: reseed epoch = local epoch + 1
660
+ opId: crypto.randomUUID(),
661
+ admins: remainingAdmins,
662
+ members: remainingMembers,
663
+ protocolVersion: 2,
664
+ }
665
+
666
+ // Apply locally first
667
+ const nowSec = Math.floor(Date.now() / 1000)
668
+ const newState = applySyncMessage(group, reseedMsg, nowSec, adminPubkey)
669
+
670
+ return { newState, reseedMessage: reseedMsg }
671
+ }
672
+ ```
673
+
674
+ ### Transport Layer Integration
675
+
676
+ The sync protocol is transport-agnostic. Implement the `SyncTransport` interface to plug in any delivery mechanism:
677
+
678
+ ```typescript
679
+ import type { SyncTransport, SyncMessage } from 'canary-kit/sync'
680
+
681
+ // Example: Nostr relay transport
682
+ class NostrSyncTransport implements SyncTransport {
683
+ async send(groupId: string, message: SyncMessage, recipients?: string[]): Promise<void> {
684
+ // 1. Encode the message
685
+ // 2. Encrypt with group key (AES-256-GCM)
686
+ // 3. Publish as a Nostr event (kind 30078 for stored, kind 20078 for ephemeral)
687
+ }
688
+
689
+ subscribe(groupId: string, onMessage: (msg: SyncMessage, sender: string) => void): () => void {
690
+ // 1. Subscribe to Nostr events for this group's hash tag
691
+ // 2. Decrypt incoming events
692
+ // 3. Decode and call onMessage
693
+ // Return unsubscribe function
694
+ return () => { /* cleanup */ }
695
+ }
696
+
697
+ disconnect(): void {
698
+ // Close relay connections
699
+ }
700
+ }
701
+
702
+ // Example: WebSocket transport (for custom infrastructure)
703
+ class WebSocketSyncTransport implements SyncTransport {
704
+ // Same interface, different transport
705
+ // Messages still use the same envelope encryption
706
+ }
707
+
708
+ // Example: Signal/SMS transport (for bootstrapping)
709
+ class ManualSyncTransport implements SyncTransport {
710
+ // For manual seed distribution via QR code or secure messaging
711
+ // Only used for initial group setup, not ongoing sync
712
+ }
713
+ ```
714
+
715
+ ### Node Recovery After Extended Offline Period
716
+
717
+ When a node comes back online after missing multiple sync messages:
718
+
719
+ ```typescript
720
+ import { syncCounter, type GroupState } from 'canary-kit'
721
+ import { applySyncMessage, decodeSyncMessage } from 'canary-kit/sync'
722
+
723
+ /**
724
+ * Recover state after being offline. Fetch and apply all missed messages.
725
+ * The invariants handle out-of-order application automatically.
726
+ */
727
+ async function recoverAfterOffline(
728
+ persistedState: GroupState,
729
+ missedMessages: Array<{ payload: string; sender: string }>,
730
+ ): Promise<GroupState> {
731
+ // 1. Refresh the time-based counter (monotonic -- never regresses)
732
+ let state = syncCounter(persistedState)
733
+
734
+ // 2. Apply each missed message in order
735
+ // Even if order is wrong, invariants ensure correctness
736
+ const nowSec = Math.floor(Date.now() / 1000)
737
+ for (const { payload, sender } of missedMessages) {
738
+ try {
739
+ const msg = decodeSyncMessage(payload)
740
+ state = applySyncMessage(state, msg, nowSec, sender)
741
+ } catch {
742
+ // Skip invalid messages (wrong protocol version, etc.)
743
+ continue
744
+ }
745
+ }
746
+
747
+ // 3. If the node missed a reseed, it cannot decrypt new-epoch messages.
748
+ // The node must be re-invited by an admin (state-snapshot or new group invite).
749
+ // This is by design: ensures forward secrecy after member removal.
750
+
751
+ return state
752
+ }
753
+ ```
754
+
755
+ ### Deployment Checklist
756
+
757
+ - [ ] **Persist state after every successful `applySyncMessage`.** The in-memory state must match what's on disk to survive crashes.
758
+ - [ ] **Use `applySyncMessageWithResult()` for observability.** Unlike `applySyncMessage` which silently returns unchanged state on rejection, `applySyncMessageWithResult` returns `{ state, applied }` so you can log rejected messages.
759
+ - [ ] **Handle decryption failures gracefully.** After a reseed, old-epoch messages will fail to decrypt. This is expected, not an error.
760
+ - [ ] **Set `creator` in `createGroup()`.** Without a creator, `admins` is empty and all privileged sync operations (member-join, reseed, member-leave of others) are silently rejected.
761
+ - [ ] **Always pass `sender` to `applySyncMessage`.** Privileged actions require a sender. Counter-advance requires sender to be a current group member. Omitting sender causes silent rejection.
762
+ - [ ] **Bound your message store.** `consumedOps` is capped at 1000 entries per epoch. For high-throughput groups, ensure your transport delivers within the eviction window.
763
+ - [ ] **Monitor clock drift.** Messages with timestamps more than 60 seconds in the future are rejected (`MAX_FUTURE_SKEW_SEC`). Fire-and-forget messages older than 5 minutes are dropped (`FIRE_AND_FORGET_FRESHNESS_SEC`).