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/API.md +306 -0
- package/COOKBOOK.md +763 -0
- package/INTEGRATION.md +329 -3
- package/README.md +17 -0
- package/SECURITY.md +2 -1
- package/llms-full.txt +71 -12
- package/llms.txt +16 -9
- package/package.json +3 -1
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`).
|