Kontinuum Node — Protocols
Wire-level protocols for inter-node communication, DHT model, mailbox semantics.
Audience: node developers. Concrete data structures, wire formats, propagation rules.
Связанные документы:
architecture.md— start here for overview / glossary / tier modeloperations.md— storage layer, replication policy, cert lifecycle, admin surfacesapp-integration.md— client-side flows that use these protocols
Section codes (§5, §10, §11) preserved from единой v0.6 спеки. §10.5 (free-tier UX) → в app-integration.md.
5. Модель DHT
5.1 Global DHT
Один Kademlia overlay, key-space 256 бит. Все Tier 0/1/2 участвуют (через mandatory dht_routing).
| Тип записи | Ключ | Значение |
|---|---|---|
| Node registry | node:{node_id} | Signed{ multiaddrs, tier, capabilities, geo_zone, last_seen } |
| Tier 0 CRL | crl:{tier0_pubkey} | Signed{ revoked_node_ids[], generation, timestamp } |
| Identity stub | identity:{identity_id_hash} | Signed{ pubkey, personal_space_hint, last_seen } (опционально, см. §6.1) |
| Public space manifest | pub-space:{space_id} | Signed{ owner_identity, manifest_hash, replication_set } |
| Public content providers | cnt:{blob_hash} | [Signed{ node_id, region, until }] |
Replication factor в global DHT — стандартный Kademlia k = 20.
5.1.1 Надёжность Global DHT
Множественные слои защиты:
- Kademlia k=20: каждая запись реплицируется у 20 ближайших по xor-distance нод. Потеря < 20 нод одновременно — данные не теряются.
- Periodic re-publish: владелец записи (или нода-host) каждые ~24 часа refresh'ит запись. Если владелец offline > N дней — запись eventually decays.
- k-bucket refresh: ноды периодически lookup'ят случайные точки своего key-space → обнаруживают новых соседей при churn'е.
- Signed records + LWW: каждая запись подписана издателем. Concurrent updates разрешаются по signed timestamp (last-writer-wins). Подмена невозможна без compromise ключа.
- Disjoint-path lookups: client идёт через ≥3 независимых пути к разным k-bucket'ам → anti-Eclipse.
- Anti-entropy gossip поверх Kademlia (§11d): Merkle-tree reconciliation между Tier 1/2 нодами для catch-up рассинхронизаций.
- Tier 0 anchor stability: hardcoded multiaddrs Tier 0 гарантируют bootstrap даже при churn'е остальной сети.
- Geographic distribution: при равномерной сети k=20 копий автоматически распределяются по всем регионам.
Лимит one-overlay модели: при ~100M+ identity-stub records write-rate под periodic re-publish становится bottleneck'ом (см. §18.2). Решение — sharded DHT по identity_id prefix (см. §18.3). Это open question для v1.0+; текущая v0.4 архитектура рассчитана на ≤10M идентичностей в одном overlay.
5.2 Space DHT (унифицированный примитив)
Все DHT'ы — это Space DHTs, различаются только membership-правилами:
| Тип Space | Members | Создание |
|---|---|---|
| Personal Space | Только устройства owner'а | Auto при первой Tier 1/2 ноде. Один на identity. |
| Shared Space (group) | Owner + ≥1 invited identities | Явно пользователем |
| Direct Space | Owner + 1 invited (частный случай Shared Space) | Auto при первом mutual messaging или явно |
Owner для Direct Space:
- При явном создании или first-message auto-create: owner = initiator (тот, кто послал первое сообщение / создал явно).
- При mediator-introduction (§6.3): owner = тот, кого mediator выбрал первым в UI «Introduce these two» (тот, кому Charlie кликнул первым).
- Owner — единственный, кто может Revoke и KeyRotation. Второй member может только SelfLeave.
struct SpaceDescriptor {
space_id: SpaceId, // blake3(creator_pubkey || space_kind || nonce)
space_dht_id: DhtId, // blake3(space_id || "dht")
members: Vec<SignedMemberOp>, // append-only log с revoke-ops
space_key_version: u64, // увеличивается при KeyRotation
replication_set: Vec<NodeId>,
created_at: u64,
}5.2.1 Member ops
enum MemberOp {
Add {
identity_id: IdentityId,
pubkey: Vec<u8>,
role: Role,
added_at: u64,
},
Revoke {
identity_id: IdentityId,
revoked_at: u64,
reason: Option<String>,
},
KeyRotation {
new_space_key_version: u64,
wrapped_keys: Vec<(IdentityId, Vec<u8>)>, // новый key, зашифрованный per-member
rotated_at: u64,
},
RoleChange { identity_id, new_role, changed_at },
SelfLeave { identity_id, left_at }, // member выходит сам, owner не нужен
}
struct Role {
can_invite: bool,
can_publish: bool,
can_pin: bool,
can_kick: bool, // обычно только owner
}
struct SignedMemberOp {
op: MemberOp,
op_seqno: u64, // monotonic, уникальный per Space
transaction_id: Option<TxId>, // = blake3(timestamp || nonce); если часть atomic-group
transaction_total: Option<u8>, // сколько op'ов в группе (обычно 2 для Revoke+KeyRotation)
transaction_index: Option<u8>, // позиция этой op (0..N-1)
signed_by: IdentityId,
signature: Vec<u8>,
}Семантика:
- Текущая membership = детерминированная свёртка лога (replay all ops в порядке
op_seqno). - Add требует подписи owner'а (или identity с
can_invite=true). - Revoke требует подписи owner'а (или identity с
can_kick=true). - SelfLeave не требует owner-signature — member сам подписывает свой выход.
5.2.2 Atomic Revoke + KeyRotation
Чтобы избежать race condition при concurrent membership changes:
Atomic-group через transaction_id:
- Revoke и KeyRotation получают разные
op_seqno(monotonic, уникальные), но одинtransaction_id=blake3(timestamp || nonce). Поляtransaction_total = 2,transaction_index = 0для Revoke и1для KeyRotation. - Receiving нода видит op с
transaction_id = T→ кладёт в quarantine buffer, не применяет сразу. - Ждёт остальные
transaction_total - 1op'ов с тем жеTчерез DhtPut propagation или anti-entropy gossip. - Все собраны → применяет atomically в порядке
transaction_index. - Timeout 60 секунд: если не все op'ы собраны — reject все, owner ретраит (новый
transaction_id).
Adaptive quorum + degraded mode:
- Owner перед публикацией pair собирает acknowledgment от
min(2, available_live_replicas)Tier 1/2 replicas Space DHT (что они приняли все предыдущие операции с меньшим seqno). - Для Direct Space (2 члена → 2 ноды): если friend offline → достаточно одной живой replica (owner's own).
- Timeout 30 секунд на quorum collection. Не достигнут → переход в degraded mode:
- Owner применяет op'ы локально на своей ноде, помечает их
pending_propagation = true. - Anti-entropy gossip (§11d) catches up при возвращении offline-friends online.
- UX-warning owner'у: «Removal applied locally. Other devices will sync the removal when they come online.»
- Owner применяет op'ы локально на своей ноде, помечает их
- Edge case (kicked friend никогда не возвращается online): kick остаётся локально; новые operations идут с обновлённой membership; eventual sync при возможном возвращении.
Propagation через DHT:
SignedMemberOp с transaction_id распространяется обычным DhtPut (§11b) на k=20 ближайших нод Space DHT. Receiving ноды quarantine'т ops до сбора полной транзакции — это lazy two-phase commit без отдельного wire-message.
5.2.3 Forward secrecy после Revoke
Старые blob'ы, скачанные kicked member ДО Revoke, мы технически не можем «забрать обратно» — они уже расшифрованы в его памяти. Это inherent design constraint (UX-предупреждение в §5.2.4).
Различение ролей:
- Owner ноды — тот, кто оплачивает VPS / держит home server. Имеет admin-доступ к серверу и физический доступ к диску.
- Owner Space — тот, кто создал конкретный Space. Управляет membership, держит current
space_key, выпускает KeyRotation. - В family-mode и friend-replication эти роли разные identity (host-нода ≠ space-owner).
Блокировка future re-download через три механизма:
- Auth-shim ACL (immediate): при
POST /presignдля blob в Space S auth_shim проверяетrequester_identity ∈ current_members(S). Kicked → 403. Применяется единообразно ко всем requester'ам, включая admin API владельца ноды (нет escape hatch). - Provider records re-signing: новые provider records после Revoke подписаны новым
space_key version. Kicked member не имеет нового key → не может валидировать DHT-ответы. - Lazy blob re-encryption (background): post-Revoke job перешифровывает все blob'ы Space новым
space_key(читает blob → decrypts → re-encrypts → writes под новым content-address). Старый ciphertext eventually GC'нется. Этот job имеет приоритет над read-only freeze: даже при PRO lapse / cert revocation (§13.3 day 7+), re-encryption продолжает работать до завершения. Иначе forward secrecy guarantee не достигается.
Уровни forward-secrecy guarantee:
- Immediate (мгновенно после Revoke): kicked не может скачать новые blob'ы через auth-shim (включая admin-доступ через host's API).
- Eventually (после re-encryption job завершения): kicked не может расшифровать blob'ы, даже если получил ciphertext через физический доступ к диску host's ноды или через external bucket напрямую.
Inherent compromise: auth-shim защищает от сетевых requester'ов. Физический доступ к диску host's ноды у её admin-владельца остаётся — но без current space_key ciphertext бесполезен после re-encryption job. До завершения job окно уязвимости существует.
5.2.4 UX-предупреждение при Revoke
При попытке kick member UI показывает диалог:
⚠️ Member already has access to content shared with them prior to removal. Sensitive content shared in the future will not be visible to them. Re-encryption of existing content will start in background and complete within ~24h depending on volume.
5.3 Мультиплексирование нескольких DHT в одной ноде
Одна kontinuum-node процесс держит один libp2p Swarm = один Noise+Yamux транспорт, и N Kademlia behaviours в Swarm — по одной на каждую DHT.
struct Frame {
dht_namespace: DhtId,
payload: Bytes,
}Архитектура:
- Connection pool: к каждому peer'у одно живое TCP/QUIC соединение, независимо от количества общих DHT. Yamux мультиплексирует logical streams.
- Frame routing: incoming frame → match
dht_namespace→ forward в соответствующий Kademlia behaviour. Если нода не участвует в этом namespace → reject (UnknownDht). - Memory: ~50KB на routing table + ~5KB на active peer connection state. 50 spaces × 50 peers ≈ 2.5MB + 250KB ≈ ~3MB RAM total.
- Per-DHT квоты (технические, защита от bloat'a):
max_records_per_dht— нарушение → reject DhtPut.max_storage_bytes_per_dht— нарушение → reject новых записей.max_ops_per_sec_per_dht— token bucket, превышение → reject.
- Защита от bloat: один зловредный/перегруженный Space DHT не задушит остальные.
- Connection upgrade: при добавлении новой DHT membership (новый join Shared Space) routing table создаётся on-demand, peer'ы discover'ятся через bootstrap из global DHT.
5.3.1 Implementation strategy: N independent + partial hybrid
v1.0 baseline (Option A): N independent libp2p-kad behaviours, каждый со своим Protocol ID (/kontinuum/dht/global/1.0.0, /kontinuum/dht/space/{namespace_hex}/1.0.0).
- Pros: standard libp2p, minimal new code, hardened by years of libp2p production use.
- Cons: N × routing table memory; per-DHT handshake overhead.
Partial-B hybrid optimization (планируется к включению): для many small Space DHTs (когда у пользователя > 30 active shared spaces) — group их в один shared Kademlia behaviour с prefix-based key partitioning внутри. Global DHT и Personal Space DHT остаются independent.
- Trigger: profiling показывает routing tables memory > 100MB на ноду в production.
- Effort: ~1 человеко-месяц, не breaks wire compatibility (Protocol ID для shared Kademlia добавляется как новый, старые independent — продолжают работать параллельно).
- Effect: routing-table savings только для shared spaces, без full unified Kademlia rewrite.
Full unified Kademlia (Option B) отложен; реализация — ~2-3 человеко-месяца + breaks wire compatibility + dual-mode period ~3-6 месяцев. Не планируется без актуального production-evidence о bottleneck'е.
10. Mailbox / store-and-forward
10.1 Структура
Append-only log per identity_id.
struct MailboxEntry {
seqno: u64,
enqueued_at: u64,
msg_type: SharingMessageType,
encrypted_payload: Vec<u8>,
sender_signature: Vec<u8>,
ttl_override: Option<u64>,
}
struct DeviceCursor {
device_id: DeviceId,
last_seen_seqno: u64,
last_heartbeat: u64,
device_signature: Vec<u8>, // владельцу нельзя обмануть GC
}10.2 GC-политика (модель C + D)
Entry удаляется когда выполнено любое из условий:
- Все «живые» cursor'ы (
now - last_heartbeat < 7 days) прошли seqno, ИЛИ - Entry старше
ttl_overrideдля своего типа.
Cursor liveness: устройство не было online 7 дней → cursor «застывает», не блокирует GC.
Технические квоты (защита инфры, не SKU):
mailbox_max_bytesper identity — техническая защита от bloat'а.mailbox_max_messagesper identity — hard cap (например 10 000).- Переполнение → force-delete oldest-delivered-to-1-plus-devices.
Конкретные числа — параметры конфига ноды, не тарифные планы.
10.3 TTL по типам сообщений
Тип SharingMessage | TTL |
|---|---|
Publish | 30 дней |
Share | до all-ack ИЛИ 30 дней |
Ack | 24 часа |
FileChunk | до ack ИЛИ 7 дней |
Ping / Pong | drop, не store |
SyncDigest / SyncSnapshot / SyncUpdate | merge в state, не log |
DeviceRevoked | 90 дней (security-critical) |
Stream* | drop (live-only) |
ThumbnailRequest/Response | 24 часа |
DirectoryListRequest/Response | 1 час |
10.4 Scope mailbox'a
- Standard relay-node — хостит mailbox владельца.
- PRO VPS / home-node single — хостит mailbox владельца.
- PRO VPS / home-node multi (family-mode) — хостит mailbox владельца + mailboxes whitelisted tenants (per-tenant квоты).
- Общественный mailbox-pool не существует. Каждый mailbox привязан к чьей-то оплаченной инфре.
11. Inter-node протокол
Бинарный wire-protocol между нодами поверх Noise + Yamux. Клиенту не виден. Сериализация — CBOR (RFC 8949, length-prefixed) для wire-формата (обеспечивает schema evolution + cross-language support для будущих Android/iOS native клиентов). bincode используется только для internal persistence (SQLite blobs, cache files) — где cross-language не нужен и важен performance.
Transport: Noise — consistent с kontinuum-app::p2p codebase, не требует certificate management, simpler state machine vs TLS 1.3. libp2p built-in support.
Frame envelope:
struct Frame {
dht_namespace: DhtId, // 32 bytes, выбирает routing table
payload: WireMessage,
}(a) Handshake & identity
Происходит при первом контакте между двумя нодами.
NodeHello (initiator → responder):
struct NodeHello {
node_id: NodeId,
tier: u8,
cert: Option<Tier0SignedCert>, // обязателен для Tier 1
capabilities: Vec<Capability>,
capacity: NodeCapacity,
participates_in_dhts: Vec<DhtId>,
protocol_version: u32,
}NodeHelloAck (responder → initiator):
struct NodeHelloAck {
node_id: NodeId,
accepted: bool,
reject_reason: Option<String>,
my_clock: u64, // для clock-skew detection
}Error cases:
- cert невалиден / expired → reject.
- protocol_version mismatch → reject.
- responder в drain mode → reject с retry-after hint.
(b) DHT operations (per-namespace)
Стандартный Kademlia, обёрнутый в namespace prefix.
DhtPut { key, signed_record }
DhtGet { key, request_id } → DhtGetResponse { request_id, records: Vec<SignedRecord> }
DhtFindNode { target, request_id } → DhtFindNodeResponse { request_id, closest: Vec<NodeId> }
DhtIterate { range_start, range_end, request_id } → стрим записей (для anti-entropy)Семантика:
DhtPut— node принимает только записи, для которых она в k-ближайших к key. Проверяет подпись + freshness.DhtGet— возвращает все live records для key (concurrent updates resolves по signed timestamp).- Per-DHT квоты применяются на
DhtPut(rejects при превышенииmax_records).
(c) Replication & storage
Proactive / on-demand передача blob'ов.
ReplicaOffer { blob_hash, size, owner_identity, pin_proof }
ReplicaPull { blob_hash } // запрос «дай мне этот blob»
ReplicaPush { blob_hash, chunks, manifest } // отправка blob'a
ReplicaAck { blob_hash, result: Stored | RejectedQuota | RejectedDuplicate }Когда используется:
- При repair (§9.6) — пушим blob новой реплике.
- При friend-replication bonus (§9.3) — пушим member's node при join Shared Space.
- При user pin (§9.5) — forced push в owner's storage.
(d) Anti-entropy gossip
Cassandra/Riak-style active reconciliation между нодами.
MerkleRootExchange { dht_id, partition_start, partition_end, root_hash }
MerkleDescend { dht_id, partition_start, partition_end, branch_path }
MerkleLeaf { dht_id, key_range_start, key_range_end, records: Vec<SignedRecord> }Семантика:
- Periodic (e.g. каждые 5 минут) ноды обмениваются Merkle-tree корнями своих partitions.
- Mismatch root → descend tree → найти divergent leaf → exchange records.
- Reconcile: LWW по signed timestamp (или CRDT merge для state-sync типов).
Эффект: обнаруживает рассинхронизации после churn'а / network partition'ов, чинит без явного запроса.
(e) Challenge-response (PoS для Tier 2)
Random Merkle-proof challenges для верификации что Tier 2 нода реально хранит blob'ы, которые декларирует.
StorageChallenge {
challenger: NodeId,
blob_hash: BlobHash,
byte_offset: u64,
byte_length: u32,
merkle_path: MerklePath,
nonce: [u8; 16],
}
StorageProof {
challenger: NodeId,
blob_hash: BlobHash,
byte_window_hash: [u8; 32],
merkle_proof: MerkleProof,
signature: Vec<u8>,
}
ChallengeResult {
responder: NodeId,
blob_hash: BlobHash,
outcome: Passed | Failed | Timeout,
score_delta: i32,
}Cost-asymmetry: verifier O(log n), responder O(n) I/O. Делает Sybil-via-fake-storage экспоненциально дорогой.
Persistent failures → eviction из provider records, score decay.
(f) Mailbox / store-forward
MailboxDeposit {
recipient_identity: IdentityId,
encrypted_payload: Vec<u8>,
sender_signature: Signature,
ttl: u32,
}
MailboxHandover {
recipient_identity: IdentityId,
target_node: NodeId, // передача mailbox-hosting на другую ноду
}
MailboxCursorUpdate {
device_id: DeviceId,
cursor_seqno: u64,
device_signature: Signature,
}
MailboxFlush {
recipient_identity: IdentityId,
drained_until_seqno: u64, // нода-host сообщает sender'ам что прочитано
}Поток:
- Sender →
MailboxDeposit→ mailbox-host получателя (lookup через global DHTmbox:запись). - Device получателя → connect к mailbox-host → читает log с current cursor.
- Device →
MailboxCursorUpdateпосле успешного sync. - Host применяет GC согласно §10.2.
(g) Lifecycle / billing
Propagation cert lifecycle events через gossip.
CertRevocation {
revoked_node_id: NodeId,
reason: String,
tier0_signature: Signature,
revoked_at: u64,
}
CertRenewal {
node_id: NodeId,
new_cert: Tier0SignedCert,
}
TierTransition {
node_id: NodeId,
from_tier: u8,
to_tier: u8,
effective_at: u64,
}Поток:
- Tier 0 публикует
CertRevocationв global DHT (crl:{tier0_pubkey}запись). - Соседние ноды видят через ~1–2 минуты gossip propagation.
- Помечают revoked-ноду как read-only / orphan; reject
DhtPut/ReplicaPushк ней.
(h) Status & load
Periodic broadcast (e.g. каждые 30 сек) для placement-decisions.
NodeStatus {
current_load_pct: u8,
storage_used_pct: u8,
dht_records: u64,
last_lookup_ms: u32,
recent_failures: u32,
}Используется placement-algorithm чтобы избегать перегруженных нод при выборе репликаторов.