canary-kit 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CANARY.md +1065 -0
  2. package/INTEGRATION.md +351 -0
  3. package/LICENSE +21 -0
  4. package/NIP-CANARY.md +624 -0
  5. package/README.md +187 -0
  6. package/SECURITY.md +92 -0
  7. package/dist/beacon.d.ts +104 -0
  8. package/dist/beacon.d.ts.map +1 -0
  9. package/dist/beacon.js +197 -0
  10. package/dist/beacon.js.map +1 -0
  11. package/dist/counter.d.ts +37 -0
  12. package/dist/counter.d.ts.map +1 -0
  13. package/dist/counter.js +62 -0
  14. package/dist/counter.js.map +1 -0
  15. package/dist/crypto.d.ts +111 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +309 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/derive.d.ts +68 -0
  20. package/dist/derive.d.ts.map +1 -0
  21. package/dist/derive.js +85 -0
  22. package/dist/derive.js.map +1 -0
  23. package/dist/encoding.d.ts +56 -0
  24. package/dist/encoding.d.ts.map +1 -0
  25. package/dist/encoding.js +98 -0
  26. package/dist/encoding.js.map +1 -0
  27. package/dist/group.d.ts +185 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +263 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +12 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/nostr.d.ts +134 -0
  36. package/dist/nostr.d.ts.map +1 -0
  37. package/dist/nostr.js +175 -0
  38. package/dist/nostr.js.map +1 -0
  39. package/dist/presets.d.ts +26 -0
  40. package/dist/presets.d.ts.map +1 -0
  41. package/dist/presets.js +39 -0
  42. package/dist/presets.js.map +1 -0
  43. package/dist/session.d.ts +114 -0
  44. package/dist/session.d.ts.map +1 -0
  45. package/dist/session.js +173 -0
  46. package/dist/session.js.map +1 -0
  47. package/dist/sync-crypto.d.ts +66 -0
  48. package/dist/sync-crypto.d.ts.map +1 -0
  49. package/dist/sync-crypto.js +125 -0
  50. package/dist/sync-crypto.js.map +1 -0
  51. package/dist/sync.d.ts +191 -0
  52. package/dist/sync.d.ts.map +1 -0
  53. package/dist/sync.js +568 -0
  54. package/dist/sync.js.map +1 -0
  55. package/dist/token.d.ts +186 -0
  56. package/dist/token.d.ts.map +1 -0
  57. package/dist/token.js +344 -0
  58. package/dist/token.js.map +1 -0
  59. package/dist/verify.d.ts +45 -0
  60. package/dist/verify.d.ts.map +1 -0
  61. package/dist/verify.js +59 -0
  62. package/dist/verify.js.map +1 -0
  63. package/dist/wordlist.d.ts +28 -0
  64. package/dist/wordlist.d.ts.map +1 -0
  65. package/dist/wordlist.js +297 -0
  66. package/dist/wordlist.js.map +1 -0
  67. package/llms-full.txt +1461 -0
  68. package/llms.txt +180 -0
  69. package/package.json +144 -0
