canary-kit 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INTEGRATION.md CHANGED
@@ -367,19 +367,122 @@ authorisation:
367
367
 
368
368
  CANARY tokens rotate on a time-based counter (P4: Replay Resistance). For
369
369
  transaction-specific dynamic linking (required by FCA for remote electronic
370
- payments), construct the session namespace or counter from transaction parameters:
370
+ payments), construct the session namespace or counter from transaction parameters.
371
+
372
+ #### Full FCA SCA implementation
373
+
374
+ ```typescript
375
+ import { createSession, deriveSeed, generateSeed } from 'canary-kit/session'
376
+ ```
377
+
378
+ **Step 1: Server-side seed derivation (backend, runs once per customer)**
371
379
 
372
380
  ```typescript
381
+ // The master key lives in an HSM — never in application code
382
+ // deriveSeed uses HMAC-SHA256 with null-byte-separated components
383
+ const customerSeed = deriveSeed(
384
+ hsmMasterKey, // Uint8Array from HSM (min 16 bytes)
385
+ customerId, // e.g. 'CUST-2026-00491'
386
+ seedVersion.toString(), // increment to rotate without re-enrolment
387
+ )
388
+ // Deliver customerSeed to the customer's app over TLS during login
389
+ // Store in iOS Keychain / Android KeyStore (possession factor)
390
+ ```
391
+
392
+ **Step 2: Standard call verification (agent side)**
393
+
394
+ ```typescript
395
+ // Agent's desktop app — seed was derived from the same master_key + customer_id
373
396
  const session = createSession({
374
- secret: seed,
375
- namespace: `aviva:payment:${payeeId}:${amountPence}`,
397
+ secret: customerSeed,
398
+ namespace: 'aviva',
399
+ roles: ['caller', 'agent'],
400
+ myRole: 'agent',
401
+ preset: 'call', // 30-second rotation, 1 word, ±1 tolerance
402
+ theirIdentity: customerId,
403
+ })
404
+
405
+ // Agent asks: "What's your verification word?"
406
+ // Customer speaks their word from the app
407
+ const result = session.verify(spokenWord)
408
+
409
+ switch (result.status) {
410
+ case 'valid':
411
+ // Customer identity confirmed — two SCA factors satisfied:
412
+ // Knowledge: customer knows the seed (derives the correct word)
413
+ // Possession: seed stored on their device behind biometrics
414
+ const agentWord = session.myToken()
415
+ // Agent reads aloud: "Your confirmation word is: [agentWord]"
416
+ // Customer verifies on their app — bidirectional authentication
417
+ break
418
+
419
+ case 'duress':
420
+ // Customer spoke their duress word — they are under coercion
421
+ showVerified() // maintain plausible deniability
422
+ triggerDuressProtocol(result.identities) // silent security alert
423
+ break
424
+
425
+ case 'invalid':
426
+ // Word does not match — escalate to manual identity checks
427
+ escalateToSecurity()
428
+ break
429
+ }
430
+ ```
431
+
432
+ **Step 3: Transaction-specific dynamic linking (FCA requirement)**
433
+
434
+ FCA SCA requires authentication codes to be dynamically linked to the specific
435
+ transaction amount and payee for remote electronic payments. Encode transaction
436
+ parameters into the session namespace:
437
+
438
+ ```typescript
439
+ // Payment: £1,250.00 to payee 'ACME-CORP'
440
+ const paymentSession = createSession({
441
+ secret: customerSeed,
442
+ namespace: `aviva:payment:ACME-CORP:125000`, // payee + amount in pence
376
443
  roles: ['caller', 'agent'],
377
444
  myRole: 'agent',
378
445
  preset: 'call',
379
446
  theirIdentity: customerId,
380
447
  })
448
+
449
+ // This session produces different words from the standard session —
450
+ // the token is cryptographically bound to the transaction parameters.
451
+ // Changing the amount or payee produces a different word.
452
+ const paymentWord = paymentSession.myToken()
453
+ ```
454
+
455
+ **Step 4: Customer-side implementation (mobile app)**
456
+
457
+ ```typescript
458
+ // Customer's app — same seed, same namespace, opposite role
459
+ const customerSession = createSession({
460
+ secret: customerSeed, // retrieved from Keychain/KeyStore
461
+ namespace: 'aviva',
462
+ roles: ['caller', 'agent'],
463
+ myRole: 'caller', // customer is the caller
464
+ preset: 'call',
465
+ theirIdentity: 'aviva-agent',
466
+ })
467
+
468
+ // App displays:
469
+ // "Your word: [customerSession.myToken()]" — speak this to the agent
470
+ // "Expect to hear: [customerSession.theirToken()]" — agent should say this
471
+ // Countdown bar showing seconds until rotation
381
472
  ```
