canary-kit 2.7.0 → 2.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +306 -0
- package/COOKBOOK.md +763 -0
- package/INTEGRATION.md +329 -3
- package/README.md +17 -0
- package/SECURITY.md +2 -1
- package/llms-full.txt +71 -12
- package/llms.txt +16 -9
- package/package.json +3 -1
package/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:
|
|
375
|
-
namespace:
|
|
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
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,
|
|
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
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
|
1173
|
-
encodeSyncMessage(msg: SyncMessage): string
|
|
1174
|
-
applySyncMessage(state: GroupState, msg: SyncMessage, sender?: string):
|
|
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
|
-
|
|
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
|
|
1355
|
-
state =
|
|
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
|
-
-
|
|
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?) →
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.7.2",
|
|
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",
|