package/CANARY.md ADDED
@@ -0,0 +1,1065 @@
1
+ CANARY Protocol
2
+ ===============
3
+
4
+ Coercion-Resistant Spoken Verification Protocol
5
+ ------------------------------------------------
6
+
7
+ `version 1.0` `draft`
8
+
9
+ > **Compatibility policy:** Protocol v1 has `draft` status. No backward
10
+ > compatibility is guaranteed until `stable` designation. Breaking changes
11
+ > increment the `protocolVersion` field; implementations MUST reject messages
12
+ > with an unrecognised version.
13
+
14
+ ## Abstract
15
+
16
+ CANARY is a protocol for coercion-resistant spoken verification. It combines
17
+ deterministic token derivation (extending the HMAC-counter pattern from
18
+ HOTP/TOTP) with duress signalling and human-spoken output — three properties
19
+ that exist independently in prior standards but have never been combined in a
20
+ single protocol.
21
+
22
+ The protocol is transport-agnostic — no Nostr, no cryptocurrency, no network
23
+ assumptions. Implementations can operate over Nostr, Signal, Matrix, SMS,
24
+ Meshtastic, radio, or in-person QR codes.
25
+
26
+ The protocol is defined in three layers:
27
+
28
+ 1. **CANARY-DERIVE** — Deterministic token derivation from a shared secret, context string,
29
+ and counter
30
+ 2. **CANARY-DURESS** — Coercion-resistant alternate tokens and dead man's switch (liveness
31
+ monitoring)
32
+ 3. **CANARY-WORDLIST** — Spoken-word encoding optimised for voice clarity
33
+
34
+ ## Motivation
35
+
36
+ AI voice cloning now requires as little as three seconds of audio. A thirty-second clip
37
+ and thirty minutes of work can produce a convincing clone. The security advice is already
38
+ widespread — "agree on a family safe word" — but common implementations are dangerously
39
+ naive:
40
+
41
+ - Static words that never rotate (one compromise burns the word forever)
42
+ - Human-chosen words with low entropy (predictable and guessable)
43
+ - No duress signalling (if a member is forced to reveal the word, there is no silent alarm)
44
+ - No protocol (just "remember a word" — no tooling, no synchronisation, no offline support)
45
+
46
+ Existing standards each solve part of the problem but not all of it:
47
+
48
+ | Standard | Rotating tokens | Coercion resistance | Human-spoken output |
49
+ |----------------|:---------------:|:-------------------:|:-------------------:|
50
+ | TOTP (RFC 6238)| Yes | No | No |
51
+ | HOTP (RFC 4226)| Yes | No | No |
52
+ | SAS (ZRTP) | No | No | Yes |
53
+ | BIP-39 | No | No | Yes |
54
+ | **CANARY** | **Yes** | **Yes** | **Yes** |
55
+
56
+ CANARY solves all of these by combining well-understood cryptographic primitives
57
+ (HMAC-SHA256, counters, wordlists) into a single protocol with coercion resistance as a
58
+ first-class property.
59
+
60
+ ### Relationship to HOTP/TOTP
61
+
62
+ CANARY's derivation follows the HMAC-counter pattern established by HOTP
63
+ (RFC 4226) and TOTP (RFC 6238). The core operation — `HMAC(secret, counter)`,
64
+ truncated and encoded — is structurally identical. CANARY extends this pattern
65
+ with context-string domain separation, enabling multiple derivation channels
66
+ (verification, duress, liveness) from a single shared secret without requiring
67
+ separate keys or counters.
68
+
69
+ | Property | HOTP/TOTP | CANARY |
70
+ |---------------------|---------------------|-------------------------------------|
71
+ | Hash function | SHA-1 (SHA-256 opt) | SHA-256 (mandatory) |
72
+ | Counter encoding | 8-byte BE integer | Context string + 4-byte BE integer |
73
+ | Output encoding | 6–8 digit code | Words, PIN, or hex |
74
+ | Derivation channels | 1 per (secret, ctr) | Multiple via context strings |
75
+ | Coercion resistance | None | Duress + liveness channels |
76
+ | Target verifier | Machine | Human (spoken) |
77
+
78
+ ## Terminology
79
+
80
+ | Term | Definition |
81
+ |--------------------|------------------------------------------------------------------------------------------------|
82
+ | Secret | A 256-bit (32-byte) shared key known to all verifying parties |
83
+ | Context | A UTF-8 string identifying the derivation purpose (e.g. `"canary:verify"`, `"dispatch:handoff"`) |
84
+ | Counter | An unsigned integer determining the current token; scheme is application-defined |
85
+ | Identity | A UTF-8 string identifying a specific person (pubkey, username, employee ID, etc.) |
86
+ | Verification token | The current group token, derived from secret + context + counter |
87
+ | Duress token | A person's coercion token, derived from secret + context + identity + counter |
88
+ | Liveness token | A heartbeat token proving "I am alive and hold the secret" |
89
+ | Encoding | Output format: words, PIN, or hex |
90
+ | Wordlist | A curated list of exactly 2048 words optimised for spoken clarity |
91
+ | Burn-after-use | Advancing the counter early after a token is used for verification |
92
+
93
+ ---
94
+
95
+ ## CANARY-DERIVE
96
+
97
+ Core deterministic token derivation. The universal primitive that all other layers build on.
98
+
99
+ ### Algorithm
100
+
101
+ ```
102
+ token_bytes = HMAC-SHA256(secret, utf8(context) || counter_be32)
103
+ ```
104
+
105
+ Where:
106
+
107
+ - `secret` — 256-bit (32-byte) shared key
108
+ - `context` — UTF-8 encoded string identifying the derivation purpose
109
+ - `counter_be32` — 4-byte big-endian unsigned integer
110
+ - Output: 32 bytes (256 bits), truncated and encoded per the output format
111
+
112
+ The `context` string ensures the same secret can derive different tokens for different
113
+ purposes without collision. For example, `"canary:verify"` and `"dispatch:handoff"` produce
114
+ entirely independent token sequences from the same secret.
115
+
116
+ ### Counter Schemes
117
+
118
+ The protocol does not mandate a specific counter scheme. Implementations choose one or
119
+ more based on their use case:
120
+
121
+ | Scheme | Counter value | Use case |
122
+ |-----------------|-------------------------------------|-----------------------------------------|
123
+ | **Time-based** | `floor(unix_time / period)` | Canary groups (7d), short-lived (30s) |
124
+ | **Sequence** | Monotonic integer | Burn-after-use, one-time tokens |
125
+ | **Event-based** | Deterministic from event ID | Platforms (hash of task ID) |
126
+
127
+ ### Tolerance Window
128
+
129
+ Verifiers SHOULD accept tokens within `±tolerance` counter values to handle clock skew
130
+ and latency. The tolerance value is application-defined (e.g. short-lived tokens: ±1 epoch,
131
+ canary-kit groups: 0).
132
+
133
+ Recommended tolerance values by use case:
134
+
135
+ | Use case | Tolerance | Rationale |
136
+ |-------------------|-----------|----------------------------------------------|
137
+ | Casual (family) | ±1 | Forgiving; handles minor sync drift |
138
+ | High-security | 0 | Strict; requires exact counter match |
139
+ | TOTP-equivalent | ±1 | Standard TOTP practice (RFC 6238 §5.2) |
140
+
141
+ The tolerance used by verifiers MUST be communicated to duress token derivers, because the
142
+ duress collision avoidance window (see §Collision Avoidance) is computed from this value.
143
+
144
+ ### Counter Acceptance
145
+
146
+ Implementations that receive counter advancement signals (burn-after-use notifications,
147
+ re-sync messages) MUST enforce the following rules:
148
+
149
+ 1. **Authorised source:** Counter advancement signals MUST originate from a party known
150
+ to hold the shared secret. The mechanism for verifying this is transport-defined
151
+ (e.g. cryptographic signature from a known group member).
152
+
153
+ 2. **Monotonic advancement:** Implementations MUST reject counter updates where
154
+ `new_counter <= local_counter`. This provides replay protection and prevents
155
+ counter rollback.
156
+
157
+ 3. **Bounded jumps:** Implementations MUST reject counter updates where
158
+ `new_counter > time_based_counter + max_offset`. The default `max_offset` is
159
+ 100. This bounds the damage from a compromised sender attempting to desynchronise
160
+ the group by jumping the counter far ahead.
161
+
162
+ ---
163
+
164
+ ## CANARY-SYNC: Transport-Agnostic Synchronisation
165
+
166
+ CANARY-SYNC is the protocol layer for propagating group state mutations and telemetry
167
+ across any transport without depending on Nostr or any specific relay infrastructure.
168
+ It operates over any channel capable of delivering authenticated, ordered or unordered
169
+ messages — WebSocket, Bluetooth LE, Meshtastic, Matrix, or a direct TCP connection.
170
+
171
+ ### Protocol Version
172
+
173
+ All CANARY-SYNC messages carry a `protocolVersion` field. The current version is **2**.
174
+ Receivers MUST reject any message whose `protocolVersion` does not exactly equal `2`.
175
+ Version negotiation is not supported — a version mismatch is a hard error. Senders MUST
176
+ inject `protocolVersion: 2` into every message before encoding.
177
+
178
+ ### Message Types
179
+
180
+ CANARY-SYNC defines eight message types grouped by function:
181
+
182
+ #### State Mutations
183
+
184
+ These messages change persistent group state and require epoch/opId replay protection.
185
+ Only admins may send state-mutation messages (except `counter-advance` and self-leave —
186
+ see §Privileged Actions).
187
+
188
+ | Type | Required Fields | Description |
189
+ |------|----------------|-------------|
190
+ | `member-join` | `pubkey`, `displayName?`, `timestamp`, `epoch`, `opId` | Add a member to the group. Admin-only unless the sender is adding themselves (self-join proves possession of the group key via envelope decryption). |
191
+ | `member-leave` | `pubkey`, `timestamp`, `epoch`, `opId` | Remove a member from the group. Admin-only unless `pubkey` equals `sender` (self-leave). |
192
+ | `counter-advance` | `counter`, `usageOffset`, `timestamp` | Advance the group counter (burn-after-use). Any current member may send; subject to monotonicity and bounded-jump constraints (see §Bounded Counter Advance). No `epoch`/`opId` required. |
193
+ | `reseed` | `seed`, `counter`, `timestamp`, `epoch`, `opId`, `admins[]`, `members[]` | Replace seed, counter, members, and admins atomically. Admin-only. `epoch` MUST equal `current_epoch + 1`. Clears all replay state. |
194
+
195
+ #### Telemetry (Fire-and-Forget)
196
+
197
+ These messages convey real-time signals that do not persist in group state. They are
198
+ subject to freshness constraints but not epoch-based replay protection.
199
+
200
+ | Type | Required Fields | Description |
201
+ |------|----------------|-------------|
202
+ | `beacon` | `lat`, `lon`, `accuracy`, `timestamp`, `opId` | Share the sender's current location. `lat` MUST be in `[−90, 90]`; `lon` in `[−180, 180]`; `accuracy` in `[0, 20_000_000]` metres. |
203
+ | `duress-alert` | `lat`, `lon`, `timestamp`, `opId`, `subject?` | Emergency location alert. Same coordinate constraints as `beacon`. See §Passive Duress. |
204
+ | `liveness-checkin` | `pubkey`, `timestamp`, `opId` | Heartbeat proving presence. Receiver MUST verify that `pubkey` equals `sender`. See §Passive Duress. |
205
+
206
+ #### Recovery
207
+
208
+ | Type | Required Fields | Description |
209
+ |------|----------------|-------------|
210
+ | `state-snapshot` | `seed`, `counter`, `usageOffset`, `members[]`, `admins[]`, `epoch`, `opId`, `timestamp`, `prevEpochSeed?` | Admin-issued full state snapshot for catch-up. Subject to same-epoch anti-rollback constraints; higher-epoch snapshots are deliberately disabled (see §State Snapshot Recovery). |
211
+
212
+ ### Field Constraints
213
+
214
+ - `pubkey`, `seed`, and `prevEpochSeed` fields MUST be 64-character lowercase hex strings
215
+ (32 bytes). `seed` in `reseed` is a `Uint8Array` in-process; it is hex-encoded for wire
216
+ transport and decoded back to bytes on receipt.
217
+ - `opId` MUST be a non-empty string of at most 128 characters.
218
+ - `epoch` and `counter` MUST be non-negative integers.
219
+ - `admins` MUST be a subset of `members` (all admins are members).
220
+ - `timestamp` MUST be a non-negative integer (Unix seconds).
221
+
222
+ ### Privileged Actions
223
+
224
+ Certain message types require the sender to be an admin of the group at the time of
225
+ processing. The following rules govern privilege:
226
+
227
+ - `reseed` and `state-snapshot` are always privileged.
228
+ - `member-join` is privileged unless `msg.pubkey === sender` (self-join). Self-join is
229
+ permitted because successful decryption of the group envelope proves the sender holds
230
+ a valid admin-issued group key.
231
+ - `member-leave` is privileged unless `msg.pubkey === sender` (self-leave).
232
+ - `counter-advance` is not privileged; any current group member MAY send it.
233
+ - Fire-and-forget messages (`beacon`, `duress-alert`, `liveness-checkin`) are not privileged.
234
+
235
+ Implementations MUST fail closed: a privileged message with no identified sender MUST be
236
+ silently dropped without modifying group state.
237
+
238
+ ### Epoch-Based Replay Protection
239
+
240
+ Each `reseed` increments the epoch counter by exactly one. Epoch numbers MUST be
241
+ monotonically increasing (Invariant I1).
242
+
243
+ Every state-mutating message that is subject to replay protection carries an `opId`. The
244
+ group state maintains a `consumedOps` list of opIds processed within the current epoch. A
245
+ message whose `opId` is already in `consumedOps` MUST be silently dropped.
246
+
247
+ On `reseed`, the new epoch begins and `consumedOps` is reset — the `reseed` message's own
248
+ `opId` is the sole initial entry (Invariant I4).
249
+
250
+ To bound memory growth, `consumedOps` is capped at 1000 entries per epoch. When this
251
+ limit is exceeded, the oldest entries are evicted and a `consumedOpsFloor` timestamp is
252
+ recorded. Any subsequent message with `timestamp ≤ consumedOpsFloor` MUST be dropped,
253
+ preventing replay of evicted operations.
254
+
255
+ Epoch matching rules for privileged operations:
256
+
257
+ | Message type | Epoch constraint |
258
+ |---|---|
259
+ | `reseed` | `msg.epoch` MUST equal `state.epoch + 1` |
260
+ | All other privileged ops | `msg.epoch` MUST equal `state.epoch` |
261
+ | `state-snapshot` (same-epoch) | `msg.epoch` MUST equal `state.epoch` |
262
+ | `state-snapshot` (higher-epoch) | Rejected — see §State Snapshot Recovery |
263
+
264
+ Messages with `msg.epoch < state.epoch` (stale epoch) MUST be silently dropped (I6).
265
+
266
+ ### Fire-and-Forget Freshness
267
+
268
+ Telemetry messages (`beacon`, `duress-alert`, `liveness-checkin`) MUST pass a freshness
269
+ gate before being accepted:
270
+
271
+ - Messages older than **300 seconds** (`FIRE_AND_FORGET_FRESHNESS_SEC`) are silently dropped.
272
+ - Messages timestamped more than **60 seconds** (`MAX_FUTURE_SKEW_SEC`) in the future are
273
+ silently dropped.
274
+
275
+ These messages do not modify group state. Implementations SHOULD route them to
276
+ application-layer handlers (e.g. alert the group on `duress-alert`, update a presence
277
+ indicator on `liveness-checkin`). See §Passive Duress.
278
+
279
+ ### Bounded Counter Advance
280
+
281
+ The `counter-advance` message is not gated by admin privilege but is bounded to limit the
282
+ damage from a compromised or malicious group member.
283
+
284
+ On receipt of a `counter-advance` message, receivers MUST enforce:
285
+
286
+ 1. **Member check:** `sender` MUST be a current group member. Non-members MUST be rejected.
287
+ 2. **Monotonicity:** `msg.counter + msg.usageOffset` MUST be strictly greater than
288
+ `state.counter + state.usageOffset`. Counter rollback MUST be rejected.
289
+ 3. **Bounded jump:** `msg.counter + msg.usageOffset` MUST NOT exceed
290
+ `floor(now / rotationInterval) + 100`. The offset of **100** (`MAX_COUNTER_ADVANCE_OFFSET`)
291
+ is a hard cap. Messages that would advance the counter beyond this bound MUST be dropped.
292
+
293
+ The bound cross-references the **Bounded jumps** rule in §Counter Acceptance: both use a
294
+ `max_offset` of 100 relative to the current time-based counter.
295
+
296
+ ### State Invariants
297
+
298
+ The following invariants MUST be preserved by all conforming implementations:
299
+
300
+ | # | Invariant | Description |
301
+ |---|-----------|-------------|
302
+ | **I1** | Epoch monotonicity | `state.epoch` only increases. No message may decrease it. |
303
+ | **I2** | opId uniqueness per epoch | Within a given epoch, each `opId` is processed at most once. Duplicate `opId` values MUST be dropped. |
304
+ | **I3** | Admin-only mutations | Only admins may execute privileged actions (reseed, state-snapshot, adding/removing other members). |
305
+ | **I4** | Reseed atomicity | A `reseed` atomically replaces `{seed, counter, usageOffset, members, admins, epoch}` and resets `consumedOps` to `[reseed.opId]`. No partial application is permitted. |
306
+ | **I5** | Same-epoch snapshot anti-rollback | A same-epoch `state-snapshot` MUST be rejected unless: seed matches, incoming effective counter ≥ local effective counter, incoming members ⊇ local members, and incoming admins ⊇ local admins. |
307
+ | **I6** | Stale-epoch rejection | Any privileged message with `msg.epoch < state.epoch` MUST be silently dropped. |
308
+
309
+ ### State Snapshot Recovery
310
+
311
+ `state-snapshot` is an admin-issued message that allows group members who missed
312
+ intermediate transitions to resynchronise their local state.
313
+
314
+ #### Same-Epoch Recovery
315
+
316
+ When `msg.epoch === state.epoch`, the snapshot is accepted if and only if all of the
317
+ following hold:
318
+
319
+ 1. `msg.seed === state.seed` — the seed has not changed within this epoch (no silent reseed).
320
+ 2. `msg.counter + msg.usageOffset >= state.counter + state.usageOffset` — counter does not
321
+ regress.
322
+ 3. `msg.members` is a superset of `state.members` — member removals within an epoch require
323
+ a reseed.
324
+ 4. `msg.admins` is a superset of `state.admins` — admin demotions require a reseed.
325
+
326
+ On acceptance, the receiver advances to the snapshot's counter, members, and admins, and
327
+ appends the `opId` to `consumedOps`.
328
+
329
+ #### Higher-Epoch Recovery — Deliberately Disabled
330
+
331
+ Higher-epoch snapshots (`msg.epoch > state.epoch`) are **rejected**. A group member who
332
+ misses one or more `reseed` messages cannot recover via snapshot and MUST be re-invited
333
+ by an admin.
334
+
335
+ This restriction eliminates the stale-admin hijack attack surface: a removed admin whose
336
+ local state is stale could otherwise fabricate a higher-epoch snapshot and push a forged
337
+ group state to members who missed the reseed. By rejecting higher-epoch snapshots entirely,
338
+ the attack surface is reduced to same-epoch fabrication, which is mitigated by the
339
+ sender-must-be-in-snapshot-admins self-consistency check at the transport layer.
340
+
341
+ **Known limitation:** Full mitigation of the stale-admin fabrication attack requires either
342
+ quorum-based recovery (multiple admins must co-sign a snapshot) or a verifiable reseed
343
+ chain (signed epoch transitions stored on a relay). Both are deferred to a future protocol
344
+ version.
345
+
346
+ ### Deterministic Serialisation
347
+
348
+ All CANARY-SYNC messages MUST be serialised deterministically for signing. The canonical
349
+ form is produced by `canonicaliseSyncMessage`:
350
+
351
+ 1. Binary fields (`seed` in `reseed`) are hex-encoded before serialisation.
352
+ 2. All object keys are sorted recursively (depth-first).
353
+ 3. Arrays are serialised in their original order (element order is significant).
354
+ 4. No whitespace is emitted.
355
+ 5. `undefined` values are omitted.
356
+
357
+ This canonical form is the byte string over which inner signatures are computed (H2). The
358
+ `protocolVersion` field MUST be present in the canonical form — the sender is responsible
359
+ for injecting `protocolVersion: 2` before both encoding and signing, ensuring that the
360
+ canonical bytes always reflect the actual wire value.
361
+
362
+ Wire encoding uses `JSON.stringify` with the `protocolVersion` field added. Binary fields
363
+ (`seed` in `reseed`) are hex-encoded for safe JSON round-tripping via `bytesToHex`.
364
+
365
+ ### Group Key Derivation and Envelope Encryption
366
+
367
+ CANARY-SYNC messages are transmitted inside encrypted envelopes keyed to the group seed.
368
+
369
+ **Group key derivation:**
370
+
371
+ ```
372
+ group_key = HMAC-SHA256(hex_to_bytes(seed), utf8("canary:sync:key"))
373
+ ```
374
+
375
+ **Envelope encryption:** AES-256-GCM with a random 12-byte nonce. The wire format is:
376
+
377
+ ```
378
+ base64(IV || ciphertext || auth_tag)
379
+ ```
380
+
381
+ where `IV` is 12 bytes, `ciphertext` is the UTF-8 encoded JSON message, and `auth_tag` is
382
+ the 16-byte GCM authentication tag appended by the Web Crypto API. Decryption MUST throw
383
+ on authentication failure.
384
+
385
+ **Per-participant signing key derivation:**
386
+
387
+ ```
388
+ signing_key = HMAC-SHA256(hex_to_bytes(seed), utf8("canary:sync:sign:") || hex_to_bytes(personal_privkey))
389
+ ```
390
+
391
+ Binding the personal private key ensures each participant's signing identity is unique
392
+ within the group, even across reseed events. A reseed invalidates all prior signing keys
393
+ derived from the old seed.
394
+
395
+ **Group tag hashing:** To avoid correlating events to a known group name, transport-layer
396
+ routing tags use a privacy-preserving hash:
397
+
398
+ ```
399
+ group_tag = hex(SHA256(utf8(group_id)))
400
+ ```
401
+
402
+ ### Cross-References
403
+
404
+ - **§Counter Acceptance** — defines the bounded-jump rule that `counter-advance` enforces.
405
+ - **§Passive Duress** — defines liveness monitoring; `liveness-checkin` and `duress-alert`
406
+ are the CANARY-SYNC wire representations of liveness heartbeats and active duress signals.
407
+ - **§Seed Storage** — the group seed from which `group_key` and `signing_key` are derived
408
+ MUST be wiped from storage on group dissolution.
409
+
410
+ ---
411
+
412
+ ### Output Encodings
413
+
414
+ The encoding is a presentation layer, not part of the derivation. The same `token_bytes`
415
+ can be rendered in multiple formats:
416
+
417
+ | Format | Encoding | Example |
418
+ |-----------|----------------------------------------------------------|-------------------------------|
419
+ | **Words** | `uint16_be(bytes[i*2..i*2+2]) mod 2048` → wordlist | `net`, `throw drafter category` |
420
+ | **PIN** | First N bytes as big-endian integer, mod 10^digits | `2796` |
421
+ | **Hex** | Lowercase hex pairs, truncated to length | `c51524053f1f27a4` |
422
+
423
+ #### Word Encoding
424
+
425
+ Each word consumes 2 bytes (16 bits) from the token, reduced modulo the wordlist size
426
+ (2048 = 11 effective bits per word):
427
+
428
+ ```
429
+ for i in 0..word_count:
430
+ index = uint16_be(token_bytes[i*2 .. i*2+2]) mod 2048
431
+ words[i] = wordlist[index]
432
+ ```
433
+
434
+ A single HMAC-SHA256 output (32 bytes) provides enough material for up to 16 words.
435
+
436
+ #### PIN Encoding
437
+
438
+ The first `ceil(digits × 0.415)` bytes are interpreted as a big-endian integer, reduced
439
+ modulo `10^digits`, zero-padded to the requested length:
440
+
441
+ ```
442
+ value = big_endian_int(token_bytes[0..N])
443
+ pin = (value mod 10^digits), zero-padded to `digits` characters
444
+ ```
445
+
446
+ ### Token Length and Security
447
+
448
+ More output = more security, harder to speak:
449
+
450
+ | Format | Bits | Possibilities | Use case |
451
+ |--------------|-------|---------------|----------------------------------|
452
+ | 1 word | ~11 | ~2,048 | Casual verification, voice call |
453
+ | 2–3 words | ~22–33| ~4M–8B | Group identity, high-security |
454
+ | 4-digit PIN | ~13.3 | 10,000 | Quick handoff (dispatch) |
455
+ | 6-digit code | ~19.9 | 1,000,000 | TOTP-equivalent |
456
+
457
+ ### Directional Pair Pattern
458
+
459
+ A single shared token has an echo problem: the second party to speak could parrot
460
+ the first. CANARY solves this with **directional context strings** — each side
461
+ derives a different token from the same secret.
462
+
463
+ A directional pair uses a namespace and two role identifiers to construct two
464
+ context strings:
465
+
466
+ ```
467
+ context_a = namespace + ":" + role_a
468
+ context_b = namespace + ":" + role_b
469
+ ```
470
+
471
+ Each party derives BOTH tokens. Party A speaks their token; Party B verifies it
472
+ against context_a. Then Party B speaks their token; Party A verifies against
473
+ context_b. Neither token can be derived from the other without the shared secret.
474
+
475
+ #### Example — Insurance Phone Call
476
+
477
+ ```
478
+ caller_word = HMAC-SHA256(secret, utf8("aviva\0caller") || counter_be32)
479
+ agent_word = HMAC-SHA256(secret, utf8("aviva\0agent") || counter_be32)
480
+ ```
481
+
482
+ > **Note:** The null byte (`\0`) separator between namespace and role prevents
483
+ > concatenation ambiguity (e.g. namespace `a:b` + role `c` vs namespace `a` +
484
+ > role `b:c`).
485
+
486
+ 1. Agent: "What's your verification word?"
487
+ 2. Caller speaks their word — Agent verifies against `aviva\0caller` context ✓
488
+ 3. Caller: "And what's mine?"
489
+ 4. Agent speaks their word — Caller verifies against `aviva\0agent` context ✓
490
+
491
+ An eavesdropper hearing "bid" cannot derive "choose". Both parties have
492
+ independently proved knowledge of the shared secret.
493
+
494
+ #### Duress in Directional Pairs
495
+
496
+ Each party's duress token is derived from their OWN directional context:
497
+
498
+ ```
499
+ caller_duress = HMAC-SHA256(secret, utf8("aviva\0caller:duress") || 0x00 || utf8(caller_id) || counter_be32)
500
+ agent_duress = HMAC-SHA256(secret, utf8("aviva\0agent:duress") || 0x00 || utf8(agent_id) || counter_be32)
501
+ ```
502
+
503
+ If the caller speaks their duress word instead of their verification word, the
504
+ agent's system detects it and triggers the appropriate response — without the
505
+ caller ever revealing they are under coercion.
506
+
507
+ #### Convention
508
+
509
+ Implementations MUST use a null-byte separator (`namespace\0role`) for directional
510
+ context strings. The namespace identifies the application or domain. The roles
511
+ identify the two parties. Example namespaces:
512
+
513
+ | Namespace | Roles | Use case |
514
+ |-------------|------------------------------|--------------------------------|
515
+ | `dispatch` | `requester`, `provider` | Task handoff verification |
516
+ | `aviva` | `caller`, `agent` | Insurance phone verification |
517
+ | `barclays` | `customer`, `agent` | Banking phone verification |
518
+ | `id` | `subject`, `verifier` | Identity verification |
519
+
520
+ ### Session Abstraction
521
+
522
+ Implementations MAY provide a **Session** object that wraps a directional pair with
523
+ role awareness and lifecycle methods. A Session encapsulates the shared secret, the
524
+ namespace, the two roles, and the caller's own role, and exposes the following
525
+ interface:
526
+
527
+ | Method | Description |
528
+ |--------|-------------|
529
+ | `counter(nowSec?)` | Return the current counter — time-derived (floor(t / rotationSeconds)) or fixed |
530
+ | `myToken(nowSec?)` | Derive the token this party speaks to prove their own identity |
531
+ | `theirToken(nowSec?)` | Derive the token expected from the other party |
532
+ | `verify(spoken, nowSec?)` | Verify a word spoken by the other party; returns a result indicating pass, duress, or fail |
533
+ | `pair(nowSec?)` | Return both tokens keyed by role name |
534
+
535
+ Sessions are constructed from a `SessionConfig` specifying the secret, namespace,
536
+ roles tuple, own role, and optional parameters (rotationSeconds, tolerance, encoding,
537
+ preset, fixed counter). Implementations SHOULD validate that the two roles are
538
+ distinct and that `myRole` is one of the configured roles.
539
+
540
+ #### Fixed-Counter Mode
541
+
542
+ When `rotationSeconds` is set to `0`, the Session operates in **fixed-counter mode**.
543
+ The counter is supplied explicitly at construction time rather than derived from the
544
+ clock. This is appropriate for single-use tokens tied to a specific event identifier
545
+ (e.g. a task ID or booking reference) rather than to a time window.
546
+
547
+ Implementations MUST require an explicit `counter` value when `rotationSeconds=0`.
548
+ Implementations MUST reject a `counter` value when `rotationSeconds>0`, since the
549
+ counter is derived deterministically from the current time in that case.
550
+
551
+ #### Session Presets
552
+
553
+ Implementations MAY provide named presets that configure a Session for a specific
554
+ two-party use case:
555
+
556
+ | Preset | Words | Rotation | Tolerance | Use case |
557
+ |--------|-------|----------|-----------|----------|
558
+ | `call` | 1 | 30 seconds | 1 | Phone verification for insurance, banking, and call centres |
559
+ | `handoff` | 1 | single-use (fixed counter) | 0 | Physical handoff for rideshare, delivery, and task completion |
560
+
561
+ The `call` preset uses a 30-second rotation window with a tolerance of ±1 counter,
562
+ giving a 90-second acceptance window to accommodate clock skew between caller and
563
+ agent.
564
+
565
+ The `handoff` preset uses fixed-counter mode (`rotationSeconds=0`), with a tolerance
566
+ of 0. The counter MUST be set to a value agreed out-of-band (e.g. the event ID
567
+ converted to a 32-bit integer). This ensures the token is single-use and bound to
568
+ a specific event, not to a time window.
569
+
570
+ #### Deterministic Seed Derivation
571
+
572
+ When multiple sessions share a master secret but MUST produce independent, isolated
573
+ token streams, implementations SHOULD derive per-session seeds deterministically
574
+ using HMAC:
575
+
576
+ ```
577
+ sessionSeed = HMAC-SHA256(masterKey, utf8(component_0) || 0x00 || utf8(component_1) || ...)
578
+ ```
579
+
580
+ Null-byte separators between components prevent concatenation ambiguity (the byte
581
+ sequence `"ab" || 0x00 || "c"` is distinct from `"a" || 0x00 || "bc"`). Components
582
+ SHOULD be chosen to uniquely identify the session context (e.g. namespace, task ID,
583
+ participant identifiers).
584
+
585
+ ---
586
+
587
+ ## CANARY-DURESS
588
+
589
+ Coercion resistance and liveness monitoring. The differentiator from existing standards.
590
+
591
+ ### Active Duress — Token Derivation
592
+
593
+ Each identity has a distinct duress token, derived independently from the verification
594
+ token. In finite output spaces (e.g. 2048-word wordlist), two identities may derive
595
+ the same duress token — this is expected and handled by multi-match attribution (see
596
+ Verification Flow).
597
+
598
+ ```
599
+ duress_bytes = HMAC-SHA256(secret, utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32)
600
+ ```
601
+
602
+ Where:
603
+
604
+ - `context + ":duress"` — the verification context with `":duress"` appended
605
+ - `0x00` — a null-byte separator preventing concatenation ambiguity between the context
606
+ suffix and the identity string
607
+ - `identity` — UTF-8 encoded identifier (pubkey, username, employee ID)
608
+ - Same `counter` as the verification token
609
+
610
+ **Key property:** The duress token is computationally independent from the verification
611
+ token. An attacker who knows one cannot derive the other. But a verifier with the shared
612
+ secret can check for both.
613
+
614
+ ### Collision Avoidance
615
+
616
+ If the duress token, after encoding, is identical to any verification token within the
617
+ collision window, the implementation MUST re-derive by appending incrementing suffix bytes
618
+ to the HMAC data.
619
+
620
+ #### Collision Window
621
+
622
+ The collision window MUST be `±(2 × maxTolerance)` counter values centred on the deriver's
623
+ counter. The 2× factor accounts for worst-case counter drift: the deriver and the verifier
624
+ may each be off by `maxTolerance` in opposite directions, so the verifier could check
625
+ normal tokens anywhere in a `±(2 × maxTolerance)` range from the deriver's perspective.
626
+
627
+ The `maxTolerance` used when deriving the duress token MUST equal the `tolerance` used by
628
+ the verifier. Using an insufficient value allows duress tokens to collide with normal tokens
629
+ at distant counters, causing silent alarm suppression.
630
+
631
+ Implementations SHOULD enforce a practical upper bound on tolerance (the reference
632
+ implementation uses `MAX_TOLERANCE = 10`).
633
+
634
+ ```
635
+ collision_window = [max(0, counter - 2 * maxTolerance),
636
+ min(MAX_UINT32, counter + 2 * maxTolerance)]
637
+
638
+ forbidden_tokens = { encode(derive(secret, context, c)) for c in collision_window }
639
+
640
+ duress_data = utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32
641
+ duress_bytes = HMAC-SHA256(secret, duress_data)
642
+ duress_token = encode(duress_bytes, encoding)
643
+
644
+ suffix = 1
645
+ while duress_token in forbidden_tokens and suffix <= 255:
646
+ duress_bytes = HMAC-SHA256(secret, duress_data || byte(suffix))
647
+ duress_token = encode(duress_bytes, encoding)
648
+ suffix += 1
649
+
650
+ if duress_token in forbidden_tokens:
651
+ ERROR: duress collision unresolvable
652
+ ```
653
+
654
+ Collision avoidance operates at the encoding level, not the byte level, because different
655
+ byte arrays can encode to the same output via modulo reduction. Implementations MUST
656
+ apply this check whenever computing a duress token. If all 255 suffix retries produce
657
+ a collision (probability effectively zero for any practical encoding), the implementation
658
+ MUST raise an error. Implementations MUST NOT return a duress token that matches any
659
+ verification token in the collision window — this would cause a duress signal to be
660
+ classified as valid, suppressing the silent alarm.
661
+
662
+ ### Verification Flow
663
+
664
+ ```
665
+ verify(secret, context, counter, input, identities[], tolerance?) ->
666
+ { status: 'valid' } |
667
+ { status: 'duress', identities: string[] } |
668
+ { status: 'invalid' }
669
+ ```
670
+
671
+ The verification algorithm uses exact-counter-first priority:
672
+
673
+ 1. Derive the verification token at the **exact** counter. If the input matches → `valid`.
674
+ Same-counter collision avoidance guarantees no ambiguity at this step.
675
+
676
+ 2. For each identity, for each counter in the tolerance window
677
+ `[counter - tolerance, ..., counter + tolerance]`:
678
+ derive the duress token (using `maxTolerance = tolerance`).
679
+ Collect all matching identities. If any match →
680
+ `duress` with all matching identities.
681
+
682
+ 3. For each counter in the tolerance window **excluding the exact counter**:
683
+ derive the verification token. If the input matches → `valid`.
684
+
685
+ 4. No match → `invalid`.
686
+
687
+ This ordering ensures that a duress token at the expected counter is never masked by a
688
+ normal token at an adjacent counter (fail-safe).
689
+
690
+ #### Multi-Match Attribution
691
+
692
+ When checking duress tokens, the verifier MUST check all identities and collect all
693
+ matches. If exactly one identity matches, the result is `duress` with that identity.
694
+ If multiple identities match, the result is `duress` with all matching identities.
695
+ The verifier MUST NOT short-circuit after the first duress match.
696
+
697
+ #### Duress Collision Probability
698
+
699
+ In finite output spaces, distinct identities may derive identical duress tokens at the
700
+ same counter (birthday problem). This is a known limitation, not a protocol flaw.
701
+
702
+ | Members | 1 word (~11 bits) | 2 words (~22 bits) | 3 words (~33 bits) |
703
+ |---------|-------------------|--------------------|--------------------|
704
+ | 5 | ~0.5% | ~0.00012% | negligible |
705
+ | 10 | ~2.2% | ~0.00048% | negligible |
706
+ | 20 | ~9.3% | ~0.0019% | negligible |
707
+ | 50 | ~45% | ~0.012% | negligible |
708
+
709
+ Groups of 10 or more members SHOULD use 2+ words for reliable attribution. PIN
710
+ encoding with 4 digits (10,000 outputs) has similar collision properties to 1-word
711
+ encoding (2,048 outputs).
712
+
713
+ ### Deniability Properties
714
+
715
+ The duress token is indistinguishable from a wrong answer to any party that does not hold
716
+ the shared secret:
717
+
718
+ - An attacker hears a plausible word from the wordlist (or a plausible PIN).
719
+ - The attacker cannot verify whether it is the verification token or the duress token
720
+ without the shared secret.
721
+ - If the attacker demands the "real" token, the member can assert that the duress token
722
+ is the real token. The attacker cannot refute this.
723
+
724
+ ### Threat-Profile Presets
725
+
726
+ Implementations MAY provide named threat-profile presets that select a word count and
727
+ rotation interval appropriate for a given risk level and operational context. These are
728
+ recommendations, not requirements — implementations MAY define additional presets or
729
+ allow operators to configure custom profiles.
730
+
731
+ | Preset | Words | Rotation | Tolerance | Use case |
732
+ |--------|-------|----------|-----------|----------|
733
+ | `family` | 1 | 7 days | 1 | Casual family and friend groups |
734
+ | `field-ops` | 2 | 24 hours | 1 | High-security field operations |
735
+ | `enterprise` | 2 | 48 hours | 1 | Corporate and institutional use |
736
+ | `event` | 1 | 4 hours | 1 | Temporary event-based groups |
737
+
738
+ The `family` preset prioritises usability: a single word with a weekly rotation is easy
739
+ to remember and sufficient for live voice calls where the attacker has at most one
740
+ attempt. It is NOT suitable for text-based or asynchronous verification where an
741
+ attacker can brute-force all 2,048 words offline.
742
+
743
+ The `field-ops` preset prioritises security: two-word phrases (~22 bits of entropy)
744
+ with daily rotation are recommended for journalism, activism, and operational contexts
745
+ where the threat model includes motivated, resourced adversaries.
746
+
747
+ The `enterprise` preset extends the rotation window to 48 hours, balancing security
748
+ with operational convenience for larger teams where frequent re-verification is
749
+ impractical.
750
+
751
+ The `event` preset uses a 4-hour rotation aligned with typical conference or festival
752
+ session schedules. It is intended for ephemeral groups formed at a specific event.
753
+
754
+ ### Limitations
755
+
756
+ - If the attacker has compromised the member's device AND obtained the shared secret, they
757
+ can derive both the verification token and the duress token. At this point, the device is
758
+ the weakest link, not the protocol.
759
+ - If the attacker knows the CANARY protocol exists and demands the member display the
760
+ application, the member would expose the verification token. Implementations SHOULD NOT
761
+ display the duress token in the default UI. The duress token SHOULD be accessible only
762
+ through a non-obvious secondary gesture.
763
+
764
+ ### Implementation UX Requirements
765
+
766
+ Implementations that display verification tokens to users MUST follow these
767
+ requirements to preserve duress deniability:
768
+
769
+ 1. Implementations MUST NOT display the duress token alongside the verification
770
+ token in any default UI view.
771
+ 2. Implementations SHOULD require a non-obvious secondary gesture (e.g. long-press,
772
+ hidden menu, specific swipe pattern) to access duress-related functionality.
773
+ 3. When a duress token is detected during verification, implementations MUST NOT
774
+ display any visible indication that could alert an attacker observing the
775
+ verifier's screen. The response SHOULD appear identical to a failed verification
776
+ from the attacker's perspective.
777
+ 4. Implementations SHOULD NOT label any UI element with the word "duress" or
778
+ "coercion" in the default interface.
779
+
780
+ ### Passive Duress — Dead Man's Switch
781
+
782
+ The protocol defines a liveness token for heartbeat-based absence detection:
783
+
784
+ ```
785
+ liveness_bytes = HMAC-SHA256(secret, utf8(context + ":alive") || 0x00 || utf8(identity) || counter_be32)
786
+ ```
787
+
788
+ The liveness token proves both identity and knowledge of the secret — not just a ping.
789
+
790
+ Liveness monitoring parameters are application-defined:
791
+
792
+ | Parameter | Description | Example |
793
+ |----------------------|-------------------------------------------------|-------------|
794
+ | `heartbeat_interval` | Expected time between liveness proofs | 300s, 30s |
795
+ | `grace_period` | Time after missed heartbeat before DMS triggers | 2× interval |
796
+
797
+ **DMS trigger actions** are implementation-specific:
798
+
799
+ | Implementation | Action on DMS trigger |
800
+ |----------------|--------------------------------------------------------------------|
801
+ | Canary-kit | Derive liveness token; monitoring and alerting are application-layer |
802
+ | Key management | Lock signing keys, broadcast revocation |
803
+ | Dispatch | Escalate to dispatch, freeze escrow |
804
+ | Banking | Freeze account, alert fraud team |
805
+
806
+ The CANARY protocol defines liveness token derivation. The monitoring loop
807
+ (tracking heartbeat intervals, detecting missed check-ins, triggering alerts)
808
+ is application-defined. Libraries SHOULD provide the `deriveLivenessToken()`
809
+ primitive. Applications SHOULD implement heartbeat tracking with configurable
810
+ `heartbeat_interval` and `grace_period`.
811
+
812
+ ---
813
+
814
+ ## CANARY-WORDLIST
815
+
816
+ Spoken-word encoding optimised for voice clarity.
817
+
818
+ ### Encoding Scheme
819
+
820
+ - 2048 words = 11 bits per word
821
+ - Token bytes split into 2-byte chunks, each mapped to a word index via
822
+ `uint16_be mod 2048`
823
+ - 1 word = ~11 bits, 2 words = ~22 bits, 3 words = ~33 bits
824
+
825
+ ### Phonetic Clarity Criteria
826
+
827
+ Wordlists MUST satisfy:
828
+
829
+ 1. **No homophones** — words must sound distinct (`write` vs `right` excluded)
830
+ 2. **Distinct first syllable** — listeners can identify the word early
831
+ 3. **Cross-accent pronounceable** — works across major accent families
832
+ 4. **No offensive words** — culturally appropriate across contexts
833
+ 5. **Single-word only** — no compounds, no hyphens
834
+ 6. **4–8 letters preferred** — short enough to speak, long enough to be distinct
835
+
836
+ ### Wordlist Requirements
837
+
838
+ Words MUST be:
839
+
840
+ - Between 3 and 8 characters in length (inclusive)
841
+ - Unambiguous when spoken aloud
842
+ - Phonetically distinct from every other word in the list
843
+ - Free of offensive meanings in major languages
844
+ - Easy to pronounce across common English accents
845
+
846
+ Words MUST NOT be:
847
+
848
+ - Homophones of other words in the list (e.g. there/their, right/write)
849
+ - Within 2 phonetic edits of another list word (e.g. cat/bat, pen/ten)
850
+ - Confusable over degraded audio channels (e.g. ship/chip, thin/fin)
851
+ - Emotionally charged words that could cause alarm if overheard (e.g. bomb, kill, death)
852
+
853
+ ### Format
854
+
855
+ The wordlist is a plain text file, one word per line, with exactly 2048 entries. Lines are
856
+ numbered 0 through 2047. The word at line N is `wordlist[N]`.
857
+
858
+ ### Reference Wordlist
859
+
860
+ `en-v1` — 2048-word English wordlist curated from BIP-39 with additional phonetic
861
+ filtering. Maintained in the `canary-kit` reference implementation.
862
+
863
+ The canonical English wordlist (`en-v1`) begins from the BIP-39 English wordlist (2048
864
+ words) with the following modifications:
865
+
866
+ 1. Remove all words that fail the spoken-clarity requirements above.
867
+ 2. Replace removed words with words from a supplementary spoken-word corpus.
868
+ 3. Validate the complete list against phonetic distance metrics.
869
+
870
+ The full `en-v1` wordlist is defined in Appendix A of [NIP-CANARY.md](NIP-CANARY.md). Any
871
+ implementation claiming compliance with this protocol MUST use the exact wordlist defined
872
+ there.
873
+
874
+ ### Why Not BIP-39 Directly?
875
+
876
+ CANARY's `en-v1` wordlist is **derived from** BIP-39 English — it starts with the same
877
+ 2048 words, then filters for spoken-word clarity. The two lists optimise for different
878
+ threat models:
879
+
880
+ | Concern | BIP-39 | CANARY `en-v1` |
881
+ |---------|--------|----------------|
882
+ | **Primary channel** | Written (paper backup) | Spoken (voice call, radio) |
883
+ | **Identification** | First 4 characters unique | First syllable distinct |
884
+ | **Homophones** | Allowed (`write`/`right`) | Removed |
885
+ | **Audio confusion** | Not considered | Filtered (`ship`/`chip`, `thin`/`fin`) |
886
+ | **Emotional charge** | Allowed (`bomb`, `kill`) | Removed (avoids alarm if overheard) |
887
+
888
+ BIP-39 words are designed to be **written down and read back** — uniqueness from the first
889
+ 4 characters is enough. CANARY words are designed to be **spoken aloud over a degraded
890
+ channel** — a phone call, a radio, a noisy conference room. The filtering removes ~15% of
891
+ BIP-39 words and replaces them with concrete, unambiguous alternatives.
892
+
893
+ **Custom wordlists are supported.** The `wordlist` field in group configuration accepts any
894
+ wordlist identifier. Implementations MAY offer BIP-39 as an alternative for teams that
895
+ prefer familiarity over spoken clarity. The protocol does not mandate `en-v1` — it mandates
896
+ that all group members use the **same** list.
897
+
898
+ ### Internationalisation
899
+
900
+ Other languages can propose wordlists following the same criteria. Each list is identified
901
+ by a locale tag (`es-v1`, `fr-v1`, `ja-v1`). The protocol does not mandate a specific
902
+ language.
903
+
904
+ ---
905
+
906
+ ## Security Considerations
907
+
908
+ ### Threat Model
909
+
910
+ | Threat | TOTP | CANARY | Mitigation |
911
+ |----------------------------------|:----:|:-------:|----------------------------------------------------------|
912
+ | Impersonation | Yes | Yes | Verification token challenge |
913
+ | AI voice/video clone | No | Yes | Shared secret — clone does not know the current token |
914
+ | Coercion (forced auth) | No | **Yes** | Duress token alerts silently; attacker cannot distinguish|
915
+ | Replay attack | Yes | Yes | Counter rotation / burn-after-use |
916
+ | Absence detection | No | **Yes** | Liveness token / dead man's switch |
917
+ | Transport interception | — | Yes | End-to-end encryption at the transport layer |
918
+ | Device compromise | — | Yes | Re-seed immediately; exclude compromised member |
919
+ | Wordlist brute force (live call) | — | Yes | 11 bits per word; attacker gets one attempt |
920
+
921
+ ### Entropy Analysis
922
+
923
+ - Single word: ~11 bits (1 in 2048). Adequate for real-time verification where the
924
+ attacker has one attempt.
925
+ - Two words: ~22 bits (1 in ~4,194,304). Recommended for higher-security groups.
926
+ - Three words: ~33 bits (1 in ~8,589,934,592). Maximum word configuration.
927
+ - 4-digit PIN: ~13.3 bits (1 in 10,000). Quick handoff scenarios.
928
+ - 6-digit code: ~19.9 bits (1 in 1,000,000). TOTP-equivalent.
929
+
930
+ ### Seed Storage
931
+
932
+ The shared secret MUST be stored securely on member devices:
933
+
934
+ - Encrypted at rest using the platform keychain or secure enclave where available
935
+ - Never exported in plaintext after initial receipt
936
+ - Wiped from storage on group dissolution
937
+ - Protected by device authentication (PIN or biometric) before displaying any derived
938
+ token
939
+
940
+ ### Shared Secret Trade-off
941
+
942
+ CANARY uses a symmetric shared secret because offline derivation requires it.
943
+ Any member can verify any other member without contacting a server, a key server,
944
+ or any online authority. This property — the ability to verify identity with no
945
+ network — is a deliberate design choice for scenarios where connectivity is
946
+ unreliable, adversarial, or unavailable.
947
+
948
+ The trade-off: a single compromised member or device can derive all verification
949
+ and duress tokens for the group until the secret is rotated. The blast radius of
950
+ a compromise is the entire group.
951
+
952
+ **Why not per-member secrets?** Per-member derived sub-secrets would require a
953
+ verifier to know or fetch the speaker's sub-secret before verification. This
954
+ introduces an online dependency or pre-distribution of all members' sub-secrets,
955
+ negating the simplicity of a single shared key.
956
+
957
+ **Mitigation strategy:** Detect compromise quickly and reseed immediately.
958
+
959
+ - Re-seed on member removal (`reason=member_removed`) — SHOULD for trusted
960
+ departures, MUST for adversarial or unknown circumstances
961
+ - Re-seed on suspected compromise (`reason=compromise`)
962
+ - Re-seed on duress detection (`reason=duress`)
963
+ - Periodic scheduled re-seed even without known compromise — intervals should
964
+ match the group's risk profile (see Threat-Profile Presets in the reference
965
+ implementation)
966
+
967
+ The protocol provides the machinery for fast recovery. The operational discipline
968
+ of regular reseeds is where real-world resilience comes from.
969
+
970
+ ### Timing Safety
971
+
972
+ Verification functions MUST NOT leak information through execution time.
973
+ Implementations MUST use constant-time comparison for all token matching
974
+ (e.g. XOR-and-accumulate, not early-exit string comparison). All verification
975
+ branches (exact match, duress check, tolerance window) MUST be computed
976
+ regardless of which branch matches — the result is determined after all
977
+ comparisons complete, not by short-circuiting on the first match.
978
+
979
+ This prevents an attacker from distinguishing verification tokens from duress
980
+ tokens by measuring response time.
981
+
982
+ ---
983
+
984
+ ## Test Vectors
985
+
986
+ ### CANARY Protocol Vectors
987
+
988
+ The following vectors define canonical expected outputs for the universal CANARY protocol.
989
+ Any implementation claiming conformance MUST produce identical results.
990
+
991
+ **Inputs:**
992
+
993
+ ```
994
+ SECRET = 0000000000000000000000000000000000000000000000000000000000000001
995
+ CONTEXT = canary:verify
996
+ IDENTITY = alice
997
+ ```
998
+
999
+ **Algorithm:**
1000
+
1001
+ ```
1002
+ CANARY-DERIVE:
1003
+ token_bytes = HMAC-SHA256(hex_to_bytes(SECRET), utf8(CONTEXT) || counter_be32)
1004
+
1005
+ CANARY-DURESS:
1006
+ duress_bytes = HMAC-SHA256(hex_to_bytes(SECRET), utf8(CONTEXT + ":duress") || 0x00 || utf8(IDENTITY) || counter_be32)
1007
+
1008
+ Liveness:
1009
+ liveness_bytes = HMAC-SHA256(hex_to_bytes(SECRET), utf8(CONTEXT + ":alive") || 0x00 || utf8(IDENTITY) || counter_be32)
1010
+ ```
1011
+
1012
+ **Vector Table:**
1013
+
1014
+ | # | Function | Context | Identity | Counter | Encoding | Expected output |
1015
+ |----|--------------------|------------------|------------|---------|-------------|--------------------------------------------------------------------|
1016
+ | 1 | deriveTokenBytes | `canary:verify` | — | 0 | raw hex | `c51524053f1f27a4c871c63069f285ce5ac5b69a40d6caa5af9b6945dd9556d1` |
1017
+ | 2 | deriveToken | `canary:verify` | — | 0 | 1 word | `net` |
1018
+ | 3 | deriveToken | `canary:verify` | — | 1 | 1 word | `famous` |
1019
+ | 4 | deriveToken | `dispatch:handoff` | — | 0 | 4-digit PIN | `2818` |
1020
+ | 5 | deriveToken | `id:verify` | — | 0 | 3 words | `decrease mistake require` |
1021
+ | 6 | deriveDuressToken | `canary:verify` | `alice` | 0 | 1 word | `airport` |
1022
+ | 7 | deriveDuressToken | `dispatch:handoff` | `rider123` | 0 | 4-digit PIN | `0973` |
1023
+ | 8 | verifyToken | `canary:verify` | `alice` | 0 | input: `net` | `{ status: 'valid' }` |
1024
+ | 9 | verifyToken | `canary:verify` | `alice` | 0 | input: `airport`| `{ status: 'duress', identities: ['alice'] }` |
1025
+ | 10 | deriveLivenessToken| `canary:verify` | `alice` | 0 | raw hex | `b38a10676ea8d4e716ad606e0b2ae7d9678e47ff44b0920a68ed6cb02e9bb858` |
1026
+ | 11 | deriveToken | `aviva:caller` | — | 0 | 1 word | `bid` |
1027
+ | 12 | deriveToken | `aviva:agent` | — | 0 | 1 word | `choose` |
1028
+
1029
+ Notes:
1030
+
1031
+ - Vector 5 uses a different context string, demonstrating that the same secret derives
1032
+ independent tokens per context.
1033
+ - Vector 6: `airport` is distinct from `net` — no collision re-derivation needed.
1034
+ - Vector 7: `0973` is distinct from `2818` — no collision re-derivation needed.
1035
+ - Vectors 8–9: Round-trip verification confirms correct classification of normal tokens
1036
+ as `valid` and duress tokens as `duress` with the correct identity.
1037
+ - Vectors 11–12: Directional pair — same secret, different context strings produce
1038
+ different tokens (`bid` vs `choose`). An eavesdropper hearing one cannot derive the other.
1039
+
1040
+ ---
1041
+
1042
+ ## Reference Implementation
1043
+
1044
+ TypeScript: `canary-kit` (npm)
1045
+
1046
+ ```
1047
+ npm install canary-kit
1048
+ ```
1049
+
1050
+ ### CANARY Protocol API
1051
+
1052
+ ```typescript
1053
+ import {
1054
+ deriveToken, deriveTokenBytes,
1055
+ deriveDuressToken, deriveDuressTokenBytes,
1056
+ verifyToken,
1057
+ deriveLivenessToken,
1058
+ } from 'canary-kit/token'
1059
+
1060
+ import {
1061
+ encodeAsWords, encodeAsPin, encodeAsHex,
1062
+ } from 'canary-kit/encoding'
1063
+ ```
1064
+
1065
+ Source: `https://github.com/TheCryptoDonkey/canary-kit`