@swal/edge-mesh 0.0.0-stage → 0.1.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/LICENSE +128 -0
- package/NOTICE +37 -0
- package/README.md +470 -2
- package/dist/adapters/gpu-agent/index.d.ts +61 -0
- package/dist/adapters/gpu-agent/index.d.ts.map +1 -0
- package/dist/adapters/gpu-agent/index.js +364 -0
- package/dist/adapters/gpu-agent/index.js.map +1 -0
- package/dist/adapters/maloca-salud/citas-distribuidas.d.ts +19 -0
- package/dist/adapters/maloca-salud/citas-distribuidas.d.ts.map +1 -0
- package/dist/adapters/maloca-salud/citas-distribuidas.js +51 -0
- package/dist/adapters/maloca-salud/citas-distribuidas.js.map +1 -0
- package/dist/adapters/maloca-salud/eps-bridge.d.ts +15 -0
- package/dist/adapters/maloca-salud/eps-bridge.d.ts.map +1 -0
- package/dist/adapters/maloca-salud/eps-bridge.js +27 -0
- package/dist/adapters/maloca-salud/eps-bridge.js.map +1 -0
- package/dist/adapters/maloca-salud/patient-profile.d.ts +20 -0
- package/dist/adapters/maloca-salud/patient-profile.d.ts.map +1 -0
- package/dist/adapters/maloca-salud/patient-profile.js +36 -0
- package/dist/adapters/maloca-salud/patient-profile.js.map +1 -0
- package/dist/adapters/maloca-salud/types.d.ts +40 -0
- package/dist/adapters/maloca-salud/types.d.ts.map +1 -0
- package/dist/adapters/maloca-salud/types.js +2 -0
- package/dist/adapters/maloca-salud/types.js.map +1 -0
- package/dist/adapters/maloca-veeduria/contract-bridge.d.ts +31 -0
- package/dist/adapters/maloca-veeduria/contract-bridge.d.ts.map +1 -0
- package/dist/adapters/maloca-veeduria/contract-bridge.js +103 -0
- package/dist/adapters/maloca-veeduria/contract-bridge.js.map +1 -0
- package/dist/adapters/maloca-veeduria/evidentia-veeduria.d.ts +14 -0
- package/dist/adapters/maloca-veeduria/evidentia-veeduria.d.ts.map +1 -0
- package/dist/adapters/maloca-veeduria/evidentia-veeduria.js +40 -0
- package/dist/adapters/maloca-veeduria/evidentia-veeduria.js.map +1 -0
- package/dist/adapters/maloca-veeduria/licitante-profile.d.ts +21 -0
- package/dist/adapters/maloca-veeduria/licitante-profile.d.ts.map +1 -0
- package/dist/adapters/maloca-veeduria/licitante-profile.js +58 -0
- package/dist/adapters/maloca-veeduria/licitante-profile.js.map +1 -0
- package/dist/adapters/maloca-veeduria/types.d.ts +30 -0
- package/dist/adapters/maloca-veeduria/types.d.ts.map +1 -0
- package/dist/adapters/maloca-veeduria/types.js +2 -0
- package/dist/adapters/maloca-veeduria/types.js.map +1 -0
- package/dist/adapters/maloca-xavier/agent-profile.d.ts +32 -0
- package/dist/adapters/maloca-xavier/agent-profile.d.ts.map +1 -0
- package/dist/adapters/maloca-xavier/agent-profile.js +56 -0
- package/dist/adapters/maloca-xavier/agent-profile.js.map +1 -0
- package/dist/adapters/maloca-xavier/codegraph-bridge.d.ts +27 -0
- package/dist/adapters/maloca-xavier/codegraph-bridge.d.ts.map +1 -0
- package/dist/adapters/maloca-xavier/codegraph-bridge.js +58 -0
- package/dist/adapters/maloca-xavier/codegraph-bridge.js.map +1 -0
- package/dist/adapters/maloca-xavier/llm-router.d.ts +25 -0
- package/dist/adapters/maloca-xavier/llm-router.d.ts.map +1 -0
- package/dist/adapters/maloca-xavier/llm-router.js +58 -0
- package/dist/adapters/maloca-xavier/llm-router.js.map +1 -0
- package/dist/authz/index.d.ts +81 -0
- package/dist/authz/index.d.ts.map +1 -0
- package/dist/authz/index.js +271 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/chat/index.d.ts +138 -0
- package/dist/chat/index.d.ts.map +1 -0
- package/dist/chat/index.js +394 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/offline-queue.d.ts +32 -0
- package/dist/chat/offline-queue.d.ts.map +1 -0
- package/dist/chat/offline-queue.js +93 -0
- package/dist/chat/offline-queue.js.map +1 -0
- package/dist/core/DefaultEdgeMeshNode.d.ts +17 -0
- package/dist/core/DefaultEdgeMeshNode.d.ts.map +1 -0
- package/dist/core/DefaultEdgeMeshNode.js +57 -0
- package/dist/core/DefaultEdgeMeshNode.js.map +1 -0
- package/dist/core/node.d.ts +26 -0
- package/dist/core/node.d.ts.map +1 -0
- package/dist/core/node.js +16 -0
- package/dist/core/node.js.map +1 -0
- package/dist/edge-mesh.d.ts +139 -0
- package/dist/edge-mesh.d.ts.map +1 -0
- package/dist/edge-mesh.js +1146 -0
- package/dist/edge-mesh.js.map +1 -0
- package/dist/gos/GosBridge.d.ts +49 -0
- package/dist/gos/GosBridge.d.ts.map +1 -0
- package/dist/gos/GosBridge.js +133 -0
- package/dist/gos/GosBridge.js.map +1 -0
- package/dist/governance/authority.d.ts +44 -0
- package/dist/governance/authority.d.ts.map +1 -0
- package/dist/governance/authority.js +123 -0
- package/dist/governance/authority.js.map +1 -0
- package/dist/governance/index.d.ts +66 -0
- package/dist/governance/index.d.ts.map +1 -0
- package/dist/governance/index.js +210 -0
- package/dist/governance/index.js.map +1 -0
- package/dist/governance/merge.d.ts +31 -0
- package/dist/governance/merge.d.ts.map +1 -0
- package/dist/governance/merge.js +167 -0
- package/dist/governance/merge.js.map +1 -0
- package/dist/identity/index.d.ts +199 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +415 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/maloca/backoffice.d.ts +31 -0
- package/dist/maloca/backoffice.d.ts.map +1 -0
- package/dist/maloca/backoffice.js +40 -0
- package/dist/maloca/backoffice.js.map +1 -0
- package/dist/maloca/event-bus.d.ts +60 -0
- package/dist/maloca/event-bus.d.ts.map +1 -0
- package/dist/maloca/event-bus.js +175 -0
- package/dist/maloca/event-bus.js.map +1 -0
- package/dist/maloca/evidentia.d.ts +136 -0
- package/dist/maloca/evidentia.d.ts.map +1 -0
- package/dist/maloca/evidentia.js +447 -0
- package/dist/maloca/evidentia.js.map +1 -0
- package/dist/maloca/gateway/api.d.ts +146 -0
- package/dist/maloca/gateway/api.d.ts.map +1 -0
- package/dist/maloca/gateway/api.js +228 -0
- package/dist/maloca/gateway/api.js.map +1 -0
- package/dist/maloca/gateway/auth.d.ts +20 -0
- package/dist/maloca/gateway/auth.d.ts.map +1 -0
- package/dist/maloca/gateway/auth.js +115 -0
- package/dist/maloca/gateway/auth.js.map +1 -0
- package/dist/maloca/gateway/dashboard.d.ts +42 -0
- package/dist/maloca/gateway/dashboard.d.ts.map +1 -0
- package/dist/maloca/gateway/dashboard.js +67 -0
- package/dist/maloca/gateway/dashboard.js.map +1 -0
- package/dist/maloca/gateway/websocket.d.ts +34 -0
- package/dist/maloca/gateway/websocket.d.ts.map +1 -0
- package/dist/maloca/gateway/websocket.js +71 -0
- package/dist/maloca/gateway/websocket.js.map +1 -0
- package/dist/maloca/karma.d.ts +165 -0
- package/dist/maloca/karma.d.ts.map +1 -0
- package/dist/maloca/karma.js +307 -0
- package/dist/maloca/karma.js.map +1 -0
- package/dist/maloca/kernel.d.ts +20 -0
- package/dist/maloca/kernel.d.ts.map +1 -0
- package/dist/maloca/kernel.js +76 -0
- package/dist/maloca/kernel.js.map +1 -0
- package/dist/maloca/metadata.d.ts +30 -0
- package/dist/maloca/metadata.d.ts.map +1 -0
- package/dist/maloca/metadata.js +80 -0
- package/dist/maloca/metadata.js.map +1 -0
- package/dist/maloca/perfil.d.ts +57 -0
- package/dist/maloca/perfil.d.ts.map +1 -0
- package/dist/maloca/perfil.js +113 -0
- package/dist/maloca/perfil.js.map +1 -0
- package/dist/maloca/plugin-registry.d.ts +27 -0
- package/dist/maloca/plugin-registry.d.ts.map +1 -0
- package/dist/maloca/plugin-registry.js +76 -0
- package/dist/maloca/plugin-registry.js.map +1 -0
- package/dist/maloca/polygon-bridge.d.ts +63 -0
- package/dist/maloca/polygon-bridge.d.ts.map +1 -0
- package/dist/maloca/polygon-bridge.js +198 -0
- package/dist/maloca/polygon-bridge.js.map +1 -0
- package/dist/maloca/types.d.ts +44 -0
- package/dist/maloca/types.d.ts.map +1 -0
- package/dist/maloca/types.js +2 -0
- package/dist/maloca/types.js.map +1 -0
- package/dist/medico/aval.d.ts +149 -0
- package/dist/medico/aval.d.ts.map +1 -0
- package/dist/medico/aval.js +281 -0
- package/dist/medico/aval.js.map +1 -0
- package/dist/medico/canonica.d.ts +96 -0
- package/dist/medico/canonica.d.ts.map +1 -0
- package/dist/medico/canonica.js +148 -0
- package/dist/medico/canonica.js.map +1 -0
- package/dist/medico/credencial.d.ts +160 -0
- package/dist/medico/credencial.d.ts.map +1 -0
- package/dist/medico/credencial.js +240 -0
- package/dist/medico/credencial.js.map +1 -0
- package/dist/medico/index.d.ts +36 -0
- package/dist/medico/index.d.ts.map +1 -0
- package/dist/medico/index.js +39 -0
- package/dist/medico/index.js.map +1 -0
- package/dist/medico/malla.d.ts +218 -0
- package/dist/medico/malla.d.ts.map +1 -0
- package/dist/medico/malla.js +347 -0
- package/dist/medico/malla.js.map +1 -0
- package/dist/medico/registro-profesional.d.ts +84 -0
- package/dist/medico/registro-profesional.d.ts.map +1 -0
- package/dist/medico/registro-profesional.js +135 -0
- package/dist/medico/registro-profesional.js.map +1 -0
- package/dist/medico/registro.d.ts +149 -0
- package/dist/medico/registro.d.ts.map +1 -0
- package/dist/medico/registro.js +173 -0
- package/dist/medico/registro.js.map +1 -0
- package/dist/medico/types.d.ts +287 -0
- package/dist/medico/types.d.ts.map +1 -0
- package/dist/medico/types.js +135 -0
- package/dist/medico/types.js.map +1 -0
- package/dist/medico/verificacion-registro.d.ts +300 -0
- package/dist/medico/verificacion-registro.d.ts.map +1 -0
- package/dist/medico/verificacion-registro.js +319 -0
- package/dist/medico/verificacion-registro.js.map +1 -0
- package/dist/mesh/index.d.ts +457 -0
- package/dist/mesh/index.d.ts.map +1 -0
- package/dist/mesh/index.js +929 -0
- package/dist/mesh/index.js.map +1 -0
- package/dist/namespaces/encrypted-plugin.d.ts +56 -0
- package/dist/namespaces/encrypted-plugin.d.ts.map +1 -0
- package/dist/namespaces/encrypted-plugin.js +173 -0
- package/dist/namespaces/encrypted-plugin.js.map +1 -0
- package/dist/namespaces/index.d.ts +52 -0
- package/dist/namespaces/index.d.ts.map +1 -0
- package/dist/namespaces/index.js +134 -0
- package/dist/namespaces/index.js.map +1 -0
- package/dist/namespaces/ivn-proofs.d.ts +54 -0
- package/dist/namespaces/ivn-proofs.d.ts.map +1 -0
- package/dist/namespaces/ivn-proofs.js +162 -0
- package/dist/namespaces/ivn-proofs.js.map +1 -0
- package/dist/namespaces/offers-gossip.d.ts +95 -0
- package/dist/namespaces/offers-gossip.d.ts.map +1 -0
- package/dist/namespaces/offers-gossip.js +213 -0
- package/dist/namespaces/offers-gossip.js.map +1 -0
- package/dist/node-memory/idb-store.d.ts +22 -0
- package/dist/node-memory/idb-store.d.ts.map +1 -0
- package/dist/node-memory/idb-store.js +154 -0
- package/dist/node-memory/idb-store.js.map +1 -0
- package/dist/node-memory/index.d.ts +14 -0
- package/dist/node-memory/index.d.ts.map +1 -0
- package/dist/node-memory/index.js +159 -0
- package/dist/node-memory/index.js.map +1 -0
- package/dist/node-memory/sync-flush.d.ts +11 -0
- package/dist/node-memory/sync-flush.d.ts.map +1 -0
- package/dist/node-memory/sync-flush.js +37 -0
- package/dist/node-memory/sync-flush.js.map +1 -0
- package/dist/node-memory/types.d.ts +34 -0
- package/dist/node-memory/types.d.ts.map +1 -0
- package/dist/node-memory/types.js +2 -0
- package/dist/node-memory/types.js.map +1 -0
- package/dist/node-memory/xavier-store.d.ts +12 -0
- package/dist/node-memory/xavier-store.d.ts.map +1 -0
- package/dist/node-memory/xavier-store.js +75 -0
- package/dist/node-memory/xavier-store.js.map +1 -0
- package/dist/op-log/index.d.ts +49 -0
- package/dist/op-log/index.d.ts.map +1 -0
- package/dist/op-log/index.js +190 -0
- package/dist/op-log/index.js.map +1 -0
- package/dist/presence/health.d.ts +54 -0
- package/dist/presence/health.d.ts.map +1 -0
- package/dist/presence/health.js +139 -0
- package/dist/presence/health.js.map +1 -0
- package/dist/presence/index.d.ts +85 -0
- package/dist/presence/index.d.ts.map +1 -0
- package/dist/presence/index.js +255 -0
- package/dist/presence/index.js.map +1 -0
- package/dist/presence/peer-health.d.ts +40 -0
- package/dist/presence/peer-health.d.ts.map +1 -0
- package/dist/presence/peer-health.js +118 -0
- package/dist/presence/peer-health.js.map +1 -0
- package/dist/protocol/canonical.d.ts +10 -0
- package/dist/protocol/canonical.d.ts.map +1 -0
- package/dist/protocol/canonical.js +32 -0
- package/dist/protocol/canonical.js.map +1 -0
- package/dist/protocol/crypto.d.ts +299 -0
- package/dist/protocol/crypto.d.ts.map +1 -0
- package/dist/protocol/crypto.js +739 -0
- package/dist/protocol/crypto.js.map +1 -0
- package/dist/protocol/index.d.ts +30 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +116 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/utils.d.ts +5 -0
- package/dist/protocol/utils.d.ts.map +1 -0
- package/dist/protocol/utils.js +27 -0
- package/dist/protocol/utils.js.map +1 -0
- package/dist/salones/manager.d.ts +100 -0
- package/dist/salones/manager.d.ts.map +1 -0
- package/dist/salones/manager.js +240 -0
- package/dist/salones/manager.js.map +1 -0
- package/dist/security/rate-limiter.d.ts +13 -0
- package/dist/security/rate-limiter.d.ts.map +1 -0
- package/dist/security/rate-limiter.js +32 -0
- package/dist/security/rate-limiter.js.map +1 -0
- package/dist/snapshot/index.d.ts +90 -0
- package/dist/snapshot/index.d.ts.map +1 -0
- package/dist/snapshot/index.js +189 -0
- package/dist/snapshot/index.js.map +1 -0
- package/dist/storage/index.d.ts +49 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +176 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/optimizer.d.ts +28 -0
- package/dist/storage/optimizer.d.ts.map +1 -0
- package/dist/storage/optimizer.js +50 -0
- package/dist/storage/optimizer.js.map +1 -0
- package/dist/storage/ydoc-persistence.d.ts +27 -0
- package/dist/storage/ydoc-persistence.d.ts.map +1 -0
- package/dist/storage/ydoc-persistence.js +128 -0
- package/dist/storage/ydoc-persistence.js.map +1 -0
- package/dist/sync/ChunkSyncManager.d.ts +41 -0
- package/dist/sync/ChunkSyncManager.d.ts.map +1 -0
- package/dist/sync/ChunkSyncManager.js +115 -0
- package/dist/sync/ChunkSyncManager.js.map +1 -0
- package/dist/sync/conflict-resolver.d.ts +33 -0
- package/dist/sync/conflict-resolver.d.ts.map +1 -0
- package/dist/sync/conflict-resolver.js +54 -0
- package/dist/sync/conflict-resolver.js.map +1 -0
- package/dist/sync/engine.d.ts +168 -0
- package/dist/sync/engine.d.ts.map +1 -0
- package/dist/sync/engine.js +315 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/index.d.ts +3 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +3 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/transport/TorTransportAdapter.d.ts +70 -0
- package/dist/transport/TorTransportAdapter.d.ts.map +1 -0
- package/dist/transport/TorTransportAdapter.js +192 -0
- package/dist/transport/TorTransportAdapter.js.map +1 -0
- package/dist/transport/memory.d.ts +31 -0
- package/dist/transport/memory.d.ts.map +1 -0
- package/dist/transport/memory.js +114 -0
- package/dist/transport/memory.js.map +1 -0
- package/dist/transport/peerjs.d.ts +45 -0
- package/dist/transport/peerjs.d.ts.map +1 -0
- package/dist/transport/peerjs.js +301 -0
- package/dist/transport/peerjs.js.map +1 -0
- package/dist/transport/pqc-handshake.d.ts +72 -0
- package/dist/transport/pqc-handshake.d.ts.map +1 -0
- package/dist/transport/pqc-handshake.js +129 -0
- package/dist/transport/pqc-handshake.js.map +1 -0
- package/dist/transport/relay-config.d.ts +11 -0
- package/dist/transport/relay-config.d.ts.map +1 -0
- package/dist/transport/relay-config.js +48 -0
- package/dist/transport/relay-config.js.map +1 -0
- package/dist/transport/relay-server.d.ts +37 -0
- package/dist/transport/relay-server.d.ts.map +1 -0
- package/dist/transport/relay-server.js +230 -0
- package/dist/transport/relay-server.js.map +1 -0
- package/dist/transport/tor.d.ts +47 -0
- package/dist/transport/tor.d.ts.map +1 -0
- package/dist/transport/tor.js +158 -0
- package/dist/transport/tor.js.map +1 -0
- package/dist/transport/types.d.ts +34 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +2 -0
- package/dist/transport/types.js.map +1 -0
- package/dist/types/index.d.ts +293 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +73 -0
- package/dist/types/index.js.map +1 -0
- package/dist/web/admission.d.ts +24 -0
- package/dist/web/admission.d.ts.map +1 -0
- package/dist/web/admission.js +41 -0
- package/dist/web/admission.js.map +1 -0
- package/dist/web/crypto.d.ts +18 -0
- package/dist/web/crypto.d.ts.map +1 -0
- package/dist/web/crypto.js +78 -0
- package/dist/web/crypto.js.map +1 -0
- package/dist/web/exchange.d.ts +23 -0
- package/dist/web/exchange.d.ts.map +1 -0
- package/dist/web/exchange.js +54 -0
- package/dist/web/exchange.js.map +1 -0
- package/dist/web/fragment.d.ts +58 -0
- package/dist/web/fragment.d.ts.map +1 -0
- package/dist/web/fragment.js +198 -0
- package/dist/web/fragment.js.map +1 -0
- package/dist/web/index.d.ts +25 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +14 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/merge/index.d.ts +104 -0
- package/dist/web/merge/index.d.ts.map +1 -0
- package/dist/web/merge/index.js +236 -0
- package/dist/web/merge/index.js.map +1 -0
- package/dist/web/oplog/hlc.d.ts +19 -0
- package/dist/web/oplog/hlc.d.ts.map +1 -0
- package/dist/web/oplog/hlc.js +39 -0
- package/dist/web/oplog/hlc.js.map +1 -0
- package/dist/web/oplog/index.d.ts +10 -0
- package/dist/web/oplog/index.d.ts.map +1 -0
- package/dist/web/oplog/index.js +6 -0
- package/dist/web/oplog/index.js.map +1 -0
- package/dist/web/oplog/log.d.ts +182 -0
- package/dist/web/oplog/log.d.ts.map +1 -0
- package/dist/web/oplog/log.js +693 -0
- package/dist/web/oplog/log.js.map +1 -0
- package/dist/web/oplog/store.d.ts +40 -0
- package/dist/web/oplog/store.d.ts.map +1 -0
- package/dist/web/oplog/store.js +85 -0
- package/dist/web/oplog/store.js.map +1 -0
- package/dist/web/oplog/sync.d.ts +54 -0
- package/dist/web/oplog/sync.d.ts.map +1 -0
- package/dist/web/oplog/sync.js +162 -0
- package/dist/web/oplog/sync.js.map +1 -0
- package/dist/web/oplog/types.d.ts +119 -0
- package/dist/web/oplog/types.d.ts.map +1 -0
- package/dist/web/oplog/types.js +2 -0
- package/dist/web/oplog/types.js.map +1 -0
- package/dist/web/pairing.d.ts +186 -0
- package/dist/web/pairing.d.ts.map +1 -0
- package/dist/web/pairing.js +395 -0
- package/dist/web/pairing.js.map +1 -0
- package/dist/web/pq.d.ts +36 -0
- package/dist/web/pq.d.ts.map +1 -0
- package/dist/web/pq.js +59 -0
- package/dist/web/pq.js.map +1 -0
- package/dist/web/provider.d.ts +188 -0
- package/dist/web/provider.d.ts.map +1 -0
- package/dist/web/provider.js +2042 -0
- package/dist/web/provider.js.map +1 -0
- package/dist/web/rooms.d.ts +20 -0
- package/dist/web/rooms.d.ts.map +1 -0
- package/dist/web/rooms.js +51 -0
- package/dist/web/rooms.js.map +1 -0
- package/dist/web/rotation.d.ts +73 -0
- package/dist/web/rotation.d.ts.map +1 -0
- package/dist/web/rotation.js +130 -0
- package/dist/web/rotation.js.map +1 -0
- package/dist/web/secstate.d.ts +213 -0
- package/dist/web/secstate.d.ts.map +1 -0
- package/dist/web/secstate.js +789 -0
- package/dist/web/secstate.js.map +1 -0
- package/dist/web/store.d.ts +9 -0
- package/dist/web/store.d.ts.map +1 -0
- package/dist/web/store.js +32 -0
- package/dist/web/store.js.map +1 -0
- package/dist/web/transports/loopback.d.ts +11 -0
- package/dist/web/transports/loopback.d.ts.map +1 -0
- package/dist/web/transports/loopback.js +97 -0
- package/dist/web/transports/loopback.js.map +1 -0
- package/dist/web/transports/qr-sdp.d.ts +27 -0
- package/dist/web/transports/qr-sdp.d.ts.map +1 -0
- package/dist/web/transports/qr-sdp.js +131 -0
- package/dist/web/transports/qr-sdp.js.map +1 -0
- package/dist/web/transports/ws.d.ts +12 -0
- package/dist/web/transports/ws.d.ts.map +1 -0
- package/dist/web/transports/ws.js +139 -0
- package/dist/web/transports/ws.js.map +1 -0
- package/dist/web/trust/canonical.d.ts +8 -0
- package/dist/web/trust/canonical.d.ts.map +1 -0
- package/dist/web/trust/canonical.js +27 -0
- package/dist/web/trust/canonical.js.map +1 -0
- package/dist/web/trust/docs.d.ts +51 -0
- package/dist/web/trust/docs.d.ts.map +1 -0
- package/dist/web/trust/docs.js +170 -0
- package/dist/web/trust/docs.js.map +1 -0
- package/dist/web/trust/index.d.ts +10 -0
- package/dist/web/trust/index.d.ts.map +1 -0
- package/dist/web/trust/index.js +7 -0
- package/dist/web/trust/index.js.map +1 -0
- package/dist/web/trust/keys.d.ts +37 -0
- package/dist/web/trust/keys.d.ts.map +1 -0
- package/dist/web/trust/keys.js +75 -0
- package/dist/web/trust/keys.js.map +1 -0
- package/dist/web/trust/store.d.ts +150 -0
- package/dist/web/trust/store.d.ts.map +1 -0
- package/dist/web/trust/store.js +587 -0
- package/dist/web/trust/store.js.map +1 -0
- package/dist/web/trust/types.d.ts +134 -0
- package/dist/web/trust/types.d.ts.map +1 -0
- package/dist/web/trust/types.js +4 -0
- package/dist/web/trust/types.js.map +1 -0
- package/dist/web/types.d.ts +115 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +2 -0
- package/dist/web/types.js.map +1 -0
- package/dist/web/util.d.ts +12 -0
- package/dist/web/util.d.ts.map +1 -0
- package/dist/web/util.js +54 -0
- package/dist/web/util.js.map +1 -0
- package/dist/web/webrtc.d.ts +35 -0
- package/dist/web/webrtc.d.ts.map +1 -0
- package/dist/web/webrtc.js +304 -0
- package/dist/web/webrtc.js.map +1 -0
- package/package.json +138 -4
|
@@ -0,0 +1,2042 @@
|
|
|
1
|
+
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates, } from "y-protocols/awareness";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { canIssue, idMatchesPub, isDeviceId, isEpoch, MAX_EPOCH, MAX_EPOCH_SKIP, } from "./admission.js";
|
|
4
|
+
import { deriveDocMaterial, deriveSenderKey, hkdf, importAesKey, openUpdate, sealUpdate, } from "./crypto.js";
|
|
5
|
+
import { DEFAULT_MAX_FRAME, DEFAULT_MAX_MESSAGE, F_FRAG, fragment, Reassembler, } from "./fragment.js";
|
|
6
|
+
import { createPairOffer, decodePairPayload, derivePairKey, GuestPairing, HostPairing, hostProofBytes, } from "./pairing.js";
|
|
7
|
+
import { identityVerify, kemKeygen, ML_DSA_PUBLIC_KEY_BYTES, ML_KEM_PUBLIC_KEY_BYTES, ML_KEM_SECRET_KEY_BYTES, } from "./pq.js";
|
|
8
|
+
import { derivePairRoomId, deriveRoomId, fingerprint, meshNamespace, } from "./rooms.js";
|
|
9
|
+
import { generateEcdhIdentity, MAX_ROT_MEMBERS, MAX_ROT_REFS, rotationId, rotationPreId, rotationSigBytes, unwrapMeshKey, wrapMeshKey, wrapsHash, } from "./rotation.js";
|
|
10
|
+
import { MAX_MEMBERS_PER_ADMIN, SecurityState, vaultSigner, } from "./secstate.js";
|
|
11
|
+
import { idbStore, memoryStore } from "./store.js";
|
|
12
|
+
import { b64uDecode, b64uEncode, concat, equalBytes, fromUtf8, randomBytes, utf8, } from "./util.js";
|
|
13
|
+
import { connectViaSignaling } from "./webrtc.js";
|
|
14
|
+
const F_DATA = 1; // legacy unsigned data frame (only with signFrames:false)
|
|
15
|
+
const F_PAIR = 2;
|
|
16
|
+
const F_SDATA = 3; // signed data frame: plaintext = kind | sess(8) | seq(u32) | sigLen(u16) | sig | body
|
|
17
|
+
const K_SV = 0;
|
|
18
|
+
const K_UPDATE = 1;
|
|
19
|
+
const K_AWARENESS = 2;
|
|
20
|
+
// kind 3 (pre-round-5 rotation frames) is retired: such frames are ignored
|
|
21
|
+
const K_CHANNEL = 4; // body = nsLen(u16) | namespace | payload
|
|
22
|
+
const K_HELLO = 5; // body = nonce(16): fresh challenge of this link (S1)
|
|
23
|
+
const K_AUTH = 6; // body = peer's nonce(16) | epoch(u32), signed: the sender is live on THIS link (S1)
|
|
24
|
+
/** round 5: security documents (JSON {t:"inv",ids} | {t:"want",ids} | {t:"docs",docs}), also under retired keys */
|
|
25
|
+
const K_TRUST = 7;
|
|
26
|
+
const NONCE_BYTES = 16;
|
|
27
|
+
/** finding 4: bytes of bulk data waiting for one link beyond the message in progress (then the link is closed) */
|
|
28
|
+
const MAX_OUTSTANDING = 16 * 1024 * 1024;
|
|
29
|
+
/** SF4: per link, at most this many challenges sent and answered */
|
|
30
|
+
const HANDSHAKE_MAX = 8;
|
|
31
|
+
/** frames kept per link while waiting for the peer's K_AUTH (also bounded by PRE_AUTH_MAX bytes) */
|
|
32
|
+
const EARLY_MAX_FRAMES = 32;
|
|
33
|
+
/**
|
|
34
|
+
* R4-S1: signature checks a link may cause before it authenticates (handshake frames only; a legit peer needs at most
|
|
35
|
+
* HANDSHAKE_MAX challenges under up to 1 + RETIRED_TRY keys plus HANDSHAKE_MAX answers = 48). Beyond: the link closes.
|
|
36
|
+
*/
|
|
37
|
+
const HS_VERIFY_MAX = 64;
|
|
38
|
+
/**
|
|
39
|
+
* R5-S2: the same limits across links. Handshake frames already verified on ANY link are dropped unverified (a global
|
|
40
|
+
* window), one claimed identity may cost at most HS_VERIFY_PER_ID checks per minute over all links, a room holds at
|
|
41
|
+
* most MAX_UNAUTH_PER_ROOM links that have not authenticated (MAX_UNAUTH_PER_PEER per announced peer), frames kept
|
|
42
|
+
* before a peer's K_AUTH share EARLY_TOTAL bytes, and in a retired room a sender is answered at most
|
|
43
|
+
* LEGACY_ANSWERS_PER_MIN times per minute (and never challenged again: stragglers come back by themselves).
|
|
44
|
+
*/
|
|
45
|
+
const HS_VERIFY_PER_ID = 128;
|
|
46
|
+
const MAX_UNAUTH_PER_ROOM = 32;
|
|
47
|
+
const MAX_UNAUTH_PER_PEER = 2;
|
|
48
|
+
const EARLY_TOTAL = 4 * 1024 * 1024;
|
|
49
|
+
const LEGACY_ANSWERS_PER_MIN = 2;
|
|
50
|
+
/** Replay window per sender session: seqs at most this far behind the highest one are still accepted once. */
|
|
51
|
+
const REPLAY_WINDOW = 1024;
|
|
52
|
+
const ORIGIN = Symbol("swal-mesh");
|
|
53
|
+
/** round 5: trust-channel limits (per message / per peer) */
|
|
54
|
+
const TRUST_INV_MAX = 20_000;
|
|
55
|
+
const TRUST_WANT_MAX = 2048;
|
|
56
|
+
const TRUST_DOCS_MAX = 256;
|
|
57
|
+
const TRUST_BATCH_BYTES = 512 * 1024;
|
|
58
|
+
/** documents served to one peer per minute, and failed documents accepted from one sender per minute */
|
|
59
|
+
const TRUST_SERVE_PER_MIN = 8192;
|
|
60
|
+
const TRUST_FAIL_PER_MIN = 64;
|
|
61
|
+
/** R4-S2: bounded caches drop their least recently used entry instead of being cleared all at once. */
|
|
62
|
+
class Lru {
|
|
63
|
+
cap;
|
|
64
|
+
m = new Map();
|
|
65
|
+
constructor(cap) {
|
|
66
|
+
this.cap = cap;
|
|
67
|
+
}
|
|
68
|
+
get(k) {
|
|
69
|
+
const v = this.m.get(k);
|
|
70
|
+
if (v !== undefined) {
|
|
71
|
+
this.m.delete(k);
|
|
72
|
+
this.m.set(k, v);
|
|
73
|
+
}
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
has(k) {
|
|
77
|
+
return this.get(k) !== undefined;
|
|
78
|
+
}
|
|
79
|
+
set(k, v) {
|
|
80
|
+
this.m.delete(k);
|
|
81
|
+
this.m.set(k, v);
|
|
82
|
+
if (this.m.size > this.cap)
|
|
83
|
+
this.m.delete(this.m.keys().next().value);
|
|
84
|
+
}
|
|
85
|
+
clear() {
|
|
86
|
+
this.m.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** SF5: retired keys this device itself held, kept in its LOCAL store (never read from the shared doc) */
|
|
90
|
+
const RETIRED_KEY = "retired";
|
|
91
|
+
const MAX_RETIRED = 16;
|
|
92
|
+
/** Retired keys tried on (and used to seal) rotation frames, so devices on another branch/epoch still get them. */
|
|
93
|
+
const RETIRED_TRY = 4;
|
|
94
|
+
const MAX_CANDIDATES = 16;
|
|
95
|
+
/** Total order of rotations (B4): higher epoch first, then the lower rotation id. Deterministic on every device. */
|
|
96
|
+
const betterRot = (a, b) => (a.epoch !== b.epoch ? a.epoch > b.epoch : a.id < b.id);
|
|
97
|
+
const HOLD_MAX_FRAMES = 64;
|
|
98
|
+
const HOLD_MAX_BYTES = 8 * 1024 * 1024;
|
|
99
|
+
/** S5: held frames of ALL links together */
|
|
100
|
+
const HOLD_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
101
|
+
/** S5: largest message reassembled on a link before it is authenticated, and for all such links together */
|
|
102
|
+
const PRE_AUTH_MAX = 1024 * 1024;
|
|
103
|
+
const PRE_AUTH_TOTAL = 8 * 1024 * 1024;
|
|
104
|
+
/** S5: largest pairing message (JSON); the grant no longer carries the doc, which arrives by normal sync */
|
|
105
|
+
const PAIR_MAX = 256 * 1024;
|
|
106
|
+
const HOLD_MS = 30_000;
|
|
107
|
+
const u32 = (n) => {
|
|
108
|
+
const b = new Uint8Array(4);
|
|
109
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, false);
|
|
110
|
+
return b;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* What a device signs for a data frame: bound to the room (mesh key + epoch), the sender, the kind and a per-sender
|
|
114
|
+
* session + sequence number (S1: receivers drop duplicates and replays).
|
|
115
|
+
*/
|
|
116
|
+
const frameSigBytes = (rid, sender, kind, sess, seq, body) => concat(utf8(`swal-frame/v2|${rid}|${sender}|`), new Uint8Array([kind]), sess, u32(seq), body);
|
|
117
|
+
export function createMesh(opts) {
|
|
118
|
+
const { appId, topic: topicName, doc, vault } = opts;
|
|
119
|
+
const transports = opts.signaling ?? [];
|
|
120
|
+
const now = opts.now ?? (() => Date.now());
|
|
121
|
+
const awareness = new Awareness(doc);
|
|
122
|
+
const listeners = new Map();
|
|
123
|
+
const emit = (e, d) => {
|
|
124
|
+
for (const cb of listeners.get(e) ?? []) {
|
|
125
|
+
try {
|
|
126
|
+
cb(d);
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
let destroyed = false;
|
|
132
|
+
let running = false;
|
|
133
|
+
let meshKey = null;
|
|
134
|
+
let docMat = null;
|
|
135
|
+
let sigKey = null;
|
|
136
|
+
let dataRid = "";
|
|
137
|
+
let instanceId = opts.instance ?? "";
|
|
138
|
+
let epoch = 0;
|
|
139
|
+
const localNum = (x) => (isEpoch(x) ? x : 0); // SF1: an out-of-range local value is ignored
|
|
140
|
+
let status = "off";
|
|
141
|
+
const links = new Set();
|
|
142
|
+
// per-sender AES-GCM keys, cached PER KEY MATERIAL (never by epoch number: two meshes, or two concurrent
|
|
143
|
+
// rotations, can share an epoch number). Reset by loadKeys (B3).
|
|
144
|
+
let senderKeys = new WeakMap();
|
|
145
|
+
const store = opts.store ??
|
|
146
|
+
vault.store ??
|
|
147
|
+
(opts.persist === "idb" && typeof indexedDB !== "undefined"
|
|
148
|
+
? idbStore(`swal-mesh-local/${appId}/${topicName}`)
|
|
149
|
+
: memoryStore());
|
|
150
|
+
let root = null;
|
|
151
|
+
/** round 5: the security state (signed documents, web/trust membership); null until a root is pinned */
|
|
152
|
+
let sec = null;
|
|
153
|
+
const selfPub = b64uEncode(vault.devicePublicKey);
|
|
154
|
+
const signer = vaultSigner(vault, selfPub);
|
|
155
|
+
let trusted = new Map(); // members other than this one (and the owner), from the security state
|
|
156
|
+
const legacy = new Map(); // retired data rid -> its epoch material
|
|
157
|
+
let curRot = null; // rotation that produced the current key (null: epoch 0 / unknown)
|
|
158
|
+
let curRotId = null; // its id, kept even before the document itself has arrived (pairing grant)
|
|
159
|
+
const cands = new Map(); // verified rotations not adopted (yet)
|
|
160
|
+
const unwrapFailed = new Set(); // rotations whose wrap for us did not open (reported once)
|
|
161
|
+
let rotChain = Promise.resolve();
|
|
162
|
+
/** Every key change (own rotation, adoption, conflict resolution) runs one at a time. */
|
|
163
|
+
function serialRot(f) {
|
|
164
|
+
const run = rotChain.then(f);
|
|
165
|
+
rotChain = run.catch(() => { });
|
|
166
|
+
return run;
|
|
167
|
+
}
|
|
168
|
+
let ecdhId = null;
|
|
169
|
+
let kemId = null;
|
|
170
|
+
const rooms = new Map(); // rid -> leave
|
|
171
|
+
const linkSubs = [];
|
|
172
|
+
let hostSession = null;
|
|
173
|
+
let guestSession = null;
|
|
174
|
+
let pairRids = new Set();
|
|
175
|
+
const maxFrame = opts.maxFrameBytes ?? DEFAULT_MAX_FRAME;
|
|
176
|
+
const signFrames = opts.signFrames !== false;
|
|
177
|
+
const peerIds = () => [
|
|
178
|
+
...new Set([...links]
|
|
179
|
+
.filter((l) => l.deviceId &&
|
|
180
|
+
l.rid !== "pair" &&
|
|
181
|
+
!l.legacy &&
|
|
182
|
+
!l.closing &&
|
|
183
|
+
!isRevoked(l.deviceId))
|
|
184
|
+
.map((l) => l.deviceId)),
|
|
185
|
+
];
|
|
186
|
+
const setStatus = () => {
|
|
187
|
+
const s = !running
|
|
188
|
+
? "off"
|
|
189
|
+
: peerIds().length > 0
|
|
190
|
+
? "online"
|
|
191
|
+
: "connecting";
|
|
192
|
+
if (s !== status) {
|
|
193
|
+
status = s;
|
|
194
|
+
emit("status", s);
|
|
195
|
+
}
|
|
196
|
+
emit("peers", peerIds());
|
|
197
|
+
};
|
|
198
|
+
const err = (e) => emit("error", e);
|
|
199
|
+
// ---- devices: members by signed web/trust grants (round 5). Nothing in the shared doc counts. ----
|
|
200
|
+
const selfRole = () => root?.deviceId === vault.deviceId
|
|
201
|
+
? "owner"
|
|
202
|
+
: (sec?.roleOf(vault.deviceId) ?? null);
|
|
203
|
+
const selfDevice = () => {
|
|
204
|
+
const g = sec?.memberGrant(vault.deviceId);
|
|
205
|
+
return {
|
|
206
|
+
deviceId: vault.deviceId,
|
|
207
|
+
pub: selfPub,
|
|
208
|
+
name: g?.name ?? opts.deviceName ?? "device",
|
|
209
|
+
addedAt: g?.issuedAt ?? 0,
|
|
210
|
+
...(selfRole() ? { role: selfRole() } : {}),
|
|
211
|
+
...(g ? { admittedBy: g.issuer } : {}),
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
/** A device that had a grant and has none left that makes it a member (revoked, or cut by the owner). */
|
|
215
|
+
const isRevoked = (id) => sec?.isOut(id) ?? false;
|
|
216
|
+
// a device revoked a moment ago is never listed, even before the trust state is recomputed
|
|
217
|
+
const devices = () => [
|
|
218
|
+
selfDevice(),
|
|
219
|
+
...[...trusted.values()].filter((d) => !isRevoked(d.deviceId)),
|
|
220
|
+
].sort((a, b) => a.addedAt - b.addedAt);
|
|
221
|
+
const roleOf = (id) => id === vault.deviceId
|
|
222
|
+
? selfRole()
|
|
223
|
+
: isRevoked(id)
|
|
224
|
+
? null
|
|
225
|
+
: (trusted.get(id)?.role ?? null);
|
|
226
|
+
let trustChain = Promise.resolve();
|
|
227
|
+
/**
|
|
228
|
+
* R4-S2: one trust pass at most waits behind the running one. Every change of the security state asks for a pass;
|
|
229
|
+
* those that arrive while one is queued share it (it starts after them, so it sees their documents).
|
|
230
|
+
*/
|
|
231
|
+
let trustQueued = null;
|
|
232
|
+
const refreshTrust = () => {
|
|
233
|
+
if (trustQueued)
|
|
234
|
+
return trustQueued;
|
|
235
|
+
const run = trustChain.then(() => {
|
|
236
|
+
trustQueued = null;
|
|
237
|
+
return computeTrust();
|
|
238
|
+
});
|
|
239
|
+
trustQueued = run.catch(err);
|
|
240
|
+
trustChain = trustQueued;
|
|
241
|
+
return trustQueued;
|
|
242
|
+
};
|
|
243
|
+
async function computeTrust() {
|
|
244
|
+
const next = new Map();
|
|
245
|
+
if (sec && root) {
|
|
246
|
+
if (root.deviceId !== vault.deviceId)
|
|
247
|
+
next.set(root.deviceId, {
|
|
248
|
+
deviceId: root.deviceId,
|
|
249
|
+
pub: root.pub,
|
|
250
|
+
name: ownerName ?? "owner",
|
|
251
|
+
addedAt: 0,
|
|
252
|
+
role: "owner",
|
|
253
|
+
});
|
|
254
|
+
for (const m of sec.members()) {
|
|
255
|
+
if (m.deviceId === vault.deviceId)
|
|
256
|
+
continue;
|
|
257
|
+
if (opts.authorizeDevice &&
|
|
258
|
+
!(await opts.authorizeDevice(m.deviceId, b64uDecode(m.pub))))
|
|
259
|
+
continue;
|
|
260
|
+
next.set(m.deviceId, {
|
|
261
|
+
deviceId: m.deviceId,
|
|
262
|
+
pub: m.pub,
|
|
263
|
+
name: m.grant.name || m.deviceId,
|
|
264
|
+
addedAt: m.grant.issuedAt,
|
|
265
|
+
role: m.role,
|
|
266
|
+
admittedBy: m.grant.issuer,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
trusted = next;
|
|
271
|
+
for (const l of [...links])
|
|
272
|
+
if (l.deviceId && isRevoked(l.deviceId))
|
|
273
|
+
closeRec(l);
|
|
274
|
+
emit("devices", devices());
|
|
275
|
+
replayHeld();
|
|
276
|
+
if (sec && running) {
|
|
277
|
+
void serialRot(considerRotations).catch(err);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ---- persistence ----
|
|
281
|
+
let persistence = null;
|
|
282
|
+
let ownerName = null;
|
|
283
|
+
/** Open (or switch to) the security state of the pinned root, and follow its changes. */
|
|
284
|
+
let secUnsub = null;
|
|
285
|
+
async function openSecurity(r) {
|
|
286
|
+
secUnsub?.();
|
|
287
|
+
sec = await SecurityState.open({
|
|
288
|
+
root: r,
|
|
289
|
+
store,
|
|
290
|
+
schema: opts.trustSchema,
|
|
291
|
+
now,
|
|
292
|
+
});
|
|
293
|
+
secUnsub = sec.onChange(() => void refreshTrust());
|
|
294
|
+
}
|
|
295
|
+
const ready = (async () => {
|
|
296
|
+
// B1: a deviceId IS the fingerprint of the identity key; every peer enforces it, so must we
|
|
297
|
+
if (vault.devicePublicKey.length !== ML_DSA_PUBLIC_KEY_BYTES)
|
|
298
|
+
throw new Error("vault.devicePublicKey must be an ML-DSA-65 public key (1952 bytes; ECDSA identities are no longer accepted)");
|
|
299
|
+
if (!(await idMatchesPub(vault.deviceId, selfPub))) {
|
|
300
|
+
throw new Error("vault.deviceId must be deviceIdOf(vault.devicePublicKey)");
|
|
301
|
+
}
|
|
302
|
+
const r = (await store.get("root"));
|
|
303
|
+
if (r &&
|
|
304
|
+
typeof r.deviceId === "string" &&
|
|
305
|
+
typeof r.pub === "string" &&
|
|
306
|
+
typeof r.mid === "string")
|
|
307
|
+
root = r;
|
|
308
|
+
const on = await store.get("ownerName");
|
|
309
|
+
if (typeof on === "string")
|
|
310
|
+
ownerName = on;
|
|
311
|
+
const cr = await store.get("rotId");
|
|
312
|
+
if (typeof cr === "string")
|
|
313
|
+
curRotId = cr;
|
|
314
|
+
if (root) {
|
|
315
|
+
await openSecurity(root);
|
|
316
|
+
const c = curRotId ? sec.rotation(curRotId) : undefined;
|
|
317
|
+
if (c)
|
|
318
|
+
curRot = c;
|
|
319
|
+
}
|
|
320
|
+
if (opts.persist === "idb" && typeof indexedDB !== "undefined") {
|
|
321
|
+
const { IndexeddbPersistence } = await import("y-indexeddb");
|
|
322
|
+
const p = new IndexeddbPersistence(`swal-mesh/${appId}/${topicName}`, doc);
|
|
323
|
+
persistence = p;
|
|
324
|
+
await p.whenSynced;
|
|
325
|
+
}
|
|
326
|
+
if (opts.persist === "idb" && typeof indexedDB === "undefined") {
|
|
327
|
+
throw new Error("persist:'idb' requested but IndexedDB is not available");
|
|
328
|
+
}
|
|
329
|
+
if (sec)
|
|
330
|
+
await publishKex();
|
|
331
|
+
await refreshTrust();
|
|
332
|
+
epoch = Math.max(localNum(await vault.getEpoch?.()), localNum(await store.get("epoch")), epoch); // known before start
|
|
333
|
+
// already paired: resume (unless the app is about to move this device to another mesh, see `resume`)
|
|
334
|
+
if (opts.resume !== false && root && trusted.size > 0 && !destroyed)
|
|
335
|
+
await start();
|
|
336
|
+
})();
|
|
337
|
+
ready.catch(err);
|
|
338
|
+
// ---- key-agreement identity (rotation wraps) ----
|
|
339
|
+
async function ecdhIdentity() {
|
|
340
|
+
if (!ecdhId) {
|
|
341
|
+
ecdhId =
|
|
342
|
+
(await vault.getEcdhIdentity?.()) ?? (await generateEcdhIdentity());
|
|
343
|
+
}
|
|
344
|
+
return ecdhId;
|
|
345
|
+
}
|
|
346
|
+
async function kemIdentity() {
|
|
347
|
+
if (!kemId) {
|
|
348
|
+
const k = (await vault.getKemIdentity?.()) ?? kemKeygen();
|
|
349
|
+
if (k.publicKey.length !== ML_KEM_PUBLIC_KEY_BYTES ||
|
|
350
|
+
k.secretKey.length !== ML_KEM_SECRET_KEY_BYTES)
|
|
351
|
+
throw new Error("vault.getKemIdentity must return an ML-KEM-768 key pair");
|
|
352
|
+
kemId = k;
|
|
353
|
+
}
|
|
354
|
+
return kemId;
|
|
355
|
+
}
|
|
356
|
+
/** This device's key-agreement record (a signed document; a new one only when the keys changed). */
|
|
357
|
+
async function publishKex() {
|
|
358
|
+
if (!sec)
|
|
359
|
+
return;
|
|
360
|
+
const ecdh = b64uEncode((await ecdhIdentity()).publicKey);
|
|
361
|
+
const kem = b64uEncode((await kemIdentity()).publicKey);
|
|
362
|
+
const cur = sec.keyAgreement(vault.deviceId);
|
|
363
|
+
if (cur?.ecdh === ecdh && cur.kem === kem)
|
|
364
|
+
return;
|
|
365
|
+
await addLocal([await sec.kexRecord(signer, ecdh, kem)]);
|
|
366
|
+
}
|
|
367
|
+
/** Key-agreement keys of a device, from its signed record (members only use those of members). */
|
|
368
|
+
function kexOf(deviceId) {
|
|
369
|
+
const k = sec?.keyAgreement(deviceId);
|
|
370
|
+
return k ? { pub: b64uDecode(k.ecdh), kem: b64uDecode(k.kem) } : null;
|
|
371
|
+
}
|
|
372
|
+
// ---- keys ----
|
|
373
|
+
function senderKey(material, deviceId) {
|
|
374
|
+
let m = senderKeys.get(material);
|
|
375
|
+
if (!m) {
|
|
376
|
+
m = new Map();
|
|
377
|
+
senderKeys.set(material, m);
|
|
378
|
+
}
|
|
379
|
+
let key = m.get(deviceId);
|
|
380
|
+
if (!key) {
|
|
381
|
+
if (m.size >= 4096)
|
|
382
|
+
m.clear();
|
|
383
|
+
key = deriveSenderKey(material, topicName, deviceId);
|
|
384
|
+
m.set(deviceId, key);
|
|
385
|
+
}
|
|
386
|
+
return key;
|
|
387
|
+
}
|
|
388
|
+
async function persistEpoch(n) {
|
|
389
|
+
await vault.setEpoch?.(n);
|
|
390
|
+
await store.set("epoch", n);
|
|
391
|
+
}
|
|
392
|
+
async function loadKeys(raw) {
|
|
393
|
+
senderKeys = new WeakMap(); // B3: nothing derived from a previous mesh key survives a key change
|
|
394
|
+
meshKey = raw ?? (await vault.getOrCreateMeshKey());
|
|
395
|
+
// B2: the epoch is device-local state (vault / local store), advanced only by a verified rotation or by the
|
|
396
|
+
// authenticated pairing grant. It is NEVER read from the shared doc, which every member can write.
|
|
397
|
+
epoch = Math.max(localNum(await vault.getEpoch?.()), localNum(await store.get("epoch")), epoch);
|
|
398
|
+
docMat = await deriveDocMaterial(meshKey, topicName);
|
|
399
|
+
sigKey = await importAesKey(await hkdf(meshKey, `swal-signal/v1|${topicName}`));
|
|
400
|
+
instanceId =
|
|
401
|
+
opts.instance ?? (root ? await fingerprint(b64uDecode(root.pub)) : "");
|
|
402
|
+
dataRid = await deriveRoomId(meshKey, appId, topicName, epoch, instanceId || undefined);
|
|
403
|
+
if (curRot && curRot.epoch !== epoch)
|
|
404
|
+
curRot = null;
|
|
405
|
+
}
|
|
406
|
+
// ---- link I/O: per-link ordered queue; messages above maxFrame are fragmented (H5) ----
|
|
407
|
+
const outQ = new WeakMap();
|
|
408
|
+
const outBytes = new WeakMap(); // finding 4: bytes accepted for a link, not handed over yet
|
|
409
|
+
function sendBytes(link, bytes, alive = () => true) {
|
|
410
|
+
const pending = outBytes.get(link) ?? 0;
|
|
411
|
+
// finding 4: a peer that drains far slower than we produce must not make us buffer without bound. A single
|
|
412
|
+
// message of any size (up to maxMessageBytes) may wait alone; beyond MAX_OUTSTANDING of backlog the link is
|
|
413
|
+
// closed (the peer resyncs from state vectors when it reconnects).
|
|
414
|
+
if (pending > 0 && pending + bytes.length > MAX_OUTSTANDING) {
|
|
415
|
+
err(new Error(`link ${link.id}: more than ${MAX_OUTSTANDING} bytes waiting for a slow peer: closing it`));
|
|
416
|
+
for (const r of [...links])
|
|
417
|
+
if (r.link === link)
|
|
418
|
+
closeRec(r);
|
|
419
|
+
try {
|
|
420
|
+
link.close();
|
|
421
|
+
}
|
|
422
|
+
catch { }
|
|
423
|
+
return Promise.resolve();
|
|
424
|
+
}
|
|
425
|
+
outBytes.set(link, pending + bytes.length);
|
|
426
|
+
const next = (outQ.get(link) ?? Promise.resolve())
|
|
427
|
+
.then(async () => {
|
|
428
|
+
try {
|
|
429
|
+
if (!alive())
|
|
430
|
+
return;
|
|
431
|
+
for (const f of await fragment(bytes, maxFrame)) {
|
|
432
|
+
if (link.drain)
|
|
433
|
+
await link.drain(); // BL3: paced by the peer instead of piling up in memory
|
|
434
|
+
if (!alive())
|
|
435
|
+
return;
|
|
436
|
+
link.send(f);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
outBytes.set(link, Math.max(0, (outBytes.get(link) ?? 0) - bytes.length));
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
.catch(err);
|
|
444
|
+
outQ.set(link, next);
|
|
445
|
+
return next;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* finding 4: control frames (rotations, link handshake) never wait behind bulk data nor on drain(): handed to the
|
|
449
|
+
* link at once (`sendPriority` puts them at the next message boundary of the link's own queue when it has one).
|
|
450
|
+
*/
|
|
451
|
+
async function sendPriority(link, bytes) {
|
|
452
|
+
for (const f of await fragment(bytes, maxFrame))
|
|
453
|
+
(link.sendPriority ?? link.send).call(link, f);
|
|
454
|
+
}
|
|
455
|
+
const signCache = new WeakMap();
|
|
456
|
+
let selfSess = randomBytes(8); // this instance's sender session (a restart is a new session)
|
|
457
|
+
let selfSeq = 0;
|
|
458
|
+
function signFor(rid, kind, body) {
|
|
459
|
+
const c = signCache.get(body); // a broadcast signs once (same seq) for all links
|
|
460
|
+
if (c && c.rid === rid && c.kind === kind)
|
|
461
|
+
return c.p;
|
|
462
|
+
if (selfSeq >= 0xffffffff) {
|
|
463
|
+
selfSess = randomBytes(8);
|
|
464
|
+
selfSeq = 0;
|
|
465
|
+
}
|
|
466
|
+
const sess = selfSess;
|
|
467
|
+
const seq = ++selfSeq;
|
|
468
|
+
const p = vault
|
|
469
|
+
.sign(frameSigBytes(rid, vault.deviceId, kind, sess, seq, body))
|
|
470
|
+
.then((sig) => ({ sig, sess, seq }));
|
|
471
|
+
signCache.set(body, { rid, kind, p });
|
|
472
|
+
return p;
|
|
473
|
+
}
|
|
474
|
+
// receiver side of S1: highest seq + recently seen seqs per (sender, session)
|
|
475
|
+
const replay = new Map();
|
|
476
|
+
/** R4-S1: the same test as freshSeq, without recording: run BEFORE the signature check (recorded after it). */
|
|
477
|
+
function seqFresh(sender, sess, seq) {
|
|
478
|
+
const w = replay.get(`${sender}|${sess}`);
|
|
479
|
+
return !w || !(seq + REPLAY_WINDOW <= w.max || w.seen.has(seq));
|
|
480
|
+
}
|
|
481
|
+
function freshSeq(sender, sess, seq) {
|
|
482
|
+
const k = `${sender}|${sess}`;
|
|
483
|
+
let w = replay.get(k);
|
|
484
|
+
if (!w) {
|
|
485
|
+
if (replay.size >= 4096)
|
|
486
|
+
replay.delete(replay.keys().next().value);
|
|
487
|
+
w = { max: 0, seen: new Set() };
|
|
488
|
+
replay.set(k, w);
|
|
489
|
+
}
|
|
490
|
+
if (seq + REPLAY_WINDOW <= w.max || w.seen.has(seq))
|
|
491
|
+
return false;
|
|
492
|
+
w.seen.add(seq);
|
|
493
|
+
if (seq > w.max)
|
|
494
|
+
w.max = seq;
|
|
495
|
+
if (w.seen.size > 2 * REPLAY_WINDOW)
|
|
496
|
+
for (const x of w.seen)
|
|
497
|
+
if (x + REPLAY_WINDOW <= w.max)
|
|
498
|
+
w.seen.delete(x);
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
function sendFrame(rec, kind, body) {
|
|
502
|
+
const lg = rec.legacy;
|
|
503
|
+
return sendFrameWith(rec, kind, body, lg ? lg.material : docMat, lg ? lg.rid : dataRid);
|
|
504
|
+
}
|
|
505
|
+
/** Recent retired keys (newest first): rotation frames are also sealed under them, and receivers try them. */
|
|
506
|
+
const retiredKeys = (n = RETIRED_TRY) => [...legacy.values()].sort((x, y) => y.epoch - x.epoch).slice(0, n);
|
|
507
|
+
/**
|
|
508
|
+
* A rotation must reach peers still on the previous key or on a concurrent branch: its security-document frame is
|
|
509
|
+
* sealed under the current key, the recent retired ones and the key the peer is known to hold.
|
|
510
|
+
*/
|
|
511
|
+
async function sendTrustEverywhere(rec, body) {
|
|
512
|
+
if (rec.legacy)
|
|
513
|
+
return sendFrameWith(rec, K_TRUST, body, rec.legacy.material, rec.legacy.rid, true);
|
|
514
|
+
await sendFrameWith(rec, K_TRUST, body, docMat, dataRid, true);
|
|
515
|
+
const recent = retiredKeys();
|
|
516
|
+
for (const r of recent)
|
|
517
|
+
await sendFrameWith(rec, K_TRUST, body, r.material, r.rid, true);
|
|
518
|
+
const c = rec.common;
|
|
519
|
+
if (c && c.rid !== dataRid && !recent.some((r) => r.rid === c.rid))
|
|
520
|
+
await sendFrameWith(rec, K_TRUST, body, c.material, c.rid, true);
|
|
521
|
+
}
|
|
522
|
+
async function sendFrameWith(rec, kind, body, mat, rid, priority = false) {
|
|
523
|
+
if (!mat || rec.closing)
|
|
524
|
+
return;
|
|
525
|
+
const key = await senderKey(mat, vault.deviceId);
|
|
526
|
+
const id = utf8(vault.deviceId);
|
|
527
|
+
let inner;
|
|
528
|
+
if (signFrames) {
|
|
529
|
+
const { sig, sess, seq } = await signFor(rid, kind, body);
|
|
530
|
+
inner = concat(new Uint8Array([kind]), sess, u32(seq), new Uint8Array([sig.length >> 8, sig.length & 0xff]), sig, body);
|
|
531
|
+
}
|
|
532
|
+
else
|
|
533
|
+
inner = concat(new Uint8Array([kind]), body);
|
|
534
|
+
const sealed = await sealUpdate(key, inner, `${rid}|${vault.deviceId}`);
|
|
535
|
+
if (rec.closing)
|
|
536
|
+
return;
|
|
537
|
+
const frame = concat(new Uint8Array([signFrames ? F_SDATA : F_DATA, id.length]), id, sealed);
|
|
538
|
+
if (priority)
|
|
539
|
+
return sendPriority(rec.link, frame);
|
|
540
|
+
await sendBytes(rec.link, frame, () => !rec.closing);
|
|
541
|
+
}
|
|
542
|
+
const established = () => [...links].filter((l) => !l.closing &&
|
|
543
|
+
!l.legacy &&
|
|
544
|
+
(!signFrames || l.authed) &&
|
|
545
|
+
(l.rid === dataRid || l.rid === "") &&
|
|
546
|
+
!(l.deviceId && isRevoked(l.deviceId)));
|
|
547
|
+
/** Synchronous: the link leaves `links` right now (the transport's onClose may fire much later). */
|
|
548
|
+
function closeRec(rec) {
|
|
549
|
+
rec.closing = true;
|
|
550
|
+
links.delete(rec);
|
|
551
|
+
try {
|
|
552
|
+
rec.link.close();
|
|
553
|
+
}
|
|
554
|
+
catch { }
|
|
555
|
+
setStatus();
|
|
556
|
+
}
|
|
557
|
+
const broadcast = (kind, body, except) => {
|
|
558
|
+
for (const l of established())
|
|
559
|
+
if (l !== except)
|
|
560
|
+
sendFrame(l, kind, body).catch(err);
|
|
561
|
+
};
|
|
562
|
+
// one stable sender per link: the pairing state machines identify the active link by it
|
|
563
|
+
const pairSenders = new WeakMap();
|
|
564
|
+
function sendPair(link) {
|
|
565
|
+
let f = pairSenders.get(link);
|
|
566
|
+
if (!f) {
|
|
567
|
+
f = (m) => void sendBytes(link, concat(new Uint8Array([F_PAIR]), utf8(JSON.stringify(m))));
|
|
568
|
+
pairSenders.set(link, f);
|
|
569
|
+
}
|
|
570
|
+
return f;
|
|
571
|
+
}
|
|
572
|
+
const reject = (reason, from) => emit("rejected", { reason, from });
|
|
573
|
+
const heldAt = new WeakMap(); // first time a frame was held (kept across replays)
|
|
574
|
+
let heldTotal = 0; // S5: bytes held over all links
|
|
575
|
+
function dropHeld(rec) {
|
|
576
|
+
if (rec.held)
|
|
577
|
+
heldTotal -= rec.held.bytes;
|
|
578
|
+
rec.held = undefined;
|
|
579
|
+
}
|
|
580
|
+
function hold(rec, data) {
|
|
581
|
+
if (heldTotal + data.length > HOLD_TOTAL_BYTES) {
|
|
582
|
+
rec.lostHeld = true; // S5: global cap (the sender resyncs later; SF4 re-challenges the link)
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (!rec.held)
|
|
586
|
+
rec.held = { frames: [], bytes: 0 };
|
|
587
|
+
const h = rec.held;
|
|
588
|
+
const t = heldAt.get(data) ?? now();
|
|
589
|
+
heldAt.set(data, t);
|
|
590
|
+
h.frames.push({ d: data, t });
|
|
591
|
+
h.bytes += data.length;
|
|
592
|
+
heldTotal += data.length;
|
|
593
|
+
while (h.frames.length > HOLD_MAX_FRAMES || h.bytes > HOLD_MAX_BYTES) {
|
|
594
|
+
const n = h.frames.shift().d.length;
|
|
595
|
+
h.bytes -= n;
|
|
596
|
+
heldTotal -= n;
|
|
597
|
+
rec.lostHeld = true;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** R5-S2: frames that arrived before the peer's K_AUTH, kept sealed; all links share EARLY_TOTAL bytes. */
|
|
601
|
+
let earlyTotal = 0;
|
|
602
|
+
function keepEarly(rec, data) {
|
|
603
|
+
if (!rec.early)
|
|
604
|
+
rec.early = { frames: [], bytes: 0 };
|
|
605
|
+
const e = rec.early;
|
|
606
|
+
if (e.frames.length < EARLY_MAX_FRAMES &&
|
|
607
|
+
e.bytes + data.length <= PRE_AUTH_MAX &&
|
|
608
|
+
earlyTotal + data.length <= EARLY_TOTAL) {
|
|
609
|
+
e.frames.push(data);
|
|
610
|
+
e.bytes += data.length;
|
|
611
|
+
earlyTotal += data.length;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function dropEarly(rec) {
|
|
615
|
+
if (rec.early)
|
|
616
|
+
earlyTotal -= rec.early.bytes;
|
|
617
|
+
rec.early = undefined;
|
|
618
|
+
}
|
|
619
|
+
/** R5-S2: handshake frames verified on any link (sender|session|seq) */
|
|
620
|
+
const hsGlobal = new Lru(8192);
|
|
621
|
+
/** Re-run held frames once the trust state changed (a pending admission may have arrived). */
|
|
622
|
+
function replayHeld() {
|
|
623
|
+
for (const rec of links) {
|
|
624
|
+
const h = rec.held;
|
|
625
|
+
if (!h || rec.closing)
|
|
626
|
+
continue;
|
|
627
|
+
dropHeld(rec);
|
|
628
|
+
for (const f of h.frames) {
|
|
629
|
+
if (now() - f.t > HOLD_MS) {
|
|
630
|
+
rec.lostHeld = true;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
rec.chain = rec.chain.then(() => onData(rec, f.d)).catch(err);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// SF4: a link that lost held frames (expired or over the caps) may have lost its handshake: challenge again
|
|
637
|
+
if (signFrames) {
|
|
638
|
+
for (const rec of links) {
|
|
639
|
+
if (!rec.lostHeld || rec.authed || rec.rid === "pair")
|
|
640
|
+
continue;
|
|
641
|
+
rec.lostHeld = false;
|
|
642
|
+
resendHello(rec);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function onData(rec, data) {
|
|
647
|
+
const lg = rec.legacy;
|
|
648
|
+
if (!running || rec.closing || data.length < 2)
|
|
649
|
+
return;
|
|
650
|
+
const signed = data[0] === F_SDATA;
|
|
651
|
+
if (!signed && signFrames)
|
|
652
|
+
return reject("unsigned frame");
|
|
653
|
+
const idLen = data[1];
|
|
654
|
+
if (data.length < 2 + idLen)
|
|
655
|
+
return;
|
|
656
|
+
const sender = fromUtf8(data.subarray(2, 2 + idLen));
|
|
657
|
+
if (!isDeviceId(sender))
|
|
658
|
+
return;
|
|
659
|
+
if (rec.deviceId && rec.deviceId !== sender)
|
|
660
|
+
return reject("frame sender does not match the link", sender);
|
|
661
|
+
const revoked = isRevoked(sender);
|
|
662
|
+
// signed frames from a sender whose admission has not reached us yet: hold them (still encrypted, no key derived)
|
|
663
|
+
if (signed && !revoked && !trusted.has(sender)) {
|
|
664
|
+
if (!lg)
|
|
665
|
+
hold(rec, data);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// the link's key first; on a live link also the recent retired keys, which may only carry rotations (B4)
|
|
669
|
+
const tries = lg
|
|
670
|
+
? [lg]
|
|
671
|
+
: [
|
|
672
|
+
{ material: docMat, rid: dataRid, epoch },
|
|
673
|
+
...retiredKeys(MAX_RETIRED),
|
|
674
|
+
];
|
|
675
|
+
let plain = null;
|
|
676
|
+
let rid = "";
|
|
677
|
+
let retired = false;
|
|
678
|
+
let via = null; // the key this frame came under
|
|
679
|
+
for (let i = 0; i < tries.length && !plain; i++) {
|
|
680
|
+
const t = tries[i];
|
|
681
|
+
if (!t.material)
|
|
682
|
+
continue;
|
|
683
|
+
try {
|
|
684
|
+
plain = await openUpdate(await senderKey(t.material, sender), data.subarray(2 + idLen), `${t.rid}|${sender}`);
|
|
685
|
+
rid = t.rid;
|
|
686
|
+
retired = i > 0;
|
|
687
|
+
via = { material: t.material, rid: t.rid, epoch: t.epoch };
|
|
688
|
+
}
|
|
689
|
+
catch { } // wrong key / tampered / other epoch: try the next one, else drop silently
|
|
690
|
+
}
|
|
691
|
+
if (!plain || rec.closing || plain.length < 1)
|
|
692
|
+
return;
|
|
693
|
+
const kind = plain[0];
|
|
694
|
+
let body = plain.subarray(1);
|
|
695
|
+
let sess = "";
|
|
696
|
+
if (signed) {
|
|
697
|
+
if (plain.length < 15)
|
|
698
|
+
return reject("malformed signed frame", sender);
|
|
699
|
+
const sessBytes = plain.subarray(1, 9);
|
|
700
|
+
const seq = new DataView(plain.buffer, plain.byteOffset + 9, 4).getUint32(0, false);
|
|
701
|
+
const sigLen = (plain[13] << 8) | plain[14];
|
|
702
|
+
if (plain.length < 15 + sigLen)
|
|
703
|
+
return reject("malformed signed frame", sender);
|
|
704
|
+
const sig = plain.subarray(15, 15 + sigLen);
|
|
705
|
+
body = plain.subarray(15 + sigLen);
|
|
706
|
+
sess = b64uEncode(sessBytes);
|
|
707
|
+
if (revoked)
|
|
708
|
+
return; // a revoked device is never listened to
|
|
709
|
+
const pub = trusted.get(sender)?.pub;
|
|
710
|
+
if (!pub)
|
|
711
|
+
return;
|
|
712
|
+
// R4-S1: everything that can be decided without the (2 ms) signature check is decided first
|
|
713
|
+
const hsKey = `${sender}|${sess}|${seq}`;
|
|
714
|
+
if (rec.authed) {
|
|
715
|
+
if (sess !== rec.peerSess || !seqFresh(sender, sess, seq))
|
|
716
|
+
return; // replay / other link
|
|
717
|
+
}
|
|
718
|
+
else if (kind !== K_HELLO && kind !== K_AUTH) {
|
|
719
|
+
// the peer authenticated us first and already talks: keep a few frames (unverified, still sealed) until its
|
|
720
|
+
// K_AUTH reaches us; they are verified once, when they are run again after it
|
|
721
|
+
keepEarly(rec, data);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
// a replayed handshake frame (on this link or any other): dropped unverified
|
|
726
|
+
if (rec.hsSeen?.has(hsKey) || hsGlobal.has(hsKey))
|
|
727
|
+
return;
|
|
728
|
+
rec.hsChecks = (rec.hsChecks ?? 0) + 1;
|
|
729
|
+
if (rec.hsChecks > HS_VERIFY_MAX ||
|
|
730
|
+
!budget(budgets.hs, sender, 1, HS_VERIFY_PER_ID)) {
|
|
731
|
+
reject("too many handshake frames", sender);
|
|
732
|
+
return closeRec(rec);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
let ok = false;
|
|
736
|
+
try {
|
|
737
|
+
ok = identityVerify(b64uDecode(pub), frameSigBytes(rid, sender, kind, sessBytes, seq, body), sig);
|
|
738
|
+
}
|
|
739
|
+
catch { }
|
|
740
|
+
if (!ok)
|
|
741
|
+
return reject("bad frame signature", sender);
|
|
742
|
+
if (rec.closing)
|
|
743
|
+
return;
|
|
744
|
+
if (!rec.authed) {
|
|
745
|
+
if (!rec.hsSeen)
|
|
746
|
+
rec.hsSeen = new Set();
|
|
747
|
+
if (rec.hsSeen.size < 2 * HS_VERIFY_MAX)
|
|
748
|
+
rec.hsSeen.add(hsKey);
|
|
749
|
+
hsGlobal.set(hsKey, true);
|
|
750
|
+
}
|
|
751
|
+
else if (kind === K_HELLO || kind === K_AUTH)
|
|
752
|
+
freshSeq(sender, sess, seq); // (other kinds: recorded below)
|
|
753
|
+
// S1: a link carries nothing but its handshake until the peer signed OUR fresh challenge on it
|
|
754
|
+
// the handshake may arrive under one of our recent retired keys (a key switch raced with it, or the peer is on
|
|
755
|
+
// another branch): it is then bound to THAT key's room and epoch, and answered under the same key
|
|
756
|
+
if (kind === K_HELLO) {
|
|
757
|
+
if (body.length !== NONCE_BYTES || !via)
|
|
758
|
+
return;
|
|
759
|
+
rec.common = via;
|
|
760
|
+
rec.hellosIn = (rec.hellosIn ?? 0) + 1;
|
|
761
|
+
// R5-S2: in a retired room a sender is answered a couple of times per minute at most (whatever the links)
|
|
762
|
+
const mayAnswer = !rec.legacy ||
|
|
763
|
+
budget(budgets.legacyAnswer, `${rec.rid}|${sender}`, 1, LEGACY_ANSWERS_PER_MIN);
|
|
764
|
+
if ((rec.answers ?? 0) < HANDSHAKE_MAX && mayAnswer)
|
|
765
|
+
await answerHello(rec, body, sender, via);
|
|
766
|
+
// SF4: a repeated challenge means our earlier messages may have been lost (e.g. held and expired while
|
|
767
|
+
// the peer was not yet admitted here): challenge again too
|
|
768
|
+
if (rec.hellosIn > 1 && !rec.authed)
|
|
769
|
+
resendHello(rec);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (kind === K_AUTH) {
|
|
773
|
+
if (rec.authed || !via)
|
|
774
|
+
return;
|
|
775
|
+
const ep = via.epoch;
|
|
776
|
+
const okAuth = rec.nonce !== undefined &&
|
|
777
|
+
body.length > NONCE_BYTES + 4 &&
|
|
778
|
+
equalBytes(body.subarray(0, NONCE_BYTES), rec.nonce) &&
|
|
779
|
+
new DataView(body.buffer, body.byteOffset + NONCE_BYTES, 4).getUint32(0, false) === ep &&
|
|
780
|
+
fromUtf8(body.subarray(NONCE_BYTES + 4)) === vault.deviceId;
|
|
781
|
+
if (!okAuth)
|
|
782
|
+
return reject("bad link authentication", sender);
|
|
783
|
+
rec.common = via;
|
|
784
|
+
rec.deviceId = sender;
|
|
785
|
+
rec.authed = true;
|
|
786
|
+
rec.peerSess = sess;
|
|
787
|
+
rec.lift?.();
|
|
788
|
+
setStatus();
|
|
789
|
+
const early = rec.early?.frames ?? [];
|
|
790
|
+
dropEarly(rec);
|
|
791
|
+
for (const f of early)
|
|
792
|
+
rec.chain = rec.chain.then(() => onData(rec, f)).catch(err);
|
|
793
|
+
return maybeStart(rec);
|
|
794
|
+
}
|
|
795
|
+
if (!rec.authed) {
|
|
796
|
+
// the peer authenticated us first and already talks: keep a few frames until its K_AUTH reaches us
|
|
797
|
+
keepEarly(rec, data);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (sess !== rec.peerSess || !freshSeq(sender, sess, seq))
|
|
801
|
+
return; // replay / other link
|
|
802
|
+
}
|
|
803
|
+
else if (revoked)
|
|
804
|
+
return;
|
|
805
|
+
if (retired) {
|
|
806
|
+
// security documents travel under any key the peer may hold (a straggler, a peer on another branch)
|
|
807
|
+
if (kind === K_TRUST && via)
|
|
808
|
+
await handleTrust(rec, body, sender, via);
|
|
809
|
+
// (d) an authenticated peer still talks under one of our retired keys: it missed a rotation. Offer it the
|
|
810
|
+
// stored rotations from that key's epoch (once per link and current rotation).
|
|
811
|
+
else if (via && rec.authed)
|
|
812
|
+
offerRotations(rec, sender, via);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!rec.deviceId) {
|
|
816
|
+
rec.deviceId = sender; // legacy unsigned wire only: the link is bound to its first sender
|
|
817
|
+
rec.lift?.();
|
|
818
|
+
setStatus();
|
|
819
|
+
}
|
|
820
|
+
if (lg) {
|
|
821
|
+
// retired room: only security documents (the peer catches up with the rotations and revocations it missed)
|
|
822
|
+
if (kind === K_TRUST)
|
|
823
|
+
await handleTrust(rec, body, sender, lg);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (kind === K_SV) {
|
|
827
|
+
await sendFrame(rec, K_UPDATE, Y.encodeStateAsUpdate(doc, body));
|
|
828
|
+
}
|
|
829
|
+
else if (kind === K_UPDATE) {
|
|
830
|
+
if (opts.authorizeUpdate && !(await opts.authorizeUpdate(sender, body)))
|
|
831
|
+
return reject("update not authorized", sender);
|
|
832
|
+
if (rec.closing)
|
|
833
|
+
return;
|
|
834
|
+
Y.applyUpdate(doc, body, ORIGIN);
|
|
835
|
+
}
|
|
836
|
+
else if (kind === K_AWARENESS) {
|
|
837
|
+
applyAwarenessUpdate(awareness, body, ORIGIN);
|
|
838
|
+
}
|
|
839
|
+
else if (kind === K_TRUST) {
|
|
840
|
+
await handleTrust(rec, body, sender, {
|
|
841
|
+
material: docMat,
|
|
842
|
+
rid: dataRid,
|
|
843
|
+
epoch,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
else if (kind === K_CHANNEL) {
|
|
847
|
+
const n = body.length >= 2 ? (body[0] << 8) | body[1] : -1;
|
|
848
|
+
if (n < 0 || body.length < 2 + n)
|
|
849
|
+
return reject("malformed channel frame", sender);
|
|
850
|
+
const ns = fromUtf8(body.subarray(2, 2 + n));
|
|
851
|
+
const k = ns.slice(ns.lastIndexOf("/") + 1);
|
|
852
|
+
if (ns !== chanNs(k))
|
|
853
|
+
return reject("foreign channel", sender);
|
|
854
|
+
const payload = body.subarray(2 + n);
|
|
855
|
+
for (const cb of [...(chanSubs.get(k) ?? [])]) {
|
|
856
|
+
try {
|
|
857
|
+
cb(payload, sender);
|
|
858
|
+
}
|
|
859
|
+
catch (e) {
|
|
860
|
+
err(e);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// ---- channels: app/instance-namespaced message streams over the same encrypted + signed frames ----
|
|
866
|
+
const chanSubs = new Map();
|
|
867
|
+
const chanNs = (kind) => `${meshNamespace(appId, instanceId)}/${kind}`;
|
|
868
|
+
function channel(kind) {
|
|
869
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(kind))
|
|
870
|
+
throw new Error(`invalid channel kind "${kind}"`);
|
|
871
|
+
const mine = new Set();
|
|
872
|
+
return {
|
|
873
|
+
get namespace() {
|
|
874
|
+
return chanNs(kind);
|
|
875
|
+
},
|
|
876
|
+
async send(data, o = {}) {
|
|
877
|
+
if (!running)
|
|
878
|
+
throw new Error("mesh is not connected");
|
|
879
|
+
const ns = utf8(chanNs(kind));
|
|
880
|
+
const body = concat(new Uint8Array([ns.length >> 8, ns.length & 0xff]), ns, data);
|
|
881
|
+
const targets = established().filter((l) => l.deviceId && (o.to === undefined || l.deviceId === o.to));
|
|
882
|
+
await Promise.all(targets.map((l) => sendFrame(l, K_CHANNEL, body)));
|
|
883
|
+
},
|
|
884
|
+
onMessage(cb) {
|
|
885
|
+
const set = chanSubs.get(kind) ?? new Set();
|
|
886
|
+
chanSubs.set(kind, set);
|
|
887
|
+
set.add(cb);
|
|
888
|
+
mine.add(cb);
|
|
889
|
+
return () => {
|
|
890
|
+
set.delete(cb);
|
|
891
|
+
mine.delete(cb);
|
|
892
|
+
};
|
|
893
|
+
},
|
|
894
|
+
close() {
|
|
895
|
+
for (const cb of mine)
|
|
896
|
+
chanSubs.get(kind)?.delete(cb);
|
|
897
|
+
mine.clear();
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
const linkKey = (l) => l.legacy ?? { material: docMat, rid: dataRid, epoch };
|
|
902
|
+
const budgets = {
|
|
903
|
+
serve: new Map(),
|
|
904
|
+
fail: new Map(),
|
|
905
|
+
hs: new Map(),
|
|
906
|
+
legacyAnswer: new Map(),
|
|
907
|
+
};
|
|
908
|
+
/** Per-peer budget over a sliding minute; false when `n` more would exceed `max`. */
|
|
909
|
+
function budget(m, peer, n, max) {
|
|
910
|
+
const t = now();
|
|
911
|
+
let b = m.get(peer);
|
|
912
|
+
if (!b || t - b.t > 60_000) {
|
|
913
|
+
b = { t, n: 0 };
|
|
914
|
+
m.delete(peer);
|
|
915
|
+
m.set(peer, b);
|
|
916
|
+
if (m.size > 4096)
|
|
917
|
+
m.delete(m.keys().next().value);
|
|
918
|
+
}
|
|
919
|
+
if (b.n + n > max)
|
|
920
|
+
return false;
|
|
921
|
+
b.n += n;
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
function sendTrust(rec, msg, key) {
|
|
925
|
+
const body = utf8(JSON.stringify(msg));
|
|
926
|
+
if (rec.legacy)
|
|
927
|
+
return sendFrameWith(rec, K_TRUST, body, rec.legacy.material, rec.legacy.rid, true);
|
|
928
|
+
return sendFrameWith(rec, K_TRUST, body, key.material, key.rid, true);
|
|
929
|
+
}
|
|
930
|
+
/** Documents in batches (count and bytes bounded). */
|
|
931
|
+
async function sendDocs(rec, docs, key) {
|
|
932
|
+
let batch = [];
|
|
933
|
+
let bytes = 0;
|
|
934
|
+
for (const d of docs) {
|
|
935
|
+
const n = JSON.stringify(d).length;
|
|
936
|
+
if (batch.length &&
|
|
937
|
+
(batch.length >= TRUST_DOCS_MAX || bytes + n > TRUST_BATCH_BYTES)) {
|
|
938
|
+
await sendTrust(rec, { t: "docs", docs: batch }, key);
|
|
939
|
+
batch = [];
|
|
940
|
+
bytes = 0;
|
|
941
|
+
}
|
|
942
|
+
batch.push(d);
|
|
943
|
+
bytes += n;
|
|
944
|
+
}
|
|
945
|
+
if (batch.length)
|
|
946
|
+
await sendTrust(rec, { t: "docs", docs: batch }, key);
|
|
947
|
+
}
|
|
948
|
+
/** Tell every authenticated peer (but `except`) about new documents. */
|
|
949
|
+
function announce(keys, except) {
|
|
950
|
+
if (!keys.length)
|
|
951
|
+
return;
|
|
952
|
+
for (const l of [...links]) {
|
|
953
|
+
if (l === except ||
|
|
954
|
+
l.closing ||
|
|
955
|
+
l.rid === "pair" ||
|
|
956
|
+
!l.deviceId ||
|
|
957
|
+
isRevoked(l.deviceId))
|
|
958
|
+
continue;
|
|
959
|
+
if (signFrames && !l.authed)
|
|
960
|
+
continue;
|
|
961
|
+
void sendTrust(l, { t: "inv", ids: keys }, linkKey(l)).catch(err);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/** Add documents made or received here, and announce the new ones. */
|
|
965
|
+
async function addLocal(docs, except, from) {
|
|
966
|
+
if (!sec)
|
|
967
|
+
return [];
|
|
968
|
+
// R6-S2b: `from` is the peer that sent these documents, so the trust store can charge its waiting slots to it
|
|
969
|
+
const outs = await sec.addMany(docs, { from });
|
|
970
|
+
announce(outs
|
|
971
|
+
.filter((o) => o.status === "accepted" && o.id)
|
|
972
|
+
.map((o) => o.id), except);
|
|
973
|
+
return outs;
|
|
974
|
+
}
|
|
975
|
+
async function handleTrust(rec, body, sender, key) {
|
|
976
|
+
if (!sec)
|
|
977
|
+
return;
|
|
978
|
+
let m;
|
|
979
|
+
try {
|
|
980
|
+
m = JSON.parse(fromUtf8(body));
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
return reject("malformed trust message", sender);
|
|
984
|
+
}
|
|
985
|
+
if (!m || typeof m !== "object")
|
|
986
|
+
return;
|
|
987
|
+
if (m.t === "inv" &&
|
|
988
|
+
Array.isArray(m.ids) &&
|
|
989
|
+
m.ids.length <= TRUST_INV_MAX) {
|
|
990
|
+
const want = sec.missing(m.ids).slice(0, TRUST_WANT_MAX);
|
|
991
|
+
if (want.length)
|
|
992
|
+
await sendTrust(rec, { t: "want", ids: want }, key);
|
|
993
|
+
}
|
|
994
|
+
else if (m.t === "want" &&
|
|
995
|
+
Array.isArray(m.ids) &&
|
|
996
|
+
m.ids.length <= TRUST_WANT_MAX) {
|
|
997
|
+
const ids = m.ids.filter((x) => typeof x === "string");
|
|
998
|
+
if (!budget(budgets.serve, sender, ids.length, TRUST_SERVE_PER_MIN))
|
|
999
|
+
return reject("security documents asked too often", sender);
|
|
1000
|
+
await sendDocs(rec, await sec.get(ids), key);
|
|
1001
|
+
}
|
|
1002
|
+
else if (m.t === "docs" &&
|
|
1003
|
+
Array.isArray(m.docs) &&
|
|
1004
|
+
m.docs.length <= TRUST_DOCS_MAX) {
|
|
1005
|
+
// a sender whose documents keep failing (bad signatures, junk) is cut off for a while: each costs a check
|
|
1006
|
+
for (let i = 0; i < m.docs.length; i += 16) {
|
|
1007
|
+
if (!budget(budgets.fail, sender, 0, TRUST_FAIL_PER_MIN))
|
|
1008
|
+
return reject("too many bad security documents", sender);
|
|
1009
|
+
const outs = await addLocal(m.docs.slice(i, i + 16), rec, sender);
|
|
1010
|
+
for (const o of outs)
|
|
1011
|
+
if (o.status === "rejected")
|
|
1012
|
+
reject(o.reason ?? "invalid security document", sender);
|
|
1013
|
+
const bad = outs.filter((o) => o.status === "rejected").length;
|
|
1014
|
+
if (bad && !budget(budgets.fail, sender, bad, TRUST_FAIL_PER_MIN))
|
|
1015
|
+
return reject("too many bad security documents", sender);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/** Rotation documents `peer` (still on a key of `fromEpoch`) can adopt, best last (at most MAX_EPOCH_SKIP ahead). */
|
|
1020
|
+
function rotationsFor(peer, fromEpoch) {
|
|
1021
|
+
if (!sec)
|
|
1022
|
+
return [];
|
|
1023
|
+
return sec
|
|
1024
|
+
.rotations()
|
|
1025
|
+
.filter((r) => r.to.includes(peer) &&
|
|
1026
|
+
r.epoch > fromEpoch &&
|
|
1027
|
+
r.epoch <= fromEpoch + MAX_EPOCH_SKIP &&
|
|
1028
|
+
r.epoch <= epoch)
|
|
1029
|
+
.map(({ id: _id, ...d }) => d);
|
|
1030
|
+
}
|
|
1031
|
+
const offered = new Set();
|
|
1032
|
+
/** (d) a peer still talks under one of our retired keys: hand it what it missed, under that key. */
|
|
1033
|
+
function offerRotations(rec, peer, via) {
|
|
1034
|
+
const k = `${curRot?.id ?? epoch}|${peer}|${via.epoch}`;
|
|
1035
|
+
if (offered.has(k) || !sec)
|
|
1036
|
+
return;
|
|
1037
|
+
if (offered.size >= 4096)
|
|
1038
|
+
offered.clear();
|
|
1039
|
+
offered.add(k);
|
|
1040
|
+
void (async () => {
|
|
1041
|
+
await sendDocs(rec, rotationsFor(peer, via.epoch), via);
|
|
1042
|
+
if (sec)
|
|
1043
|
+
await sendTrust(rec, { t: "inv", ids: sec.inventory() }, via);
|
|
1044
|
+
})().catch(err);
|
|
1045
|
+
}
|
|
1046
|
+
// ---- rotations (owner only issues them; every device adopts the best one it can open) ----
|
|
1047
|
+
/**
|
|
1048
|
+
* Adopt the best rotation we are a recipient of (highest epoch, then lowest id), one window of MAX_EPOCH_SKIP at a
|
|
1049
|
+
* time. Only owner-identity devices issue rotations, so concurrency only exists between devices of the owner;
|
|
1050
|
+
* every device applies the same rule to the same set. Rotations are documents: whatever order they arrive in, and
|
|
1051
|
+
* whoever relays them, every device ends on the same one.
|
|
1052
|
+
*/
|
|
1053
|
+
async function considerRotations() {
|
|
1054
|
+
if (!sec || !root || !running)
|
|
1055
|
+
return;
|
|
1056
|
+
const me = vault.deviceId;
|
|
1057
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
1058
|
+
const ownerKex = kexOf(root.deviceId);
|
|
1059
|
+
for (const r of sec.rotations()) {
|
|
1060
|
+
if (!curRot && curRotId === r.id && r.epoch === epoch)
|
|
1061
|
+
curRot = r; // the rotation our pairing key came from
|
|
1062
|
+
if (r.epoch < epoch ||
|
|
1063
|
+
r.id === curRot?.id ||
|
|
1064
|
+
cands.has(r.id) ||
|
|
1065
|
+
unwrapFailed.has(r.id))
|
|
1066
|
+
continue;
|
|
1067
|
+
if (r.epoch > epoch + MAX_EPOCH_SKIP ||
|
|
1068
|
+
!r.to.includes(me) ||
|
|
1069
|
+
!r.wraps[me])
|
|
1070
|
+
continue;
|
|
1071
|
+
if (opts.canRotate) {
|
|
1072
|
+
let ok = true;
|
|
1073
|
+
for (const t of r.revoked)
|
|
1074
|
+
if (!(await opts.canRotate(r.from, t)))
|
|
1075
|
+
ok = false;
|
|
1076
|
+
if (!ok) {
|
|
1077
|
+
unwrapFailed.add(r.id);
|
|
1078
|
+
emit("rejected", {
|
|
1079
|
+
reason: "rotation not authorized",
|
|
1080
|
+
from: r.from,
|
|
1081
|
+
epoch: r.epoch,
|
|
1082
|
+
});
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (!ownerKex)
|
|
1087
|
+
continue; // the owner's key record has not arrived yet: tried again on the next change
|
|
1088
|
+
try {
|
|
1089
|
+
const key = await unwrapMeshKey((await ecdhIdentity()).privateKey, (await kemIdentity()).secretKey, ownerKex.pub, await rotationPreId(r), r.from, me, r.wraps[me]);
|
|
1090
|
+
cands.set(r.id, { rec: r, key });
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
unwrapFailed.add(r.id);
|
|
1094
|
+
emit("rejected", {
|
|
1095
|
+
reason: "rotation wrap does not authenticate",
|
|
1096
|
+
from: r.from,
|
|
1097
|
+
epoch: r.epoch,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const cur = { epoch, id: curRot ? curRot.id : (curRotId ?? "\uffff") }; // genesis/unknown loses to any rotation of its epoch
|
|
1102
|
+
let best = null;
|
|
1103
|
+
for (const [id, c] of cands) {
|
|
1104
|
+
if (c.rec.epoch < epoch) {
|
|
1105
|
+
cands.delete(id);
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (betterRot(c.rec, best ? best.rec : cur))
|
|
1109
|
+
best = c;
|
|
1110
|
+
}
|
|
1111
|
+
if (!best)
|
|
1112
|
+
break;
|
|
1113
|
+
await adopt(best);
|
|
1114
|
+
}
|
|
1115
|
+
while (cands.size > MAX_CANDIDATES)
|
|
1116
|
+
cands.delete(cands.keys().next().value);
|
|
1117
|
+
await ownerRekey();
|
|
1118
|
+
}
|
|
1119
|
+
async function adopt(c) {
|
|
1120
|
+
// (c) the rotation we leave stays a candidate (same epoch): nothing is lost if the choice has to be revisited
|
|
1121
|
+
if (curRot &&
|
|
1122
|
+
meshKey &&
|
|
1123
|
+
curRot.epoch === c.rec.epoch &&
|
|
1124
|
+
curRot.id !== c.rec.id)
|
|
1125
|
+
cands.set(curRot.id, { rec: curRot, key: meshKey });
|
|
1126
|
+
const { oldEpoch, oldKey, oldRid } = await switchEpoch(c.key, c.rec.epoch);
|
|
1127
|
+
curRot = c.rec;
|
|
1128
|
+
curRotId = c.rec.id;
|
|
1129
|
+
await store.set("rotId", curRotId);
|
|
1130
|
+
cands.delete(c.rec.id);
|
|
1131
|
+
for (const [id, x] of cands)
|
|
1132
|
+
if (x.rec.epoch < epoch)
|
|
1133
|
+
cands.delete(id);
|
|
1134
|
+
for (const l of [...links])
|
|
1135
|
+
if (l.deviceId && isRevoked(l.deviceId))
|
|
1136
|
+
closeRec(l);
|
|
1137
|
+
await rememberRetired(oldEpoch, oldKey, oldRid);
|
|
1138
|
+
void refreshTrust();
|
|
1139
|
+
for (const t of c.rec.revoked)
|
|
1140
|
+
emit("revoked", { deviceId: t, epoch });
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* A known revocation has not been executed by an owner rotation yet: the devices it cut off may hold the current
|
|
1144
|
+
* key, whenever they were paired (R5-B2). The UI shows it as "pendiente de que el dueño se conecte".
|
|
1145
|
+
*/
|
|
1146
|
+
const rekeyPending = () => sec ? sec.unexecuted().devices.length > 0 : false;
|
|
1147
|
+
/**
|
|
1148
|
+
* Members a rotation left out although they were admitted before it (e.g. their key record was not known yet), or
|
|
1149
|
+
* members whose grant handed over no key (re-anchored): the owner re-keys to include them.
|
|
1150
|
+
*/
|
|
1151
|
+
function missed() {
|
|
1152
|
+
if (!sec || !curRot)
|
|
1153
|
+
return [];
|
|
1154
|
+
const cr = curRot;
|
|
1155
|
+
const out = [];
|
|
1156
|
+
for (const d of trusted.values()) {
|
|
1157
|
+
if (d.deviceId === root?.deviceId || cr.to.includes(d.deviceId))
|
|
1158
|
+
continue;
|
|
1159
|
+
const g = sec.memberGrant(d.deviceId);
|
|
1160
|
+
if (!g || (g.epoch !== undefined && g.epoch >= cr.epoch))
|
|
1161
|
+
continue;
|
|
1162
|
+
if (kexOf(d.deviceId))
|
|
1163
|
+
out.push(d.deviceId);
|
|
1164
|
+
}
|
|
1165
|
+
return out;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Owner devices only: re-key at epoch + 1 without every device a revocation cut off that no owner rotation has
|
|
1169
|
+
* executed yet (an admin's revocation is executed here), and to include members a previous rotation missed. Runs
|
|
1170
|
+
* after every change of the security state (R5-S3: a re-key refused once is retried as soon as it can work).
|
|
1171
|
+
*/
|
|
1172
|
+
async function ownerRekey() {
|
|
1173
|
+
if (!running ||
|
|
1174
|
+
!sec ||
|
|
1175
|
+
!root ||
|
|
1176
|
+
root.deviceId !== vault.deviceId ||
|
|
1177
|
+
destroyed)
|
|
1178
|
+
return;
|
|
1179
|
+
const u = sec.unexecuted();
|
|
1180
|
+
if (u.devices.length === 0 && missed().length === 0)
|
|
1181
|
+
return;
|
|
1182
|
+
// R4-S3: no rotation lists more than a receiver accepts: cut in several rotations if needed
|
|
1183
|
+
const chunks = [];
|
|
1184
|
+
let cur = [];
|
|
1185
|
+
let refs = 0;
|
|
1186
|
+
for (const dev of u.devices) {
|
|
1187
|
+
const n = sec.trust.grantsOf(dev).length;
|
|
1188
|
+
if (cur.length &&
|
|
1189
|
+
(cur.length >= MAX_ROT_MEMBERS || refs + n > MAX_ROT_REFS)) {
|
|
1190
|
+
chunks.push(cur);
|
|
1191
|
+
cur = [];
|
|
1192
|
+
refs = 0;
|
|
1193
|
+
}
|
|
1194
|
+
cur.push(dev);
|
|
1195
|
+
refs += n;
|
|
1196
|
+
}
|
|
1197
|
+
chunks.push(cur);
|
|
1198
|
+
for (const chunk of chunks) {
|
|
1199
|
+
if (!running || destroyed || !sec)
|
|
1200
|
+
return;
|
|
1201
|
+
try {
|
|
1202
|
+
await rotate(sec.unexecuted(new Set(chunk)));
|
|
1203
|
+
}
|
|
1204
|
+
catch (e) {
|
|
1205
|
+
err(e);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
async function onPairFrame(rec, data) {
|
|
1211
|
+
if (data.length > PAIR_MAX)
|
|
1212
|
+
return reject("pairing message too large"); // S5
|
|
1213
|
+
let msg;
|
|
1214
|
+
try {
|
|
1215
|
+
msg = JSON.parse(fromUtf8(data.subarray(1)));
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (!msg || typeof msg !== "object")
|
|
1221
|
+
return;
|
|
1222
|
+
const send = sendPair(rec.link);
|
|
1223
|
+
const m = msg;
|
|
1224
|
+
if (hostSession && pairRids.has(rec.rid))
|
|
1225
|
+
await hostSession.s.handle(m, send);
|
|
1226
|
+
else if (guestSession && pairRids.has(rec.rid))
|
|
1227
|
+
await guestSession.s.handle(m, send);
|
|
1228
|
+
}
|
|
1229
|
+
const preAuthBudget = { used: 0, max: PRE_AUTH_TOTAL };
|
|
1230
|
+
/** S1: challenge the peer of a fresh data link; data flows once both sides answered each other's challenge. */
|
|
1231
|
+
function startHandshake(rec) {
|
|
1232
|
+
rec.nonce = randomBytes(NONCE_BYTES);
|
|
1233
|
+
// note 9: an unauthenticated link holds budgets (pre-auth reassembly, held frames): it does not stay forever
|
|
1234
|
+
const t = setTimeout(() => {
|
|
1235
|
+
if (!rec.authed && !rec.closing) {
|
|
1236
|
+
reject("link handshake timed out", rec.deviceId);
|
|
1237
|
+
closeRec(rec);
|
|
1238
|
+
}
|
|
1239
|
+
}, opts.handshakeTimeoutMs ?? 120_000);
|
|
1240
|
+
t.unref?.();
|
|
1241
|
+
rec.link.onClose(() => clearTimeout(t));
|
|
1242
|
+
rec.hellosOut = 1;
|
|
1243
|
+
const lg = rec.legacy;
|
|
1244
|
+
sendFrameWith(rec, K_HELLO, rec.nonce, lg ? lg.material : docMat, lg ? lg.rid : dataRid, true).catch(err);
|
|
1245
|
+
}
|
|
1246
|
+
/** SF4: same challenge again, at most HANDSHAKE_MAX times per link. */
|
|
1247
|
+
function resendHello(rec) {
|
|
1248
|
+
// R5-S2: never in a retired room (a straggler challenges us again by itself)
|
|
1249
|
+
if (!rec.nonce ||
|
|
1250
|
+
rec.authed ||
|
|
1251
|
+
rec.closing ||
|
|
1252
|
+
rec.legacy ||
|
|
1253
|
+
(rec.hellosOut ?? 0) >= HANDSHAKE_MAX)
|
|
1254
|
+
return;
|
|
1255
|
+
rec.hellosOut = (rec.hellosOut ?? 0) + 1;
|
|
1256
|
+
// a fresh copy: a new signature and sequence number, so the peer sees a new frame, not a replay (R4-S1)
|
|
1257
|
+
const nonce = rec.nonce.slice();
|
|
1258
|
+
// under the current AND the recent retired keys: the peer may still be on (or have switched from) any of them
|
|
1259
|
+
void (async () => {
|
|
1260
|
+
const lg = rec.legacy;
|
|
1261
|
+
await sendFrameWith(rec, K_HELLO, nonce, lg ? lg.material : docMat, lg ? lg.rid : dataRid, true);
|
|
1262
|
+
if (!lg)
|
|
1263
|
+
for (const r of retiredKeys())
|
|
1264
|
+
await sendFrameWith(rec, K_HELLO, nonce, r.material, r.rid, true);
|
|
1265
|
+
})().catch(err);
|
|
1266
|
+
}
|
|
1267
|
+
async function answerHello(rec, peerNonce, peer, via) {
|
|
1268
|
+
rec.answers = (rec.answers ?? 0) + 1;
|
|
1269
|
+
rec.authSent = true;
|
|
1270
|
+
// bound to the challenger and to the key the challenge came under (sealed under that same key)
|
|
1271
|
+
await sendFrameWith(rec, K_AUTH, concat(peerNonce, u32(via.epoch), utf8(peer)), via.material, via.rid, true);
|
|
1272
|
+
maybeStart(rec);
|
|
1273
|
+
}
|
|
1274
|
+
function maybeStart(rec) {
|
|
1275
|
+
if (!rec.authed || !rec.authSent || rec.started || rec.closing)
|
|
1276
|
+
return;
|
|
1277
|
+
rec.started = true;
|
|
1278
|
+
const peer = rec.deviceId;
|
|
1279
|
+
if (rec.legacy) {
|
|
1280
|
+
// retired room (BL1): the peer may be a straggler, or a partition that rotated on its own. Hand it the
|
|
1281
|
+
// rotations it can adopt and our inventory; it converges (and re-keys if needed) and does the same for us.
|
|
1282
|
+
const lg = rec.legacy;
|
|
1283
|
+
void (async () => {
|
|
1284
|
+
await sendDocs(rec, rotationsFor(peer, lg.epoch), lg);
|
|
1285
|
+
if (sec)
|
|
1286
|
+
await sendTrust(rec, { t: "inv", ids: sec.inventory() }, lg);
|
|
1287
|
+
})().catch(err);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
sendFrame(rec, K_SV, Y.encodeStateVector(doc)).catch(err);
|
|
1291
|
+
if (awareness.getLocalState())
|
|
1292
|
+
sendFrame(rec, K_AWARENESS, encodeAwarenessUpdate(awareness, [doc.clientID])).catch(err);
|
|
1293
|
+
if (sec)
|
|
1294
|
+
void sendTrust(rec, { t: "inv", ids: sec.inventory() }, linkKey(rec)).catch(err);
|
|
1295
|
+
// the peer may be on another key of this epoch (its handshake crossed a key switch): push the rotation we are on
|
|
1296
|
+
// if it is a recipient, under every key it may hold
|
|
1297
|
+
const cur = curRot;
|
|
1298
|
+
if (cur && cur.to.includes(peer)) {
|
|
1299
|
+
const { id: _id, ...d } = cur;
|
|
1300
|
+
void sendTrustEverywhere(rec, utf8(JSON.stringify({ t: "docs", docs: [d] }))).catch(err);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
function wireLink(link, rid) {
|
|
1304
|
+
const isData = running && rid === dataRid;
|
|
1305
|
+
const lg = running && !isData ? legacy.get(rid) : undefined;
|
|
1306
|
+
const isPair = pairRids.has(rid);
|
|
1307
|
+
if (rid !== "" && !isData && !isPair && !lg)
|
|
1308
|
+
return link.close();
|
|
1309
|
+
const rec = {
|
|
1310
|
+
link,
|
|
1311
|
+
rid: isPair && !isData ? "pair" : rid,
|
|
1312
|
+
chain: Promise.resolve(),
|
|
1313
|
+
};
|
|
1314
|
+
if (lg)
|
|
1315
|
+
rec.legacy = lg;
|
|
1316
|
+
if (rid === "")
|
|
1317
|
+
rec.rid = ""; // room-less (qr-sdp): frames are self-describing
|
|
1318
|
+
// R5-S2: a bounded number of links still to authenticate per room, and per announced peer
|
|
1319
|
+
if (rec.rid !== "pair") {
|
|
1320
|
+
const pending = [...links].filter((l) => !l.authed && !l.closing && l.rid === rec.rid);
|
|
1321
|
+
if (pending.length >= MAX_UNAUTH_PER_ROOM ||
|
|
1322
|
+
pending.filter((l) => l.link.id === link.id).length >=
|
|
1323
|
+
MAX_UNAUTH_PER_PEER) {
|
|
1324
|
+
reject("too many links waiting to authenticate", link.id);
|
|
1325
|
+
return link.close();
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
links.add(rec);
|
|
1329
|
+
// S5: until the link is authenticated it may reassemble at most 1 MiB, charged to a budget shared by all
|
|
1330
|
+
// unauthenticated links; afterwards the configured limit applies
|
|
1331
|
+
const reasm = new Reassembler({
|
|
1332
|
+
maxMessageBytes: PRE_AUTH_MAX,
|
|
1333
|
+
maxPendingBytes: PRE_AUTH_MAX,
|
|
1334
|
+
shared: preAuthBudget,
|
|
1335
|
+
});
|
|
1336
|
+
rec.lift = () => reasm.setLimits({
|
|
1337
|
+
maxMessageBytes: opts.maxMessageBytes ?? DEFAULT_MAX_MESSAGE,
|
|
1338
|
+
maxPendingBytes: opts.maxMessageBytes ?? DEFAULT_MAX_MESSAGE,
|
|
1339
|
+
shared: null,
|
|
1340
|
+
});
|
|
1341
|
+
const dispatch = (d) => d[0] === F_PAIR
|
|
1342
|
+
? onPairFrame({ ...rec, rid: rid === "" ? ([...pairRids][0] ?? "") : rid }, d)
|
|
1343
|
+
: d[0] === F_DATA || d[0] === F_SDATA
|
|
1344
|
+
? onData(rec, d)
|
|
1345
|
+
: undefined;
|
|
1346
|
+
link.onMessage((d) => {
|
|
1347
|
+
if (rec.closing)
|
|
1348
|
+
return;
|
|
1349
|
+
rec.chain = rec.chain
|
|
1350
|
+
.then(async () => {
|
|
1351
|
+
if (d[0] !== F_FRAG)
|
|
1352
|
+
return dispatch(d);
|
|
1353
|
+
const whole = await reasm.push(d);
|
|
1354
|
+
if (whole && whole[0] !== F_FRAG && !rec.closing)
|
|
1355
|
+
return dispatch(whole);
|
|
1356
|
+
})
|
|
1357
|
+
.catch(err);
|
|
1358
|
+
});
|
|
1359
|
+
link.onClose(() => {
|
|
1360
|
+
reasm.clear();
|
|
1361
|
+
dropHeld(rec);
|
|
1362
|
+
dropEarly(rec);
|
|
1363
|
+
links.delete(rec);
|
|
1364
|
+
setStatus();
|
|
1365
|
+
});
|
|
1366
|
+
if (isPair && guestSession)
|
|
1367
|
+
guestSession.s.attach(sendPair(link));
|
|
1368
|
+
if (signFrames && (isData || lg || (rid === "" && running)))
|
|
1369
|
+
startHandshake(rec);
|
|
1370
|
+
else if (isData || (rid === "" && running)) {
|
|
1371
|
+
sendFrame(rec, K_SV, Y.encodeStateVector(doc)).catch(err);
|
|
1372
|
+
const st = awareness.getLocalState();
|
|
1373
|
+
if (st)
|
|
1374
|
+
sendFrame(rec, K_AWARENESS, encodeAwarenessUpdate(awareness, [doc.clientID])).catch(err);
|
|
1375
|
+
}
|
|
1376
|
+
if (rid === "" && guestSession)
|
|
1377
|
+
guestSession.s.attach(sendPair(link));
|
|
1378
|
+
setStatus();
|
|
1379
|
+
}
|
|
1380
|
+
// ---- rooms ----
|
|
1381
|
+
let subscribed = false;
|
|
1382
|
+
async function joinRoom(rid, key) {
|
|
1383
|
+
if (rooms.has(rid))
|
|
1384
|
+
return;
|
|
1385
|
+
if (!subscribed) {
|
|
1386
|
+
subscribed = true;
|
|
1387
|
+
for (const t of transports)
|
|
1388
|
+
if (t.kind === "link")
|
|
1389
|
+
linkSubs.push(t.onLink((l, r) => !destroyed && wireLink(l, r)));
|
|
1390
|
+
}
|
|
1391
|
+
const stops = [];
|
|
1392
|
+
rooms.set(rid, () => {
|
|
1393
|
+
for (const s of stops)
|
|
1394
|
+
s();
|
|
1395
|
+
});
|
|
1396
|
+
for (const t of transports) {
|
|
1397
|
+
try {
|
|
1398
|
+
if (t.kind === "link") {
|
|
1399
|
+
await t.join(rid, vault.deviceId);
|
|
1400
|
+
stops.push(() => t.leave(rid));
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
const stop = await connectViaSignaling(t, {
|
|
1404
|
+
rid,
|
|
1405
|
+
selfId: vault.deviceId,
|
|
1406
|
+
key,
|
|
1407
|
+
rtc: opts.rtc,
|
|
1408
|
+
onLink: (l) => !destroyed && wireLink(l, rid),
|
|
1409
|
+
onError: err,
|
|
1410
|
+
});
|
|
1411
|
+
stops.push(stop);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
catch (e) {
|
|
1415
|
+
err(e); // a dead transport must not take the mesh down: others still run
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
function leaveRoom(rid) {
|
|
1420
|
+
rooms.get(rid)?.();
|
|
1421
|
+
rooms.delete(rid);
|
|
1422
|
+
}
|
|
1423
|
+
async function start() {
|
|
1424
|
+
if (running || destroyed)
|
|
1425
|
+
return;
|
|
1426
|
+
await loadKeys();
|
|
1427
|
+
running = true;
|
|
1428
|
+
setStatus();
|
|
1429
|
+
await joinRoom(dataRid, sigKey);
|
|
1430
|
+
await syncLegacy();
|
|
1431
|
+
// adopt what is pending (rotations already held) and, on an owner device, execute what is pending at once (a
|
|
1432
|
+
// revocation recorded before a restart, or a re-key interrupted by the app tearing the mesh down)
|
|
1433
|
+
void serialRot(considerRotations).catch(err);
|
|
1434
|
+
}
|
|
1435
|
+
function stopNetwork() {
|
|
1436
|
+
running = false;
|
|
1437
|
+
for (const rid of [...rooms.keys()])
|
|
1438
|
+
leaveRoom(rid);
|
|
1439
|
+
for (const l of [...links])
|
|
1440
|
+
closeRec(l);
|
|
1441
|
+
links.clear();
|
|
1442
|
+
legacy.clear();
|
|
1443
|
+
for (const u of linkSubs.splice(0))
|
|
1444
|
+
u();
|
|
1445
|
+
subscribed = false;
|
|
1446
|
+
pairRids = new Set();
|
|
1447
|
+
hostSession = null;
|
|
1448
|
+
guestSession = null;
|
|
1449
|
+
setStatus();
|
|
1450
|
+
}
|
|
1451
|
+
// ---- document / awareness propagation ----
|
|
1452
|
+
const onDocUpdate = (u, origin) => {
|
|
1453
|
+
if (origin !== ORIGIN && running)
|
|
1454
|
+
broadcast(K_UPDATE, u);
|
|
1455
|
+
};
|
|
1456
|
+
doc.on("update", onDocUpdate);
|
|
1457
|
+
const onAwareness = (c, origin) => {
|
|
1458
|
+
if (origin === ORIGIN || !running)
|
|
1459
|
+
return;
|
|
1460
|
+
broadcast(K_AWARENESS, encodeAwarenessUpdate(awareness, [
|
|
1461
|
+
...c.added,
|
|
1462
|
+
...c.updated,
|
|
1463
|
+
...c.removed,
|
|
1464
|
+
]));
|
|
1465
|
+
};
|
|
1466
|
+
awareness.on("update", onAwareness);
|
|
1467
|
+
// ---- rotation ----
|
|
1468
|
+
// The new mesh key never travels under the shared (old) key: it is wrapped per remaining device with a hybrid
|
|
1469
|
+
// key, HKDF(ML-KEM-768 secret || ECDH(owner static key, peer static key), swal-rotate/v4|preId|from|to) -> AES-GCM,
|
|
1470
|
+
// where preId hashes the rotation document (epoch, recipients, cuts, nonce); the owner signs its id. Every device
|
|
1471
|
+
// keeps the recent rotation documents in its local store and hands them to devices that were offline (through a
|
|
1472
|
+
// retired room, see `legacy`); a revoked device has no wrap and cannot unwrap anyone else's.
|
|
1473
|
+
async function switchEpoch(newKey, newEpoch) {
|
|
1474
|
+
// a re-key still in flight when the app destroyed the mesh must not overwrite the vault: the next instance on
|
|
1475
|
+
// the same vault would start from a key nobody else ever received
|
|
1476
|
+
if (destroyed)
|
|
1477
|
+
throw new Error("mesh destroyed during a key change");
|
|
1478
|
+
const oldRid = dataRid;
|
|
1479
|
+
const old = { epoch, rid: oldRid, material: docMat };
|
|
1480
|
+
const oldKey = meshKey;
|
|
1481
|
+
await vault.setMeshKey(newKey);
|
|
1482
|
+
await persistEpoch(newEpoch);
|
|
1483
|
+
epoch = newEpoch;
|
|
1484
|
+
await loadKeys(newKey);
|
|
1485
|
+
// links stay up across the rotation; the retired room stays joined ONLY to serve wraps to stragglers
|
|
1486
|
+
for (const l of links)
|
|
1487
|
+
if (l.rid === oldRid && !l.legacy)
|
|
1488
|
+
l.rid = dataRid;
|
|
1489
|
+
legacy.set(oldRid, old);
|
|
1490
|
+
legacy.delete(dataRid);
|
|
1491
|
+
// SF5: a bounded number of retired rooms (the oldest are left)
|
|
1492
|
+
while (legacy.size > MAX_RETIRED) {
|
|
1493
|
+
const oldest = [...legacy.values()].sort((x, y) => x.epoch - y.epoch)[0];
|
|
1494
|
+
legacy.delete(oldest.rid);
|
|
1495
|
+
leaveRoom(oldest.rid);
|
|
1496
|
+
}
|
|
1497
|
+
await joinRoom(dataRid, sigKey);
|
|
1498
|
+
return { oldEpoch: old.epoch, oldKey, oldRid };
|
|
1499
|
+
}
|
|
1500
|
+
/** SF5: persist a key this device retired, so a restart keeps serving stragglers from that room. */
|
|
1501
|
+
async function rememberRetired(e, key, rid) {
|
|
1502
|
+
const list = (await store.get(RETIRED_KEY)) ?? [];
|
|
1503
|
+
const next = [
|
|
1504
|
+
...list.filter((x) => x.rid !== rid),
|
|
1505
|
+
{ e, k: b64uEncode(key), rid },
|
|
1506
|
+
].slice(-MAX_RETIRED);
|
|
1507
|
+
await store.set(RETIRED_KEY, next);
|
|
1508
|
+
}
|
|
1509
|
+
/** SF5: rejoin the rooms of keys this device retired (from its local store; the shared doc is never trusted). */
|
|
1510
|
+
async function syncLegacy() {
|
|
1511
|
+
if (!running)
|
|
1512
|
+
return;
|
|
1513
|
+
const list = (await store.get(RETIRED_KEY)) ?? [];
|
|
1514
|
+
for (const v of list.slice(-MAX_RETIRED)) {
|
|
1515
|
+
const e = v?.e;
|
|
1516
|
+
if (!isEpoch(e) || e > epoch || typeof v?.k !== "string")
|
|
1517
|
+
continue;
|
|
1518
|
+
let raw;
|
|
1519
|
+
try {
|
|
1520
|
+
raw = b64uDecode(v.k);
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
if (raw.length !== 32)
|
|
1526
|
+
continue;
|
|
1527
|
+
const rid = await deriveRoomId(raw, appId, topicName, e, instanceId || undefined);
|
|
1528
|
+
if (rid === dataRid || (legacy.has(rid) && rooms.has(rid)))
|
|
1529
|
+
continue;
|
|
1530
|
+
legacy.set(rid, {
|
|
1531
|
+
epoch: e,
|
|
1532
|
+
rid,
|
|
1533
|
+
material: await deriveDocMaterial(raw, topicName),
|
|
1534
|
+
});
|
|
1535
|
+
await joinRoom(rid, await importAesKey(await hkdf(raw, `swal-signal/v1|${topicName}`)));
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Owner only: re-key at epoch + 1 for every member (not revoked, key record known), cutting `part.devices` and
|
|
1540
|
+
* executing `part.cut` (their grants). The rotation is a signed document: recorded here first, then handed to every
|
|
1541
|
+
* connected device under every key it may hold, then adopted.
|
|
1542
|
+
*/
|
|
1543
|
+
async function rotate(part) {
|
|
1544
|
+
const me = vault.deviceId;
|
|
1545
|
+
if (!sec || root?.deviceId !== me)
|
|
1546
|
+
throw new Error("only the owner re-keys the mesh");
|
|
1547
|
+
const newEpoch = epoch + 1;
|
|
1548
|
+
if (newEpoch > MAX_EPOCH)
|
|
1549
|
+
throw new Error("epoch limit reached: re-create the mesh");
|
|
1550
|
+
const cutDevs = new Set(part.devices);
|
|
1551
|
+
for (const l of [...links])
|
|
1552
|
+
if (l.deviceId && (cutDevs.has(l.deviceId) || isRevoked(l.deviceId)))
|
|
1553
|
+
closeRec(l);
|
|
1554
|
+
const pubs = new Map();
|
|
1555
|
+
for (const d of trusted.values()) {
|
|
1556
|
+
if (d.deviceId === me || cutDevs.has(d.deviceId) || isRevoked(d.deviceId))
|
|
1557
|
+
continue;
|
|
1558
|
+
const k = kexOf(d.deviceId);
|
|
1559
|
+
if (k)
|
|
1560
|
+
pubs.set(d.deviceId, k);
|
|
1561
|
+
else
|
|
1562
|
+
err(new Error(`no key record of ${d.deviceId} yet: it gets the key with a later rotation`));
|
|
1563
|
+
}
|
|
1564
|
+
// R4-S3/R5-S3: a rotation receivers would refuse must never be adopted here (the owner would end alone)
|
|
1565
|
+
if (pubs.size > MAX_ROT_MEMBERS ||
|
|
1566
|
+
cutDevs.size > MAX_ROT_MEMBERS ||
|
|
1567
|
+
part.cut.length > MAX_ROT_REFS)
|
|
1568
|
+
throw new Error(`re-key refused: ${pubs.size} recipients and ${cutDevs.size} cut devices; a rotation carries at most ${MAX_ROT_MEMBERS} of each`);
|
|
1569
|
+
const newKey = randomBytes(32);
|
|
1570
|
+
const base = {
|
|
1571
|
+
t: "rot",
|
|
1572
|
+
v: 2,
|
|
1573
|
+
inst: root.mid,
|
|
1574
|
+
epoch: newEpoch,
|
|
1575
|
+
from: me,
|
|
1576
|
+
to: [...pubs.keys()].sort(),
|
|
1577
|
+
revoked: [...cutDevs].sort(),
|
|
1578
|
+
cut: [...part.cut].sort(),
|
|
1579
|
+
revs: [...part.revs].sort().slice(0, MAX_ROT_REFS),
|
|
1580
|
+
n: b64uEncode(randomBytes(16)),
|
|
1581
|
+
};
|
|
1582
|
+
// wraps are bound to the pre-id; the id also commits to the whole wrap set, and the owner signs the id
|
|
1583
|
+
const pid = await rotationPreId(base);
|
|
1584
|
+
const priv = (await ecdhIdentity()).privateKey;
|
|
1585
|
+
const wraps = {};
|
|
1586
|
+
for (const [to, k] of pubs)
|
|
1587
|
+
wraps[to] = await wrapMeshKey(priv, k.pub, k.kem, pid, me, to, newKey);
|
|
1588
|
+
const wh = await wrapsHash(wraps);
|
|
1589
|
+
const id = await rotationId({ ...base, wh });
|
|
1590
|
+
const sig = b64uEncode(await vault.sign(rotationSigBytes(id)));
|
|
1591
|
+
if (destroyed)
|
|
1592
|
+
return; // torn down while signing: publish nothing, keep the vault as it was
|
|
1593
|
+
const docR = { ...base, wh, wraps, sig };
|
|
1594
|
+
const out = await sec.add(docR); // verified like any received rotation; records its cuts for good
|
|
1595
|
+
if (out.status !== "accepted" && out.status !== "duplicate")
|
|
1596
|
+
throw new Error(`re-key refused: ${out.reason}`);
|
|
1597
|
+
// 1) every connected device gets the rotation under every key it may hold, 2) switch
|
|
1598
|
+
const body = utf8(JSON.stringify({ t: "docs", docs: [docR] }));
|
|
1599
|
+
await Promise.all([...links]
|
|
1600
|
+
.filter((l) => !l.closing &&
|
|
1601
|
+
l.deviceId &&
|
|
1602
|
+
l.rid !== "pair" &&
|
|
1603
|
+
(!signFrames || l.authed))
|
|
1604
|
+
.map((l) => sendTrustEverywhere(l, body).catch(err)));
|
|
1605
|
+
await adopt({ rec: { ...docR, id }, key: newKey });
|
|
1606
|
+
announce([`R:${id}`]);
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Revoke a device: signed web/trust revocations of its grants, cut here at once and handed to every device. On the
|
|
1610
|
+
* owner the mesh is re-keyed right away; on an admin it is a REQUEST that the next owner device online executes
|
|
1611
|
+
* (until then `rekeyPending` is true: the revoked device still holds the current key).
|
|
1612
|
+
*/
|
|
1613
|
+
async function revoke(deviceId, o = {}) {
|
|
1614
|
+
await ready;
|
|
1615
|
+
if (deviceId === vault.deviceId)
|
|
1616
|
+
throw new Error("cannot revoke the current device");
|
|
1617
|
+
if (!sec || !root)
|
|
1618
|
+
throw new Error("unknown device");
|
|
1619
|
+
await refreshTrust();
|
|
1620
|
+
if (deviceId === root.deviceId)
|
|
1621
|
+
throw new Error(`not authorized to revoke ${deviceId}`);
|
|
1622
|
+
if (sec.trust.grantsOf(deviceId).length === 0)
|
|
1623
|
+
throw new Error("unknown device");
|
|
1624
|
+
if (opts.canRotate && !(await opts.canRotate(vault.deviceId, deviceId)))
|
|
1625
|
+
throw new Error(`not authorized to revoke ${deviceId}`);
|
|
1626
|
+
const docs = await sec.revocationsFor(signer, deviceId, {
|
|
1627
|
+
heads: o.heads,
|
|
1628
|
+
reason: o.reason,
|
|
1629
|
+
now: now(),
|
|
1630
|
+
});
|
|
1631
|
+
if (docs.length === 0 && !isRevoked(deviceId))
|
|
1632
|
+
throw new Error(`not authorized to revoke ${deviceId}`);
|
|
1633
|
+
if (!running)
|
|
1634
|
+
await start();
|
|
1635
|
+
await addLocal(docs);
|
|
1636
|
+
// cut the revoked device off SYNCHRONOUSLY: its link must not be reachable by anything below
|
|
1637
|
+
for (const l of [...links])
|
|
1638
|
+
if (l.deviceId === deviceId || (l.deviceId && isRevoked(l.deviceId)))
|
|
1639
|
+
closeRec(l);
|
|
1640
|
+
await refreshTrust();
|
|
1641
|
+
emit("revoked", {
|
|
1642
|
+
deviceId,
|
|
1643
|
+
epoch,
|
|
1644
|
+
pending: root.deviceId !== vault.deviceId,
|
|
1645
|
+
});
|
|
1646
|
+
await serialRot(ownerRekey).catch(err);
|
|
1647
|
+
}
|
|
1648
|
+
/** Owner only: own grants for devices that lost membership through a revoked admin (cascade). */
|
|
1649
|
+
async function reanchor(deviceIds) {
|
|
1650
|
+
await ready;
|
|
1651
|
+
if (!sec || root?.deviceId !== vault.deviceId)
|
|
1652
|
+
throw new Error("only the owner re-anchors devices");
|
|
1653
|
+
const grants = [];
|
|
1654
|
+
for (const id of deviceIds) {
|
|
1655
|
+
if (sec.memberGrant(id))
|
|
1656
|
+
continue;
|
|
1657
|
+
const old = sec.trust.grantsOf(id)[0];
|
|
1658
|
+
if (!old)
|
|
1659
|
+
throw new Error(`unknown device ${id}`);
|
|
1660
|
+
grants.push(await sec.issueGrant(signer, old.subject.pub, {
|
|
1661
|
+
role: old.delegate >= 1 ? "admin" : "member",
|
|
1662
|
+
name: old.name,
|
|
1663
|
+
now: now(),
|
|
1664
|
+
}));
|
|
1665
|
+
}
|
|
1666
|
+
await addLocal(grants);
|
|
1667
|
+
await refreshTrust();
|
|
1668
|
+
await serialRot(ownerRekey).catch(err);
|
|
1669
|
+
}
|
|
1670
|
+
// ---- pairing ----
|
|
1671
|
+
/** First pairing of a fresh mesh: this device becomes its owner (the pinned trust root). */
|
|
1672
|
+
async function ensureRoot() {
|
|
1673
|
+
if (root && sec)
|
|
1674
|
+
return root;
|
|
1675
|
+
// a NEW mesh id, never one read from anywhere else (anybody could copy another mesh's id)
|
|
1676
|
+
const mid = b64uEncode(randomBytes(16));
|
|
1677
|
+
root = { mid, deviceId: vault.deviceId, pub: selfPub };
|
|
1678
|
+
await store.set("root", root);
|
|
1679
|
+
await openSecurity(root);
|
|
1680
|
+
await publishKex();
|
|
1681
|
+
return root;
|
|
1682
|
+
}
|
|
1683
|
+
/** Grant chain of this device (its member grant, then its issuers' up to the root): what a guest needs to verify. */
|
|
1684
|
+
function ownChain() {
|
|
1685
|
+
const out = [];
|
|
1686
|
+
let g = sec?.memberGrant(vault.deviceId) ?? null;
|
|
1687
|
+
for (let i = 0; g && i < 8; i++) {
|
|
1688
|
+
out.push(g);
|
|
1689
|
+
g = g.parent ? (sec?.trust.getGrant(g.parent) ?? null) : null;
|
|
1690
|
+
}
|
|
1691
|
+
return out;
|
|
1692
|
+
}
|
|
1693
|
+
async function pairHost(o = {}) {
|
|
1694
|
+
await ready;
|
|
1695
|
+
const guestRole = o.role ?? "member";
|
|
1696
|
+
const r = await ensureRoot();
|
|
1697
|
+
await refreshTrust();
|
|
1698
|
+
const mine = selfRole();
|
|
1699
|
+
if (!mine || !canIssue(mine, guestRole))
|
|
1700
|
+
throw new Error(`this device (${mine ?? "not admitted"}) cannot admit a ${guestRole}`);
|
|
1701
|
+
// R5-S3: refuse an admission the next re-key could not carry (receivers accept at most MAX_ROT_MEMBERS recipients)
|
|
1702
|
+
if (trusted.size + 1 >= MAX_ROT_MEMBERS)
|
|
1703
|
+
throw new Error(`the mesh is full (${MAX_ROT_MEMBERS} devices)`);
|
|
1704
|
+
if (mine === "admin" &&
|
|
1705
|
+
(sec?.trust
|
|
1706
|
+
.docs()
|
|
1707
|
+
.filter((d) => d.t === "grant" && d.issuer === vault.deviceId).length ??
|
|
1708
|
+
0) >= MAX_MEMBERS_PER_ADMIN)
|
|
1709
|
+
throw new Error(`this admin already admitted ${MAX_MEMBERS_PER_ADMIN} devices`);
|
|
1710
|
+
await vault.getOrCreateMeshKey();
|
|
1711
|
+
const offer = await createPairOffer(vault, {
|
|
1712
|
+
mid: r.mid,
|
|
1713
|
+
root: r.deviceId,
|
|
1714
|
+
appId,
|
|
1715
|
+
topic: topicName,
|
|
1716
|
+
now: now(),
|
|
1717
|
+
});
|
|
1718
|
+
const rid = await derivePairRoomId(offer.pairSecret);
|
|
1719
|
+
const pairKey = await derivePairKey(offer.pairSecret);
|
|
1720
|
+
const host = new HostPairing(offer, {
|
|
1721
|
+
now,
|
|
1722
|
+
verify: identityVerify,
|
|
1723
|
+
prove: async (transcript) => ({
|
|
1724
|
+
pub: selfPub,
|
|
1725
|
+
sig: b64uEncode(await vault.sign(hostProofBytes(transcript, vault.deviceId, selfPub))),
|
|
1726
|
+
}),
|
|
1727
|
+
onSas: (p) => emit("sas", { role: "host", ...p }),
|
|
1728
|
+
buildGrant: async (guest) => {
|
|
1729
|
+
// B1: the ack already proved possession of guest.pub and deviceId = fingerprint(pub)
|
|
1730
|
+
if (guest.deviceId === vault.deviceId || guest.deviceId === r.deviceId)
|
|
1731
|
+
throw new Error("guest claims the host/root identity");
|
|
1732
|
+
if (!sec)
|
|
1733
|
+
throw new Error("no security state");
|
|
1734
|
+
if (opts.authorizeDevice &&
|
|
1735
|
+
!(await opts.authorizeDevice(guest.deviceId, b64uDecode(guest.pub)))) {
|
|
1736
|
+
throw new Error("device not authorized by the mesh policy");
|
|
1737
|
+
}
|
|
1738
|
+
// the key, its epoch and its rotation are read together, at the very moment the grant is made (a re-key may
|
|
1739
|
+
// have happened since the offer was shown)
|
|
1740
|
+
const keyNow = meshKey ?? (await vault.getOrCreateMeshKey());
|
|
1741
|
+
const epochNow = epoch;
|
|
1742
|
+
const rotNow = curRot;
|
|
1743
|
+
// a grant of its own (re-admission included: a new grant id, untouched by earlier revocations), handed the
|
|
1744
|
+
// key of this epoch
|
|
1745
|
+
const grant = await sec.issueGrant(signer, guest.pub, {
|
|
1746
|
+
role: guestRole === "admin" ? "admin" : "member",
|
|
1747
|
+
name: guest.name,
|
|
1748
|
+
epoch: epochNow,
|
|
1749
|
+
now: now(),
|
|
1750
|
+
});
|
|
1751
|
+
const dev = {
|
|
1752
|
+
...guest,
|
|
1753
|
+
addedAt: grant.issuedAt,
|
|
1754
|
+
role: guestRole,
|
|
1755
|
+
admittedBy: vault.deviceId,
|
|
1756
|
+
};
|
|
1757
|
+
const extra = o.extra ? await o.extra(dev) : undefined;
|
|
1758
|
+
await addLocal([grant]);
|
|
1759
|
+
await refreshTrust();
|
|
1760
|
+
const docs = [grant, ...ownChain()];
|
|
1761
|
+
for (const k of [sec.kexDoc(r.deviceId), sec.kexDoc(vault.deviceId)])
|
|
1762
|
+
if (k && !docs.includes(k))
|
|
1763
|
+
docs.push(k);
|
|
1764
|
+
if (rotNow && JSON.stringify(rotNow).length < 128 * 1024) {
|
|
1765
|
+
const { id: _id, ...d } = rotNow;
|
|
1766
|
+
docs.push(d);
|
|
1767
|
+
}
|
|
1768
|
+
return {
|
|
1769
|
+
meshKey: b64uEncode(keyNow),
|
|
1770
|
+
epoch: epochNow,
|
|
1771
|
+
mid: r.mid,
|
|
1772
|
+
hostDevice: selfDevice(),
|
|
1773
|
+
root: r,
|
|
1774
|
+
docs,
|
|
1775
|
+
...(rotNow ? { rotId: rotNow.id } : {}),
|
|
1776
|
+
...(extra !== undefined ? { extra } : {}),
|
|
1777
|
+
};
|
|
1778
|
+
},
|
|
1779
|
+
onPaired: (d) => {
|
|
1780
|
+
emit("paired", trusted.get(d.deviceId) ?? d);
|
|
1781
|
+
endPairing(rid);
|
|
1782
|
+
void start().catch(err);
|
|
1783
|
+
},
|
|
1784
|
+
onFail: (reason) => {
|
|
1785
|
+
emit("error", new Error(`pairing failed: ${reason}`));
|
|
1786
|
+
endPairing(rid);
|
|
1787
|
+
},
|
|
1788
|
+
});
|
|
1789
|
+
hostSession = { s: host, rid };
|
|
1790
|
+
pairRids.add(rid);
|
|
1791
|
+
await joinRoom(rid, pairKey);
|
|
1792
|
+
const timer = setTimeout(() => endPairing(rid), offer.payload.exp - now() + 1000);
|
|
1793
|
+
const endPairingOnce = () => clearTimeout(timer);
|
|
1794
|
+
host._end = endPairingOnce;
|
|
1795
|
+
return {
|
|
1796
|
+
payload: offer.encoded,
|
|
1797
|
+
expiresAt: offer.payload.exp,
|
|
1798
|
+
cancel: () => {
|
|
1799
|
+
host.cancel();
|
|
1800
|
+
endPairing(rid);
|
|
1801
|
+
},
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
function endPairing(rid) {
|
|
1805
|
+
hostSession?.s?._end?.();
|
|
1806
|
+
leaveRoom(rid);
|
|
1807
|
+
pairRids.delete(rid);
|
|
1808
|
+
// let the last frame (grant, possibly fragmented) flush before dropping the pairing links
|
|
1809
|
+
const pairLinks = [...links].filter((l) => l.rid === "pair");
|
|
1810
|
+
for (const l of pairLinks) {
|
|
1811
|
+
const drained = Promise.race([
|
|
1812
|
+
outQ.get(l.link) ?? Promise.resolve(),
|
|
1813
|
+
new Promise((r) => setTimeout(r, 10_000)),
|
|
1814
|
+
]);
|
|
1815
|
+
void drained.then(() => setTimeout(() => l.link.close(), 500));
|
|
1816
|
+
}
|
|
1817
|
+
if (hostSession?.rid === rid)
|
|
1818
|
+
hostSession = null;
|
|
1819
|
+
if (guestSession?.rid === rid)
|
|
1820
|
+
guestSession = null;
|
|
1821
|
+
setStatus();
|
|
1822
|
+
}
|
|
1823
|
+
const ROOT_MISMATCH_ERR = "pairing refused: this mesh id is pinned to another owner key";
|
|
1824
|
+
const MOVE_MESH_ERR = "this device belongs to another mesh: to join a different one, create a new Mesh with a fresh Y.Doc";
|
|
1825
|
+
/** No shared content at all (the mesh itself never writes to the doc). */
|
|
1826
|
+
function docIsFresh() {
|
|
1827
|
+
for (const [, t] of doc.share) {
|
|
1828
|
+
const ty = t;
|
|
1829
|
+
if (ty._start !== null || ty._map.size > 0)
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1832
|
+
return true;
|
|
1833
|
+
}
|
|
1834
|
+
async function pairJoin(encoded, o = {}) {
|
|
1835
|
+
await ready;
|
|
1836
|
+
const p = decodePairPayload(encoded);
|
|
1837
|
+
if (p.appId !== appId || p.topic !== topicName)
|
|
1838
|
+
throw new Error("pairing payload is for a different app/topic");
|
|
1839
|
+
// B3: a device moving to ANOTHER mesh would merge this mesh's doc into it (and keep serving it). Only a fresh
|
|
1840
|
+
// doc may change meshes: create a new Mesh with a new Y.Doc for that.
|
|
1841
|
+
// S6: trust on first use. The QR names the root; a device never re-pins another key for a mesh id it already knows
|
|
1842
|
+
if (root && root.mid === p.mid && root.deviceId !== p.root)
|
|
1843
|
+
throw new Error(ROOT_MISMATCH_ERR);
|
|
1844
|
+
const moving = root !== null && root.mid !== p.mid;
|
|
1845
|
+
if (moving && !docIsFresh())
|
|
1846
|
+
throw new Error(MOVE_MESH_ERR);
|
|
1847
|
+
// ...and while moving, the old mesh must not fill the fresh doc: go offline from it (resumed on failure)
|
|
1848
|
+
const resumeOld = moving && running;
|
|
1849
|
+
if (resumeOld)
|
|
1850
|
+
stopNetwork();
|
|
1851
|
+
const confirm = o.confirmSas ??
|
|
1852
|
+
((code) => new Promise((resolve) => emit("sas", {
|
|
1853
|
+
role: "guest",
|
|
1854
|
+
code,
|
|
1855
|
+
confirm: () => resolve(true),
|
|
1856
|
+
reject: () => resolve(false),
|
|
1857
|
+
})));
|
|
1858
|
+
const guest = await GuestPairing.create(p, vault, {
|
|
1859
|
+
name: opts.deviceName ?? "device",
|
|
1860
|
+
onSas: async (code) => Boolean(await confirm(code)),
|
|
1861
|
+
now: now(),
|
|
1862
|
+
});
|
|
1863
|
+
const rid = await derivePairRoomId(b64uDecode(p.pairSecret));
|
|
1864
|
+
guestSession = { s: guest, rid };
|
|
1865
|
+
pairRids.add(rid);
|
|
1866
|
+
await joinRoom(rid, await derivePairKey(b64uDecode(p.pairSecret)));
|
|
1867
|
+
const timeout = setTimeout(() => guest.fail(new Error("pairing timed out")), Math.max(1000, p.exp - now()));
|
|
1868
|
+
try {
|
|
1869
|
+
const g = await guest.result;
|
|
1870
|
+
clearTimeout(timeout);
|
|
1871
|
+
// the trust root and our grant arrive over the SAS-authenticated session; the grant chain must be valid and
|
|
1872
|
+
// issued by the very host that proved the identity named by the QR
|
|
1873
|
+
if (!g.root || !Array.isArray(g.docs))
|
|
1874
|
+
throw new Error("pairing grant carries no grant (host too old?)");
|
|
1875
|
+
if (!isEpoch(g.epoch))
|
|
1876
|
+
throw new Error("pairing grant: bad epoch");
|
|
1877
|
+
if (g.root.mid !== p.mid || g.mid !== p.mid)
|
|
1878
|
+
throw new Error("pairing grant: mesh id does not match the pairing code");
|
|
1879
|
+
if (g.root.deviceId !== p.root)
|
|
1880
|
+
throw new Error("pairing grant: trust root does not match the pairing code");
|
|
1881
|
+
if (!(await idMatchesPub(g.root.deviceId, g.root.pub)))
|
|
1882
|
+
throw new Error("pairing grant: invalid trust root");
|
|
1883
|
+
if (root &&
|
|
1884
|
+
root.mid === g.root.mid &&
|
|
1885
|
+
(root.deviceId !== g.root.deviceId || root.pub !== g.root.pub))
|
|
1886
|
+
throw new Error(ROOT_MISMATCH_ERR);
|
|
1887
|
+
// verify in a scratch state first: nothing is pinned or stored before the grant checks out
|
|
1888
|
+
const probe = await SecurityState.open({
|
|
1889
|
+
root: g.root,
|
|
1890
|
+
store: memoryStore(),
|
|
1891
|
+
schema: opts.trustSchema,
|
|
1892
|
+
now,
|
|
1893
|
+
});
|
|
1894
|
+
await probe.addMany(g.docs);
|
|
1895
|
+
const mine = probe.memberGrant(vault.deviceId);
|
|
1896
|
+
if (!mine || mine.subject.pub !== selfPub)
|
|
1897
|
+
throw new Error("pairing grant: invalid grant for this device");
|
|
1898
|
+
// the host proved (inside the session) that it holds hostId's ML-DSA key; the grant must come from it
|
|
1899
|
+
if (mine.issuer !== p.hostId)
|
|
1900
|
+
throw new Error("pairing grant: grant not issued by the paired host");
|
|
1901
|
+
if (probe.identityOf(p.hostId) !== g.hostProof?.pub)
|
|
1902
|
+
throw new Error("pairing grant: grant not issued by the paired host");
|
|
1903
|
+
const switching = root?.mid !== g.root.mid;
|
|
1904
|
+
if (switching && root && !docIsFresh())
|
|
1905
|
+
throw new Error(MOVE_MESH_ERR);
|
|
1906
|
+
// same mesh: never step back to an older epoch (and its older key) because the host lags behind us
|
|
1907
|
+
if (!switching && root && g.epoch < epoch)
|
|
1908
|
+
throw new Error(`pairing refused: the host is at epoch ${g.epoch}, this device at ${epoch}`);
|
|
1909
|
+
// leave the current network BEFORE touching keys or the doc: nothing of one mesh may reach the other
|
|
1910
|
+
if (running)
|
|
1911
|
+
stopNetwork();
|
|
1912
|
+
if (switching) {
|
|
1913
|
+
// nothing of the old mesh's security state survives a move
|
|
1914
|
+
secUnsub?.();
|
|
1915
|
+
sec = null;
|
|
1916
|
+
trusted = new Map();
|
|
1917
|
+
legacy.clear();
|
|
1918
|
+
replay.clear();
|
|
1919
|
+
cands.clear();
|
|
1920
|
+
unwrapFailed.clear();
|
|
1921
|
+
curRot = null;
|
|
1922
|
+
curRotId = null;
|
|
1923
|
+
for (const k of [
|
|
1924
|
+
"sec/trust",
|
|
1925
|
+
"sec/kex",
|
|
1926
|
+
"sec/rots",
|
|
1927
|
+
"sec/cuts",
|
|
1928
|
+
"sec/gone",
|
|
1929
|
+
])
|
|
1930
|
+
await store.set(k, []);
|
|
1931
|
+
await store.set(RETIRED_KEY, []);
|
|
1932
|
+
await store.set("rotId", null);
|
|
1933
|
+
}
|
|
1934
|
+
root = g.root;
|
|
1935
|
+
await store.set("root", root);
|
|
1936
|
+
if (g.hostDevice &&
|
|
1937
|
+
g.hostDevice.deviceId === g.root.deviceId &&
|
|
1938
|
+
typeof g.hostDevice.name === "string") {
|
|
1939
|
+
ownerName = g.hostDevice.name;
|
|
1940
|
+
await store.set("ownerName", ownerName);
|
|
1941
|
+
}
|
|
1942
|
+
if (!sec)
|
|
1943
|
+
await openSecurity(root);
|
|
1944
|
+
await sec.addMany(g.docs);
|
|
1945
|
+
const key = b64uDecode(g.meshKey);
|
|
1946
|
+
await vault.setMeshKey(key);
|
|
1947
|
+
await persistEpoch(g.epoch);
|
|
1948
|
+
epoch = g.epoch;
|
|
1949
|
+
// the rotation that produced this key (if any): lets this device take part in tie-breaks of its epoch
|
|
1950
|
+
curRotId = typeof g.rotId === "string" ? g.rotId : null;
|
|
1951
|
+
const cr = curRotId ? sec?.rotation(curRotId) : undefined;
|
|
1952
|
+
curRot = cr && cr.epoch === g.epoch ? cr : null;
|
|
1953
|
+
cands.clear();
|
|
1954
|
+
await store.set("rotId", curRotId);
|
|
1955
|
+
await publishKex();
|
|
1956
|
+
await refreshTrust();
|
|
1957
|
+
endPairing(rid);
|
|
1958
|
+
emit("paired", g.hostDevice);
|
|
1959
|
+
await start();
|
|
1960
|
+
return { host: g.hostDevice, extra: g.extra };
|
|
1961
|
+
}
|
|
1962
|
+
catch (e) {
|
|
1963
|
+
clearTimeout(timeout);
|
|
1964
|
+
endPairing(rid);
|
|
1965
|
+
if (resumeOld && !running && root?.mid !== p.mid)
|
|
1966
|
+
void start().catch(err);
|
|
1967
|
+
throw e;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return {
|
|
1971
|
+
get status() {
|
|
1972
|
+
return status;
|
|
1973
|
+
},
|
|
1974
|
+
get peers() {
|
|
1975
|
+
return peerIds();
|
|
1976
|
+
},
|
|
1977
|
+
get epoch() {
|
|
1978
|
+
return epoch;
|
|
1979
|
+
},
|
|
1980
|
+
get root() {
|
|
1981
|
+
return root;
|
|
1982
|
+
},
|
|
1983
|
+
get rekeyPending() {
|
|
1984
|
+
return rekeyPending();
|
|
1985
|
+
},
|
|
1986
|
+
get namespace() {
|
|
1987
|
+
return meshNamespace(appId, instanceId);
|
|
1988
|
+
},
|
|
1989
|
+
channel,
|
|
1990
|
+
role(deviceId = vault.deviceId) {
|
|
1991
|
+
return roleOf(deviceId);
|
|
1992
|
+
},
|
|
1993
|
+
awareness,
|
|
1994
|
+
ready,
|
|
1995
|
+
pairHost,
|
|
1996
|
+
pairJoin,
|
|
1997
|
+
devices,
|
|
1998
|
+
revoke,
|
|
1999
|
+
reanchor,
|
|
2000
|
+
get security() {
|
|
2001
|
+
const st = sec;
|
|
2002
|
+
if (!st)
|
|
2003
|
+
return null;
|
|
2004
|
+
return {
|
|
2005
|
+
trust: st.trust,
|
|
2006
|
+
docs: async () => st.get(st.inventory()),
|
|
2007
|
+
rotations: () => st.rotations(),
|
|
2008
|
+
keyAgreement: (id) => st.keyAgreement(id),
|
|
2009
|
+
executedCuts: () => st.executedCuts(),
|
|
2010
|
+
add: async (d) => (await addLocal([d]))[0] ?? { status: "rejected" },
|
|
2011
|
+
addMany: (ds) => addLocal(ds),
|
|
2012
|
+
issueGrant: (subjectPub, o2) => st.issueGrant(signer, subjectPub, { ...o2, now: now() }),
|
|
2013
|
+
};
|
|
2014
|
+
},
|
|
2015
|
+
leave() {
|
|
2016
|
+
stopNetwork();
|
|
2017
|
+
},
|
|
2018
|
+
destroy() {
|
|
2019
|
+
if (destroyed)
|
|
2020
|
+
return;
|
|
2021
|
+
destroyed = true;
|
|
2022
|
+
stopNetwork();
|
|
2023
|
+
doc.off("update", onDocUpdate);
|
|
2024
|
+
removeAwarenessStates(awareness, [doc.clientID], ORIGIN);
|
|
2025
|
+
awareness.destroy();
|
|
2026
|
+
for (const t of transports)
|
|
2027
|
+
t.close();
|
|
2028
|
+
void persistence?.destroy();
|
|
2029
|
+
listeners.clear();
|
|
2030
|
+
},
|
|
2031
|
+
on(event, cb) {
|
|
2032
|
+
let set = listeners.get(event);
|
|
2033
|
+
if (!set) {
|
|
2034
|
+
set = new Set();
|
|
2035
|
+
listeners.set(event, set);
|
|
2036
|
+
}
|
|
2037
|
+
set.add(cb);
|
|
2038
|
+
return () => void set.delete(cb);
|
|
2039
|
+
},
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
//# sourceMappingURL=provider.js.map
|