382
473
 
474
+ **Replacing SMS OTP with CANARY**
475
+
476
+ | Property | SMS OTP | CANARY |
477
+ |----------|---------|--------|
478
+ | Factor type | Possession only (SIM) | Knowledge + Possession |
479
+ | Phishing resistance | None (code can be relayed) | High (HMAC-derived, no transmittable code) |
480
+ | Offline capable | No (requires SMS delivery) | Yes (derived locally after initial sync) |
481
+ | Bidirectional | No (institution never proves identity) | Yes (mutual verification) |
482
+ | Coercion resistance | None | Duress word triggers silent alert |
483
+ | SIM-swap vulnerability | Critical | None (seed in device secure storage) |
484
+ | Dynamic linking | Separate mechanism needed | Built into namespace construction |
485
+
383
486
  ### RBI Digital Payment Authentication (India)
384
487
 
385
488
  Pattern 1 satisfies the RBI's two-factor authentication requirement for digital
@@ -634,6 +737,229 @@ Nostr relays. The vault is a single JSON blob containing all group states,
634
737
  encrypted with the user's own keypair, published as a kind 30078 event with
635
738
  a `d` tag of `canary:vault`.
636
739
 
740
+ ## Distributed Deployment Architecture
741
+
742
+ When deploying CANARY across multiple nodes (multi-region call centres, redundant
743
+ backend services, or clustered enterprise systems), verification state must be
744
+ synchronised without compromising the protocol's deepfake-proof guarantees.
745
+
746
+ ### Architecture overview
747
+
748
+ ```
749
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
750
+ │ Node A │ │ Node B │ │ Node C │
751
+ │ (London) │ │ (Mumbai) │ │ (Dubai) │
752
+ │ │ │ │ │ │
753
+ │ GroupState │◄───►│ GroupState │◄───►│ GroupState │
754
+ │ applySyncMsg │ │ applySyncMsg │ │ applySyncMsg │
755
+ │ │ │ │ │ │
756
+ │ Verify ✓ │ │ Verify ✓ │ │ Verify ✓ │
757
+ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
758
+ │ │ │
759
+ └────────────────────┼────────────────────┘
760
+
761
+ ┌───────┴───────┐
762
+ │ Nostr Relays │
763
+ │ (sync layer) │
764
+ └───────────────┘
765
+ ```
766
+
767
+ ### Key principle: verification is stateless, sync is not
768
+
769
+ Verification word derivation (`deriveVerificationWord`, `deriveToken`,
770
+ `session.myToken()`) is a pure function of `(seed, counter)`. Any node with the
771
+ seed can derive the correct word independently. No network round-trip needed.
772
+
773
+ State synchronisation is only required for:
774
+ - **Counter advances** (burn-after-use rotation)
775
+ - **Member changes** (join/leave triggers reseed)
776
+ - **Reseed events** (new seed distribution)
777
+
778
+ This means nodes can verify callers even during network partitions. Sync is
779
+ an optimisation for counter freshness, not a requirement for verification.
780
+
781
+ ### Conflict resolution via authority invariants
782
+
783
+ The sync protocol enforces six invariants (I1-I6) that guarantee convergence:
784
+
785
+ ```typescript
786
+ import {
787
+ applySyncMessage,
788
+ decodeSyncMessage,
789
+ decryptEnvelope,
790
+ deriveGroupKey,
791
+ type SyncMessage,
792
+ } from 'canary-kit/sync'
793
+
794
+ // Each node maintains its own GroupState and applies messages identically
795
+ function handleIncomingSync(
796
+ localState: GroupState,
797
+ encryptedPayload: string,
798
+ senderPubkey: string,
799
+ ): GroupState {
800
+ const groupKey = deriveGroupKey(localState.seed)
801
+ const decrypted = await decryptEnvelope(groupKey, encryptedPayload)
802
+ const msg = decodeSyncMessage(decrypted)
803
+ const nowSec = Math.floor(Date.now() / 1000)
804
+
805
+ // applySyncMessage enforces all invariants:
806
+ // I1: sender must be in admins (for privileged actions)
807
+ // I2: opId must not be consumed (replay protection)
808
+ // I3: epoch must match local epoch (non-reseed ops)
809
+ // I4: reseed epoch must be local.epoch + 1
810
+ // I5: snapshot epoch must be >= local epoch
811
+ // I6: stale epoch messages are dropped
812
+ return applySyncMessage(localState, msg, nowSec, senderPubkey)
813
+ }
814
+ ```
815
+
816
+ ### Eventual consistency model
817
+
818
+ CANARY's sync protocol guarantees **eventual consistency** across nodes:
819
+
820
+ | Scenario | Resolution |
821
+ |----------|------------|
822
+ | Two nodes advance counter simultaneously | Monotonic rule: `effective(incoming) > effective(local)` — highest wins, lower is no-op |
823
+ | Node misses a counter advance | Next advance carries the latest counter; stale node catches up |
824
+ | Node misses a reseed | Stale node rejects subsequent messages (epoch mismatch I3); admin must re-invite |
825
+ | Network partition heals | Nodes exchange messages; invariant checks accept valid, reject stale |
826
+ | Duplicate message delivery | opId replay guard (I2) silently drops duplicates |
827
+ | Clock skew between nodes | ±60 second future skew tolerance; counter derived from `floor(time / interval)` absorbs drift |
828
+
829
+ ### Multi-node sync implementation
830
+
831
+ ```typescript
832
+ import {
833
+ encodeSyncMessage,
834
+ applySyncMessage,
835
+ applySyncMessageWithResult,
836
+ encryptEnvelope,
837
+ decryptEnvelope,
838
+ deriveGroupKey,
839
+ STORED_MESSAGE_TYPES,
840
+ } from 'canary-kit/sync'
841
+ import { buildSignalEvent, buildStoredSignalEvent } from 'canary-kit/nostr'
842
+
843
+ // Publish a state change to all nodes via Nostr relay
844
+ async function broadcastSync(
845
+ group: GroupState,
846
+ msg: SyncMessage,
847
+ signer: EventSigner,
848
+ ): Promise<void> {
849
+ const groupKey = deriveGroupKey(group.seed)
850
+ const encrypted = await encryptEnvelope(groupKey, encodeSyncMessage(msg))
851
+
852
+ // Stored messages (member changes, reseeds) use kind 30078
853
+ // so offline nodes receive them when they reconnect.
854
+ // Ephemeral messages (beacons, liveness) use kind 20078.
855
+ const event = STORED_MESSAGE_TYPES.has(msg.type)
856
+ ? buildStoredSignalEvent({
857
+ groupId: group.name,
858
+ signalType: msg.type,
859
+ encryptedContent: encrypted,
860
+ })
861
+ : buildSignalEvent({
862
+ groupId: group.name,
863
+ signalType: msg.type,
864
+ encryptedContent: encrypted,
865
+ })
866
+
867
+ const signed = await signer.sign({ ...event, pubkey: signer.pubkey })
868
+ await relay.publish(signed)
869
+ }
870
+
871
+ // Receive and apply sync messages from other nodes
872
+ function onSyncMessage(
873
+ localState: GroupState,
874
+ encryptedPayload: string,
875
+ senderPubkey: string,
876
+ ): { state: GroupState; applied: boolean } {
877
+ const groupKey = deriveGroupKey(localState.seed)
878
+ const decrypted = await decryptEnvelope(groupKey, encryptedPayload)
879
+ const msg = decodeSyncMessage(decrypted)
880
+ const nowSec = Math.floor(Date.now() / 1000)
881
+
882
+ // applySyncMessageWithResult tells you whether the message was applied
883
+ // or silently rejected by invariant checks — useful for logging
884
+ const result = applySyncMessageWithResult(localState, msg, nowSec, senderPubkey)
885
+
886
+ if (!result.applied) {
887
+ // Message rejected — log for debugging (never log seed material)
888
+ auditLog.warn('sync_rejected', {
889
+ type: msg.type,
890
+ epoch: (msg as { epoch?: number }).epoch,
891
+ localEpoch: localState.epoch,
892
+ sender: senderPubkey,
893
+ })
894
+ }
895
+
896
+ return result
897
+ }
898
+ ```
899
+
900
+ ### Handling network partitions
901
+
902
+ During a partition, each node continues to derive and verify words locally.
903
+ When the partition heals:
904
+
905
+ 1. **Counter advances** — the monotonic rule resolves ordering automatically.
906
+ Only the highest effective counter is kept; lower values are no-ops.
907
+
908
+ 2. **Member changes during partition** — if an admin added/removed members while
909
+ a node was partitioned, the node will reject post-reseed messages (epoch
910
+ mismatch). The admin must send a `state-snapshot` to catch up partitioned
911
+ nodes within the same epoch, or re-invite for cross-epoch recovery.
912
+
913
+ 3. **Verification during partition** — works normally. Words derive from
914
+ `(seed, counter)` which both sides share. The only risk is counter drift
915
+ if one side burned words (burn-after-use) while the other didn't. The
916
+ tolerance window (`±tolerance` counter values) absorbs small drift.
917
+
918
+ ### Error handling
919
+
920
+ ```typescript
921
+ import { decodeSyncMessage } from 'canary-kit/sync'
922
+
923
+ // decodeSyncMessage validates all fields and rejects malformed messages
924
+ try {
925
+ const msg = decodeSyncMessage(payload)
926
+ } catch (err) {
927
+ // Common rejection reasons:
928
+ // 'Unsupported protocol version' — sender is on a different protocol version
929
+ // 'Invalid sync message type' — unknown message type (forward compatibility)
930
+ // 'not valid JSON' — corrupted or tampered payload
931
+ // All are safe to log and discard
932
+ }
933
+
934
+ // applySyncMessage returns unchanged state on rejection (fail-closed)
935
+ // Use applySyncMessageWithResult to distinguish applied from rejected
936
+ const { state, applied } = applySyncMessageWithResult(group, msg, nowSec, sender)
937
+ if (!applied) {
938
+ // Invariant violation — message is either:
939
+ // - From a non-admin (I1)
940
+ // - Replayed opId (I2)
941
+ // - Wrong epoch (I3/I4/I6)
942
+ // - Stale counter (monotonic check)
943
+ // Safe to discard. Do not retry.
944
+ }
945
+ ```
946
+
947
+ ### Deepfake-proof guarantees in distributed deployments
948
+
949
+ The distributed architecture preserves all CANARY security properties:
950
+
951
+ | Property | How it's preserved across nodes |
952
+ |----------|-------------------------------|
953
+ | P1: Token Unpredictability | Each node derives tokens from the same `(seed, counter)` — pure HMAC-SHA256 |
954
+ | P2: Duress Indistinguishability | Duress derivation is local; duress alerts propagate via encrypted sync |
955
+ | P4: Replay Resistance | Counter monotonicity enforced by `applySyncMessage`; opId deduplication prevents replay |
956
+ | P5: Coercion Resistance | Duress signals broadcast to all nodes via sync; all nodes alert security teams |
957
+ | P6: Liveness Guarantee | Liveness heartbeats are fire-and-forget; freshness gate (300s) prevents stale replay |
958
+ | P8: Timing Safety | Constant-time string comparison (`timingSafeStringEqual`) on every node |
959
+
960
+ The sync layer is a consistency optimisation. The security properties are
961
+ properties of the cryptographic derivation, not the transport.
962
+
637
963
  ## Licence
