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.
- package/CANARY.md +1065 -0
- package/INTEGRATION.md +351 -0
- package/LICENSE +21 -0
- package/NIP-CANARY.md +624 -0
- package/README.md +187 -0
- package/SECURITY.md +92 -0
- package/dist/beacon.d.ts +104 -0
- package/dist/beacon.d.ts.map +1 -0
- package/dist/beacon.js +197 -0
- package/dist/beacon.js.map +1 -0
- package/dist/counter.d.ts +37 -0
- package/dist/counter.d.ts.map +1 -0
- package/dist/counter.js +62 -0
- package/dist/counter.js.map +1 -0
- package/dist/crypto.d.ts +111 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +309 -0
- package/dist/crypto.js.map +1 -0
- package/dist/derive.d.ts +68 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +85 -0
- package/dist/derive.js.map +1 -0
- package/dist/encoding.d.ts +56 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +98 -0
- package/dist/encoding.js.map +1 -0
- package/dist/group.d.ts +185 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +263 -0
- package/dist/group.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/nostr.d.ts +134 -0
- package/dist/nostr.d.ts.map +1 -0
- package/dist/nostr.js +175 -0
- package/dist/nostr.js.map +1 -0
- package/dist/presets.d.ts +26 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +39 -0
- package/dist/presets.js.map +1 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +173 -0
- package/dist/session.js.map +1 -0
- package/dist/sync-crypto.d.ts +66 -0
- package/dist/sync-crypto.d.ts.map +1 -0
- package/dist/sync-crypto.js +125 -0
- package/dist/sync-crypto.js.map +1 -0
- package/dist/sync.d.ts +191 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +568 -0
- package/dist/sync.js.map +1 -0
- package/dist/token.d.ts +186 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +344 -0
- package/dist/token.js.map +1 -0
- package/dist/verify.d.ts +45 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +59 -0
- package/dist/verify.js.map +1 -0
- package/dist/wordlist.d.ts +28 -0
- package/dist/wordlist.d.ts.map +1 -0
- package/dist/wordlist.js +297 -0
- package/dist/wordlist.js.map +1 -0
- package/llms-full.txt +1461 -0
- package/llms.txt +180 -0
- 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
|
+
```
|