yakmesh 2.8.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +637 -0
- package/CONTRIBUTING.md +42 -0
- package/Caddyfile +77 -0
- package/README.md +119 -29
- package/adapters/adapter-mlv-bible/README.md +124 -0
- package/adapters/adapter-mlv-bible/index.js +400 -0
- package/adapters/chat-mod-adapter.js +532 -0
- package/adapters/content-adapter.js +273 -0
- package/content/api.js +50 -41
- package/content/index.js +2 -2
- package/content/store.js +355 -173
- package/dashboard/index.html +19 -3
- package/database/replication.js +117 -37
- package/docs/CRYPTO-AGILITY.md +204 -0
- package/docs/MTLS-RESEARCH.md +367 -0
- package/docs/NAMCHE-SPEC.md +681 -0
- package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
- package/docs/PRECISION-DISCLOSURE.md +96 -0
- package/docs/README.md +76 -0
- package/docs/ROADMAP-2.4.0.md +447 -0
- package/docs/ROADMAP-2.5.0.md +244 -0
- package/docs/SECURITY-AUDIT-REPORT.md +306 -0
- package/docs/SST-INTEGRATION.md +712 -0
- package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
- package/docs/TERNARY-AUDIT-REPORT.md +247 -0
- package/docs/TME-FAQ.md +221 -0
- package/docs/WHITEPAPER.md +623 -0
- package/docs/adapters.html +1001 -0
- package/docs/advanced-systems.html +1045 -0
- package/docs/annex.html +1046 -0
- package/docs/api.html +970 -0
- package/docs/business/response-templates.md +160 -0
- package/docs/c2c.html +1225 -0
- package/docs/cli.html +1332 -0
- package/docs/configuration.html +1248 -0
- package/docs/darshan.html +1085 -0
- package/docs/dharma.html +966 -0
- package/docs/docs-bundle.html +1075 -0
- package/docs/docs.css +3120 -0
- package/docs/docs.js +556 -0
- package/docs/doko.html +969 -0
- package/docs/geo-proof.html +858 -0
- package/docs/getting-started.html +840 -0
- package/docs/gumba-tutorial.html +1144 -0
- package/docs/gumba.html +1098 -0
- package/docs/index.html +914 -0
- package/docs/jhilke.html +1312 -0
- package/docs/karma.html +1100 -0
- package/docs/katha.html +1037 -0
- package/docs/lama.html +978 -0
- package/docs/mandala.html +1067 -0
- package/docs/mani.html +964 -0
- package/docs/mantra.html +967 -0
- package/docs/mesh.html +1409 -0
- package/docs/nakpak.html +869 -0
- package/docs/namche.html +928 -0
- package/docs/nav-order.json +53 -0
- package/docs/prahari.html +1043 -0
- package/docs/prism-bash.min.js +1 -0
- package/docs/prism-javascript.min.js +1 -0
- package/docs/prism-json.min.js +1 -0
- package/docs/prism-tomorrow.min.css +1 -0
- package/docs/prism.min.js +1 -0
- package/docs/privacy.html +699 -0
- package/docs/quick-reference.html +1181 -0
- package/docs/sakshi.html +1402 -0
- package/docs/sandboxing.md +386 -0
- package/docs/seva.html +911 -0
- package/docs/sherpa.html +871 -0
- package/docs/studio.html +860 -0
- package/docs/stupa.html +995 -0
- package/docs/tailwind.min.css +2 -0
- package/docs/tattva.html +1332 -0
- package/docs/terms.html +686 -0
- package/docs/time-server-deployment.md +166 -0
- package/docs/time-sources.html +1392 -0
- package/docs/tivra.html +1127 -0
- package/docs/trademark-policy.html +686 -0
- package/docs/tribhuj.html +1183 -0
- package/docs/trust-security.html +1029 -0
- package/docs/tutorials/backup-recovery.html +654 -0
- package/docs/tutorials/dashboard.html +604 -0
- package/docs/tutorials/domain-setup.html +605 -0
- package/docs/tutorials/host-website.html +456 -0
- package/docs/tutorials/mesh-network.html +505 -0
- package/docs/tutorials/mobile-access.html +445 -0
- package/docs/tutorials/privacy.html +467 -0
- package/docs/tutorials/raspberry-pi.html +600 -0
- package/docs/tutorials/security-basics.html +539 -0
- package/docs/tutorials/share-files.html +431 -0
- package/docs/tutorials/troubleshooting.html +637 -0
- package/docs/tutorials/trust-karma.html +419 -0
- package/docs/tutorials/yak-protocol.html +456 -0
- package/docs/tutorials.html +1034 -0
- package/docs/vani.html +1270 -0
- package/docs/webserver.html +809 -0
- package/docs/yak-protocol.html +940 -0
- package/docs/yak-timeserver-design.md +475 -0
- package/docs/yakapp.html +1015 -0
- package/docs/ypc27.html +1069 -0
- package/docs/yurt.html +1344 -0
- package/embedded-docs/bundle.js +334 -74
- package/gossip/protocol.js +247 -27
- package/identity/key-resolver.js +262 -0
- package/identity/machine-seed.js +632 -0
- package/identity/node-key.js +669 -368
- package/identity/tribhuj-ratchet.js +506 -0
- package/knowledge-base.js +37 -8
- package/launcher/yakmesh.bat +62 -0
- package/launcher/yakmesh.sh +70 -0
- package/mesh/annex.js +462 -108
- package/mesh/beacon-broadcast.js +113 -1
- package/mesh/darshan.js +1718 -0
- package/mesh/gumba.js +1567 -0
- package/mesh/jhilke.js +651 -0
- package/mesh/katha.js +1012 -0
- package/mesh/nakpak-routing.js +8 -5
- package/mesh/network.js +724 -34
- package/mesh/pulse-sync.js +4 -1
- package/mesh/rate-limiter.js +127 -15
- package/mesh/seva.js +526 -0
- package/mesh/sherpa-discovery.js +89 -8
- package/mesh/sybil-defense.js +19 -5
- package/mesh/temporal-encoder.js +4 -3
- package/mesh/vani.js +1364 -0
- package/mesh/yurt.js +1340 -0
- package/models/entropy-sentinel.onnx +0 -0
- package/models/karma-trust.onnx +0 -0
- package/models/manifest.json +43 -0
- package/models/sakshi-anomaly.onnx +0 -0
- package/oracle/code-proof-protocol.js +7 -6
- package/oracle/codebase-lock.js +257 -28
- package/oracle/index.js +74 -15
- package/oracle/ma902-snmp.js +678 -0
- package/oracle/module-sealer.js +5 -3
- package/oracle/network-identity.js +16 -0
- package/oracle/packet-checksum.js +201 -0
- package/oracle/sst.js +579 -0
- package/oracle/ternary-144t.js +714 -0
- package/oracle/ternary-ml.js +481 -0
- package/oracle/time-api.js +239 -0
- package/oracle/time-source.js +137 -47
- package/oracle/validation-oracle-hardened.js +1111 -1071
- package/oracle/validation-oracle.js +4 -2
- package/oracle/ypc27.js +211 -0
- package/package.json +20 -3
- package/protocol/yak-handler.js +35 -9
- package/protocol/yak-protocol.js +28 -13
- package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
- package/reference/cpp/yakmesh_ypc27.cpp +179 -0
- package/sbom.json +87 -0
- package/scripts/security-audit.mjs +264 -0
- package/scripts/update-docs-nav.js +194 -0
- package/scripts/update-docs-sidebar.cjs +164 -0
- package/security/crypto-config.js +4 -3
- package/security/dharma-moderation.js +517 -0
- package/security/doko-identity.js +193 -143
- package/security/domain-consensus.js +86 -85
- package/security/fs-hardening.js +620 -0
- package/security/hardware-attestation.js +5 -3
- package/security/hybrid-trust.js +227 -87
- package/security/karma-rate-limiter.js +692 -0
- package/security/khata-protocol.js +22 -21
- package/security/khata-trust-integration.js +277 -150
- package/security/memory-safety.js +635 -0
- package/security/mesh-auth.js +11 -10
- package/security/mesh-revocation.js +373 -5
- package/security/namche-gateway.js +298 -69
- package/security/sakshi.js +460 -3
- package/security/sangha.js +770 -0
- package/security/secure-config.js +473 -0
- package/security/silicon-parity.js +13 -10
- package/security/steadywatch.js +1142 -0
- package/security/strike-system.js +32 -3
- package/security/temporal-signing.js +488 -0
- package/security/trit-commitment.js +464 -0
- package/server/crypto/annex.js +247 -0
- package/server/darshan-api.js +343 -0
- package/server/index.js +3259 -362
- package/server/komm-api.js +668 -0
- package/utils/accel.js +2273 -0
- package/utils/ternary-id.js +79 -0
- package/utils/verify-worker.js +57 -0
- package/webserver/index.js +95 -5
- package/assets/yakmesh-logo.png +0 -0
- package/assets/yakmesh-logo.svg +0 -80
- package/assets/yakmesh-logo2.png +0 -0
- package/assets/yakmesh-logo2sm.png +0 -0
- package/assets/ymsm.png +0 -0
- package/website/assets/silhouettes/adapters.svg +0 -107
- package/website/assets/silhouettes/api-endpoints.svg +0 -115
- package/website/assets/silhouettes/atomic-clock.svg +0 -83
- package/website/assets/silhouettes/base-camp.svg +0 -81
- package/website/assets/silhouettes/bridge.svg +0 -69
- package/website/assets/silhouettes/docs-bundle.svg +0 -113
- package/website/assets/silhouettes/doko-basket.svg +0 -70
- package/website/assets/silhouettes/fortress.svg +0 -93
- package/website/assets/silhouettes/gateway.svg +0 -54
- package/website/assets/silhouettes/gears.svg +0 -93
- package/website/assets/silhouettes/globe-satellite.svg +0 -67
- package/website/assets/silhouettes/karma-wheel.svg +0 -137
- package/website/assets/silhouettes/lama-council.svg +0 -141
- package/website/assets/silhouettes/mandala-network.svg +0 -169
- package/website/assets/silhouettes/mani-stones.svg +0 -149
- package/website/assets/silhouettes/mantra-wheel.svg +0 -116
- package/website/assets/silhouettes/mesh-nodes.svg +0 -113
- package/website/assets/silhouettes/nakpak.svg +0 -56
- package/website/assets/silhouettes/peak-lightning.svg +0 -73
- package/website/assets/silhouettes/sherpa.svg +0 -69
- package/website/assets/silhouettes/stupa-tower.svg +0 -119
- package/website/assets/silhouettes/tattva-eye.svg +0 -78
- package/website/assets/silhouettes/terminal.svg +0 -74
- package/website/assets/silhouettes/webserver.svg +0 -145
- package/website/assets/silhouettes/yak.svg +0 -78
- package/website/assets/yakmesh-logo.png +0 -0
- package/website/assets/yakmesh-logo.webp +0 -0
- package/website/assets/yakmesh-logo128x140.webp +0 -0
- package/website/assets/yakmesh-logo2.png +0 -0
- package/website/assets/yakmesh-logo2.svg +0 -51
- package/website/assets/yakmesh-logo40x44.webp +0 -0
- package/website/assets/yakmesh.gif +0 -0
- package/website/assets/yakmesh.ico +0 -0
- package/website/assets/yakmesh.jpg +0 -0
- package/website/assets/yakmesh.pdf +0 -0
- package/website/assets/yakmesh.png +0 -0
- package/website/assets/yakmesh.svg +0 -70
- package/website/assets/yakmesh128.webp +0 -0
- package/website/assets/yakmesh32.png +0 -0
- package/website/assets/yakmesh32.svg +0 -65
- package/website/assets/yakmesh32o.ico +0 -2
- package/website/assets/yakmesh32o.svg +0 -65
- package/website/assets/yakmesh32o.svgz +0 -0
package/mesh/gumba.js
ADDED
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GUMBA - Guarded Universal Message Bundle Access
|
|
3
|
+
*
|
|
4
|
+
* A novel cryptographic access control system where:
|
|
5
|
+
* - Keys NEVER leave the host node
|
|
6
|
+
* - Access is granted via cryptographic PROOFS, not key distribution
|
|
7
|
+
* - The proof IS the access - not the key
|
|
8
|
+
*
|
|
9
|
+
* ╔═══════════════════════════════════════════════════════════════════════════════╗
|
|
10
|
+
* ║ "The key is not the access. The proof is the access." ║
|
|
11
|
+
* ║ ║
|
|
12
|
+
* ║ Like a guarded temple: You don't get a copy of the master key. ║
|
|
13
|
+
* ║ You prove you belong. The guardian opens the door. ║
|
|
14
|
+
* ║ The door stays locked. The key stays hidden. ║
|
|
15
|
+
* ╚═══════════════════════════════════════════════════════════════════════════════╝
|
|
16
|
+
*
|
|
17
|
+
* SECURITY MODEL:
|
|
18
|
+
* - Bundle Key: Derived deterministically, never transmitted
|
|
19
|
+
* - Membership: Merkle tree of authorized DOKO hashes
|
|
20
|
+
* - Access Proof: Challenge-response, attestation, or ZK proof
|
|
21
|
+
* - Content Delivery: Via ANNEX E2E tunnel after access granted
|
|
22
|
+
*
|
|
23
|
+
* PROOF TYPES:
|
|
24
|
+
* 1. CHALLENGE - Sign a nonce with DOKO private key
|
|
25
|
+
* 2. ATTESTATION - Another member vouches for you (time-limited)
|
|
26
|
+
* 3. MERKLE - Prove membership in tree without revealing identity
|
|
27
|
+
*
|
|
28
|
+
* Part of the Himalayan Protocol Family:
|
|
29
|
+
* - ANNEX: E2E encrypted channels (used for content delivery)
|
|
30
|
+
* - DOKO: Identity certificates (used for membership)
|
|
31
|
+
* - GUMBA: Access control (this module)
|
|
32
|
+
*
|
|
33
|
+
* Named after the Tibetan word for "monastery" - a guarded sacred space
|
|
34
|
+
* where access is granted to those who prove their dedication.
|
|
35
|
+
*
|
|
36
|
+
* @module mesh/gumba
|
|
37
|
+
* @license MIT
|
|
38
|
+
* @copyright 2026 YAKMESH™ Contributors
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto';
|
|
42
|
+
import { sha3_256 as _nobleSha3 } from '@noble/hashes/sha3.js';
|
|
43
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
44
|
+
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
|
|
45
|
+
// ACCEL: Hardware-accelerated crypto
|
|
46
|
+
import { sha3_256, mlDsa65Sign, mlDsa65Verify } from '../utils/accel.js';
|
|
47
|
+
import { createLogger } from '../utils/logger.js';
|
|
48
|
+
import EventEmitter from 'events';
|
|
49
|
+
|
|
50
|
+
const log = createLogger('mesh:gumba');
|
|
51
|
+
const peerTag = (id) => id?.split('-pq-').pop() || id?.slice?.(-8) || String(id);
|
|
52
|
+
|
|
53
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
// CONFIGURATION
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
|
|
57
|
+
export const GUMBA_CONFIG = Object.freeze({
|
|
58
|
+
// Encryption
|
|
59
|
+
symmetricAlgorithm: 'aes-256-gcm',
|
|
60
|
+
nonceSize: 12,
|
|
61
|
+
authTagLength: 16,
|
|
62
|
+
|
|
63
|
+
// Key derivation
|
|
64
|
+
keyDerivationSalt: 'YAKMESH-GUMBA-2026',
|
|
65
|
+
bundleVersion: 1,
|
|
66
|
+
|
|
67
|
+
// Access control
|
|
68
|
+
challengeExpiry: 30000, // 30 second challenge window
|
|
69
|
+
attestationMaxAge: 86400000, // 24 hour attestation validity
|
|
70
|
+
proofCacheTime: 300000, // 5 minute proof cache
|
|
71
|
+
|
|
72
|
+
// Bundle limits
|
|
73
|
+
maxBundleSize: 10 * 1024 * 1024, // 10MB max bundle
|
|
74
|
+
maxMembers: 10000, // Max members per bundle
|
|
75
|
+
maxMessagesPerBundle: 100000, // Max messages before rotation
|
|
76
|
+
|
|
77
|
+
// Message types
|
|
78
|
+
messageTypes: {
|
|
79
|
+
// Access protocol
|
|
80
|
+
CHALLENGE: 'gumba:challenge',
|
|
81
|
+
RESPONSE: 'gumba:response',
|
|
82
|
+
ACCESS_GRANTED: 'gumba:access_granted',
|
|
83
|
+
ACCESS_DENIED: 'gumba:access_denied',
|
|
84
|
+
|
|
85
|
+
// Attestation
|
|
86
|
+
ATTEST: 'gumba:attest',
|
|
87
|
+
REVOKE_ATTEST: 'gumba:revoke_attest',
|
|
88
|
+
|
|
89
|
+
// Content
|
|
90
|
+
CONTENT: 'gumba:content',
|
|
91
|
+
SYNC: 'gumba:sync',
|
|
92
|
+
|
|
93
|
+
// Membership
|
|
94
|
+
MEMBER_ADD: 'gumba:member_add',
|
|
95
|
+
MEMBER_REMOVE: 'gumba:member_remove',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Proof types for access verification
|
|
101
|
+
*/
|
|
102
|
+
export const GUMBA_PROOF_TYPE = Object.freeze({
|
|
103
|
+
CHALLENGE: 'challenge', // Direct challenge-response
|
|
104
|
+
ATTESTATION: 'attestation', // Vouched by existing member
|
|
105
|
+
MERKLE: 'merkle', // ZK merkle inclusion proof
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Member roles in a GUMBA bundle
|
|
110
|
+
*/
|
|
111
|
+
export const GUMBA_ROLE = Object.freeze({
|
|
112
|
+
OWNER: 'owner', // Can add/remove members, delete bundle
|
|
113
|
+
ADMIN: 'admin', // Can add/remove members
|
|
114
|
+
MEMBER: 'member', // Can read and write
|
|
115
|
+
READER: 'reader', // Can only read
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
119
|
+
// GUMBA KEY - Deterministic key derivation (NEVER transmitted)
|
|
120
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* GumbaKey - The bundle encryption key
|
|
124
|
+
*
|
|
125
|
+
* Derived deterministically from owner's secret + bundle ID.
|
|
126
|
+
* This key NEVER leaves the host node. Ever.
|
|
127
|
+
*/
|
|
128
|
+
export class GumbaKey {
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} bundleId - Unique bundle identifier
|
|
131
|
+
* @param {Uint8Array} ownerSecret - Owner's secret key material
|
|
132
|
+
*/
|
|
133
|
+
constructor(bundleId, ownerSecret) {
|
|
134
|
+
this.bundleId = bundleId;
|
|
135
|
+
this.createdAt = Date.now();
|
|
136
|
+
|
|
137
|
+
// Derive the bundle key (deterministic, reproducible on same node)
|
|
138
|
+
this.key = this._deriveKey(ownerSecret);
|
|
139
|
+
|
|
140
|
+
// Track usage for rotation
|
|
141
|
+
this.messageCount = 0;
|
|
142
|
+
this.lastUsed = Date.now();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive encryption key from owner secret
|
|
147
|
+
* Uses HKDF-like construction with SHA3-256
|
|
148
|
+
*/
|
|
149
|
+
_deriveKey(ownerSecret) {
|
|
150
|
+
// Stage 1: Extract
|
|
151
|
+
const prk = createHash('sha3-256')
|
|
152
|
+
.update(GUMBA_CONFIG.keyDerivationSalt)
|
|
153
|
+
.update(ownerSecret)
|
|
154
|
+
.digest();
|
|
155
|
+
|
|
156
|
+
// Stage 2: Expand with bundle context
|
|
157
|
+
const info = Buffer.concat([
|
|
158
|
+
utf8ToBytes(`GUMBA-v${GUMBA_CONFIG.bundleVersion}`),
|
|
159
|
+
utf8ToBytes(':'),
|
|
160
|
+
utf8ToBytes(this.bundleId),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
return createHash('sha3-256')
|
|
164
|
+
.update(prk)
|
|
165
|
+
.update(info)
|
|
166
|
+
.update(Buffer.from([0x01])) // Counter for HKDF
|
|
167
|
+
.digest();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Encrypt content into the bundle
|
|
172
|
+
*/
|
|
173
|
+
seal(plaintext, metadata = {}) {
|
|
174
|
+
const nonce = randomBytes(GUMBA_CONFIG.nonceSize);
|
|
175
|
+
const cipher = createCipheriv(
|
|
176
|
+
GUMBA_CONFIG.symmetricAlgorithm,
|
|
177
|
+
this.key,
|
|
178
|
+
nonce,
|
|
179
|
+
{ authTagLength: GUMBA_CONFIG.authTagLength }
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Include metadata in AAD for integrity
|
|
183
|
+
const aad = Buffer.from(JSON.stringify({
|
|
184
|
+
bundleId: this.bundleId,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
...metadata,
|
|
187
|
+
}));
|
|
188
|
+
cipher.setAAD(aad);
|
|
189
|
+
|
|
190
|
+
const data = typeof plaintext === 'string' ? plaintext : JSON.stringify(plaintext);
|
|
191
|
+
const encrypted = Buffer.concat([
|
|
192
|
+
cipher.update(data, 'utf8'),
|
|
193
|
+
cipher.final(),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
this.messageCount++;
|
|
197
|
+
this.lastUsed = Date.now();
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
nonce: nonce.toString('hex'),
|
|
201
|
+
ciphertext: encrypted.toString('hex'),
|
|
202
|
+
authTag: cipher.getAuthTag().toString('hex'),
|
|
203
|
+
aad: aad.toString('hex'),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Decrypt content from the bundle
|
|
209
|
+
* Only called locally - plaintext delivered via ANNEX
|
|
210
|
+
*/
|
|
211
|
+
unseal(encrypted) {
|
|
212
|
+
const nonce = Buffer.from(encrypted.nonce, 'hex');
|
|
213
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, 'hex');
|
|
214
|
+
const authTag = Buffer.from(encrypted.authTag, 'hex');
|
|
215
|
+
const aad = Buffer.from(encrypted.aad, 'hex');
|
|
216
|
+
|
|
217
|
+
const decipher = createDecipheriv(
|
|
218
|
+
GUMBA_CONFIG.symmetricAlgorithm,
|
|
219
|
+
this.key,
|
|
220
|
+
nonce,
|
|
221
|
+
{ authTagLength: GUMBA_CONFIG.authTagLength }
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
decipher.setAAD(aad);
|
|
225
|
+
decipher.setAuthTag(authTag);
|
|
226
|
+
|
|
227
|
+
const decrypted = Buffer.concat([
|
|
228
|
+
decipher.update(ciphertext),
|
|
229
|
+
decipher.final(),
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
return decrypted.toString('utf8');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Check if key needs rotation
|
|
237
|
+
*/
|
|
238
|
+
needsRotation() {
|
|
239
|
+
return this.messageCount >= GUMBA_CONFIG.maxMessagesPerBundle;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Securely clear key material
|
|
244
|
+
*/
|
|
245
|
+
destroy() {
|
|
246
|
+
if (this.key) {
|
|
247
|
+
this.key.fill(0);
|
|
248
|
+
this.key = null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
254
|
+
// GUMBA MEMBER TREE - Merkle tree for membership proofs
|
|
255
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* MemberTree - Merkle tree of authorized DOKO identities
|
|
259
|
+
*
|
|
260
|
+
* Enables:
|
|
261
|
+
* - O(log n) membership verification
|
|
262
|
+
* - Merkle proofs for ZK-style access
|
|
263
|
+
* - Efficient member add/remove
|
|
264
|
+
*/
|
|
265
|
+
export class GumbaMemberTree {
|
|
266
|
+
constructor() {
|
|
267
|
+
this.members = new Map(); // dokoId -> { role, addedAt, addedBy }
|
|
268
|
+
this.leafHashes = []; // Sorted leaf hashes
|
|
269
|
+
this.root = null; // Merkle root
|
|
270
|
+
this.dirty = true; // Needs rebuild
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Hash a DOKO ID for tree inclusion
|
|
275
|
+
*/
|
|
276
|
+
static hashMember(dokoId, role) {
|
|
277
|
+
return bytesToHex(sha3_256(utf8ToBytes(`${dokoId}:${role}`)));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Add a member to the tree
|
|
282
|
+
*/
|
|
283
|
+
addMember(dokoId, role = GUMBA_ROLE.MEMBER, addedBy = null) {
|
|
284
|
+
if (this.members.size >= GUMBA_CONFIG.maxMembers) {
|
|
285
|
+
throw new Error('Maximum members reached');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.members.set(dokoId, {
|
|
289
|
+
role,
|
|
290
|
+
addedAt: Date.now(),
|
|
291
|
+
addedBy,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
this.dirty = true;
|
|
295
|
+
log.debug('Member added', { dokoId: dokoId.slice(0, 16), role });
|
|
296
|
+
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Remove a member from the tree
|
|
302
|
+
*/
|
|
303
|
+
removeMember(dokoId) {
|
|
304
|
+
const removed = this.members.delete(dokoId);
|
|
305
|
+
if (removed) {
|
|
306
|
+
this.dirty = true;
|
|
307
|
+
log.debug('Member removed', { dokoId: dokoId.slice(0, 16) });
|
|
308
|
+
}
|
|
309
|
+
return removed;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Check if a DOKO is a member
|
|
314
|
+
*/
|
|
315
|
+
isMember(dokoId) {
|
|
316
|
+
return this.members.has(dokoId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get member info
|
|
321
|
+
*/
|
|
322
|
+
getMember(dokoId) {
|
|
323
|
+
return this.members.get(dokoId) || null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get member role
|
|
328
|
+
*/
|
|
329
|
+
getRole(dokoId) {
|
|
330
|
+
return this.members.get(dokoId)?.role || null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if member has required role
|
|
335
|
+
*/
|
|
336
|
+
hasRole(dokoId, requiredRole) {
|
|
337
|
+
const member = this.members.get(dokoId);
|
|
338
|
+
if (!member) return false;
|
|
339
|
+
|
|
340
|
+
const roleHierarchy = [GUMBA_ROLE.READER, GUMBA_ROLE.MEMBER, GUMBA_ROLE.ADMIN, GUMBA_ROLE.OWNER];
|
|
341
|
+
const memberLevel = roleHierarchy.indexOf(member.role);
|
|
342
|
+
const requiredLevel = roleHierarchy.indexOf(requiredRole);
|
|
343
|
+
|
|
344
|
+
return memberLevel >= requiredLevel;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Rebuild the Merkle tree
|
|
349
|
+
*/
|
|
350
|
+
rebuild() {
|
|
351
|
+
if (!this.dirty) return this.root;
|
|
352
|
+
|
|
353
|
+
// Create sorted leaf hashes
|
|
354
|
+
this.leafHashes = Array.from(this.members.entries())
|
|
355
|
+
.map(([dokoId, info]) => GumbaMemberTree.hashMember(dokoId, info.role))
|
|
356
|
+
.sort();
|
|
357
|
+
|
|
358
|
+
if (this.leafHashes.length === 0) {
|
|
359
|
+
this.root = bytesToHex(sha3_256(utf8ToBytes('GUMBA:EMPTY')));
|
|
360
|
+
} else {
|
|
361
|
+
this.root = this._buildTree(this.leafHashes);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.dirty = false;
|
|
365
|
+
return this.root;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Build Merkle tree recursively
|
|
370
|
+
*/
|
|
371
|
+
_buildTree(leaves) {
|
|
372
|
+
if (leaves.length === 1) return leaves[0];
|
|
373
|
+
|
|
374
|
+
const nextLevel = [];
|
|
375
|
+
for (let i = 0; i < leaves.length; i += 2) {
|
|
376
|
+
const left = leaves[i];
|
|
377
|
+
const right = leaves[i + 1] || left; // Duplicate if odd
|
|
378
|
+
const parent = bytesToHex(sha3_256(hexToBytes(left + right)));
|
|
379
|
+
nextLevel.push(parent);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return this._buildTree(nextLevel);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Generate Merkle proof for a member
|
|
387
|
+
*/
|
|
388
|
+
getProof(dokoId) {
|
|
389
|
+
const member = this.members.get(dokoId);
|
|
390
|
+
if (!member) return null;
|
|
391
|
+
|
|
392
|
+
this.rebuild();
|
|
393
|
+
|
|
394
|
+
const leafHash = GumbaMemberTree.hashMember(dokoId, member.role);
|
|
395
|
+
const leafIndex = this.leafHashes.indexOf(leafHash);
|
|
396
|
+
if (leafIndex === -1) return null;
|
|
397
|
+
|
|
398
|
+
const proof = [];
|
|
399
|
+
let currentLevel = [...this.leafHashes];
|
|
400
|
+
let index = leafIndex;
|
|
401
|
+
|
|
402
|
+
while (currentLevel.length > 1) {
|
|
403
|
+
const siblingIndex = index % 2 === 0 ? index + 1 : index - 1;
|
|
404
|
+
const sibling = currentLevel[siblingIndex] || currentLevel[index];
|
|
405
|
+
|
|
406
|
+
proof.push({
|
|
407
|
+
hash: sibling,
|
|
408
|
+
position: index % 2 === 0 ? 'right' : 'left',
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Move to next level
|
|
412
|
+
const nextLevel = [];
|
|
413
|
+
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
414
|
+
const left = currentLevel[i];
|
|
415
|
+
const right = currentLevel[i + 1] || left;
|
|
416
|
+
nextLevel.push(bytesToHex(sha3_256(hexToBytes(left + right))));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
currentLevel = nextLevel;
|
|
420
|
+
index = Math.floor(index / 2);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
leaf: leafHash,
|
|
425
|
+
root: this.root,
|
|
426
|
+
path: proof,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Verify a Merkle proof
|
|
432
|
+
*/
|
|
433
|
+
static verifyProof(proof) {
|
|
434
|
+
let current = proof.leaf;
|
|
435
|
+
|
|
436
|
+
for (const step of proof.path) {
|
|
437
|
+
const combined = step.position === 'left'
|
|
438
|
+
? step.hash + current
|
|
439
|
+
: current + step.hash;
|
|
440
|
+
current = bytesToHex(sha3_256(hexToBytes(combined)));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return current === proof.root;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get the current root
|
|
448
|
+
*/
|
|
449
|
+
getRoot() {
|
|
450
|
+
return this.rebuild();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Get member count
|
|
455
|
+
*/
|
|
456
|
+
get size() {
|
|
457
|
+
return this.members.size;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Export member list (for backup/sync)
|
|
462
|
+
*/
|
|
463
|
+
export() {
|
|
464
|
+
return {
|
|
465
|
+
root: this.getRoot(),
|
|
466
|
+
members: Array.from(this.members.entries()).map(([dokoId, info]) => ({
|
|
467
|
+
dokoId,
|
|
468
|
+
...info,
|
|
469
|
+
})),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Import member list
|
|
475
|
+
*/
|
|
476
|
+
import(data) {
|
|
477
|
+
this.members.clear();
|
|
478
|
+
for (const member of data.members) {
|
|
479
|
+
this.members.set(member.dokoId, {
|
|
480
|
+
role: member.role,
|
|
481
|
+
addedAt: member.addedAt,
|
|
482
|
+
addedBy: member.addedBy,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
this.dirty = true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
490
|
+
// GUMBA PROOF - Access proof generation and verification
|
|
491
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* GumbaProof - Creates and verifies access proofs
|
|
495
|
+
*/
|
|
496
|
+
export class GumbaProof {
|
|
497
|
+
/**
|
|
498
|
+
* Create a challenge for proof-of-identity
|
|
499
|
+
*/
|
|
500
|
+
static createChallenge(bundleId, targetDokoId) {
|
|
501
|
+
const nonce = bytesToHex(randomBytes(32));
|
|
502
|
+
const timestamp = Date.now();
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
type: GUMBA_PROOF_TYPE.CHALLENGE,
|
|
506
|
+
bundleId,
|
|
507
|
+
targetDokoId,
|
|
508
|
+
nonce,
|
|
509
|
+
timestamp,
|
|
510
|
+
expiry: timestamp + GUMBA_CONFIG.challengeExpiry,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Sign a challenge response with DOKO private key
|
|
516
|
+
*/
|
|
517
|
+
static signChallenge(challenge, secretKey) {
|
|
518
|
+
const payload = JSON.stringify({
|
|
519
|
+
type: challenge.type,
|
|
520
|
+
bundleId: challenge.bundleId,
|
|
521
|
+
nonce: challenge.nonce,
|
|
522
|
+
timestamp: challenge.timestamp,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const payloadBytes = utf8ToBytes(payload);
|
|
526
|
+
// API: sign(message, secretKey)
|
|
527
|
+
const signature = mlDsa65Sign(payloadBytes, secretKey);
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
...challenge,
|
|
531
|
+
signature: bytesToHex(signature),
|
|
532
|
+
signedAt: Date.now(),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Verify a challenge response
|
|
538
|
+
*/
|
|
539
|
+
static verifyChallenge(response, publicKey) {
|
|
540
|
+
// Check expiry
|
|
541
|
+
if (Date.now() > response.expiry) {
|
|
542
|
+
return { valid: false, reason: 'EXPIRED' };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Rebuild payload
|
|
546
|
+
const payload = JSON.stringify({
|
|
547
|
+
type: response.type,
|
|
548
|
+
bundleId: response.bundleId,
|
|
549
|
+
nonce: response.nonce,
|
|
550
|
+
timestamp: response.timestamp,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Verify signature
|
|
554
|
+
try {
|
|
555
|
+
const payloadBytes = utf8ToBytes(payload);
|
|
556
|
+
const signatureBytes = hexToBytes(response.signature);
|
|
557
|
+
const publicKeyBytes = typeof publicKey === 'string'
|
|
558
|
+
? hexToBytes(publicKey)
|
|
559
|
+
: publicKey;
|
|
560
|
+
|
|
561
|
+
// API: verify(signature, message, publicKey)
|
|
562
|
+
const valid = mlDsa65Verify(signatureBytes, payloadBytes, publicKeyBytes);
|
|
563
|
+
|
|
564
|
+
return { valid, reason: valid ? 'OK' : 'INVALID_SIGNATURE' };
|
|
565
|
+
} catch (err) {
|
|
566
|
+
return { valid: false, reason: 'VERIFICATION_ERROR', error: err.message };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Create an attestation (one member vouches for another)
|
|
572
|
+
*/
|
|
573
|
+
static createAttestation(options) {
|
|
574
|
+
const {
|
|
575
|
+
bundleId,
|
|
576
|
+
grantorDokoId,
|
|
577
|
+
granteeDokoId,
|
|
578
|
+
grantorSecretKey,
|
|
579
|
+
expiry = Date.now() + GUMBA_CONFIG.attestationMaxAge,
|
|
580
|
+
grantedRole = GUMBA_ROLE.READER,
|
|
581
|
+
} = options;
|
|
582
|
+
|
|
583
|
+
const attestation = {
|
|
584
|
+
type: GUMBA_PROOF_TYPE.ATTESTATION,
|
|
585
|
+
bundleId,
|
|
586
|
+
grantorDokoId,
|
|
587
|
+
granteeDokoId,
|
|
588
|
+
grantedRole,
|
|
589
|
+
createdAt: Date.now(),
|
|
590
|
+
expiry,
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// Sign with grantor's key
|
|
594
|
+
const payload = JSON.stringify(attestation);
|
|
595
|
+
const payloadBytes = utf8ToBytes(payload);
|
|
596
|
+
// API: sign(message, secretKey)
|
|
597
|
+
const signature = mlDsa65Sign(payloadBytes, grantorSecretKey);
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
...attestation,
|
|
601
|
+
signature: bytesToHex(signature),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Verify an attestation
|
|
607
|
+
*/
|
|
608
|
+
static verifyAttestation(attestation, grantorPublicKey, memberTree) {
|
|
609
|
+
// Check expiry
|
|
610
|
+
if (Date.now() > attestation.expiry) {
|
|
611
|
+
return { valid: false, reason: 'EXPIRED' };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Check grantor is a member with sufficient role
|
|
615
|
+
if (!memberTree.hasRole(attestation.grantorDokoId, GUMBA_ROLE.MEMBER)) {
|
|
616
|
+
return { valid: false, reason: 'GRANTOR_NOT_AUTHORIZED' };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Verify signature
|
|
620
|
+
try {
|
|
621
|
+
const payload = JSON.stringify({
|
|
622
|
+
type: attestation.type,
|
|
623
|
+
bundleId: attestation.bundleId,
|
|
624
|
+
grantorDokoId: attestation.grantorDokoId,
|
|
625
|
+
granteeDokoId: attestation.granteeDokoId,
|
|
626
|
+
grantedRole: attestation.grantedRole,
|
|
627
|
+
createdAt: attestation.createdAt,
|
|
628
|
+
expiry: attestation.expiry,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const payloadBytes = utf8ToBytes(payload);
|
|
632
|
+
const signatureBytes = hexToBytes(attestation.signature);
|
|
633
|
+
const publicKeyBytes = typeof grantorPublicKey === 'string'
|
|
634
|
+
? hexToBytes(grantorPublicKey)
|
|
635
|
+
: grantorPublicKey;
|
|
636
|
+
|
|
637
|
+
// API: verify(signature, message, publicKey)
|
|
638
|
+
const valid = mlDsa65Verify(signatureBytes, payloadBytes, publicKeyBytes);
|
|
639
|
+
|
|
640
|
+
return { valid, reason: valid ? 'OK' : 'INVALID_SIGNATURE' };
|
|
641
|
+
} catch (err) {
|
|
642
|
+
return { valid: false, reason: 'VERIFICATION_ERROR', error: err.message };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Create a Merkle membership proof
|
|
648
|
+
*/
|
|
649
|
+
static createMerkleProof(dokoId, memberTree) {
|
|
650
|
+
const proof = memberTree.getProof(dokoId);
|
|
651
|
+
if (!proof) {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
type: GUMBA_PROOF_TYPE.MERKLE,
|
|
657
|
+
dokoId, // Could be hidden for true ZK
|
|
658
|
+
proof,
|
|
659
|
+
timestamp: Date.now(),
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Verify a Merkle membership proof
|
|
665
|
+
*/
|
|
666
|
+
static verifyMerkleProof(merkleProof, expectedRoot) {
|
|
667
|
+
if (!merkleProof || !merkleProof.proof) {
|
|
668
|
+
return { valid: false, reason: 'INVALID_PROOF' };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Check root matches current tree
|
|
672
|
+
if (merkleProof.proof.root !== expectedRoot) {
|
|
673
|
+
return { valid: false, reason: 'ROOT_MISMATCH' };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Verify the proof path
|
|
677
|
+
const valid = GumbaMemberTree.verifyProof(merkleProof.proof);
|
|
678
|
+
|
|
679
|
+
return { valid, reason: valid ? 'OK' : 'PROOF_INVALID' };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
684
|
+
// GUMBA GATE - Access control and content gating
|
|
685
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* GumbaGate - The guardian at the door
|
|
689
|
+
*
|
|
690
|
+
* Verifies proofs and grants/denies access to bundles.
|
|
691
|
+
* "Show your proof. The guardian will decide."
|
|
692
|
+
*/
|
|
693
|
+
export class GumbaGate {
|
|
694
|
+
constructor(memberTree, options = {}) {
|
|
695
|
+
this.memberTree = memberTree;
|
|
696
|
+
this.options = options;
|
|
697
|
+
|
|
698
|
+
// Pending challenges awaiting response
|
|
699
|
+
this.pendingChallenges = new Map(); // nonce -> challenge
|
|
700
|
+
|
|
701
|
+
// Proof cache (prevent replay, speed up repeated access)
|
|
702
|
+
this.proofCache = new Map(); // dokoId -> { proof, expiresAt }
|
|
703
|
+
|
|
704
|
+
// Stats
|
|
705
|
+
this.stats = {
|
|
706
|
+
challengesIssued: 0,
|
|
707
|
+
accessGranted: 0,
|
|
708
|
+
accessDenied: 0,
|
|
709
|
+
replaysBlocked: 0,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Issue a challenge to an access requester
|
|
715
|
+
*/
|
|
716
|
+
issueChallenge(bundleId, requesterDokoId) {
|
|
717
|
+
const challenge = GumbaProof.createChallenge(bundleId, requesterDokoId);
|
|
718
|
+
|
|
719
|
+
// Store pending challenge
|
|
720
|
+
this.pendingChallenges.set(challenge.nonce, challenge);
|
|
721
|
+
|
|
722
|
+
// Set expiry cleanup
|
|
723
|
+
setTimeout(() => {
|
|
724
|
+
this.pendingChallenges.delete(challenge.nonce);
|
|
725
|
+
}, GUMBA_CONFIG.challengeExpiry);
|
|
726
|
+
|
|
727
|
+
this.stats.challengesIssued++;
|
|
728
|
+
log.debug('Challenge issued', {
|
|
729
|
+
bundleId,
|
|
730
|
+
requester: requesterDokoId.slice(0, 16),
|
|
731
|
+
nonce: challenge.nonce.slice(0, 16),
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
return challenge;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Verify an access attempt
|
|
739
|
+
* Returns access decision
|
|
740
|
+
*/
|
|
741
|
+
async verifyAccess(proof, getPublicKey) {
|
|
742
|
+
// For challenge-response, check if nonce is valid BEFORE cache
|
|
743
|
+
// This prevents replay attacks where same signed response is used twice
|
|
744
|
+
if (proof.type === GUMBA_PROOF_TYPE.CHALLENGE) {
|
|
745
|
+
const pending = this.pendingChallenges.get(proof.nonce);
|
|
746
|
+
if (!pending) {
|
|
747
|
+
this.stats.replaysBlocked++;
|
|
748
|
+
this.stats.accessDenied++;
|
|
749
|
+
return { granted: false, reason: 'CHALLENGE_NOT_FOUND_OR_EXPIRED' };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Check proof cache (for non-challenge types, or after nonce validation)
|
|
754
|
+
const cached = this._checkCache(proof);
|
|
755
|
+
if (cached) {
|
|
756
|
+
// For challenges, still consume the nonce even if cache hit
|
|
757
|
+
if (proof.type === GUMBA_PROOF_TYPE.CHALLENGE) {
|
|
758
|
+
this.pendingChallenges.delete(proof.nonce);
|
|
759
|
+
}
|
|
760
|
+
log.debug('Access granted from cache', { dokoId: cached.dokoId?.slice(0, 16) });
|
|
761
|
+
this.stats.accessGranted++;
|
|
762
|
+
return { granted: true, reason: 'CACHED', role: cached.role };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
let result;
|
|
766
|
+
|
|
767
|
+
switch (proof.type) {
|
|
768
|
+
case GUMBA_PROOF_TYPE.CHALLENGE:
|
|
769
|
+
result = await this._verifyChallengeAccess(proof, getPublicKey);
|
|
770
|
+
break;
|
|
771
|
+
|
|
772
|
+
case GUMBA_PROOF_TYPE.ATTESTATION:
|
|
773
|
+
result = await this._verifyAttestationAccess(proof, getPublicKey);
|
|
774
|
+
break;
|
|
775
|
+
|
|
776
|
+
case GUMBA_PROOF_TYPE.MERKLE:
|
|
777
|
+
result = this._verifyMerkleAccess(proof);
|
|
778
|
+
break;
|
|
779
|
+
|
|
780
|
+
default:
|
|
781
|
+
result = { granted: false, reason: 'UNKNOWN_PROOF_TYPE' };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Update stats and cache
|
|
785
|
+
if (result.granted) {
|
|
786
|
+
this.stats.accessGranted++;
|
|
787
|
+
this._cacheProof(proof, result);
|
|
788
|
+
} else {
|
|
789
|
+
this.stats.accessDenied++;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
log.debug('Access decision', {
|
|
793
|
+
type: proof.type,
|
|
794
|
+
granted: result.granted,
|
|
795
|
+
reason: result.reason,
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
return result;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Verify challenge-response access
|
|
803
|
+
*/
|
|
804
|
+
async _verifyChallengeAccess(response, getPublicKey) {
|
|
805
|
+
// Check if this challenge is pending
|
|
806
|
+
const pending = this.pendingChallenges.get(response.nonce);
|
|
807
|
+
if (!pending) {
|
|
808
|
+
this.stats.replaysBlocked++;
|
|
809
|
+
return { granted: false, reason: 'CHALLENGE_NOT_FOUND_OR_EXPIRED' };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Remove from pending (one-time use)
|
|
813
|
+
this.pendingChallenges.delete(response.nonce);
|
|
814
|
+
|
|
815
|
+
// Check membership
|
|
816
|
+
const dokoId = response.targetDokoId;
|
|
817
|
+
if (!this.memberTree.isMember(dokoId)) {
|
|
818
|
+
return { granted: false, reason: 'NOT_A_MEMBER' };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Get public key
|
|
822
|
+
const publicKey = await getPublicKey(dokoId);
|
|
823
|
+
if (!publicKey) {
|
|
824
|
+
return { granted: false, reason: 'PUBLIC_KEY_NOT_FOUND' };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Verify signature
|
|
828
|
+
const verification = GumbaProof.verifyChallenge(response, publicKey);
|
|
829
|
+
if (!verification.valid) {
|
|
830
|
+
return { granted: false, reason: verification.reason };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const role = this.memberTree.getRole(dokoId);
|
|
834
|
+
return { granted: true, reason: 'CHALLENGE_VERIFIED', role, dokoId };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Verify attestation-based access
|
|
839
|
+
*/
|
|
840
|
+
async _verifyAttestationAccess(attestation, getPublicKey) {
|
|
841
|
+
// Get grantor's public key
|
|
842
|
+
const grantorPublicKey = await getPublicKey(attestation.grantorDokoId);
|
|
843
|
+
if (!grantorPublicKey) {
|
|
844
|
+
return { granted: false, reason: 'GRANTOR_KEY_NOT_FOUND' };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Verify attestation
|
|
848
|
+
const verification = GumbaProof.verifyAttestation(
|
|
849
|
+
attestation,
|
|
850
|
+
grantorPublicKey,
|
|
851
|
+
this.memberTree
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
if (!verification.valid) {
|
|
855
|
+
return { granted: false, reason: verification.reason };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Attestation is valid - grantee gets the granted role
|
|
859
|
+
return {
|
|
860
|
+
granted: true,
|
|
861
|
+
reason: 'ATTESTATION_VERIFIED',
|
|
862
|
+
role: attestation.grantedRole,
|
|
863
|
+
dokoId: attestation.granteeDokoId,
|
|
864
|
+
attestedBy: attestation.grantorDokoId,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Verify Merkle proof access
|
|
870
|
+
*/
|
|
871
|
+
_verifyMerkleAccess(merkleProof) {
|
|
872
|
+
const expectedRoot = this.memberTree.getRoot();
|
|
873
|
+
const verification = GumbaProof.verifyMerkleProof(merkleProof, expectedRoot);
|
|
874
|
+
|
|
875
|
+
if (!verification.valid) {
|
|
876
|
+
return { granted: false, reason: verification.reason };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const role = this.memberTree.getRole(merkleProof.dokoId);
|
|
880
|
+
return {
|
|
881
|
+
granted: true,
|
|
882
|
+
reason: 'MERKLE_VERIFIED',
|
|
883
|
+
role,
|
|
884
|
+
dokoId: merkleProof.dokoId,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Check proof cache
|
|
890
|
+
*/
|
|
891
|
+
_checkCache(proof) {
|
|
892
|
+
const dokoId = proof.targetDokoId || proof.granteeDokoId || proof.dokoId;
|
|
893
|
+
if (!dokoId) return null;
|
|
894
|
+
|
|
895
|
+
const cached = this.proofCache.get(dokoId);
|
|
896
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
897
|
+
return cached;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Expired - remove
|
|
901
|
+
if (cached) {
|
|
902
|
+
this.proofCache.delete(dokoId);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Cache a successful proof
|
|
910
|
+
*/
|
|
911
|
+
_cacheProof(proof, result) {
|
|
912
|
+
const dokoId = result.dokoId;
|
|
913
|
+
if (!dokoId) return;
|
|
914
|
+
|
|
915
|
+
this.proofCache.set(dokoId, {
|
|
916
|
+
dokoId,
|
|
917
|
+
role: result.role,
|
|
918
|
+
expiresAt: Date.now() + GUMBA_CONFIG.proofCacheTime,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Clear proof cache for a DOKO (e.g., on revocation)
|
|
924
|
+
*/
|
|
925
|
+
revokeAccess(dokoId) {
|
|
926
|
+
this.proofCache.delete(dokoId);
|
|
927
|
+
log.debug('Access revoked', { dokoId: dokoId.slice(0, 16) });
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Get gate statistics
|
|
932
|
+
*/
|
|
933
|
+
getStats() {
|
|
934
|
+
return {
|
|
935
|
+
...this.stats,
|
|
936
|
+
pendingChallenges: this.pendingChallenges.size,
|
|
937
|
+
cachedProofs: this.proofCache.size,
|
|
938
|
+
memberCount: this.memberTree.size,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
944
|
+
// GUMBA BUNDLE - The encrypted content container
|
|
945
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* GumbaBundle - Encrypted message storage
|
|
949
|
+
*
|
|
950
|
+
* "Like a treasure chest in the monastery vault.
|
|
951
|
+
* The key stays with the guardian. Visitors see only what they're shown."
|
|
952
|
+
*/
|
|
953
|
+
export class GumbaBundle extends EventEmitter {
|
|
954
|
+
/**
|
|
955
|
+
* @param {string} bundleId - Unique bundle identifier
|
|
956
|
+
* @param {Object} options - Bundle configuration
|
|
957
|
+
*/
|
|
958
|
+
constructor(bundleId, options = {}) {
|
|
959
|
+
super();
|
|
960
|
+
|
|
961
|
+
this.bundleId = bundleId;
|
|
962
|
+
this.name = options.name || bundleId;
|
|
963
|
+
this.description = options.description || '';
|
|
964
|
+
this.createdAt = Date.now();
|
|
965
|
+
this.ownerDokoId = options.ownerDokoId;
|
|
966
|
+
|
|
967
|
+
// The key - NEVER LEAVES THIS NODE
|
|
968
|
+
this.key = null;
|
|
969
|
+
|
|
970
|
+
// Membership
|
|
971
|
+
this.memberTree = new GumbaMemberTree();
|
|
972
|
+
|
|
973
|
+
// Access control
|
|
974
|
+
this.gate = new GumbaGate(this.memberTree);
|
|
975
|
+
|
|
976
|
+
// Encrypted content storage
|
|
977
|
+
this.messages = []; // Sealed messages
|
|
978
|
+
this.messageIndex = 0; // Next message ID
|
|
979
|
+
|
|
980
|
+
// Metadata (not encrypted)
|
|
981
|
+
this.metadata = {
|
|
982
|
+
version: GUMBA_CONFIG.bundleVersion,
|
|
983
|
+
createdAt: this.createdAt,
|
|
984
|
+
messageCount: 0,
|
|
985
|
+
lastActivity: this.createdAt,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Initialize the bundle with owner's secret
|
|
991
|
+
* This derives the bundle key (never transmitted)
|
|
992
|
+
*/
|
|
993
|
+
initialize(ownerSecret) {
|
|
994
|
+
this.key = new GumbaKey(this.bundleId, ownerSecret);
|
|
995
|
+
|
|
996
|
+
// Add owner as first member
|
|
997
|
+
if (this.ownerDokoId) {
|
|
998
|
+
this.memberTree.addMember(this.ownerDokoId, GUMBA_ROLE.OWNER);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
log.info('Bundle initialized', {
|
|
1002
|
+
bundleId: this.bundleId,
|
|
1003
|
+
owner: this.ownerDokoId?.slice(0, 16),
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
return this;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Add a message to the bundle
|
|
1011
|
+
* @param {Object} content - Message content
|
|
1012
|
+
* @param {string} senderDokoId - Sender's DOKO ID
|
|
1013
|
+
* @returns {Object} Sealed message reference
|
|
1014
|
+
*/
|
|
1015
|
+
addMessage(content, senderDokoId) {
|
|
1016
|
+
if (!this.key) {
|
|
1017
|
+
throw new Error('Bundle not initialized');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Verify sender is a member with write access
|
|
1021
|
+
if (!this.memberTree.hasRole(senderDokoId, GUMBA_ROLE.MEMBER)) {
|
|
1022
|
+
throw new Error('SENDER_NOT_AUTHORIZED');
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const messageId = ++this.messageIndex;
|
|
1026
|
+
const timestamp = Date.now();
|
|
1027
|
+
|
|
1028
|
+
// Create message envelope
|
|
1029
|
+
const envelope = {
|
|
1030
|
+
id: messageId,
|
|
1031
|
+
type: 'message',
|
|
1032
|
+
sender: senderDokoId,
|
|
1033
|
+
content,
|
|
1034
|
+
timestamp,
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// Seal it
|
|
1038
|
+
const sealed = this.key.seal(envelope);
|
|
1039
|
+
|
|
1040
|
+
// Store
|
|
1041
|
+
this.messages.push({
|
|
1042
|
+
id: messageId,
|
|
1043
|
+
sealed,
|
|
1044
|
+
timestamp,
|
|
1045
|
+
senderHint: senderDokoId.slice(0, 8), // Minimal hint for UI
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// Update metadata
|
|
1049
|
+
this.metadata.messageCount++;
|
|
1050
|
+
this.metadata.lastActivity = timestamp;
|
|
1051
|
+
|
|
1052
|
+
this.emit('message', { messageId, sender: senderDokoId, timestamp });
|
|
1053
|
+
|
|
1054
|
+
return { messageId, timestamp };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Get messages for an authorized accessor
|
|
1059
|
+
* Returns decrypted content (delivered via ANNEX)
|
|
1060
|
+
*
|
|
1061
|
+
* @param {Object} accessResult - Result from gate.verifyAccess()
|
|
1062
|
+
* @param {Object} options - Query options
|
|
1063
|
+
*/
|
|
1064
|
+
getMessages(accessResult, options = {}) {
|
|
1065
|
+
if (!accessResult.granted) {
|
|
1066
|
+
throw new Error('ACCESS_DENIED');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const { since = 0, limit = 50 } = options;
|
|
1070
|
+
|
|
1071
|
+
// Filter by time/ID
|
|
1072
|
+
let messages = this.messages.filter(m => m.id > since);
|
|
1073
|
+
|
|
1074
|
+
// Apply limit
|
|
1075
|
+
if (limit > 0) {
|
|
1076
|
+
messages = messages.slice(-limit);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Decrypt for accessor
|
|
1080
|
+
const decrypted = messages.map(m => {
|
|
1081
|
+
try {
|
|
1082
|
+
const plaintext = this.key.unseal(m.sealed);
|
|
1083
|
+
return JSON.parse(plaintext);
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
log.error('Decrypt error', { messageId: m.id, error: err.message });
|
|
1086
|
+
return { id: m.id, error: 'DECRYPT_FAILED' };
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
messages: decrypted,
|
|
1092
|
+
bundleId: this.bundleId,
|
|
1093
|
+
accessedAs: accessResult.role,
|
|
1094
|
+
count: decrypted.length,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Add a member to the bundle
|
|
1100
|
+
*/
|
|
1101
|
+
addMember(dokoId, role, adderDokoId) {
|
|
1102
|
+
// Verify adder has permission
|
|
1103
|
+
if (!this.memberTree.hasRole(adderDokoId, GUMBA_ROLE.ADMIN)) {
|
|
1104
|
+
throw new Error('ADDER_NOT_AUTHORIZED');
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Can't grant higher role than your own
|
|
1108
|
+
const adderRole = this.memberTree.getRole(adderDokoId);
|
|
1109
|
+
const roleHierarchy = [GUMBA_ROLE.READER, GUMBA_ROLE.MEMBER, GUMBA_ROLE.ADMIN, GUMBA_ROLE.OWNER];
|
|
1110
|
+
if (roleHierarchy.indexOf(role) > roleHierarchy.indexOf(adderRole)) {
|
|
1111
|
+
throw new Error('CANNOT_GRANT_HIGHER_ROLE');
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
this.memberTree.addMember(dokoId, role, adderDokoId);
|
|
1115
|
+
this.emit('member:added', { dokoId, role, addedBy: adderDokoId });
|
|
1116
|
+
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Remove a member from the bundle
|
|
1122
|
+
*/
|
|
1123
|
+
removeMember(dokoId, removerDokoId) {
|
|
1124
|
+
// Verify remover has permission
|
|
1125
|
+
if (!this.memberTree.hasRole(removerDokoId, GUMBA_ROLE.ADMIN)) {
|
|
1126
|
+
throw new Error('REMOVER_NOT_AUTHORIZED');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Can't remove owner
|
|
1130
|
+
if (this.memberTree.getRole(dokoId) === GUMBA_ROLE.OWNER) {
|
|
1131
|
+
throw new Error('CANNOT_REMOVE_OWNER');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Can't remove someone with higher/equal role (unless owner)
|
|
1135
|
+
const removerRole = this.memberTree.getRole(removerDokoId);
|
|
1136
|
+
const targetRole = this.memberTree.getRole(dokoId);
|
|
1137
|
+
const roleHierarchy = [GUMBA_ROLE.READER, GUMBA_ROLE.MEMBER, GUMBA_ROLE.ADMIN, GUMBA_ROLE.OWNER];
|
|
1138
|
+
|
|
1139
|
+
if (removerRole !== GUMBA_ROLE.OWNER &&
|
|
1140
|
+
roleHierarchy.indexOf(targetRole) >= roleHierarchy.indexOf(removerRole)) {
|
|
1141
|
+
throw new Error('CANNOT_REMOVE_EQUAL_OR_HIGHER');
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
this.memberTree.removeMember(dokoId);
|
|
1145
|
+
this.gate.revokeAccess(dokoId);
|
|
1146
|
+
this.emit('member:removed', { dokoId, removedBy: removerDokoId });
|
|
1147
|
+
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Get bundle info (public metadata)
|
|
1153
|
+
*/
|
|
1154
|
+
getInfo() {
|
|
1155
|
+
return {
|
|
1156
|
+
bundleId: this.bundleId,
|
|
1157
|
+
name: this.name,
|
|
1158
|
+
description: this.description,
|
|
1159
|
+
memberCount: this.memberTree.size,
|
|
1160
|
+
messageCount: this.metadata.messageCount,
|
|
1161
|
+
createdAt: this.createdAt,
|
|
1162
|
+
lastActivity: this.metadata.lastActivity,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Export bundle for backup (encrypted)
|
|
1168
|
+
*/
|
|
1169
|
+
export() {
|
|
1170
|
+
return {
|
|
1171
|
+
bundleId: this.bundleId,
|
|
1172
|
+
name: this.name,
|
|
1173
|
+
description: this.description,
|
|
1174
|
+
ownerDokoId: this.ownerDokoId,
|
|
1175
|
+
metadata: this.metadata,
|
|
1176
|
+
members: this.memberTree.export(),
|
|
1177
|
+
messages: this.messages, // Still encrypted
|
|
1178
|
+
exportedAt: Date.now(),
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Import bundle from backup
|
|
1184
|
+
*/
|
|
1185
|
+
static import(data, ownerSecret) {
|
|
1186
|
+
const bundle = new GumbaBundle(data.bundleId, {
|
|
1187
|
+
name: data.name,
|
|
1188
|
+
description: data.description,
|
|
1189
|
+
ownerDokoId: data.ownerDokoId,
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
bundle.initialize(ownerSecret);
|
|
1193
|
+
bundle.memberTree.import(data.members);
|
|
1194
|
+
bundle.messages = data.messages;
|
|
1195
|
+
bundle.metadata = data.metadata;
|
|
1196
|
+
bundle.messageIndex = data.messages.length;
|
|
1197
|
+
|
|
1198
|
+
return bundle;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Clean up resources
|
|
1203
|
+
*/
|
|
1204
|
+
destroy() {
|
|
1205
|
+
if (this.key) {
|
|
1206
|
+
this.key.destroy();
|
|
1207
|
+
this.key = null;
|
|
1208
|
+
}
|
|
1209
|
+
this.messages = [];
|
|
1210
|
+
this.removeAllListeners();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1215
|
+
// GUMBA HUB - Multi-bundle management
|
|
1216
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* GumbaHub - Manages multiple GUMBA bundles on a node
|
|
1220
|
+
*
|
|
1221
|
+
* "The monastery courtyard - where all the sacred rooms are accessed"
|
|
1222
|
+
*/
|
|
1223
|
+
export class GumbaHub extends EventEmitter {
|
|
1224
|
+
/**
|
|
1225
|
+
* @param {Object} identity - Node identity with signing capability
|
|
1226
|
+
* @param {Object} annex - ANNEX instance for secure delivery
|
|
1227
|
+
* @param {Object} options - Hub configuration
|
|
1228
|
+
*/
|
|
1229
|
+
constructor(identity, annex, options = {}) {
|
|
1230
|
+
super();
|
|
1231
|
+
|
|
1232
|
+
this.identity = identity;
|
|
1233
|
+
this.annex = annex;
|
|
1234
|
+
this.options = options;
|
|
1235
|
+
|
|
1236
|
+
// Active bundles
|
|
1237
|
+
this.bundles = new Map(); // bundleId -> GumbaBundle
|
|
1238
|
+
|
|
1239
|
+
// Access sessions
|
|
1240
|
+
this.sessions = new Map(); // sessionId -> { dokoId, bundleIds, expiresAt }
|
|
1241
|
+
|
|
1242
|
+
// Public key registry (for testing/local lookups before KeyResolver integration)
|
|
1243
|
+
this.publicKeys = new Map(); // dokoId -> publicKey
|
|
1244
|
+
|
|
1245
|
+
// KeyResolver: unified key resolution (attached lazily)
|
|
1246
|
+
this.keyResolver = options.keyResolver || null;
|
|
1247
|
+
|
|
1248
|
+
// Stats
|
|
1249
|
+
this.stats = {
|
|
1250
|
+
bundlesCreated: 0,
|
|
1251
|
+
messagesProcessed: 0,
|
|
1252
|
+
accessAttempts: 0,
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
log.info('GumbaHub initialized');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Register a DOKO public key for local lookups
|
|
1260
|
+
* Useful for testing or pre-cached keys
|
|
1261
|
+
*/
|
|
1262
|
+
registerPublicKey(dokoId, publicKey) {
|
|
1263
|
+
this.publicKeys.set(dokoId, publicKey);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Create a new bundle
|
|
1268
|
+
*/
|
|
1269
|
+
createBundle(bundleId, options = {}) {
|
|
1270
|
+
if (this.bundles.has(bundleId)) {
|
|
1271
|
+
throw new Error('BUNDLE_EXISTS');
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const bundle = new GumbaBundle(bundleId, {
|
|
1275
|
+
...options,
|
|
1276
|
+
ownerDokoId: this.identity.identity.dokoId || this.identity.identity.nodeId,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// Initialize with node's secret
|
|
1280
|
+
const ownerSecret = this.identity.identity.secretKey ||
|
|
1281
|
+
this._deriveOwnerSecret(bundleId);
|
|
1282
|
+
bundle.initialize(ownerSecret);
|
|
1283
|
+
|
|
1284
|
+
this.bundles.set(bundleId, bundle);
|
|
1285
|
+
this.stats.bundlesCreated++;
|
|
1286
|
+
|
|
1287
|
+
// Forward events
|
|
1288
|
+
bundle.on('message', (data) => this.emit('bundle:message', { bundleId, ...data }));
|
|
1289
|
+
bundle.on('member:added', (data) => this.emit('bundle:member:added', { bundleId, ...data }));
|
|
1290
|
+
bundle.on('member:removed', (data) => this.emit('bundle:member:removed', { bundleId, ...data }));
|
|
1291
|
+
|
|
1292
|
+
log.info('Bundle created', { bundleId, name: options.name });
|
|
1293
|
+
|
|
1294
|
+
return bundle.getInfo();
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Get a bundle by ID
|
|
1299
|
+
*/
|
|
1300
|
+
getBundle(bundleId) {
|
|
1301
|
+
return this.bundles.get(bundleId) || null;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* List all bundles (public info only)
|
|
1306
|
+
*/
|
|
1307
|
+
listBundles() {
|
|
1308
|
+
return Array.from(this.bundles.values()).map(b => b.getInfo());
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Handle access request
|
|
1313
|
+
*
|
|
1314
|
+
* Flow:
|
|
1315
|
+
* 1. Visitor presents proof
|
|
1316
|
+
* 2. Gate verifies proof
|
|
1317
|
+
* 3. If granted, create session + deliver via ANNEX
|
|
1318
|
+
*/
|
|
1319
|
+
async handleAccessRequest(bundleId, proof, visitorNodeId) {
|
|
1320
|
+
this.stats.accessAttempts++;
|
|
1321
|
+
|
|
1322
|
+
const bundle = this.bundles.get(bundleId);
|
|
1323
|
+
if (!bundle) {
|
|
1324
|
+
return { granted: false, reason: 'BUNDLE_NOT_FOUND' };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Verify access
|
|
1328
|
+
const accessResult = await bundle.gate.verifyAccess(proof, async (dokoId) => {
|
|
1329
|
+
// Get public key from mesh/KHATA
|
|
1330
|
+
return this._getDokoPublicKey(dokoId);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
if (!accessResult.granted) {
|
|
1334
|
+
log.debug('Access denied', { bundleId, reason: accessResult.reason });
|
|
1335
|
+
return accessResult;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Create session
|
|
1339
|
+
const sessionId = bytesToHex(randomBytes(16));
|
|
1340
|
+
this.sessions.set(sessionId, {
|
|
1341
|
+
dokoId: accessResult.dokoId,
|
|
1342
|
+
role: accessResult.role,
|
|
1343
|
+
bundleId,
|
|
1344
|
+
visitorNodeId,
|
|
1345
|
+
createdAt: Date.now(),
|
|
1346
|
+
expiresAt: Date.now() + GUMBA_CONFIG.proofCacheTime,
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
log.info('Access granted', {
|
|
1350
|
+
bundleId,
|
|
1351
|
+
dokoId: accessResult.dokoId?.slice(0, 16),
|
|
1352
|
+
role: accessResult.role,
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
return {
|
|
1356
|
+
granted: true,
|
|
1357
|
+
sessionId,
|
|
1358
|
+
role: accessResult.role,
|
|
1359
|
+
bundleInfo: bundle.getInfo(),
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Get messages for an active session
|
|
1365
|
+
*/
|
|
1366
|
+
async getMessages(sessionId, options = {}) {
|
|
1367
|
+
const session = this.sessions.get(sessionId);
|
|
1368
|
+
if (!session) {
|
|
1369
|
+
return { error: 'SESSION_NOT_FOUND' };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (Date.now() > session.expiresAt) {
|
|
1373
|
+
this.sessions.delete(sessionId);
|
|
1374
|
+
return { error: 'SESSION_EXPIRED' };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const bundle = this.bundles.get(session.bundleId);
|
|
1378
|
+
if (!bundle) {
|
|
1379
|
+
return { error: 'BUNDLE_NOT_FOUND' };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Get messages (decrypted locally)
|
|
1383
|
+
const accessResult = { granted: true, role: session.role };
|
|
1384
|
+
const result = bundle.getMessages(accessResult, options);
|
|
1385
|
+
|
|
1386
|
+
this.stats.messagesProcessed += result.count;
|
|
1387
|
+
|
|
1388
|
+
// Deliver via ANNEX for E2E encryption to remote visitor
|
|
1389
|
+
if (this.annex && session.visitorNodeId) {
|
|
1390
|
+
try {
|
|
1391
|
+
await this.annex.send(session.visitorNodeId, {
|
|
1392
|
+
type: 'gumba:messages',
|
|
1393
|
+
sessionId,
|
|
1394
|
+
bundleId: session.bundleId,
|
|
1395
|
+
...result,
|
|
1396
|
+
});
|
|
1397
|
+
return { delivered: true, via: 'annex', count: result.count };
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
// HARD FAIL: No plaintext fallback. GUMBA content is encrypted for a reason.
|
|
1400
|
+
// Returning decrypted content in plaintext defeats the entire security model.
|
|
1401
|
+
log.error('ANNEX delivery failed — refusing plaintext return', {
|
|
1402
|
+
visitor: peerTag(session.visitorNodeId),
|
|
1403
|
+
error: err.message,
|
|
1404
|
+
});
|
|
1405
|
+
return { error: 'ENCRYPTION_REQUIRED', message: 'ANNEX session required for content delivery' };
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return result;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Post a message to a bundle
|
|
1414
|
+
*/
|
|
1415
|
+
async postMessage(sessionId, content) {
|
|
1416
|
+
const session = this.sessions.get(sessionId);
|
|
1417
|
+
if (!session) {
|
|
1418
|
+
return { error: 'SESSION_NOT_FOUND' };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (Date.now() > session.expiresAt) {
|
|
1422
|
+
this.sessions.delete(sessionId);
|
|
1423
|
+
return { error: 'SESSION_EXPIRED' };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const bundle = this.bundles.get(session.bundleId);
|
|
1427
|
+
if (!bundle) {
|
|
1428
|
+
return { error: 'BUNDLE_NOT_FOUND' };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
try {
|
|
1432
|
+
const result = bundle.addMessage(content, session.dokoId);
|
|
1433
|
+
return { success: true, ...result };
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
return { error: err.message };
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Issue a challenge for a bundle
|
|
1441
|
+
*/
|
|
1442
|
+
issueChallenge(bundleId, requesterDokoId) {
|
|
1443
|
+
const bundle = this.bundles.get(bundleId);
|
|
1444
|
+
if (!bundle) {
|
|
1445
|
+
return { error: 'BUNDLE_NOT_FOUND' };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
return bundle.gate.issueChallenge(bundleId, requesterDokoId);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Delete a bundle
|
|
1453
|
+
*/
|
|
1454
|
+
deleteBundle(bundleId, requesterDokoId) {
|
|
1455
|
+
const bundle = this.bundles.get(bundleId);
|
|
1456
|
+
if (!bundle) {
|
|
1457
|
+
return { error: 'BUNDLE_NOT_FOUND' };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Only owner can delete
|
|
1461
|
+
if (!bundle.memberTree.hasRole(requesterDokoId, GUMBA_ROLE.OWNER)) {
|
|
1462
|
+
return { error: 'NOT_AUTHORIZED' };
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
bundle.destroy();
|
|
1466
|
+
this.bundles.delete(bundleId);
|
|
1467
|
+
|
|
1468
|
+
// Invalidate related sessions
|
|
1469
|
+
for (const [sessionId, session] of this.sessions) {
|
|
1470
|
+
if (session.bundleId === bundleId) {
|
|
1471
|
+
this.sessions.delete(sessionId);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
log.info('Bundle deleted', { bundleId });
|
|
1476
|
+
return { success: true };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Get hub statistics
|
|
1481
|
+
*/
|
|
1482
|
+
getStats() {
|
|
1483
|
+
return {
|
|
1484
|
+
...this.stats,
|
|
1485
|
+
activeBundles: this.bundles.size,
|
|
1486
|
+
activeSessions: this.sessions.size,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Derive owner secret for a bundle
|
|
1492
|
+
* Uses node identity to derive deterministic secret
|
|
1493
|
+
*/
|
|
1494
|
+
_deriveOwnerSecret(bundleId) {
|
|
1495
|
+
return createHash('sha3-256')
|
|
1496
|
+
.update('GUMBA-OWNER')
|
|
1497
|
+
.update(this.identity.identity.nodeId)
|
|
1498
|
+
.update(bundleId)
|
|
1499
|
+
.digest();
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Get DOKO public key — unified resolution cascade
|
|
1504
|
+
*
|
|
1505
|
+
* Resolution order:
|
|
1506
|
+
* 1. Local publicKeys map (test/pre-cached)
|
|
1507
|
+
* 2. Own identity
|
|
1508
|
+
* 3. KeyResolver (DOKO cache, peers, SHERPA, etc.)
|
|
1509
|
+
*/
|
|
1510
|
+
async _getDokoPublicKey(dokoId) {
|
|
1511
|
+
// Check local registry first (backwards compat)
|
|
1512
|
+
if (this.publicKeys.has(dokoId)) {
|
|
1513
|
+
return this.publicKeys.get(dokoId);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Check if it's our own identity
|
|
1517
|
+
if (this.identity.identity.dokoId === dokoId) {
|
|
1518
|
+
return this.identity.identity.publicKey;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// KeyResolver: unified key resolution
|
|
1522
|
+
if (this.keyResolver) {
|
|
1523
|
+
const key = this.keyResolver.resolve(dokoId);
|
|
1524
|
+
if (key) return key;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
log.warn('DOKO public key not found', { dokoId: dokoId.slice(0, 16) });
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Cleanup expired sessions
|
|
1533
|
+
*/
|
|
1534
|
+
cleanupSessions() {
|
|
1535
|
+
const now = Date.now();
|
|
1536
|
+
let cleaned = 0;
|
|
1537
|
+
|
|
1538
|
+
for (const [sessionId, session] of this.sessions) {
|
|
1539
|
+
if (now > session.expiresAt) {
|
|
1540
|
+
this.sessions.delete(sessionId);
|
|
1541
|
+
cleaned++;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (cleaned > 0) {
|
|
1546
|
+
log.debug('Cleaned expired sessions', { count: cleaned });
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
return cleaned;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1554
|
+
// EXPORTS
|
|
1555
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1556
|
+
|
|
1557
|
+
export default {
|
|
1558
|
+
GumbaKey,
|
|
1559
|
+
GumbaMemberTree,
|
|
1560
|
+
GumbaProof,
|
|
1561
|
+
GumbaGate,
|
|
1562
|
+
GumbaBundle,
|
|
1563
|
+
GumbaHub,
|
|
1564
|
+
GUMBA_CONFIG,
|
|
1565
|
+
GUMBA_PROOF_TYPE,
|
|
1566
|
+
GUMBA_ROLE,
|
|
1567
|
+
};
|