638
964
 
639
965
  MIT — same as canary-kit.
package/README.md CHANGED
@@ -207,6 +207,23 @@ If you find canary-kit useful, consider sending a tip:
207
207
  - **Lightning:** `thedonkey@strike.me`
208
208
  - **Nostr zaps:** `npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2`
209
209
 
210
+ ## Part of the ForgeSworn Toolkit
211
+
212
+ [ForgeSworn](https://forgesworn.dev) builds open-source cryptographic identity, payments, and coordination tools for Nostr.
213
+
214
+ | Library | What it does |
215
+ |---------|-------------|
216
+ | [nsec-tree](https://github.com/forgesworn/nsec-tree) | Deterministic sub-identity derivation |
217
+ | [ring-sig](https://github.com/forgesworn/ring-sig) | SAG/LSAG ring signatures on secp256k1 |
218
+ | [range-proof](https://github.com/forgesworn/range-proof) | Pedersen commitment range proofs |
219
+ | [canary-kit](https://github.com/forgesworn/canary-kit) | Coercion-resistant spoken verification |
220
+ | [spoken-token](https://github.com/forgesworn/spoken-token) | Human-speakable verification tokens |
221
+ | [toll-booth](https://github.com/forgesworn/toll-booth) | L402 payment middleware |
222
+ | [geohash-kit](https://github.com/forgesworn/geohash-kit) | Geohash toolkit with polygon coverage |
223
+ | [nostr-attestations](https://github.com/forgesworn/nostr-attestations) | NIP-VA verifiable attestations |
224
+ | [dominion](https://github.com/forgesworn/dominion) | Epoch-based encrypted access control |
225
+ | [nostr-veil](https://github.com/forgesworn/nostr-veil) | Privacy-preserving Web of Trust |
226
+
210
227
  ## Licence
211
228
 
212
229
  MIT
package/SECURITY.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|-----------|
7
- | 1.x.x | Yes |
7
+ | 2.x.x | Yes |
8
+ | 1.x.x | No |
8
9
  | < 1.0.0 | No |
9
10
 
10
11
  ## Reporting a Vulnerability
package/llms-full.txt CHANGED
@@ -40,7 +40,7 @@ import { createSession, generateSeed, deriveSeed, SESSION_PRESETS } from 'canary
40
40
  import { WORDLIST, WORDLIST_SIZE, getWord, indexOf } from 'canary-kit/wordlist'
41
41
  import { KINDS, buildGroupStateEvent, buildStoredSignalEvent, buildSignalEvent, buildRumourEvent, hashGroupId } from 'canary-kit/nostr'
42
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'
43
+ import { applySyncMessage, applySyncMessageWithResult, decodeSyncMessage, encodeSyncMessage, deriveGroupKey, deriveGroupIdentity, hashGroupTag, encryptEnvelope, decryptEnvelope, PROTOCOL_VERSION, type SyncApplyResult, type SyncTransport, type EventSigner } from 'canary-kit/sync'
44
44
  ```
45
45
 
46
46
  ---
@@ -107,8 +107,10 @@ interface GroupConfig {
107
107
  rotationInterval?: number // Seconds; overrides preset value
108
108
  wordCount?: 1 | 2 | 3 // Words per challenge; overrides preset value
109
109
  wordlist?: string // Wordlist identifier (default: 'en-v1')
110
+ tolerance?: number // Counter tolerance for verification (default: 1)
110
111
  beaconInterval?: number // Beacon broadcast interval in seconds (default: 300)
111
112
  beaconPrecision?: number // Geohash precision for normal beacons 1–11 (default: 6)
113
+ creator?: string // Pubkey of group creator — only the creator is admin at bootstrap. Must be in members. Without creator, admins is empty and all privileged sync ops are silently rejected.
112
114
  }
113
115
 
114
116
  /** Serialisable persistent state for a canary group. */
@@ -116,14 +118,19 @@ interface GroupState {
116
118
  name: string
117
119
  seed: string // 64-char hex (256-bit shared secret)
118
120
  members: string[] // Nostr pubkeys (64-char hex)
121
+ admins: string[] // Pubkeys with admin privileges (reseed, add/remove others). Set via GroupConfig.creator.
119
122
  rotationInterval: number // Seconds
120
123
  wordCount: 1 | 2 | 3
121
124
  wordlist: string // e.g. 'en-v1'
122
125
  counter: number // Time-based counter at last sync
123
126
  usageOffset: number // Burn-after-use offset on top of counter
127
+ tolerance: number // Counter tolerance for verification
124
128
  createdAt: number // Unix timestamp
125
129
  beaconInterval: number // Seconds
126
130
  beaconPrecision: number // 1–11
131
+ epoch: number // Monotonic epoch — increments on reseed. Used for replay protection.
132
+ consumedOps: string[] // Consumed operation IDs within current epoch. Cleared on epoch bump.
133
+ consumedOpsFloor?: number // Timestamp floor: reject messages at or below this value (replay protection after consumedOps eviction)
127
134
  }
128
135
 
129
136
  // ── Group Presets ─────────────────────────────────────────────────────────────
@@ -429,16 +436,41 @@ const updated = addMember(state, '<charlie-pubkey>')
429
436
 
430
437
  ### removeMember(state, pubkey)
431
438
 
432
- Remove a member from the group and immediately reseed to invalidate the old shared secret. Returns new state — does not mutate.
439
+ Remove a member from the group's member list. Does NOT reseed the removed member still possesses the old seed. Use `removeMemberAndReseed()` instead unless you have a specific reason not to. Returns new state — does not mutate.
433
440
 
434
441
  ```typescript
435
442
  removeMember(state: GroupState, pubkey: string): GroupState
436
443
 
437
444
  const updated = removeMember(state, '<alice-pubkey>')
445
+ // updated.seed === state.seed (NOT reseeded — old seed still valid)
446
+ // updated.members does not include alice
447
+ ```
448
+
449
+ ### removeMemberAndReseed(state, pubkey)
450
+
451
+ Remove a member and immediately reseed, atomically invalidating the old seed. This is the recommended way to remove members — ensures forward secrecy by preventing the removed member from deriving future tokens or decrypting future beacons. Returns new state — does not mutate.
452
+
453
+ ```typescript
454
+ removeMemberAndReseed(state: GroupState, pubkey: string): GroupState
455
+
456
+ const updated = removeMemberAndReseed(state, '<alice-pubkey>')
438
457
  // updated.seed !== state.seed (reseeded)
439
458
  // updated.members does not include alice
440
459
  ```
441
460
 
461
+ ### dissolveGroup(state)
462
+
463
+ Dissolve a group, zeroing the seed and clearing all members. Preserves name and timestamps for audit trail. Callers MUST also delete the persisted record (IndexedDB, localStorage, backend) after calling this. Returns new state — does not mutate.
464
+
465
+ ```typescript
466
+ dissolveGroup(state: GroupState): GroupState
467
+
468
+ const dissolved = dissolveGroup(state)
469
+ // dissolved.seed === '0'.repeat(64)
470
+ // dissolved.members === []
471
+ // dissolved.admins === []
472
+ ```
473
+
442
474
  ### syncCounter(state, nowSec?)
443
475
 
444
476
  Refresh the counter to the current time window. Call after loading persisted state. Returns new state — does not mutate.
@@ -1159,26 +1191,53 @@ type SyncMessage =
1159
1191
  | { type: 'liveness-checkin'; pubkey: string; timestamp: number; opId: string }
1160
1192
  | { type: 'state-snapshot'; seed: string; counter: number; usageOffset: number; members: string[]; admins: string[]; epoch: number; opId: string; timestamp: number; prevEpochSeed?: string }
1161
1193
 
1162
- interface SyncResult {
1163
- state: GroupState
1164
- applied: boolean
1165
- reason?: string
1194
+ /** Result of applying a sync message, indicating whether it was accepted. */
1195
+ interface SyncApplyResult {
1196
+ state: GroupState // The resulting group state (unchanged if rejected)
1197
+ applied: boolean // Whether the message was applied
1198
+ }
1199
+
1200
+ /** Minimal interface any sync transport must implement. */
1201
+ interface SyncTransport {
1202
+ send(groupId: string, message: SyncMessage, recipients?: string[]): Promise<void>
1203
+ subscribe(groupId: string, onMessage: (msg: SyncMessage, sender: string) => void): () => void
1204
+ disconnect(): void
1205
+ }
1206
+
1207
+ /** Abstracts event signing and NIP-44 encryption for transports that need it. */
1208
+ interface EventSigner {
1209
+ pubkey: string
1210
+ sign(event: unknown): Promise<unknown>
1211
+ encrypt(plaintext: string, recipientPubkey: string): Promise<string>
1212
+ decrypt(ciphertext: string, senderPubkey: string): Promise<string>
1166
1213
  }
1167
1214
  ```
1168
1215
 
1169
1216
  ### Functions
1170
1217
 
1171
1218
  ```typescript
1172
- decodeSyncMessage(json: string): SyncMessage // Parse and validate a JSON sync message
1173
- encodeSyncMessage(msg: SyncMessage): string // Serialise a sync message to JSON
1174
- applySyncMessage(state: GroupState, msg: SyncMessage, sender?: string): SyncResult // Apply a validated message to group state
1219
+ decodeSyncMessage(json: string): SyncMessage // Parse and validate a JSON sync message. Throws on invalid input or wrong protocolVersion.
1220
+ encodeSyncMessage(msg: SyncMessage): string // Serialise a sync message to JSON. Injects protocolVersion automatically.
1221
+ applySyncMessage(state: GroupState, msg: SyncMessage, nowSec?: number, sender?: string): GroupState // Apply a sync message. Returns new state, or SAME REFERENCE if rejected (silent rejection).
1222
+ applySyncMessageWithResult(state: GroupState, msg: SyncMessage, nowSec?: number, sender?: string): SyncApplyResult // Same as applySyncMessage but returns { state, applied } for observability.
1175
1223
  ```
1176
1224
 
1225
+ **IMPORTANT — silent rejection:** `applySyncMessage` returns the unchanged GroupState reference when a message is rejected. Common causes of silent rejection:
1226
+ - Privileged action without sender, or sender not in `group.admins` (I1)
1227
+ - Replayed opId already in `consumedOps` (I2)
1228
+ - Epoch mismatch: non-reseed ops must match local epoch (I3), reseed must be local epoch + 1 (I4)
1229
+ - Stale epoch for any privileged op (I6)
1230
+ - `counter-advance` without sender, or sender not in `group.members`
1231
+ - `counter-advance` that would regress the effective counter (monotonic)
1232
+ - Fire-and-forget messages older than 5 minutes or >60s in the future
1233
+
1234
+ Use `applySyncMessageWithResult` when you need to log or alert on rejected messages.
1235
+
1177
1236
  ### Envelope Encryption
1178
1237
 
1179
1238
  ```typescript
1180
1239
  deriveGroupKey(seed: string): Uint8Array // Derive AES-256-GCM group key from seed
1181
- deriveGroupSigningKey(seed: string): Uint8Array // Derive HMAC signing key from seed
1240
+ deriveGroupIdentity(seed: string): Uint8Array // Derive HMAC signing key from seed
1182
1241
  hashGroupTag(groupId: string): string // Privacy-preserving group tag hash
1183
1242
  encryptEnvelope(key: Uint8Array, plaintext: string): Promise<string> // AES-256-GCM encrypt
1184
1243
  decryptEnvelope(key: Uint8Array, ciphertext: string): Promise<string> // AES-256-GCM decrypt
@@ -1351,8 +1410,8 @@ state = advanceCounter(state) // next call will use a different word
1351
1410
  state = addMember(state, '<diana-pubkey>')
1352
1411
  // Distribute new seed to diana via encrypted Nostr DM
1353
1412
 
1354
- // Remove a compromised member (auto-reseeds)
1355
- state = removeMember(state, '<charlie-pubkey>')
1413
+ // Remove a compromised member and reseed atomically
1414
+ state = removeMemberAndReseed(state, '<charlie-pubkey>')
1356
1415
  // Distribute new state.seed to remaining members
1357
1416
  ```
1358
1417
 
package/llms.txt CHANGED
@@ -25,14 +25,16 @@ Core derivation:
25
25
  - verifyWord(spokenWord, seedHex, memberPubkeys, counter, wordCount?) → VerifyResult
26
26
 
27
27
  Group lifecycle:
28
- - createGroup(config) → GroupState
28
+ - createGroup(config) → GroupState (config.creator sets initial admin; without creator, admins is empty)
29
29
  - getCurrentWord(state) → string
30
30
  - getCurrentDuressWord(state, memberPubkey) → string
31
31
  - advanceCounter(state) → GroupState
32
32
  - reseed(state) → GroupState
33
33
  - addMember(state, pubkey) → GroupState
34
- - removeMember(state, pubkey) → GroupState
35
- - syncCounter(state, nowSec?) → GroupState
34
+ - removeMember(state, pubkey) → GroupState (does NOT reseed)
35
+ - removeMemberAndReseed(state, pubkey) → GroupState (recommended: removes + reseeds atomically)
36
+ - dissolveGroup(state) → GroupState (zeroes seed, clears members)
37
+ - syncCounter(state, nowSec?) → GroupState (refreshes counter to current time window, monotonic)
36
38
 
37
39
  Counter:
38
40
  - getCounter(timestampSec, rotationIntervalSec?) → number
@@ -124,18 +126,23 @@ Types: BeaconPayload ({ geohash, precision, timestamp }), DuressAlert ({ type, m
124
126
 
125
127
  ### canary-kit/sync
126
128
 
127
- Transport-agnostic group state synchronisation. Authority model with 6 invariants, replay protection, epoch boundaries.
129
+ Transport-agnostic group state synchronisation. Authority model with 6 invariants, replay protection, epoch boundaries. See COOKBOOK.md for complete workflow examples.
128
130
 
129
- - decodeSyncMessage(json) → SyncMessage (validates and parses)
130
- - encodeSyncMessage(msg) → string (serialises to JSON)
131
- - applySyncMessage(state, msg, sender?) → SyncResult ({ state, applied, reason? })
131
+ - decodeSyncMessage(json) → SyncMessage (validates and parses; throws on invalid input)
132
+ - encodeSyncMessage(msg) → string (serialises to JSON; injects protocolVersion)
133
+ - applySyncMessage(state, msg, nowSec?, sender?) → GroupState (apply a sync message; returns same reference if rejected)
134
+ - applySyncMessageWithResult(state, msg, nowSec?, sender?) → SyncApplyResult ({ state, applied }) (same as above but indicates acceptance)
132
135
  - deriveGroupKey(seed) → Uint8Array (AES-256-GCM group key)
133
- - deriveGroupSigningKey(seed) → Uint8Array (HMAC signing key)
136
+ - deriveGroupIdentity(seed) → Uint8Array (group identity key)
134
137
  - hashGroupTag(groupId) → string (privacy-preserving group tag)
135
138
  - encryptEnvelope(key, plaintext) → Promise<string> (AES-256-GCM)
136
139
  - decryptEnvelope(key, ciphertext) → Promise<string> (AES-256-GCM)
137
140
 
138
- Message types: member-join, member-leave, counter-advance, reseed, beacon, duress-alert, liveness-checkin, state-snapshot
141
+ IMPORTANT: applySyncMessage silently returns unchanged state when rejected. Privileged actions (member-join of others, reseed, state-snapshot) require sender in group.admins. counter-advance requires sender in group.members. Omitting sender causes silent rejection. Set creator in createGroup() to populate admins.
142
+
143
+ Message types: member-join, member-leave, counter-advance, reseed, beacon, duress-alert, duress-clear, liveness-checkin, state-snapshot
144
+
145
+ Types: SyncApplyResult ({ state: GroupState, applied: boolean }), SyncTransport (interface for pluggable transports), EventSigner (interface for Nostr signing)
139
146
 
140
147
  ## Quick Examples
141
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canary-kit",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "Deepfake-proof identity verification. Open protocol, minimal dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,7 +46,9 @@
46
46
  "files": [
47
47
  "dist/",
48
48
  "LICENSE",
49
+ "API.md",
49
50
  "CANARY.md",
51
+ "COOKBOOK.md",
50
52
  "NIP-CANARY.md",
51
53
  "INTEGRATION.md",
52
54
  "SECURITY.md",