Skip to content

Kontinuum Node — Protocols

Wire-level protocols for inter-node communication, DHT model, mailbox semantics.

Audience: node developers. Concrete data structures, wire formats, propagation rules.

Связанные документы:

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 registrynode:{node_id}Signed{ multiaddrs, tier, capabilities, geo_zone, last_seen }
Tier 0 CRLcrl:{tier0_pubkey}Signed{ revoked_node_ids[], generation, timestamp }
Identity stubidentity:{identity_id_hash}Signed{ pubkey, personal_space_hint, last_seen } (опционально, см. §6.1)
Public space manifestpub-space:{space_id}Signed{ owner_identity, manifest_hash, replication_set }
Public content providerscnt:{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-правилами:

Тип SpaceMembersСоздание
Personal SpaceТолько устройства owner'аAuto при первой Tier 1/2 ноде. Один на identity.
Shared Space (group)Owner + ≥1 invited identitiesЯвно пользователем
Direct SpaceOwner + 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.
rust
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

rust
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 - 1 op'ов с тем же 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.»
  • 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 через три механизма:

  1. Auth-shim ACL (immediate): при POST /presign для blob в Space S auth_shim проверяет requester_identity ∈ current_members(S). Kicked → 403. Применяется единообразно ко всем requester'ам, включая admin API владельца ноды (нет escape hatch).
  2. Provider records re-signing: новые provider records после Revoke подписаны новым space_key version. Kicked member не имеет нового key → не может валидировать DHT-ответы.
  3. 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.

rust
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.

rust
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 удаляется когда выполнено любое из условий:

  1. Все «живые» cursor'ы (now - last_heartbeat < 7 days) прошли seqno, ИЛИ
  2. Entry старше ttl_override для своего типа.

Cursor liveness: устройство не было online 7 дней → cursor «застывает», не блокирует GC.

Технические квоты (защита инфры, не SKU):

  • mailbox_max_bytes per identity — техническая защита от bloat'а.
  • mailbox_max_messages per identity — hard cap (например 10 000).
  • Переполнение → force-delete oldest-delivered-to-1-plus-devices.

Конкретные числа — параметры конфига ноды, не тарифные планы.

10.3 TTL по типам сообщений

Тип SharingMessageTTL
Publish30 дней
Shareдо all-ack ИЛИ 30 дней
Ack24 часа
FileChunkдо ack ИЛИ 7 дней
Ping / Pongdrop, не store
SyncDigest / SyncSnapshot / SyncUpdatemerge в state, не log
DeviceRevoked90 дней (security-critical)
Stream*drop (live-only)
ThumbnailRequest/Response24 часа
DirectoryListRequest/Response1 час

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:

rust
struct Frame {
    dht_namespace: DhtId,  // 32 bytes, выбирает routing table
    payload: WireMessage,
}

(a) Handshake & identity

Происходит при первом контакте между двумя нодами.

NodeHello (initiator → responder):

rust
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):

rust
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.

rust
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'ов.

rust
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 между нодами.

rust
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'ы, которые декларирует.

rust
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

rust
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 DHT mbox: запись).
  • Device получателя → connect к mailbox-host → читает log с current cursor.
  • Device → MailboxCursorUpdate после успешного sync.
  • Host применяет GC согласно §10.2.

(g) Lifecycle / billing

Propagation cert lifecycle events через gossip.

rust
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.

rust
NodeStatus {
    current_load_pct: u8,
    storage_used_pct: u8,
    dht_records: u64,
    last_lookup_ms: u32,
    recent_failures: u32,
}

Используется placement-algorithm чтобы избегать перегруженных нод при выборе репликаторов.