From a36cc86224f9dfa65d5e9299833f89378c252c53 Mon Sep 17 00:00:00 2001 From: Andri Date: Sun, 26 Jul 2026 09:47:30 +0000 Subject: [PATCH 1/3] Harden core trust boundaries and delivery reliability --- .github/CODEOWNERS | 17 + CODE_OF_CONDUCT.md | 51 ++ CONTRIBUTING.md | 56 +- Cargo.lock | 3 + GOVERNANCE.md | 119 ++++ MAINTAINERS.md | 70 ++ README.md | 133 ++-- SECURITY.md | 37 +- crates/kult-node/src/lib.rs | 266 +++++++- crates/kult-node/tests/node_e2e.rs | 181 ++++- crates/kult-node/tests/single_writer.rs | 32 + .../fuzz/fuzz_targets/bundle_import.rs | 2 +- .../fuzz_targets/protocol_envelope_decode.rs | 4 +- crates/kult-protocol/src/bundle.rs | 51 +- crates/kult-protocol/src/envelope.rs | 47 +- crates/kult-protocol/src/error.rs | 9 + crates/kult-protocol/src/fragmentation.rs | 22 +- crates/kult-protocol/src/lib.rs | 10 +- crates/kult-protocol/tests/protocol.rs | 48 +- crates/kult-store/Cargo.toml | 1 + crates/kult-store/examples/sneakernet_demo.rs | 2 +- crates/kult-store/src/lib.rs | 637 +++++++++++++++++- crates/kult-store/tests/m2_sneakernet.rs | 4 +- crates/kult-store/tests/single_writer.rs | 134 ++++ crates/kult-transport/src/internet.rs | 350 ++++++++-- crates/kult-transport/src/lib.rs | 8 +- crates/kult-transport/src/mailbox.rs | 53 +- crates/kult-transport/src/sneakernet.rs | 91 ++- crates/kult-transport/tests/internet.rs | 60 +- crates/kult-transport/tests/mailbox.rs | 17 +- crates/kult-transport/tests/sneakernet.rs | 76 ++- crates/kultd/Cargo.toml | 2 + crates/kultd/src/daemon.rs | 345 +++++++++- docs/00-start-here.md | 86 ++- docs/01-why.md | 73 +- docs/02-threat-model.md | 78 ++- docs/03-architecture.md | 38 +- docs/04-cryptography.md | 22 +- docs/05-transports.md | 103 ++- docs/06-identity-trust.md | 47 +- docs/07-storage.md | 121 ++-- docs/08-roadmap.md | 125 ++-- docs/09-implementation-guide.md | 12 +- docs/11-feature-scope.md | 85 +-- docs/12-feature-delivery-plan.md | 249 ++++--- docs/13-screen-security.md | 2 +- docs/14-incognito-keyboard.md | 7 +- docs/15-contact-petnames.md | 2 +- docs/16-safe-text-formatting.md | 4 +- docs/18-message-editing.md | 34 +- docs/19-ephemeral-messages.md | 5 +- docs/20-group-polls.md | 15 +- docs/21-group-roles.md | 13 +- docs/22-linked-devices.md | 30 +- docs/23-live-audio-calls.md | 2 +- docs/24-local-release-gate.md | 18 +- docs/26-self-hosting.md | 17 + docs/28-brand-system.md | 38 ++ docs/29-stabilization-program.md | 242 +++++++ docs/adr/0006-agplv3.md | 23 +- docs/adr/0014-versioned-message-content.md | 7 +- .../adr/0015-encrypted-attachment-pipeline.md | 36 +- docs/adr/0016-group-mention-content.md | 10 +- docs/adr/0017-optional-hybrid-modes.md | 75 ++- docs/adr/0018-pairwise-rendezvous.md | 14 +- docs/adr/0020-authenticated-message-edits.md | 34 +- docs/adr/0022-convergent-group-polls.md | 61 +- .../0023-group-roles-and-owner-authority.md | 9 +- .../0024-account-authorized-linked-devices.md | 32 +- docs/adr/0026-revocable-device-authority.md | 157 +++++ docs/adr/0027-opaque-indexed-store.md | 180 +++++ docs/adr/0028-atomic-protocol-commits.md | 183 +++++ .../0029-recipient-authenticated-groups.md | 155 +++++ docs/adr/0030-first-contact-admission.md | 172 +++++ .../0031-capability-scoped-dht-discovery.md | 178 +++++ docs/adr/0032-leased-mailbox-delivery.md | 119 ++++ .../adr/0033-nonprofit-founder-stewardship.md | 132 ++++ ...-operator-minimized-reference-discovery.md | 172 +++++ docs/adr/README.md | 15 +- 79 files changed, 5370 insertions(+), 800 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 CODE_OF_CONDUCT.md create mode 100644 GOVERNANCE.md create mode 100644 MAINTAINERS.md create mode 100644 crates/kult-node/tests/single_writer.rs create mode 100644 crates/kult-store/tests/single_writer.rs create mode 100644 docs/29-stabilization-program.md create mode 100644 docs/adr/0026-revocable-device-authority.md create mode 100644 docs/adr/0027-opaque-indexed-store.md create mode 100644 docs/adr/0028-atomic-protocol-commits.md create mode 100644 docs/adr/0029-recipient-authenticated-groups.md create mode 100644 docs/adr/0030-first-contact-admission.md create mode 100644 docs/adr/0031-capability-scoped-dht-discovery.md create mode 100644 docs/adr/0032-leased-mailbox-delivery.md create mode 100644 docs/adr/0033-nonprofit-founder-stewardship.md create mode 100644 docs/adr/0034-operator-minimized-reference-discovery.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3d20cdf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Current review routing. This file does not by itself provide independent review. +* @AndriGitDev + +# Security- and compatibility-sensitive surfaces remain founder-owned until +# qualified maintainers are recorded in MAINTAINERS.md. +/crates/kult-crypto/ @AndriGitDev +/crates/kult-protocol/ @AndriGitDev +/crates/kult-store/ @AndriGitDev +/docs/02-threat-model.md @AndriGitDev +/docs/04-cryptography.md @AndriGitDev +/docs/adr/ @AndriGitDev +/docs/29-stabilization-program.md @AndriGitDev +/.github/workflows/ @AndriGitDev +/CODE_OF_CONDUCT.md @AndriGitDev +/GOVERNANCE.md @AndriGitDev +/MAINTAINERS.md @AndriGitDev +/SECURITY.md @AndriGitDev diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c8291b5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,51 @@ +# Code of Conduct + +Komms exists to make private, resilient communication useful to ordinary +people. The community should be safe for contributors and users with different +backgrounds, abilities, identities, locations, and levels of technical +experience. + +## Expected behavior + +- Treat people with respect, patience, and good faith. +- Critique ideas, designs, claims, and code with evidence; do not attack people. +- Welcome questions and make room for less experienced contributors. +- Respect privacy, consent, embargoed security information, and personal + boundaries. +- Disclose conflicts that could affect a project decision. +- Accept a moderator's reasonable direction and repair harm where possible. + +## Unacceptable behavior + +Harassment, threats, stalking, discriminatory language, sexualized attention, +doxxing, deliberate exposure of private or embargoed information, sustained +disruption, and retaliation against a reporter are not acceptable. Using the +project to facilitate surveillance, abuse, or targeted harm is also outside +this community's purpose. + +This policy applies in repository spaces, project communication, events, and +public situations where someone is representing Komms. + +## Reporting + +Report conduct concerns privately to **andri@andri.is** with the subject +`[Komms conduct]`. Include links or context, the impact, and any immediate safety +need. Do not open a public issue about a private conduct report. + +Reports will be acknowledged promptly and handled with the minimum disclosure +needed to investigate. The person named in a report will not decide the case. +If the lead maintainer is involved or no unconflicted maintainer is available, +the project will seek a mutually acceptable independent reviewer. Confidentiality +cannot be absolute when action is required to prevent imminent harm, but the +reporter will be told before broader disclosure whenever safely possible. + +## Enforcement + +Responses are proportional to context, impact, pattern, and willingness to +repair harm. They may include a private correction, warning, content removal, +temporary participation limits, or a permanent ban. Serious threats, doxxing, +or retaliation may result in immediate restriction. + +The decision and any appeal are documented privately, with a public process +summary when useful and safe. An affected person may appeal to an unconflicted +maintainer or the independent reviewer used for the case. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f4fcdd..d6ee1fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,9 +2,17 @@ Komms 0.3 Alpha is a packaged public prerelease. Its core, transports, local RPC/CLI and UniFFI surfaces, and desktop/Android/iOS shells are implemented, -while hardware qualification, production-signed/store distribution, broader -hardening, and explicitly design-gated programs remain. Testers can start with -the [Alpha testing guide](docs/27-alpha-testing.md). +with automated evidence in many areas. Clean-install internet use, abuse +admission, mailbox durability, hardware/device qualification, +production-signed distribution, independent review, and other P0 gates remain. +Testers can start with the [Alpha testing guide](docs/27-alpha-testing.md); +current priorities and evidence language are in the +[stabilization program](docs/29-stabilization-program.md). + +Komms is currently founder-directed, including intentional use of automated +implementation assistance. Contributions provide ideas, implementation, +testing, and evidence; product and release authority remains with the founder +unless explicitly delegated. ## Where contributions help @@ -17,9 +25,15 @@ Open an issue for anything in `docs/` that is wrong, unclear, or missing: - Disagreement with a recorded decision? Respond to the specific [ADR](docs/adr/README.md) and address the alternatives it already weighed. - Implementation work should start from the current gaps in the + [stabilization program](docs/29-stabilization-program.md). The [roadmap](docs/08-roadmap.md) and - [feature delivery plan](docs/12-feature-delivery-plan.md), then preserve the - relevant security, storage, compatibility, and carrier constraints. + [feature delivery plan](docs/12-feature-delivery-plan.md) are implementation + inventories, not permission to expand scope ahead of P0 work. + +Small documentation, test, accessibility, localization, and reproducibility +improvements are welcome without first running the full release matrix. An +issue is useful for design or ambiguous scope, but a focused, noncontroversial +fix does not require advance permission. ## Implementation changes @@ -31,27 +45,41 @@ Open an issue for anything in `docs/` that is wrong, unclear, or missing: crate boundaries, crypto coding standards, and review gates. Checked-in APIs are authoritative. PRs that change design without an ADR will be redirected to the ADR process, kindly. -- Run the complete [local release gate](docs/24-local-release-gate.md): `fmt`, - warnings-as-errors `clippy`, all tests, `no_std`, dependency policy, generated - bindings/shell gates, and every fuzz target. Do not use hosted CI as an - interactive compiler; publication and any hosted repetition require explicit - maintainer authorization. +- For an ordinary PR, run formatting plus the narrowest affected unit, + integration, clippy, and shell checks, and list exactly what ran. Reviewers + may request a broader check when a shared contract changes. The complete + [local release gate](docs/24-local-release-gate.md)—all targets, generated + bindings, fuzz smoke, dependency policy, and platform evidence—is required + for a publication candidate, not every contributor edit. - Update the README/status table, affected design or feature contract, platform guide, and ADR index whenever behavior, requirements, compatibility, or a release gate changes. Documentation claims must distinguish automated build evidence from hands-on device or hardware qualification. - Keep PRs scoped to one concern; cite the spec section your change implements. +- Automated assistance is permitted. The human submitter must understand and + take responsibility for the diff, verify that they have the right to submit + it, check license and provenance concerns, run the applicable tests, and be + able to explain and revise the result. Automated output is not independent + review. ## Process - **Issues** for design discussion; **PRs** for concrete text/code changes. - ADRs follow [docs/adr/template.md](docs/adr/template.md) and appear in the - [ADR index](docs/adr/README.md); new ADRs are numbered sequentially and never - edited after acceptance (write a superseding one). + [ADR index](docs/adr/README.md). New ADRs are numbered sequentially. + Normative decisions in an accepted ADR change through a superseding ADR; + factual corrections, security-boundary clarifications, and cross-reference + repairs may be made in place when they do not silently reverse the recorded + decision. - Be direct about problems and generous with people. Security arguments win on merit, not volume. +- Participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). Roles, + decisions, review requirements, and current ownership are documented in + [GOVERNANCE.md](GOVERNANCE.md) and [MAINTAINERS.md](MAINTAINERS.md). ## Licensing of contributions -By contributing you agree your contribution is licensed under [AGPLv3](LICENSE), the -project license. No CLA: the license is the agreement, symmetrically, for everyone. +By submitting a contribution, you represent that you have the right to submit it +and agree that it may be distributed under [AGPL-3.0-only](LICENSE), the +project's software license. No contributor license agreement is currently +required. diff --git a/Cargo.lock b/Cargo.lock index 3e1480b..a1d563f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "fs2", "kult-crypto", "kult-protocol", + "libc", "postcard", "proptest", "rand 0.8.7", @@ -1791,11 +1792,13 @@ dependencies = [ name = "kultd" version = "0.3.0" dependencies = [ + "fs2", "kult-crypto", "kult-node", "kult-protocol", "kult-store", "kult-transport", + "libc", "rand 0.8.7", "serde", "serde_json", diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..74e5293 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,119 @@ +# Komms governance + +Komms is founder-led by design during construction and stabilization. Founder +Andri holds final product, technical, merge, release, and delegation authority +while directing implementation toward a stable, polished, broadly capable +messenger. This is not a claim of independent oversight. Governance may evolve +when sustained adoption produces a real community capable of carrying +responsibility; no date, reviewer count, or maintainer count automatically +transfers authority. + +Komms has a nonprofit public-benefit mission. The project and every service it +operates or designates as an official default must advance broad access to +private, resilient communication. Revenue or surplus supports infrastructure, +maintenance, security, accessibility, development, and reasonable compensation +rather than private profit distribution. This is a project policy, not a claim +of registered-charity or tax-exempt status, and it does not restrict the AGPL +rights of independent operators, including commercial use. The complete +decision is [ADR-0033](docs/adr/0033-nonprofit-founder-stewardship.md). + +## Roles + +- **Lead maintainer:** accountable for product direction, delegation, merge and + release authority, and the integrity of public status claims. +- **Maintainer:** owns a documented area, reviews changes, and helps operate the + project. Appointment and removal are recorded in + [MAINTAINERS.md](MAINTAINERS.md). +- **Qualified reviewer:** supplies scoped technical, security, + interoperability, accessibility, or operational evidence independently of + the implementation authorship. Review does not grant product-direction, + merge, release, or veto authority unless the founder separately delegates a + documented maintainer role. +- **Contributor:** anyone who improves code, documentation, testing, design, + research, translations, or issue reports under the project contribution + terms. + +Current people and unfilled responsibilities are listed in +[MAINTAINERS.md](MAINTAINERS.md). The lead maintainer is accountable for an +unfilled area until it is explicitly delegated. + +## Decisions + +Issues and pull requests are the ordinary public decision record. Material +changes to protocol compatibility, cryptography, identity, trust boundaries, +storage formats, optional-service boundaries, or governance require an ADR or a +documented governance proposal before implementation. + +Accepted ADRs are normative until superseded by a later ADR. Maintainers should +state the user problem, alternatives, security and privacy consequences, +migration plan, and evidence required for acceptance. Rough consensus is +preferred; the lead maintainer makes the final decision and records the +reason when consensus is not possible. + +Founder-directed automated assistance is an intentional implementation method. +Automated systems are tools, not maintainers, reviewers, or decision-makers. +The human maintainer remains accountable for provenance, scope, review, testing, +acceptance, and public claims. Tool-assisted output is not independent review or +release evidence by itself. + +## Review and release + +Every accepted change requires accountable approval by an owner for the affected +area. During the single-maintainer Alpha the founder may author and approve a +change, with that lack of independence disclosed. Changes to cryptography, +authentication, identity, wire formats, storage migrations, release signing, or +optional-service trust boundaries require two qualified reviewers, including +one who did not author the change, before a stable release. + +While those reviewers do not exist, the project may continue clearly labelled +Alpha research and testing, but it must not describe the affected work as +independently reviewed, audited, or stable. CODEOWNERS routes review requests; +it is not evidence that independent review occurred. + +External review substantiates assurance claims; it is not shared product +governance. Reviewers may publish findings and decline to provide a positive +assurance statement. The lead maintainer decides product disposition and +release timing, but unresolved findings remain visible in the evidence ledger +and cannot be represented as closed, audited, or independently approved. + +The lead maintainer currently authorizes releases. A stable release also +requires all applicable P0 gates in the +[stabilization program](docs/29-stabilization-program.md) to be closed with +published evidence. + +## Conflicts, conduct, and appeals + +Maintainers disclose financial, employment, close personal, or competitive +interests that could reasonably affect a decision and recuse themselves where +appropriate. A person whose conduct is being reviewed does not decide that +case. If no unconflicted maintainer is available, the lead maintainer will seek +a mutually acceptable independent reviewer and document the process while +protecting reporters' privacy. + +Project participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). +Technical decisions may be appealed with new evidence in the original issue or +through a superseding proposal. Conduct decisions may be appealed privately to +an unconflicted maintainer. + +## Security and operator independence + +Vulnerabilities follow [SECURITY.md](SECURITY.md), including coordinated +disclosure. No project-operated bootstrap, mailbox, rendezvous, wake, update, or +other service may become the authority for a user's identity or receive message +plaintext or identity private keys. Operational convenience does not override +the architectural boundaries in the stabilization program. + +## Succession and evolution + +Release credentials, domains, package identities, and incident procedures +should have documented recovery and at least one authorized backup steward +before a stable release. A temporary steward may maintain security and release +continuity when the lead maintainer cannot act; permanent authority changes +must be publicly recorded. + +Governance evolution is not automatic. When sustained adoption has produced a +real community of users, contributors, reviewers, and operators, the founder may +publish a proposal for additional delegated maintainers, a foundation, a +steering body, or another accountable structure. Any transfer of authority must +be explicit, public, mission-preserving, and proportionate to the community that +actually exists. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..b82e12e --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,70 @@ +# Komms maintainers + +Komms currently has one human maintainer. This is an intentional +founder-directed model during product construction and stabilization. It also +creates continuity and independent-assurance gaps that the project reports +honestly. + +## Current maintainers + +| Person | Role | Areas | Contact | +|---|---|---|---| +| Andri (`@AndriGitDev`) | Founder and lead maintainer | Product direction, merge, release, security coordination, and all unassigned areas | `andri@andri.is` | + +The lead maintainer is accountable for final decisions under +[GOVERNANCE.md](GOVERNANCE.md). Being listed as code owner or maintainer does +not make self-review independent. + +## Contribution and review needs + +The project welcomes implementation help and qualified evidence in these areas. +These are contribution and assurance needs, not vacant shares of product +authority. Maintainer authority is delegated explicitly by the founder after +sustained, dependable participation: + +- cryptography and protocol security; +- discovery, NAT traversal, mailboxes, and radio transports; +- Android and mobile lifecycle; +- iOS and mobile lifecycle; +- desktop, product accessibility, and localization; +- release signing, reproducibility, updates, and incident response; +- community stewardship, documentation, and contributor experience; +- independent security review and interoperability testing. + +An interested contributor should open a public issue describing the area, +relevant experience, and work they would like to contribute. Security-sensitive +background details may be sent privately using [SECURITY.md](SECURITY.md). + +## Implementation assistance + +The founder intentionally uses automated analysis and implementation tools. +Those tools are not maintainers, reviewers, or independent evidence providers. +Every accepted change remains attributable to and the responsibility of the +human maintainer who approves it. + +## Appointment and expectations + +Maintainers are appointed by the lead maintainer after a record of constructive +contributions and dependable review in the relevant area. Maintainers are +expected to: + +- follow the Code of Conduct and disclose relevant conflicts; +- review within their demonstrated expertise and say when external review is + needed; +- keep status and evidence claims accurate; +- document material decisions and compatibility consequences; +- protect embargoed reports, release credentials, and contributor privacy; +- arrange a handoff or step down when they can no longer provide sustained + coverage. + +Appointments, responsibility changes, leaves, and removals are made by pull +request to this file. The reason for an involuntary removal is recorded unless +privacy or safety requires a narrower disclosure. + +## Review coverage + +[`.github/CODEOWNERS`](.github/CODEOWNERS) records current review routing. +Areas with only the founder listed still have a bus-factor and independent +assurance gap; they do not imply unassigned product authority. The release +evidence must name the actual author and reviewers; a platform approval or +CODEOWNERS match alone is not review evidence. diff --git a/README.md b/README.md index 0f7e9c7..272e7fa 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,19 @@ ![Server-independent core](https://img.shields.io/badge/core_server-required_no-success) ![Post-quantum](https://img.shields.io/badge/key_agreement-X25519_%2B_ML--KEM--768-blueviolet) -**Sovereign messaging: end-to-end encrypted, server-independent at its core, and functional on & off the grid.** +**Private messaging that keeps working.** -*Messages no carrier or optional convenience service can read or scan. The core -needs no provider and works over the internet, commodity LoRa radios, or a USB -stick in a pocket.* +*Komms aims to make ordinary conversations feel familiar while user-owned +identity, strong end-to-end encryption, and resilient internet, local, radio, +and sneakernet paths stay underneath. Its pure core has no mandatory exclusive +provider. A future Standard mode may offer replaceable optional defaults for +easy first use; those services must never receive message plaintext or identity +private keys belonging to Komms users.* + +Komms has a nonprofit public-benefit mission: private, resilient communication +should be useful to ordinary people without surveillance or exclusive-provider +lock-in. The project is founder-directed and intentionally uses automated +implementation assistance; accountability remains with the human maintainer. **New here?** Read [Start Here](docs/00-start-here.md): the whole idea in plain words, with no cryptography knowledge required. @@ -73,59 +81,61 @@ required. iOS currently remains source/Simulator-only. ## Current implementation status -Komms 0.3 Alpha is a packaged public prerelease for testing, not an audited -stable release. The current repository contains the complete -server-independent messaging core and all three application shells: +Komms 0.3 Alpha is a public prerelease for testing, not an independently +audited or stable release. The repository contains a broad implemented core and +three application shells, with substantial automated evidence. Simulator +builds and self-round-trip tests are not physical-device qualification or +independent interoperability. | Area | Current state | |---|---| -| **Core and internet/LAN delivery** | M0–M3 are complete: hybrid PQXDH, Double Ratchet, sealed envelopes, encrypted storage, sneakernet, libp2p QUIC/TCP, Kademlia discovery, volunteer mailboxes, NAT traversal, mDNS, `kult-node`, `kultd`, local RPC, CLI, and UniFFI. | -| **Off-grid delivery** | The M4 Meshtastic carrier, duty-cycle enforcement, selective retransmission, and token-blind internet↔mesh bridge are implemented and tested. The physical two-radio nightly bench still needs to be stood up. | -| **Applications** | Tauri desktop packages are published for Windows x64, universal macOS, and Linux x86-64; a debug-signed Android APK supports Android 8+ on arm64 devices and x86-64 emulators. The SwiftUI iOS shell remains source/Simulator-only. Per-push CI exercises the core, desktop, generated bindings, Android APK, and iOS Simulator build. Production signing, store distribution, and hands-on device qualification remain. | -| **Messaging features** | Pairwise and sender-key group text, authenticated immutable message edits with inspectable version history, disappearing text, view-once attachments, fixed-electorate group polls, signed owner/admin/member authority, note-to-self, scheduled text, attachments, recorded audio, still-image editing, group mentions, and B9 safe text formatting are shipped through the shared APIs and all three shells. Poll votes and voter identities are visible to members—not anonymous—and converge under offline reorder; creators close ordinarily while the current owner can commit a separately signed moderation snapshot. C6 role changes, ownership transfer, and admin requests are owner-serialized, generation-bound, and re-key the group. C4 uses exact local deadlines, coarse authenticated relay deletion hints, terminal tombstones, and KKR6 backup exclusion without promising remote erasure or screenshot prevention. Edits converge without rewriting originals; formatting remains inert. Delivery state is the honest `queued → sent → delivered` ladder, with fresh user work ahead of passive retries and a visible failure after 30 days without an encrypted receipt. | -| **Linked devices** | C2 is shipped through the shared core, strict RPC/CLI, UniFFI, and all three shells: one stable account can authorize up to eight independently keyed devices through a mutually confirmed QR/paste ceremony. Pairwise sessions, group sender chains, capability state, and delivery rows remain per physical device; encrypted explicit sync converges contacts, private organization, ordinary history, edits, polls, authority, and terminal tombstones without a cloud account. Permanent exact-device revocation and KKR7 recovery never resurrect old credentials. The published Android APK and automated iOS Simulator build provide release evidence; hands-on device qualification remains. | -| **Live audio calls** | C7 audio is shipped through direct libp2p QUIC, transient ratcheted signaling, authenticated per-call media, RPC/CLI, UniFFI, desktop, Android, and iOS. Calls never use TCP, relay-only, mailbox, sneakernet, or LoRa paths; they create no chat history or backup state and use no coordinator, SFU, STUN/TURN, or project service. Real-NAT, handoff, battery, audio-route, and device qualification remain alpha release gates; video remains later work. | -| **Attachment safety** | C1 safe file presentation is shipped over the unchanged sealed F3/F4 pipeline. Sender filenames/types remain untrusted hints; mismatched, active, unknown, or nameless files are export-only, recognized external opening is explicit and warns that no malware scan is promised, and no file auto-opens or creates mesh airtime. | -| **Private contact names** | B5 contact rename is shipped across node, RPC/CLI, UniFFI, desktop, Android, and iOS. Petnames are NFC-normalized, duplicate-capable private local labels; spoofing-risk warnings require explicit review, exact peer keys remain authoritative, and rename creates no protocol or transport work. Optional signed self-display suggestions remain deferred. | -| **Private local organization** | B10 folders, B11 conversation pins, and B18 contact/conversation labels are shipped across storage, node, RPC/CLI, UniFFI, desktop, Android, and iOS. They remain sealed endpoint-private metadata, compose as folder → labels → pins/activity, create zero peer or transport work, are preserved by `KKR7`, and may converge only through explicit authenticated own-device C2 sync. Message pins and message labels remain separate work. | -| **Appearance and accessibility** | B12 system/light/dark appearance is shipped across the sealed F5 preference, node, RPC/CLI, UniFFI, desktop, Android, and iOS. Native system changes apply live, semantic palettes meet the shared WCAG targets, high-contrast/reduced-motion preferences remain native, and security/delivery meaning always retains text or icon cues. | -| **Private custom icons** | B13 contact, group, folder, and note-to-self icons are shipped across the sealed F5 record, node, RPC/CLI, UniFFI, desktop, Android, and iOS. Generated initials are the safe fallback; eight bundled glyphs or selected local JPEG/PNG inputs become strict metadata-free 256×256 PNGs under per-icon/count/aggregate quotas. Icons create zero remote lookup, peer sync, notification, or transport work; portability is limited to `KKR7` and explicit authenticated own-device C2 sync. | -| **Screen security** | B14 is shipped as an always-on pre-unlock policy across the shared capability contract, RPC/CLI, UniFFI, and every shell. Android applies `FLAG_SECURE` to every activity; iOS obscures inactive/app-switcher and live-captured scenes without claiming universal screenshot blocking; desktop requests best-effort native content protection, shields on focus loss, and locks immediately with `Ctrl/Cmd+Shift+L`. OS, compositor, privileged-software, and external-camera limits remain explicit. | -| **Input privacy** | B15 is shipped as an always-on pre-unlock policy across the shared capability contract, RPC/CLI, UniFFI, and every textual field. Android requests `IME_FLAG_NO_PERSONALIZED_LEARNING` and no suggestions; iOS disables correction and uses secure passphrase/mnemonic fields; desktop disables webview autocomplete, correction, capitalization, and spellcheck. Keyboard, OS, webview, and writing-tool limits remain explicit. | -| **Runtime and release assurance** | The headless runtime recovers poisoned synchronization locks instead of cascading a panic, emits policy-bounded structured diagnostics through `tracing`, and accepts passphrases/restore mnemonics from owner-only secret files. Rust 1.88 is the declared and tested MSRV. Version `0.3.0` is prepared for the qualified `v0.3.0` tag with native Windows/macOS/Linux packages, an Android test APK, `SHA256SUMS`, and a Linux amd64/arm64 `kultd` image carrying provenance and an SBOM. Release publication is gated on recorded human visual approval for Android, iOS, macOS, and Linux. These remain Alpha artifacts: Windows signing, store distribution, an updater, and stable support promises are not configured. Per-push CI, the complete local matrix, and a weekly advisory/macOS/coverage workflow provide complementary evidence. | +| **Core security and storage** | Hybrid PQXDH, Double Ratchet sessions, sealed envelopes, sealed local record bodies, backup/recovery, RPC/CLI, and UniFFI paths are implemented with automated tests. The current SQLite schema still exposes exact contact/group identifiers in plaintext indexes and lacks universal row binding; ADR-0027 is required before stronger locked-database metadata claims. The combined implementation has not yet passed independent security review or independent interoperability gates. | +| **Internet, LAN, and delayed delivery** | libp2p QUIC/TCP, Kademlia discovery, NAT traversal, mDNS, and volunteer mailbox roles are implemented. Fresh app installs do not yet have a qualified distinct-NAT golden path: bootstrap and mailbox defaults require deliberate configuration, and mailbox persistence/operator behavior remains a stabilization gate. [ADR-0034](docs/adr/0034-operator-minimized-reference-discovery.md) proposes an initial founder-operated Hetzner Standard-mode bootstrap/DHT/rendezvous default with RAM-backed mutable state; it is not implemented or a durable mailbox. | +| **Off-grid delivery** | Sneakernet and the Meshtastic carrier, duty-cycle controls, retransmission, and internet↔mesh bridge paths are implemented with automated evidence. The physical two-radio bench is not yet field-qualified. | +| **Applications and messaging** | Desktop, Android, and iOS shells expose pairwise/group text and a broad Alpha feature set, including attachments, local organization, linked devices, ephemeral content, polls, roles, and direct audio-call paths. CI and simulator evidence exist; hands-on device, background lifecycle, NAT, accessibility, and localization qualification remain. | +| **Distribution** | Unsigned desktop packages and a debug-signed Android APK are published for Alpha testing; iOS is source/Simulator-only. Production signing, authenticated updates, reproducibility measurements, store distribution, upgrade/rollback qualification, and stable support are not configured. | | **Optional mobile convenience** | ADR-0017 through ADR-0019 propose reversible post-pairing rendezvous and content-free native wake. The layer is design-only: no optional service is implemented or required by the sovereign core. | +| **Trust and governance** | The project is founder-directed by design during construction and stabilization under a nonprofit public-benefit mission. The founder retains product and release authority. Independent security and interoperability evidence is still missing; automated assistance is not presented as independent review. The [stabilization program](docs/29-stabilization-program.md) defines the evidence required before stable claims. | Older `KKR1` through `KKR6` backups remain restorable; current backups are `KKR7`. KKR6 added signed group authority state and consumed admin-request ids. KKR7 adds linked-device authority, convergence state, and recovery semantics; all current backups exclude live ephemeral plaintext/media and carry terminal -tombstones so restore cannot resurrect removed content. The principal release -gaps are the physical radio bench, hands-on mobile qualification, reproducible -signed/store distribution work, remaining M6 hardening, and -an external security audit. See the [roadmap](docs/08-roadmap.md) for engineering -milestones, the [feature delivery plan](docs/12-feature-delivery-plan.md) for -the product backlog, and the [local release gate](docs/24-local-release-gate.md) -for the no-hosted-compiler workflow. - -Komms is a decentralized messenger built on four principles: - -1. **No mandatory middle.** No account or project-operated service is required - to communicate. Peers talk directly, via volunteer relays holding only sealed - ciphertext, or over radio. Optional rendezvous and native-wake services may - improve convenience, but they receive no message plaintext or identity keys - and their loss never disables the core. -2. **Cryptography at the state of the art.** Hybrid post-quantum key agreement - (X25519 + ML-KEM-768), Double Ratchet sessions with encrypted headers, and - XChaCha20-Poly1305 everywhere, assembled strictly from published, audited designs. -3. **Off-grid is a first-class citizen.** When networks are down or shut off, the same - sealed messages travel over commodity Meshtastic LoRa radios (kilometers of range, - multi-hop, ~€30 hardware), local links, or `.kkb` file sneakernet. -4. **Your keys, your data, your hardware.** Identity is a keypair you mint yourself: no - phone number, no email. History is stored locally, encrypted, exportable, and - deletable for real. - -Why this project exists, including its answer to the EU's ChatControl regime, is set -out plainly in [Why Komms](docs/01-why.md). +tombstones so restore does not recreate those records in Komms. This is +automated implementation evidence, not a promise to erase copies retained by +peers, screenshots, exported backups, or compromised endpoints. + +The [stabilization program](docs/29-stabilization-program.md) now takes priority +over feature expansion. It defines exact evidence levels, owners, P0/P1/P2 +gates, and the first 90 days. The [roadmap](docs/08-roadmap.md) remains the +engineering inventory, the [feature delivery plan](docs/12-feature-delivery-plan.md) +remains the product backlog, and the +[local release gate](docs/24-local-release-gate.md) describes existing build +checks. + +Komms is built on four principles: + +1. **Everyday messenger first.** Installation, pairing, sending, recovery, and + delivery state should make sense without transport or cryptography knowledge. +2. **No mandatory exclusive provider.** Peers may communicate directly, through + chosen volunteer mailbox operators holding sealed ciphertext, or over local, + radio, and sneakernet paths. Standard mode may use disclosed, replaceable + defaults. Optional rendezvous and native wake receive no message plaintext or + identity private keys and remain removable. +3. **Strong cryptographic building blocks, honestly qualified.** The + implementation combines published constructions including X25519 + + ML-KEM-768, Double Ratchet sessions with encrypted headers, and + XChaCha20-Poly1305. That combination still requires independent review and + interoperability evidence before it can be called audited or stable. +4. **Your keys and local data stay yours.** Identity needs no phone number or + email. Komms can delete its local encrypted history and exclude expiring + content from its own current backups, but it cannot erase copies another + person, export, screenshot, operating system, or compromised device retains. + +[Why Komms](docs/01-why.md) explains the social motivation, including concern +about policy proposals and laws that seek or allow private communications to be +scanned. It distinguishes that position from claims about the current legal +status of any particular proposal. ## Design documents @@ -149,16 +159,18 @@ out plainly in [Why Komms](docs/01-why.md). | [15: Private Contact Names](docs/15-contact-petnames.md) | B5 local petname rename contract, warnings, privacy boundary, and qualification matrix | | [16: Safe Text Formatting](docs/16-safe-text-formatting.md) | B9 source subset, active-content boundary, limits, compatibility, and qualification matrix | | [17: Safe File Presentation](docs/17-safe-file-presentation.md) | C1 filename/type policy, open/export boundary, lifecycle, and qualification matrix | -| [18: Authenticated Message Editing](docs/18-message-editing.md) | C3 immutable edit events, authorship, convergence, retained versions, compatibility, and qualification | +| [18: Authenticated Message Editing](docs/18-message-editing.md) | C3 immutable edit events, pairwise authorship, group Alpha limit, convergence, retained versions, compatibility, and qualification | | [19: Disappearing Messages and View-Once Attachments](docs/19-ephemeral-messages.md) | C4 exact local expiry, coarse relay retention, tombstones, KKR6 exclusion, honest limits, and qualification | -| [20: Group Polls](docs/20-group-polls.md) | C5 visible authenticated votes, fixed electorate, deterministic convergence, creator closure, and qualification | +| [20: Group Polls](docs/20-group-polls.md) | C5 visible votes, current member-forgery limit, fixed electorate, deterministic convergence, creator closure, and qualification | | [21: Group Roles, Ownership, and Moderation](docs/21-group-roles.md) | C6 signed owner/admin/member authority, transfer, rotation, moderation, backup, and qualification | -| [22: Linked Devices](docs/22-linked-devices.md) | C2 device certificates, confirmed linking, per-device delivery, deterministic sync, revocation, recovery, and qualification | +| [22: Linked Devices](docs/22-linked-devices.md) | C2 device certificates, confirmed linking, per-device delivery, sync, recovery, and the open permanent-revocation flaw | | [23: Live Audio Calls](docs/23-live-audio-calls.md) | C7 direct-QUIC gating, transient signaling, authenticated Opus media, platform behavior, privacy limits, and qualification | -| [24: Local Release Gate](docs/24-local-release-gate.md) | Toolchains, complete local validation, CI/audit evidence, SDK deferrals, signing boundary, and publication discipline | +| [24: Local Release Gate](docs/24-local-release-gate.md) | Toolchains, complete local validation, CI/advisory evidence, SDK deferrals, signing boundary, and publication discipline | | [25: Release Runbook](docs/25-release-runbook.md) | Versioning, native desktop/APK artifact builds, signing inputs, qualification, and explicit publication | | [26: Self-hosting](docs/26-self-hosting.md) | Hardened Docker Compose deployment, ports, secret initialization, node modes, and Alpha limits | | [27: Alpha Testing](docs/27-alpha-testing.md) | Download verification, installation, smoke testing, issue reporting, and self-hosted image quick start | +| [28: Brand System](docs/28-brand-system.md) | Cross-shell product character, tokens, hierarchy, and pragmatic name-risk monitoring | +| [29: Stabilization Program](docs/29-stabilization-program.md) | Canonical evidence vocabulary, trust gates, owners, and 90-day sequence | | [ADRs](docs/adr/README.md) | Decision index, status, and the alternatives each decision beat | ## Stack @@ -210,7 +222,7 @@ cd crates/kult-crypto && cargo +nightly fuzz run envelope_decode -- -max_total_t Before a publication candidate, run `scripts/local-release-matrix.sh` from the repository root and record every explicit `DEFERRED` platform gate. The exact -division between local checks, per-push CI, weekly audit evidence, physical +division between local checks, per-push CI, weekly advisory evidence, physical qualification, and signing is documented in the [local release gate](docs/24-local-release-gate.md). @@ -227,10 +239,17 @@ or the [self-hosting guide](docs/26-self-hosting.md) to run `kultd`. Security review, hands-on platform testing, and focused implementation of the remaining roadmap are especially valuable; see [CONTRIBUTING.md](CONTRIBUTING.md). -Security issues: [SECURITY.md](SECURITY.md). +Project decisions and ownership: [GOVERNANCE.md](GOVERNANCE.md) and +[MAINTAINERS.md](MAINTAINERS.md). Security issues: [SECURITY.md](SECURITY.md). +Participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). ## License -[AGPLv3](LICENSE). Anyone may run, study, modify, and share every component, and -modified network services must publish their source. Rationale: -[ADR-0006](docs/adr/0006-agplv3.md). +Komms software is licensed under [AGPL-3.0-only](LICENSE). Under AGPLv3 section +13, a modified covered version that supports remote network interaction must +prominently offer its remote users an opportunity to receive that version's +Corresponding Source. The AGPL permits commercial use; Komms's nonprofit +mission governs official project activity, not independent licensees. See +[ADR-0006](docs/adr/0006-agplv3.md) and +[ADR-0033](docs/adr/0033-nonprofit-founder-stewardship.md). This summary is not +legal advice. diff --git a/SECURITY.md b/SECURITY.md index 7f4b173..72238b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,20 +5,31 @@ Email **andri@andri.is**. If you need encryption for the report itself, request a key in a first message without vulnerability details. +This is currently a single-maintainer intake, as disclosed in +[MAINTAINERS.md](MAINTAINERS.md); it is not staffed around the clock. Do not +send a vulnerability through a public issue. + Please include: affected component/doc section, impact as you understand it, and reproduction steps or a proof-of-concept where applicable. -## What to expect +## Response targets + +- Acknowledgment target: **72 hours**. +- Initial assessment target for a confirmed issue: **14 days**. +- Regular updates while an accepted report remains unresolved. +- Credit in release notes, or anonymity, at the reporter's choice. -- Acknowledgment within **72 hours**. -- Assessment and a remediation plan within **14 days** for confirmed issues. -- Credit in release notes (or anonymity, your choice). No bounty program yet; this is - an unfunded open project, and recognition is what we have. +These are targets rather than a 24/7 support guarantee. If you receive no +acknowledgment, resend with `[Komms security]` in the subject. There is currently +no bounty program; do not incur expense in expectation of payment. ## Ground rules -- Coordinated disclosure: please give us the 14-day assessment window before publishing. -- The shipped alpha implementation and its specifications are both in scope. +- Coordinated disclosure: please allow the initial assessment window and agree + on a disclosure date based on impact and fix complexity. Imminent user harm + may require faster action by both parties. +- The Alpha implementation and its specifications are both in scope; neither is + represented as independently audited. Threat-model gaps, broken constructions, unstated assumptions, local data leakage, transport-policy bypasses, and platform lifecycle failures are especially valuable reports. Start with the @@ -46,6 +57,18 @@ Android release signing, and an update channel remain scaffold-only. Verify `SHA256SUMS` from the [official prerelease](https://github.com/AndriGitDev/Komms/releases/tag/v0.3.0); a third-party binary must not be represented as an official Komms release. +Optional-service and operator reports are in scope: discovery-capability or +rendezvous-slot leakage, DHT poisoning/eclipsing/suppression, signed-directory +downgrade, service-key compromise, access-log/metric/trace/crash/snapshot +leakage, cross-role correlation, a RAM-only service retaining state, a mailbox +falsely acknowledging durable custody, or failure to fall back when project +defaults are blackholed. Deployment reports should identify the source +revision, image digest, configuration, hosting/provider boundary, and relevant +key-rotation or incident procedure. The intended boundaries are +[ADR-0017](docs/adr/0017-optional-hybrid-modes.md), +[ADR-0031](docs/adr/0031-capability-scoped-dht-discovery.md), and +[ADR-0034](docs/adr/0034-operator-minimized-reference-discovery.md). + ## Scope notes Accepted limitations documented in diff --git a/crates/kult-node/src/lib.rs b/crates/kult-node/src/lib.rs index 941eba5..ee6d74e 100644 --- a/crates/kult-node/src/lib.rs +++ b/crates/kult-node/src/lib.rs @@ -60,7 +60,7 @@ use kult_store::{ ContactDeviceRecord, ContactRecord, ConversationId, ConversationMetadata, DeliveryState, DeviceStateRecord, Direction, EphemeralConversation, EphemeralMode, EphemeralRecord, EphemeralState, LocalMetadataKey, LocalMetadataRecord, MessageDeviceDeliveryRecord, - MessageRecord, NoteMessageRecord, QueueClass, QueueItem, + MessageRecord, NoteMessageRecord, PairwiseReceivePlan, QueueClass, QueueItem, ScheduledConversation as StoreScheduledConversation, ScheduledMessageRecord, Store, }; use kult_transport::{CostClass, DeliveryHint, Discovery, Reachability, Transport}; @@ -295,6 +295,11 @@ const TRANSIT_MESH_PER_TICK: usize = 4; /// Missing fragment indices per in-flight message id — the NACK half of a /// receipt (the shape of [`ReceiptPayload::nacks`]). type FragNacks = Vec<([u8; 4], Vec)>; +/// Bound one receipt's selective-retransmission work independently of the +/// reassembler's aggregate partial-message cap. +const MAX_NACK_PARTIALS_PER_TICK: usize = 32; +/// Missing indices carried in one tick across all partial messages. +const MAX_NACK_INDICES_PER_TICK: usize = 4_096; /// Receiver-side bookkeeping for one in-flight partial message: enough to /// address the NACK requesting its missing fragments (via the delivery @@ -319,6 +324,9 @@ struct SentFragments { enum Consumed { /// Fully handled (or permanently unprocessable) — never seen again. Done, + /// Fully handled by a transaction that also acknowledged its named + /// deferred-inbox source row, when one existed. + DoneAtomic, /// Cannot be processed *yet* (no matching session) — stash and retry. Later, } @@ -1764,6 +1772,9 @@ impl Node { self.advertise_capabilities(now, rng)?; // 1. Gather: previously-stashed envelopes first, then fresh arrivals. + // A present sequence means the envelope already has a durable + // pending-inbox row. It remains there until this tick explicitly + // acknowledges successful consumption or expiry. // When bridging, fresh arrivals with tokens this node does not // recognize also enter the transit queue (ADR-0009): mesh-heard // foreignness heads for the internet, carrier-surfaced transit @@ -1771,7 +1782,18 @@ impl Node { // still joins the normal receive path — "foreign" and "ours, but // the unlocking handshake hasn't arrived yet" are indistinguishable // by design, and downstream dedup absorbs the overlap. - let mut work: Vec<(Envelope, u64)> = self.store.pending_drain()?; + let mut work: Vec<(Option, Envelope, u64)> = Vec::new(); + let mut gathered = HashSet::new(); + for (sequence, envelope, first_seen) in self.store.pending_all()? { + if gathered.insert(envelope.content_id()) { + work.push((Some(sequence), envelope, first_seen)); + } else { + // Older builds could persist exact multipath duplicates. + // Keep the first stable row and remove only redundant copies. + self.store.pending_ack(sequence)?; + } + } + let mut bridge_seen = HashSet::new(); let transports = self.transports.clone(); for transport in &transports { let airtime = transport.profile().cost == CostClass::Airtime; @@ -1779,24 +1801,35 @@ impl Node { // arrive via retry or another path. if let Ok(envelopes) = transport.recv().await { for envelope in envelopes { - if airtime && self.bridge.is_some() && !self.token_is_mine(&envelope.token, now) + let content_id = envelope.content_id(); + if airtime + && self.bridge.is_some() + && !self.token_is_mine(&envelope.token, now) + && bridge_seen.insert((true, content_id)) { if let Some(bridge) = &mut self.bridge { bridge.admit(&envelope, true, now); } } - work.push((envelope, now)); + if gathered.insert(content_id) { + work.push((None, envelope, now)); + } } } if self.bridge.is_some() { if let Ok(envelopes) = transport.recv_transit().await { for envelope in envelopes { - if !self.token_is_mine(&envelope.token, now) { + let content_id = envelope.content_id(); + if !self.token_is_mine(&envelope.token, now) + && bridge_seen.insert((false, content_id)) + { if let Some(bridge) = &mut self.bridge { bridge.admit(&envelope, false, now); } } - work.push((envelope, now)); + if gathered.insert(content_id) { + work.push((None, envelope, now)); + } } } } @@ -1807,26 +1840,65 @@ impl Node { // earlier in it). Each pass consumes at least one envelope, so // this terminates. let mut acks: Vec<([u8; 32], [u8; 16])> = Vec::new(); + let mut pending_acks: Vec = Vec::new(); loop { let mut stash = Vec::new(); let mut established = false; - for (env, first_seen) in work { - match self.consume(&env, 0, now, rng, &mut acks, &mut established)? { - Consumed::Done => {} - Consumed::Later => stash.push((env, first_seen)), + for (pending_sequence, env, first_seen) in work { + let expired = now.saturating_sub(first_seen) > PENDING_TTL_SECS + || env.retention_until.is_some_and(|deadline| deadline <= now); + if expired { + if let Some(sequence) = pending_sequence { + self.store.pending_ack(sequence)?; + } + continue; + } + + match self.consume( + &env, + 0, + pending_sequence, + now, + rng, + &mut acks, + &mut established, + )? { + Consumed::Done => { + if let Some(sequence) = pending_sequence { + // Keep the row until receipts and other durable + // consequences of this receive pass are queued. + // Any intervening error can then safely replay it + // through the seen-envelope path. + pending_acks.push(sequence); + } + } + Consumed::DoneAtomic => {} + Consumed::Later => { + let sequence = match pending_sequence { + Some(sequence) => sequence, + None => match self.store.pending_push(&env, first_seen, rng) { + Ok(sequence) => sequence, + Err(kult_store::StoreError::PendingQuota) => { + // Interim overload containment. The + // interactive admission protocol in + // ADR-0030 must move this refusal before + // the carrier's accepted response. + continue; + } + Err(error) => return Err(error.into()), + }, + }; + stash.push((Some(sequence), env, first_seen)); + } } } if established && !stash.is_empty() { work = stash; continue; } - for (env, first_seen) in stash { - if now.saturating_sub(first_seen) <= PENDING_TTL_SECS - && env.retention_until.is_none_or(|deadline| deadline > now) - { - self.store.pending_push(&env, first_seen, rng)?; - } - } + // Every entry in `stash` is already durable. Leaving it in place + // is the retry action; no delete/reinsert cycle or new sequence + // number is needed. break; } @@ -1881,6 +1953,9 @@ impl Node { let nacks = nacks_by_peer.remove(&peer).unwrap_or_default(); self.queue_receipt(&peer, acks, nacks, now, rng)?; } + for sequence in pending_acks { + self.store.pending_ack(sequence)?; + } // 4. Flush the outbound queue, then — only with whatever airtime and // attention is left — third-party transit (ADR-0009). @@ -2396,6 +2471,7 @@ impl Node { &mut self, env: &Envelope, depth: u8, + pending_sequence: Option, now: u64, rng: &mut impl CryptoRngCore, acks: &mut Vec<([u8; 32], [u8; 16])>, @@ -2430,15 +2506,23 @@ impl Node { }); } let completed = self.reassembler.insert(&env.body, now); - self.store.mark_seen(&env.content_id())?; + // Top-level fragments are deliberately not persisted in the + // seen set. A completed inner envelope may still be refused + // by the bounded deferred inbox; retaining retryability of + // the full fragment set is then the only lossless outcome. + // Reassembly and inner-envelope dedup remain independently + // bounded, so exact fragment retries are safe. if let Ok(Some(payload)) = completed { if let Ok(inner) = Envelope::decode(&payload) { if let Consumed::Later = - self.consume(&inner, 1, now, rng, acks, established)? + self.consume(&inner, 1, None, now, rng, acks, established)? { // Reassembled before its session exists — stash // the inner envelope for later ticks. - self.store.pending_push(&inner, now, rng)?; + match self.store.pending_push(&inner, now, rng) { + Ok(_) | Err(kult_store::StoreError::PendingQuota) => {} + Err(error) => return Err(error.into()), + } } } } @@ -2446,7 +2530,7 @@ impl Node { } EnvelopeKind::Handshake => self.consume_handshake(env, now, rng, acks, established), EnvelopeKind::Message | EnvelopeKind::Receipt | EnvelopeKind::GroupControl => { - self.consume_ratchet(env, now, rng, acks, established) + self.consume_ratchet(env, pending_sequence, now, rng, acks, established) } EnvelopeKind::GroupMessage => self.consume_group_message(env, now, rng, acks), } @@ -2677,6 +2761,7 @@ impl Node { fn consume_ratchet( &mut self, env: &Envelope, + pending_sequence: Option, now: u64, rng: &mut impl CryptoRngCore, acks: &mut Vec<([u8; 32], [u8; 16])>, @@ -2695,19 +2780,128 @@ impl Node { let Ok(msg) = RatchetMessage::decode(&env.body) else { return done(self); }; - let Some(session) = self.sessions.get_mut(&peer_device) else { - return Ok(Consumed::Later); + // Durable state is authoritative. Decrypt into a detached candidate + // so a failed store transition cannot advance the live ratchet. + let Some(mut candidate_session) = self.store.get_session(&peer_device)? else { + return Err(NodeError::CorruptState); }; - let Ok(plaintext) = session.decrypt(rng, now, &msg, &[]) else { + let Ok(plaintext) = candidate_session.decrypt(rng, now, &msg, &[]) else { // Tampered, or outside the skipped-key window — a permanent, // honest failure per the ratchet contract. return done(self); }; - self.store.put_session(&peer_device, session, rng)?; let Ok(body) = unpad(&plaintext) else { + self.store + .put_session(&peer_device, &candidate_session, rng)?; + self.sessions.insert(peer_device, candidate_session); return done(self); }; + // First bounded ADR-0028 vertical slice: ordinary pairwise text + // commits the receiving ratchet, history, seen/replay markers, and + // exact pending-row acknowledgement as one durable transition. + // + // More stateful content kinds deliberately stay on the legacy path + // until their attachment/ephemeral/control consequences have typed + // plans of their own. + if env.kind == EnvelopeKind::Message && env.retention_until.is_none() { + let decoded = decode_content(&body); + if matches!( + decoded, + DecodedContent::LegacyText(_) | DecodedContent::Text { .. } + ) { + let (id, event_body, content, duplicate) = match decoded { + DecodedContent::LegacyText(text) => { + let mut id = [0u8; 16]; + rng.fill_bytes(&mut id); + ( + id, + text.as_bytes().to_vec(), + ContentStatus::LegacyText, + false, + ) + } + DecodedContent::Text { id, text } => { + let conversation = EphemeralConversation::Pairwise(peer); + let expired_duplicate = self + .store + .get_ephemeral_record(&conversation, &peer, &id)? + .is_some(); + let history_duplicate = + self.store.messages_with(&peer)?.iter().any(|record| { + record.direction == Direction::Inbound + && matches!( + decode_content(&record.body), + DecodedContent::Text { id: stored_id, .. } + | DecodedContent::Attachment { + id: stored_id, + .. + } + | DecodedContent::Mention { + id: stored_id, + .. + } + | DecodedContent::Edit { id: stored_id, .. } + | DecodedContent::Ephemeral { + id: stored_id, + .. + } + | DecodedContent::Poll { id: stored_id, .. } + | DecodedContent::GroupAuthority { + id: stored_id, + .. + } + if stored_id == id + ) + }); + ( + id, + text.as_bytes().to_vec(), + ContentStatus::Text { id }, + expired_duplicate || history_duplicate, + ) + } + _ => unreachable!("ordinary text variants matched above"), + }; + let message = (!duplicate).then(|| MessageRecord { + id, + peer, + direction: Direction::Inbound, + state: DeliveryState::Received, + timestamp: now, + body, + wire_id: None, + }); + self.store.commit_pairwise_receive( + PairwiseReceivePlan { + peer_device: &peer_device, + session: &candidate_session, + message: message.as_ref(), + content_id: &env.content_id(), + received_at: now, + source_pending_sequence: pending_sequence, + }, + rng, + )?; + self.sessions.insert(peer_device, candidate_session); + if !duplicate { + self.events.push_back(Event::MessageReceived { + peer, + id, + timestamp: now, + body: event_body, + content, + }); + } + acks.push((peer_device, env.content_id())); + return Ok(Consumed::DoneAtomic); + } + } + + self.store + .put_session(&peer_device, &candidate_session, rng)?; + self.sessions.insert(peer_device, candidate_session); + match env.kind { EnvelopeKind::Message => { if let DecodedContent::CallControl { control, .. } = decode_content(&body) { @@ -3284,20 +3478,28 @@ impl Node { let missing = self.reassembler.missing(now); let live: HashSet<[u8; 4]> = missing.iter().map(|(id, _)| *id).collect(); self.frag_meta.retain(|id, _| live.contains(id)); + let mut indices_left = MAX_NACK_INDICES_PER_TICK; missing .into_iter() - .filter(|(id, miss)| { + .filter_map(|(id, mut miss)| { if miss.is_empty() { - return false; + return None; } - let Some(meta) = self.frag_meta.get(id) else { - return false; + let Some(meta) = self.frag_meta.get(&id) else { + return None; }; - now.saturating_sub(meta.first_seen) >= NACK_AFTER_SECS + let due = now.saturating_sub(meta.first_seen) >= NACK_AFTER_SECS && meta .last_nack - .is_none_or(|t| now.saturating_sub(t) >= NACK_INTERVAL_SECS) + .is_none_or(|t| now.saturating_sub(t) >= NACK_INTERVAL_SECS); + if !due || indices_left == 0 { + return None; + } + miss.truncate(indices_left); + indices_left -= miss.len(); + Some((id, miss)) }) + .take(MAX_NACK_PARTIALS_PER_TICK) .collect() } @@ -3804,7 +4006,7 @@ async fn send_via( envelope: &Envelope, ) -> Result>>> { let mtu = transport.profile().mtu; - let encoded = envelope.encode(); + let encoded = envelope.try_encode()?; if encoded.len() <= mtu { transport.send(hint, envelope).await?; return Ok(None); diff --git a/crates/kult-node/tests/node_e2e.rs b/crates/kult-node/tests/node_e2e.rs index 6dc123e..6b82cfe 100644 --- a/crates/kult-node/tests/node_e2e.rs +++ b/crates/kult-node/tests/node_e2e.rs @@ -16,9 +16,9 @@ use kult_crypto::{ }; use kult_node::{ContentStatus, Event, Node}; use kult_protocol::{ - decode_content, encode_text, DecodedContent, Envelope, EnvelopeKind, CONTENT_MAGIC, + decode_content, encode_text, fragment, DecodedContent, Envelope, EnvelopeKind, CONTENT_MAGIC, }; -use kult_store::DeliveryState; +use kult_store::{DeliveryState, Store, MAX_PENDING_ENVELOPES}; use kult_transport::{ CostClass, DeliveryHint, LatencyClass, LinkProfile, Reachability, SendReceipt, SneakernetTransport, Transport, TransportError, @@ -958,8 +958,14 @@ async fn out_of_order_arrival_survives_restart() { let events = bob.tick(NOW + 10, &mut rng).await.unwrap(); assert_eq!(count_received(&events), 0); - // Bob's device restarts. The stash must survive. + // Bob's device restarts. The stash must survive under one stable row id: + // merely reading it never drains or rewrites it. drop(bob); + let store = Store::open(&bob_db, b"b").unwrap(); + let first_read = store.pending_all().unwrap(); + assert_eq!(first_read.len(), 1); + assert_eq!(store.pending_all().unwrap(), first_read); + drop(store); let mut bob = Node::open(&bob_db, b"b").unwrap(); bob.add_transport(Arc::new(mesh(2))); @@ -976,4 +982,173 @@ async fn out_of_order_arrival_survives_restart() { .collect(); assert!(bodies.contains(&b"first (handshake)".to_vec())); assert!(bodies.contains(&b"second (session)".to_vec())); + drop(bob); + let store = Store::open(&bob_db, b"b").unwrap(); + assert!( + store.pending_all().unwrap().is_empty(), + "consumed deferred row is explicitly acknowledged" + ); +} + +#[tokio::test] +async fn deferred_rows_keep_their_id_until_consumed_or_expired() { + let mut rng = StdRng::seed_from_u64(405); + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("pending.db"); + let node = Node::create(&db, b"pass", TEST_KDF, &mut rng).unwrap(); + drop(node); + + let store = Store::open(&db, b"pass").unwrap(); + let retryable = Envelope::new(EnvelopeKind::Message, [7; 32], vec![8]); + let already_expired = Envelope::new(EnvelopeKind::Message, [9; 32], vec![10]); + let retryable_sequence = store.pending_push(&retryable, NOW, &mut rng).unwrap(); + store + .pending_push(&already_expired, NOW - 31 * 86_400, &mut rng) + .unwrap(); + drop(store); + + // No session recognizes either token. The live row stays durable under + // the same sequence while the over-TTL row is explicitly acknowledged. + let mut node = Node::open(&db, b"pass").unwrap(); + node.tick(NOW, &mut rng).await.unwrap(); + drop(node); + let store = Store::open(&db, b"pass").unwrap(); + assert_eq!( + store.pending_all().unwrap(), + vec![(retryable_sequence, retryable, NOW)] + ); + drop(store); + + // Once the retained row itself passes the TTL, it too is acknowledged + // rather than decrypted, drained, or assigned a replacement sequence. + let mut node = Node::open(&db, b"pass").unwrap(); + node.tick(NOW + 31 * 86_400, &mut rng).await.unwrap(); + drop(node); + let store = Store::open(&db, b"pass").unwrap(); + assert!(store.pending_all().unwrap().is_empty()); +} + +#[tokio::test] +async fn exact_unknown_duplicates_share_one_bounded_deferred_row() { + let mut rng = StdRng::seed_from_u64(406); + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("pending-dedup.db"); + let net: Net = Arc::new(Mutex::new(HashMap::new())); + let mut node = Node::create(&db, b"pass", TEST_KDF, &mut rng).unwrap(); + node.add_transport(Arc::new(MockMesh { + net: net.clone(), + me: 2, + mtu: 64 * 1024, + duplicate: false, + })); + let unknown = Envelope::new(EnvelopeKind::Message, [7; 32], vec![8]); + + net.lock() + .unwrap() + .entry(2) + .or_default() + .extend([unknown.clone(), unknown.clone()]); + node.tick(NOW, &mut rng).await.unwrap(); + drop(node); + let store = Store::open(&db, b"pass").unwrap(); + let first = store.pending_all().unwrap(); + assert_eq!(first.len(), 1); + let stable_sequence = first[0].0; + drop(store); + + let mut node = Node::open(&db, b"pass").unwrap(); + node.add_transport(Arc::new(MockMesh { + net: net.clone(), + me: 2, + mtu: 64 * 1024, + duplicate: false, + })); + net.lock() + .unwrap() + .entry(2) + .or_default() + .extend([unknown.clone(), unknown]); + node.tick(NOW + 1, &mut rng).await.unwrap(); + drop(node); + + let store = Store::open(&db, b"pass").unwrap(); + let retained = store.pending_all().unwrap(); + assert_eq!(retained.len(), 1); + assert_eq!(retained[0].0, stable_sequence); +} + +#[tokio::test] +async fn fragment_retry_survives_a_full_deferred_inbox() { + let mut rng = StdRng::seed_from_u64(407); + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("fragment-quota.db"); + let net: Net = Arc::new(Mutex::new(HashMap::new())); + let node = Node::create(&db, b"pass", TEST_KDF, &mut rng).unwrap(); + drop(node); + + let store = Store::open(&db, b"pass").unwrap(); + let mut first_sequence = None; + for i in 0..MAX_PENDING_ENVELOPES { + let mut token = [0u8; 32]; + token[..8].copy_from_slice(&(i as u64).to_le_bytes()); + let filler = Envelope::new(EnvelopeKind::Message, token, vec![0x55]); + let sequence = store.pending_push(&filler, NOW, &mut rng).unwrap(); + first_sequence.get_or_insert(sequence); + } + drop(store); + + let inner = Envelope::new(EnvelopeKind::Message, [0xf0; 32], vec![0x33; 256]); + let fragment_envelopes: Vec = fragment(&inner.try_encode().unwrap(), 80) + .unwrap() + .into_iter() + .map(|body| Envelope::new(EnvelopeKind::Fragment, inner.token, body)) + .collect(); + net.lock() + .unwrap() + .entry(2) + .or_default() + .extend(fragment_envelopes.clone()); + let mut node = Node::open(&db, b"pass").unwrap(); + node.add_transport(Arc::new(MockMesh { + net: net.clone(), + me: 2, + mtu: 64 * 1024, + duplicate: false, + })); + node.tick(NOW, &mut rng).await.unwrap(); + drop(node); + + let store = Store::open(&db, b"pass").unwrap(); + let pending = store.pending_all().unwrap(); + assert_eq!(pending.len(), MAX_PENDING_ENVELOPES); + assert!(!pending + .iter() + .any(|(_, envelope, _)| envelope.content_id() == inner.content_id())); + for fragment in &fragment_envelopes { + assert!(!store.is_seen(&fragment.content_id()).unwrap()); + } + store.pending_ack(first_sequence.unwrap()).unwrap(); + drop(store); + + net.lock() + .unwrap() + .entry(2) + .or_default() + .extend(fragment_envelopes); + let mut node = Node::open(&db, b"pass").unwrap(); + node.add_transport(Arc::new(MockMesh { + net, + me: 2, + mtu: 64 * 1024, + duplicate: false, + })); + node.tick(NOW + 1, &mut rng).await.unwrap(); + drop(node); + + let store = Store::open(&db, b"pass").unwrap(); + let pending = store.pending_all().unwrap(); + assert_eq!(pending.len(), MAX_PENDING_ENVELOPES); + assert!(pending + .iter() + .any(|(_, envelope, _)| envelope.content_id() == inner.content_id())); } diff --git a/crates/kult-node/tests/single_writer.rs b/crates/kult-node/tests/single_writer.rs new file mode 100644 index 0000000..edf3147 --- /dev/null +++ b/crates/kult-node/tests/single_writer.rs @@ -0,0 +1,32 @@ +use kult_crypto::KdfProfile; +use kult_node::{Node, NodeError}; +use kult_store::StoreError; +use rand::rngs::StdRng; +use rand::SeedableRng; + +const TEST_KDF: KdfProfile = KdfProfile { + m_cost_kib: 8, + t_cost: 1, + p_cost: 1, +}; + +#[test] +fn node_owns_the_store_lock_for_its_complete_lifetime() { + let mut rng = StdRng::seed_from_u64(0x10cf); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("node.db"); + let node = Node::create(&database, b"pass", TEST_KDF, &mut rng).unwrap(); + + assert!(matches!( + Node::open(&database, b"pass"), + Err(NodeError::Store(StoreError::AlreadyOpen)) + )); + assert!(matches!( + Node::create(&database, b"pass", TEST_KDF, &mut rng), + Err(NodeError::Store(StoreError::AlreadyOpen)) + )); + + drop(node); + let reopened = Node::open(&database, b"pass").unwrap(); + drop(reopened); +} diff --git a/crates/kult-protocol/fuzz/fuzz_targets/bundle_import.rs b/crates/kult-protocol/fuzz/fuzz_targets/bundle_import.rs index 2dff72d..c31dee0 100644 --- a/crates/kult-protocol/fuzz/fuzz_targets/bundle_import.rs +++ b/crates/kult-protocol/fuzz/fuzz_targets/bundle_import.rs @@ -4,7 +4,7 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(envs) = kult_protocol::bundle_import(data) { - let re = kult_protocol::bundle_export(&envs); + let re = kult_protocol::bundle_export(&envs).unwrap(); assert_eq!(kult_protocol::bundle_import(&re).unwrap(), envs); } }); diff --git a/crates/kult-protocol/fuzz/fuzz_targets/protocol_envelope_decode.rs b/crates/kult-protocol/fuzz/fuzz_targets/protocol_envelope_decode.rs index dff8282..df85ce6 100644 --- a/crates/kult-protocol/fuzz/fuzz_targets/protocol_envelope_decode.rs +++ b/crates/kult-protocol/fuzz/fuzz_targets/protocol_envelope_decode.rs @@ -4,6 +4,8 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(env) = kult_protocol::Envelope::decode(data) { - assert_eq!(kult_protocol::Envelope::decode(&env.encode()).unwrap(), env); + let encoded = env.try_encode().unwrap(); + assert!(encoded.len() <= kult_protocol::MAX_ENVELOPE_BYTES); + assert_eq!(kult_protocol::Envelope::decode(&encoded).unwrap(), env); } }); diff --git a/crates/kult-protocol/src/bundle.rs b/crates/kult-protocol/src/bundle.rs index 0544438..c85fa44 100644 --- a/crates/kult-protocol/src/bundle.rs +++ b/crates/kult-protocol/src/bundle.rs @@ -7,47 +7,66 @@ use alloc::vec::Vec; -use crate::{Envelope, ProtocolError, Result}; +use crate::{Envelope, ProtocolError, Result, MAX_ENVELOPE_BYTES}; /// Bundle file magic. pub const BUNDLE_MAGIC: &[u8; 4] = b"KKB1"; +/// Maximum aggregate bytes admitted for one courier bundle. +pub const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024; +/// Maximum envelopes admitted in one courier bundle. +pub const MAX_BUNDLE_ENVELOPES: usize = 4_096; -/// Per-envelope hard cap inside bundles — matches the largest padded message -/// plus protocol overhead; rejects absurd length prefixes early. -const MAX_ENVELOPE_BYTES: usize = 128 * 1024; - -/// Serialize envelopes into a bundle. -pub fn bundle_export(envelopes: &[Envelope]) -> Vec { - let mut out = Vec::with_capacity( - 4 + envelopes - .iter() - .map(|e| 4 + e.body.len() + 34) - .sum::(), - ); +/// Serialize bounded envelopes into a bundle. +/// +/// Every entry is validated against [`MAX_ENVELOPE_BYTES`] before it is +/// emitted, so this function can never create a bundle that +/// [`bundle_import`] rejects solely for an oversized envelope. +pub fn bundle_export(envelopes: &[Envelope]) -> Result> { + if envelopes.len() > MAX_BUNDLE_ENVELOPES { + return Err(ProtocolError::TooManyBundleEntries); + } + let mut out = Vec::new(); out.extend_from_slice(BUNDLE_MAGIC); for env in envelopes { - let bytes = env.encode(); + let bytes = env.try_encode()?; + let next_len = out + .len() + .checked_add(4) + .and_then(|len| len.checked_add(bytes.len())) + .ok_or(ProtocolError::BundleTooLarge)?; + if next_len > MAX_BUNDLE_BYTES { + return Err(ProtocolError::BundleTooLarge); + } out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); out.extend_from_slice(&bytes); } - out + Ok(out) } /// Parse a bundle. Strict: bad magic, truncation, oversized entries, or an /// undecodable envelope reject the whole bundle (couriered files are either /// intact or worthless — no partial trust). pub fn bundle_import(bytes: &[u8]) -> Result> { + if bytes.len() > MAX_BUNDLE_BYTES { + return Err(ProtocolError::BundleTooLarge); + } let rest = bytes .strip_prefix(BUNDLE_MAGIC.as_slice()) .ok_or(ProtocolError::Malformed)?; let mut envelopes = Vec::new(); let mut cursor = rest; while !cursor.is_empty() { + if envelopes.len() >= MAX_BUNDLE_ENVELOPES { + return Err(ProtocolError::TooManyBundleEntries); + } if cursor.len() < 4 { return Err(ProtocolError::Malformed); } let len = u32::from_le_bytes(cursor[..4].try_into().expect("length checked")) as usize; - if len > MAX_ENVELOPE_BYTES || cursor.len() < 4 + len { + if len > MAX_ENVELOPE_BYTES { + return Err(ProtocolError::EnvelopeTooLarge); + } + if cursor.len() < 4 + len { return Err(ProtocolError::Malformed); } envelopes.push(Envelope::decode(&cursor[4..4 + len])?); diff --git a/crates/kult-protocol/src/envelope.rs b/crates/kult-protocol/src/envelope.rs index f445522..6054dc0 100644 --- a/crates/kult-protocol/src/envelope.rs +++ b/crates/kult-protocol/src/envelope.rs @@ -25,6 +25,12 @@ pub const ENVELOPE_HEADER_LEN: usize = 1 + 1 + 32 + 8; pub const ENVELOPE_V1_HEADER_LEN: usize = 1 + 1 + 32; /// v2 header length. pub const ENVELOPE_V2_HEADER_LEN: usize = ENVELOPE_HEADER_LEN; +/// Maximum bytes in one complete encoded envelope on any carrier. +/// +/// Larger messages must be split by their higher-level format or fragmented +/// for a carrier before they cross a wire boundary. Keeping this limit in the +/// protocol crate gives every decoder the same allocation ceiling. +pub const MAX_ENVELOPE_BYTES: usize = 128 * 1024; /// What an envelope carries (byte 1). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -115,9 +121,26 @@ impl Envelope { } } + /// Exact number of bytes this envelope occupies in the wire format. + pub fn encoded_len(&self) -> usize { + self.header_len().saturating_add(self.body.len()) + } + + /// Serialize to the wire format after enforcing [`MAX_ENVELOPE_BYTES`]. + /// + /// Network and other externally supplied carrier boundaries should use + /// this fallible form. [`Self::encode`] remains available for established + /// in-memory callers that construct already-bounded protocol values. + pub fn try_encode(&self) -> Result> { + if self.encoded_len() > MAX_ENVELOPE_BYTES { + return Err(ProtocolError::EnvelopeTooLarge); + } + Ok(self.encode()) + } + /// Serialize to the wire format. pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(self.header_len() + self.body.len()); + let mut out = Vec::with_capacity(self.encoded_len()); out.push(if self.retention_until.is_some() { ENVELOPE_VERSION_V2 } else { @@ -134,6 +157,9 @@ impl Envelope { /// Parse from the wire format. Never panics on arbitrary input. pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_ENVELOPE_BYTES { + return Err(ProtocolError::EnvelopeTooLarge); + } if bytes.len() < ENVELOPE_V1_HEADER_LEN { return Err(ProtocolError::Malformed); } @@ -203,4 +229,23 @@ mod tests { v2[0] = 3; assert!(Envelope::decode(&v2).is_err()); } + + #[test] + fn project_wire_limit_is_enforced_before_body_allocation() { + let max_v1_body = MAX_ENVELOPE_BYTES - ENVELOPE_V1_HEADER_LEN; + let envelope = Envelope::new(EnvelopeKind::Message, [1; 32], vec![0; max_v1_body]); + let encoded = envelope.try_encode().unwrap(); + assert_eq!(encoded.len(), MAX_ENVELOPE_BYTES); + assert_eq!(Envelope::decode(&encoded).unwrap(), envelope); + + let oversized = Envelope::new(EnvelopeKind::Message, [2; 32], vec![0; max_v1_body + 1]); + assert_eq!( + oversized.try_encode().unwrap_err(), + ProtocolError::EnvelopeTooLarge + ); + assert_eq!( + Envelope::decode(&vec![0; MAX_ENVELOPE_BYTES + 1]).unwrap_err(), + ProtocolError::EnvelopeTooLarge + ); + } } diff --git a/crates/kult-protocol/src/error.rs b/crates/kult-protocol/src/error.rs index a16a1b0..3e03776 100644 --- a/crates/kult-protocol/src/error.rs +++ b/crates/kult-protocol/src/error.rs @@ -8,6 +8,12 @@ use core::fmt; pub enum ProtocolError { /// Wire bytes could not be parsed (bad magic, length, version, or kind). Malformed, + /// A complete encoded envelope exceeds the project-wide wire limit. + EnvelopeTooLarge, + /// A courier bundle exceeds the aggregate file-size limit. + BundleTooLarge, + /// A courier bundle contains more envelopes than one import may admit. + TooManyBundleEntries, /// Payload exceeds the largest padding bucket; chunk it first. TooLarge, /// Padding was structurally invalid on removal. @@ -26,6 +32,9 @@ impl fmt::Display for ProtocolError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { Self::Malformed => "malformed protocol bytes", + Self::EnvelopeTooLarge => "encoded envelope exceeds project wire limit", + Self::BundleTooLarge => "courier bundle exceeds aggregate size limit", + Self::TooManyBundleEntries => "courier bundle contains too many envelopes", Self::TooLarge => "payload exceeds largest padding bucket", Self::BadPadding => "invalid padding", Self::MtuTooSmall => "mtu too small for fragmentation", diff --git a/crates/kult-protocol/src/fragmentation.rs b/crates/kult-protocol/src/fragmentation.rs index 16ada7d..b725e9e 100644 --- a/crates/kult-protocol/src/fragmentation.rs +++ b/crates/kult-protocol/src/fragmentation.rs @@ -10,16 +10,20 @@ use alloc::collections::BTreeMap; use alloc::vec::Vec; -use crate::{ProtocolError, Result}; +use crate::{ProtocolError, Result, MAX_ENVELOPE_BYTES}; /// Fragment header length in bytes. pub const FRAG_HEADER_LEN: usize = 4 + 2 + 2; /// How long an incomplete message is retained (24 h, normative). pub const REASSEMBLY_WINDOW_SECS: u64 = 24 * 3600; +/// Maximum fragments in one reassembled envelope. +/// +/// This still carries a maximum-size envelope over the smallest currently +/// supported radio frame, while preventing an unauthenticated fragment from +/// declaring a 65,535-entry missing-index vector. +pub const MAX_FRAGMENTS: usize = 1024; /// Maximum concurrent partial messages (fail-closed cap). const MAX_PARTIALS: usize = 256; -/// Maximum reassembled size (matches largest pad bucket + protocol overhead). -const MAX_MESSAGE_BYTES: usize = 128 * 1024; fn msg_id(payload: &[u8]) -> [u8; 4] { blake3::hash(payload).as_bytes()[..4] @@ -30,12 +34,15 @@ fn msg_id(payload: &[u8]) -> [u8; 4] { /// Split `payload` into fragment bodies of at most `mtu` bytes each /// (header included). Returns bodies ready to wrap in `Fragment` envelopes. pub fn fragment(payload: &[u8], mtu: usize) -> Result>> { + if payload.len() > MAX_ENVELOPE_BYTES { + return Err(ProtocolError::EnvelopeTooLarge); + } if mtu <= FRAG_HEADER_LEN { return Err(ProtocolError::MtuTooSmall); } let slice_len = mtu - FRAG_HEADER_LEN; let count = payload.len().div_ceil(slice_len).max(1); - if count > u16::MAX as usize { + if count > MAX_FRAGMENTS { return Err(ProtocolError::TooManyFragments); } let id = msg_id(payload); @@ -92,7 +99,10 @@ impl Reassembler { let index = u16::from_le_bytes(frag_body[4..6].try_into().expect("length checked")); let count = u16::from_le_bytes(frag_body[6..8].try_into().expect("length checked")); let slice = &frag_body[FRAG_HEADER_LEN..]; - if count == 0 || index >= count { + if count == 0 || usize::from(count) > MAX_FRAGMENTS || index >= count { + if usize::from(count) > MAX_FRAGMENTS { + return Err(ProtocolError::TooManyFragments); + } return Err(ProtocolError::Malformed); } @@ -121,7 +131,7 @@ impl Reassembler { if partial.parts.contains_key(&index) { return Ok(None); // duplicate } - if partial.bytes + slice.len() > MAX_MESSAGE_BYTES { + if partial.bytes + slice.len() > MAX_ENVELOPE_BYTES { self.partials.remove(&id); return Err(ProtocolError::ReassemblyOverflow); } diff --git a/crates/kult-protocol/src/lib.rs b/crates/kult-protocol/src/lib.rs index c169178..fa0f9ba 100644 --- a/crates/kult-protocol/src/lib.rs +++ b/crates/kult-protocol/src/lib.rs @@ -59,7 +59,9 @@ pub use attachment_bulk::{ ATTACHMENT_BULK_MAGIC, ATTACHMENT_BULK_VERSION, ATTACHMENT_CHUNK_PLAINTEXT_LEN, ATTACHMENT_SEALED_CHUNK_LEN, MAX_ATTACHMENT_BULK_LEN, MAX_MISSING_RANGES, }; -pub use bundle::{bundle_export, bundle_import, BUNDLE_MAGIC}; +pub use bundle::{ + bundle_export, bundle_import, BUNDLE_MAGIC, MAX_BUNDLE_BYTES, MAX_BUNDLE_ENVELOPES, +}; pub use call::{ decode_call_control_payload, encode_call_control_payload, CallControl, CallHangupReason, DecodedCallControl, CALL_CONTROL_BOUND_LEN, CALL_CONTROL_HANGUP_LEN, CALL_CONTROL_HEADER_LEN, @@ -88,7 +90,7 @@ pub use edit::{ }; pub use envelope::{ Envelope, EnvelopeKind, ENVELOPE_HEADER_LEN, ENVELOPE_V1_HEADER_LEN, ENVELOPE_V2_HEADER_LEN, - ENVELOPE_VERSION_V1, ENVELOPE_VERSION_V2, + ENVELOPE_VERSION_V1, ENVELOPE_VERSION_V2, MAX_ENVELOPE_BYTES, }; pub use ephemeral::{ decode_ephemeral_payload, encode_disappearing_text_payload, @@ -97,7 +99,9 @@ pub use ephemeral::{ MIN_EPHEMERAL_LIFETIME_SECS, RETENTION_BUCKET_SECS, }; pub use error::ProtocolError; -pub use fragmentation::{fragment, Reassembler, FRAG_HEADER_LEN, REASSEMBLY_WINDOW_SECS}; +pub use fragmentation::{ + fragment, Reassembler, FRAG_HEADER_LEN, MAX_FRAGMENTS, REASSEMBLY_WINDOW_SECS, +}; pub use group::{ group_admin_request_signing_bytes, GroupAdminAction, GroupAdminRequest, GroupAdminResult, GroupAnnounce, GroupAuthorityAnnounce, GroupControlPayload, GroupMemberInfo, diff --git a/crates/kult-protocol/tests/protocol.rs b/crates/kult-protocol/tests/protocol.rs index 8c58560..f36e434 100644 --- a/crates/kult-protocol/tests/protocol.rs +++ b/crates/kult-protocol/tests/protocol.rs @@ -7,7 +7,9 @@ use rand::{Rng, SeedableRng}; use kult_protocol::{ bundle_export, bundle_import, delivery_token, epoch_day, fragment, intro_token, pad, unpad, - Envelope, EnvelopeKind, MailboxKey, ProtocolError, Reassembler, ReceiptPayload, PAD_BUCKETS, + Envelope, EnvelopeKind, MailboxKey, ProtocolError, Reassembler, ReceiptPayload, + FRAG_HEADER_LEN, MAX_BUNDLE_BYTES, MAX_BUNDLE_ENVELOPES, MAX_ENVELOPE_BYTES, MAX_FRAGMENTS, + PAD_BUCKETS, }; const NOW: u64 = 1_800_000_000; @@ -121,7 +123,7 @@ fn bundle_roundtrip_and_strictness() { let envs: Vec = (0..5) .map(|i| Envelope::new(EnvelopeKind::Message, [i as u8; 32], vec![i as u8; 40 + i])) .collect(); - let bytes = bundle_export(&envs); + let bytes = bundle_export(&envs).unwrap(); assert_eq!(bundle_import(&bytes).unwrap(), envs); assert!(bundle_import(b"NOPE").is_err()); @@ -131,7 +133,29 @@ fn bundle_roundtrip_and_strictness() { // Absurd length prefix rejected. let mut evil = b"KKB1".to_vec(); evil.extend_from_slice(&u32::MAX.to_le_bytes()); - assert!(bundle_import(&evil).is_err()); + assert_eq!( + bundle_import(&evil).unwrap_err(), + ProtocolError::EnvelopeTooLarge + ); + + let oversized = Envelope::new(EnvelopeKind::Message, [9; 32], vec![0; MAX_ENVELOPE_BYTES]); + assert_eq!( + bundle_export(&[oversized]).unwrap_err(), + ProtocolError::EnvelopeTooLarge + ); +} + +#[test] +fn bundle_aggregate_limits_fail_closed() { + let tiny = Envelope::new(EnvelopeKind::Message, [9; 32], Vec::new()); + assert_eq!( + bundle_export(&vec![tiny; MAX_BUNDLE_ENVELOPES + 1]).unwrap_err(), + ProtocolError::TooManyBundleEntries + ); + assert_eq!( + bundle_import(&vec![0; MAX_BUNDLE_BYTES + 1]).unwrap_err(), + ProtocolError::BundleTooLarge + ); } #[test] @@ -230,6 +254,14 @@ fn mixed_fragments_fail_integrity() { #[test] fn fragment_edge_cases() { + assert_eq!( + fragment(&vec![0; MAX_ENVELOPE_BYTES + 1], 180).unwrap_err(), + ProtocolError::EnvelopeTooLarge + ); + assert_eq!( + fragment(&vec![0; MAX_FRAGMENTS + 1], FRAG_HEADER_LEN + 1).unwrap_err(), + ProtocolError::TooManyFragments + ); assert_eq!( fragment(&[1, 2, 3], 8).unwrap_err(), ProtocolError::MtuTooSmall @@ -247,3 +279,13 @@ fn fragment_edge_cases() { let _ = r.insert(&buf, NOW); } } + +#[test] +fn forged_fragment_count_is_bounded_before_partial_allocation() { + let mut forged = vec![0u8; FRAG_HEADER_LEN + 1]; + forged[6..8].copy_from_slice(&((MAX_FRAGMENTS + 1) as u16).to_le_bytes()); + assert_eq!( + Reassembler::new().insert(&forged, 0).unwrap_err(), + ProtocolError::TooManyFragments + ); +} diff --git a/crates/kult-store/Cargo.toml b/crates/kult-store/Cargo.toml index 23c2b93..41a47a1 100644 --- a/crates/kult-store/Cargo.toml +++ b/crates/kult-store/Cargo.toml @@ -16,6 +16,7 @@ serde = { version = "1", features = ["derive"] } postcard = { version = "1", default-features = false, features = ["alloc"] } zeroize = "1" fs2 = "0.4" +libc = "0.2" [dev-dependencies] proptest = "1" diff --git a/crates/kult-store/examples/sneakernet_demo.rs b/crates/kult-store/examples/sneakernet_demo.rs index a9e0dc3..3a9a080 100644 --- a/crates/kult-store/examples/sneakernet_demo.rs +++ b/crates/kult-store/examples/sneakernet_demo.rs @@ -108,7 +108,7 @@ fn main() { .unwrap(); let courier = dir.join("courier.kkb"); - std::fs::write(&courier, bundle_export(&[hs_env, msg_env])).unwrap(); + std::fs::write(&courier, bundle_export(&[hs_env, msg_env]).unwrap()).unwrap(); println!( "[alice] 2 sealed envelopes → {} ({} bytes on the USB stick)", courier.display(), diff --git a/crates/kult-store/src/lib.rs b/crates/kult-store/src/lib.rs index 61c28a9..4a09f68 100644 --- a/crates/kult-store/src/lib.rs +++ b/crates/kult-store/src/lib.rs @@ -12,10 +12,11 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use std::fs::{File, OpenOptions}; use std::path::{Path, PathBuf}; use rand_core::CryptoRngCore; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; @@ -77,8 +78,15 @@ pub enum StoreError { NotABackup, /// (De)serialization of a stored record failed. Serialization, - /// Filesystem operation for the private media store failed. + /// Filesystem operation for private store state failed. Io(std::io::Error), + /// Another live store owner already holds this database's writer lock. + AlreadyOpen, + /// A typed protocol transition did not match the durable source state it + /// named and was rolled back without changing the store. + InvalidTransition, + /// The bounded deferred-inbox item or sealed-byte quota is exhausted. + PendingQuota, /// Configured or protocol-hard media quota would be exceeded. MediaQuota, /// Committing a media chunk would violate the free-space reserve. @@ -142,7 +150,10 @@ impl std::fmt::Display for StoreError { Self::NotAStore => f.write_str("not a Komms store"), Self::NotABackup => f.write_str("not a Komms backup file"), Self::Serialization => f.write_str("record serialization failure"), - Self::Io(e) => write!(f, "media filesystem error: {e}"), + Self::Io(e) => write!(f, "store filesystem error: {e}"), + Self::AlreadyOpen => f.write_str("store is already open by another process"), + Self::InvalidTransition => f.write_str("invalid durable protocol transition"), + Self::PendingQuota => f.write_str("deferred inbox quota exhausted"), Self::MediaQuota => f.write_str("media quota exceeded"), Self::LowStorage => f.write_str("insufficient reserved filesystem space"), Self::MediaState => f.write_str("invalid media transfer state"), @@ -245,6 +256,28 @@ pub struct MessageRecord { pub wire_id: Option<[u8; 16]>, } +/// Complete durable consequences of accepting one ordinary pairwise message. +/// +/// The session is a candidate advanced from the last durable session. Applying +/// this plan either commits every field in one immediate SQLite transaction or +/// leaves the prior session and source pending row unchanged. +pub struct PairwiseReceivePlan<'a> { + /// Exact physical-device ratchet route whose receiving state advanced. + pub peer_device: &'a [u8; 32], + /// Candidate session after authenticating and decrypting the envelope. + pub session: &'a Session, + /// Accepted immutable history row, or `None` for an application-level + /// duplicate whose envelope still needs replay and receipt state. + pub message: Option<&'a MessageRecord>, + /// Authenticated envelope content id used for durable transport dedup. + pub content_id: &'a [u8; 16], + /// Local receive time stored with the duplicate-receipt route. + pub received_at: u64, + /// Stable deferred-inbox row consumed by this transition, when the + /// envelope came from the pending inbox rather than a fresh carrier read. + pub source_pending_sequence: Option, +} + /// A contact (sealed as one blob in the `contacts` table). /// /// Delivery hints are opaque bytes to the store — the runtime serializes @@ -439,6 +472,10 @@ const QUEUE_ROW_MAGIC_V2: &[u8; 4] = b"KQ\0\x02"; type GroupChainRow = ([u8; 32], Zeroizing>); const WRAP_AD: &[u8] = b"KK-store-wrap-v1"; +/// Maximum number of envelopes waiting for a session or handshake. +pub const MAX_PENDING_ENVELOPES: usize = 2_048; +/// Maximum aggregate sealed bytes retained by the deferred inbox. +pub const MAX_PENDING_BYTES: usize = 64 * 1024 * 1024; const SCHEMA: &str = " CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v BLOB NOT NULL); CREATE TABLE IF NOT EXISTS identity (id INTEGER PRIMARY KEY CHECK (id = 1), blob BLOB NOT NULL); @@ -468,7 +505,90 @@ CREATE TABLE IF NOT EXISTS contact_devices (rowid_ INTEGER PRIMARY KEY AUTOINCRE CREATE TABLE IF NOT EXISTS message_device_delivery (rowid_ INTEGER PRIMARY KEY AUTOINCREMENT, blob BLOB NOT NULL); "; -/// An open, unlocked Komms store. +/// Resolve one stable sidecar name without replacing the database extension. +/// +/// Existing database symlinks resolve to the target before the suffix is +/// appended. For a new database, canonicalizing its parent gives relative and +/// absolute spellings the same lock file. +fn store_lock_path(path: &Path) -> Result { + let file_name = path.file_name().ok_or_else(|| { + StoreError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "store path has no file name", + )) + })?; + let resolved = match std::fs::canonicalize(path) { + Ok(resolved) => resolved, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::canonicalize(parent)?.join(file_name) + } + Err(error) => return Err(StoreError::Io(error)), + }; + let mut sidecar = resolved.into_os_string(); + sidecar.push(".lock"); + Ok(PathBuf::from(sidecar)) +} + +/// Acquire this database's non-blocking process-wide writer exclusion. +/// +/// The sidecar intentionally remains after drop: unlinking a lock file can +/// split contenders across different inodes. Dropping the returned handle +/// releases the advisory lock. +fn acquire_store_lock(path: &Path) -> Result { + let lock_path = store_lock_path(path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let lock = options.open(lock_path)?; + match fs2::FileExt::try_lock_exclusive(&lock) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + return Err(StoreError::AlreadyOpen); + } + Err(error) => return Err(StoreError::Io(error)), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + lock.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(lock) +} + +/// Lock the opened database inode as a second writer-identity boundary. +/// +/// On Unix, this closes the hardlink-alias gap left by a pathname sidecar. +/// The connection opens first but performs no schema or application write +/// before this lock succeeds. Other platforms retain the canonical sidecar +/// boundary until an equivalent file-identity strategy is qualified. +fn acquire_database_identity_lock(path: &Path) -> Result> { + #[cfg(unix)] + { + let database = OpenOptions::new().read(true).write(true).open(path)?; + match fs2::FileExt::try_lock_exclusive(&database) { + Ok(()) => Ok(Some(database)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + Err(StoreError::AlreadyOpen) + } + Err(error) => Err(StoreError::Io(error)), + } + } + #[cfg(not(unix))] + { + let _ = path; + Ok(None) + } +} + +/// An open Komms store with its encrypted domains unlocked. pub struct Store { conn: Connection, k_identity: StorageKey, @@ -490,6 +610,12 @@ pub struct Store { k_devices: StorageKey, media_dir: PathBuf, media_limits: MediaLimits, + // Prevents another Unix process from bypassing the pathname sidecar via a + // hardlink alias to the same database inode. + _database_lock: Option, + // Kept last so normal field drop order closes SQLite and clears every + // store field before releasing the process-wide writer exclusion. + _lock: File, } impl Store { @@ -501,7 +627,9 @@ impl Store { profile: KdfProfile, rng: &mut impl CryptoRngCore, ) -> Result { + let lock = acquire_store_lock(path)?; let conn = Connection::open(path)?; + let database_lock = acquire_database_identity_lock(path)?; conn.execute_batch(SCHEMA)?; let existing: Option> = conn .query_row("SELECT v FROM meta WHERE k = 'wrapped_sk'", [], |r| { @@ -534,12 +662,20 @@ impl Store { params![wrapped], )?; - Self::with_master(conn, StorageKey::from_bytes(*sk_bytes), path) + Self::with_master( + conn, + database_lock, + lock, + StorageKey::from_bytes(*sk_bytes), + path, + ) } /// Open and unlock an existing store. pub fn open(path: &Path, passphrase: &[u8]) -> Result { + let lock = acquire_store_lock(path)?; let conn = Connection::open(path)?; + let database_lock = acquire_database_identity_lock(path)?; // Idempotent: also creates any table added since this store was — // the only schema evolution so far is purely additive. conn.execute_batch(SCHEMA)?; @@ -563,10 +699,22 @@ impl Store { let sk_vec = Zeroizing::new(kek_key.open(WRAP_AD, &wrapped)?); // wrong passphrase fails here let sk_bytes: [u8; 32] = sk_vec[..].try_into().map_err(|_| StoreError::NotAStore)?; - Self::with_master(conn, StorageKey::from_bytes(sk_bytes), path) + Self::with_master( + conn, + database_lock, + lock, + StorageKey::from_bytes(sk_bytes), + path, + ) } - fn with_master(conn: Connection, master: StorageKey, path: &Path) -> Result { + fn with_master( + conn: Connection, + database_lock: Option, + lock: File, + master: StorageKey, + path: &Path, + ) -> Result { let media_dir = media::prepare_media_directory(path)?; Ok(Self { k_identity: master.derive(b"KK-store-identity"), @@ -587,6 +735,8 @@ impl Store { media_dir, media_limits: MediaLimits::default(), conn, + _database_lock: database_lock, + _lock: lock, }) } @@ -653,6 +803,95 @@ impl Store { } } + /// Atomically commit an accepted ordinary pairwise receive transition. + /// + /// Sealing and serialization complete before `BEGIN IMMEDIATE`. The + /// transaction advances the ratchet, appends optional history, records + /// envelope dedup and receipt replay state, and acknowledges the exact + /// deferred-inbox row together. A missing named pending row or any SQL + /// failure rolls back the entire transition. + pub fn commit_pairwise_receive( + &self, + plan: PairwiseReceivePlan<'_>, + rng: &mut impl CryptoRngCore, + ) -> Result<()> { + if plan.message.is_some_and(|message| { + message.direction != Direction::Inbound + || message.state != DeliveryState::Received + || message.wire_id.is_some() + }) { + return Err(StoreError::InvalidTransition); + } + if let Some(sequence) = plan.source_pending_sequence { + let sealed: Option> = self + .conn + .query_row( + "SELECT blob FROM pending WHERE seq = ?1", + params![sequence], + |row| row.get(0), + ) + .optional()?; + let Some(sealed) = sealed else { + return Err(StoreError::InvalidTransition); + }; + let plain = self.k_pending.open(b"pending", &sealed)?; + let (envelope, _): (Vec, u64) = + postcard::from_bytes(&plain).map_err(|_| StoreError::Serialization)?; + if Envelope::decode(&envelope)?.content_id() != *plan.content_id { + return Err(StoreError::InvalidTransition); + } + } + + let sealed_session = plan.session.seal(&self.k_sessions, rng); + let sealed_message = if let Some(message) = plan.message { + let plain = postcard::to_allocvec(message).map_err(|_| StoreError::Serialization)?; + Some(self.k_messages.seal(b"message", &plain, rng)) + } else { + None + }; + let replay = postcard::to_allocvec(&(*plan.peer_device, plan.received_at)) + .map_err(|_| StoreError::Serialization)?; + let sealed_replay = self.k_queue.seal(b"receipt-replay", &replay, rng); + + let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let applied = (|| -> Result<()> { + tx.execute( + "INSERT OR REPLACE INTO sessions (peer, blob) VALUES (?1, ?2)", + params![plan.peer_device.as_slice(), sealed_session], + )?; + if let Some(sealed) = sealed_message { + tx.execute("INSERT INTO messages (blob) VALUES (?1)", params![sealed])?; + } + tx.execute( + "INSERT INTO seen (id) VALUES (?1)", + params![plan.content_id.as_slice()], + )?; + tx.execute( + "INSERT OR REPLACE INTO receipt_replay (id, blob) VALUES (?1, ?2)", + params![plan.content_id.as_slice(), sealed_replay], + )?; + if let Some(sequence) = plan.source_pending_sequence { + let removed = + tx.execute("DELETE FROM pending WHERE seq = ?1", params![sequence])?; + if removed != 1 { + return Err(StoreError::InvalidTransition); + } + } + Ok(()) + })(); + + match applied { + Ok(()) => { + tx.commit()?; + Ok(()) + } + Err(error) => { + let _ = tx.rollback(); + Err(error) + } + } + } + /// Delete one exact physical-endpoint ratchet session. pub fn delete_session(&self, peer: &[u8; 32]) -> Result<()> { self.conn.execute( @@ -871,6 +1110,7 @@ impl Store { /// Enqueue an envelope for delivery (sealed at rest; survives restarts). pub fn queue_push(&self, item: &QueueItem, rng: &mut impl CryptoRngCore) -> Result { + let envelope = item.envelope.try_encode()?; let row = QueueRowV2 { peer: item.peer, msg_id: item.msg_id, @@ -879,7 +1119,7 @@ impl Store { created_at: item.created_at, attempts: item.attempts, next_attempt_at: item.next_attempt_at, - envelope: item.envelope.encode(), + envelope, }; let encoded = postcard::to_allocvec(&row).map_err(|_| StoreError::Serialization)?; let mut plain = Vec::with_capacity(QUEUE_ROW_MAGIC_V2.len() + encoded.len()); @@ -991,6 +1231,7 @@ impl Store { item: &QueueItem, rng: &mut impl CryptoRngCore, ) -> Result<()> { + let envelope = item.envelope.try_encode()?; let row = QueueRowV2 { peer: item.peer, msg_id: item.msg_id, @@ -999,7 +1240,7 @@ impl Store { created_at: item.created_at, attempts: item.attempts, next_attempt_at: item.next_attempt_at, - envelope: item.envelope.encode(), + envelope, }; let encoded = postcard::to_allocvec(&row).map_err(|_| StoreError::Serialization)?; let mut plain = Vec::with_capacity(QUEUE_ROW_MAGIC_V2.len() + encoded.len()); @@ -1092,38 +1333,77 @@ impl Store { /// Stash an inbound envelope that cannot be consumed yet (e.g. it arrived /// before the handshake that establishes its session). Survives restarts - /// so out-of-order arrival across carriers never loses messages. + /// so out-of-order arrival across carriers never loses messages. Returns + /// the stable sequence used for later acknowledgement. pub fn pending_push( &self, env: &Envelope, first_seen: u64, rng: &mut impl CryptoRngCore, - ) -> Result<()> { - let plain = postcard::to_allocvec(&(env.encode(), first_seen)) - .map_err(|_| StoreError::Serialization)?; + ) -> Result { + let encoded = env.try_encode()?; + let plain = + postcard::to_allocvec(&(encoded, first_seen)).map_err(|_| StoreError::Serialization)?; let sealed = self.k_pending.seal(b"pending", &plain, rng); - self.conn - .execute("INSERT INTO pending (blob) VALUES (?1)", params![sealed])?; - Ok(()) + if sealed.len() > MAX_PENDING_BYTES { + return Err(StoreError::PendingQuota); + } + + let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let (count, bytes): (i64, i64) = tx.query_row( + "SELECT COUNT(*), COALESCE(SUM(length(blob)), 0) FROM pending", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let count = usize::try_from(count).map_err(|_| StoreError::Serialization)?; + let bytes = usize::try_from(bytes).map_err(|_| StoreError::Serialization)?; + if count >= MAX_PENDING_ENVELOPES + || bytes + .checked_add(sealed.len()) + .is_none_or(|total| total > MAX_PENDING_BYTES) + { + tx.rollback()?; + return Err(StoreError::PendingQuota); + } + tx.execute("INSERT INTO pending (blob) VALUES (?1)", params![sealed])?; + let sequence = tx.last_insert_rowid(); + tx.commit()?; + Ok(sequence) } - /// Remove and return all stashed inbound envelopes with their - /// first-seen timestamps (the runtime re-stashes what it still can't - /// consume). - pub fn pending_drain(&self) -> Result> { - let mut stmt = self.conn.prepare("SELECT blob FROM pending ORDER BY seq")?; - let rows = stmt.query_map([], |r| r.get::<_, Vec>(0))?; + /// Return every stashed inbound envelope without removing it. + /// + /// Each tuple is `(stable sequence, envelope, first-seen timestamp)`. + /// The caller must explicitly [`Store::pending_ack`] a sequence only + /// after the envelope is consumed or has expired. This gives deferred + /// receive processing at-least-once crash semantics. + pub fn pending_all(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT seq, blob FROM pending ORDER BY seq")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec>(1)?)))?; let mut out = Vec::new(); for row in rows { - let plain = self.k_pending.open(b"pending", &row?)?; + let (sequence, sealed) = row?; + let plain = self.k_pending.open(b"pending", &sealed)?; let (env_bytes, first_seen): (Vec, u64) = postcard::from_bytes(&plain).map_err(|_| StoreError::Serialization)?; - out.push((Envelope::decode(&env_bytes)?, first_seen)); + out.push((sequence, Envelope::decode(&env_bytes)?, first_seen)); } - self.conn.execute("DELETE FROM pending", [])?; Ok(out) } + /// Acknowledge one consumed or expired inbound envelope. + /// + /// The stable sequence makes acknowledgement row-scoped: retryable and + /// not-yet-visited rows remain durable if processing returns an error or + /// the process stops between envelopes. + pub fn pending_ack(&self, sequence: i64) -> Result<()> { + self.conn + .execute("DELETE FROM pending WHERE seq = ?1", params![sequence])?; + Ok(()) + } + // ---- groups (ADR-0012) -------------------------------------------------- /// Insert or replace a group (sealed). @@ -1491,8 +1771,11 @@ impl Store { #[cfg(test)] mod queue_tests { use super::*; - use kult_crypto::KdfProfile; - use kult_protocol::EnvelopeKind; + use kult_crypto::{ + initiate, respond, Identity, KdfProfile, OneTimePrekeySecret, PqPrekeySecret, PrekeyBundle, + RatchetMessage, SignedPrekeySecret, + }; + use kult_protocol::{pad, unpad, EnvelopeKind}; use rand::{rngs::StdRng, SeedableRng}; const TEST_KDF: KdfProfile = KdfProfile { @@ -1548,6 +1831,51 @@ mod queue_tests { assert_eq!(rows[1].1.peer, [4; 32]); } + #[test] + fn queue_rejects_oversized_objects_before_insert_or_update() { + let mut rng = StdRng::seed_from_u64(0x511cf); + let dir = tempfile::tempdir().unwrap(); + let store = Store::create( + &dir.path().join("queue-limit.db"), + b"pass", + TEST_KDF, + &mut rng, + ) + .unwrap(); + let mut item = QueueItem { + peer: [1; 32], + msg_id: None, + group_msg_id: None, + class: QueueClass::Normal, + created_at: 1, + attempts: 0, + next_attempt_at: 1, + envelope: Envelope::new(EnvelopeKind::Message, [2; 32], vec![3]), + }; + let sequence = store.queue_push(&item, &mut rng).unwrap(); + item.envelope = Envelope::new( + EnvelopeKind::Message, + [4; 32], + vec![5; kult_protocol::MAX_ENVELOPE_BYTES], + ); + assert!(matches!( + store.queue_update(sequence, &item, &mut rng), + Err(StoreError::Protocol( + kult_protocol::ProtocolError::EnvelopeTooLarge + )) + )); + assert_eq!(store.queue_all().unwrap()[0].1.envelope.body, vec![3]); + + store.queue_ack(sequence).unwrap(); + assert!(matches!( + store.queue_push(&item, &mut rng), + Err(StoreError::Protocol( + kult_protocol::ProtocolError::EnvelopeTooLarge + )) + )); + assert!(store.queue_all().unwrap().is_empty()); + } + #[test] fn accepted_envelope_receipt_route_is_sealed_and_expires() { let mut rng = StdRng::seed_from_u64(0xacc); @@ -1562,4 +1890,259 @@ mod queue_tests { assert_eq!(store.sweep_receipt_replay(123).unwrap(), 1); assert_eq!(store.receipt_replay_peer(&id).unwrap(), None); } + + #[test] + fn pending_rows_keep_stable_ids_until_explicit_acknowledgement() { + let mut rng = StdRng::seed_from_u64(0x1b0); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pending.db"); + let store = Store::create(&path, b"pass", TEST_KDF, &mut rng).unwrap(); + let first = Envelope::new(EnvelopeKind::Message, [1; 32], vec![2]); + let second = Envelope::new(EnvelopeKind::Receipt, [3; 32], vec![4]); + + let first_sequence = store.pending_push(&first, 100, &mut rng).unwrap(); + let second_sequence = store.pending_push(&second, 200, &mut rng).unwrap(); + assert_ne!(first_sequence, second_sequence); + + let first_read = store.pending_all().unwrap(); + let second_read = store.pending_all().unwrap(); + assert_eq!(first_read, second_read); + assert_eq!( + first_read, + vec![ + (first_sequence, first.clone(), 100), + (second_sequence, second.clone(), 200), + ] + ); + + drop(store); + let reopened = Store::open(&path, b"pass").unwrap(); + assert_eq!(reopened.pending_all().unwrap(), first_read); + + reopened.pending_ack(first_sequence).unwrap(); + assert_eq!( + reopened.pending_all().unwrap(), + vec![(second_sequence, second, 200)] + ); + reopened.pending_ack(second_sequence).unwrap(); + assert!(reopened.pending_all().unwrap().is_empty()); + } + + #[test] + fn pending_inbox_enforces_item_and_sealed_byte_quotas() { + let mut rng = StdRng::seed_from_u64(0x1b1); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pending-quota.db"); + let store = Store::create(&path, b"pass", TEST_KDF, &mut rng).unwrap(); + let envelope = Envelope::new(EnvelopeKind::Message, [1; 32], vec![2]); + let oversized = Envelope::new( + EnvelopeKind::Message, + [3; 32], + vec![4; kult_protocol::MAX_ENVELOPE_BYTES], + ); + assert!(matches!( + store.pending_push(&oversized, 100, &mut rng), + Err(StoreError::Protocol( + kult_protocol::ProtocolError::EnvelopeTooLarge + )) + )); + assert!(store.pending_all().unwrap().is_empty()); + + let tx = Transaction::new_unchecked(&store.conn, TransactionBehavior::Immediate).unwrap(); + for _ in 0..MAX_PENDING_ENVELOPES { + tx.execute("INSERT INTO pending (blob) VALUES (zeroblob(1))", []) + .unwrap(); + } + tx.commit().unwrap(); + assert!(matches!( + store.pending_push(&envelope, 100, &mut rng), + Err(StoreError::PendingQuota) + )); + + store.conn.execute("DELETE FROM pending", []).unwrap(); + store + .conn + .execute( + "INSERT INTO pending (blob) VALUES (zeroblob(?1))", + params![MAX_PENDING_BYTES as i64], + ) + .unwrap(); + assert!(matches!( + store.pending_push(&envelope, 100, &mut rng), + Err(StoreError::PendingQuota) + )); + } + + #[test] + fn pairwise_receive_failure_rolls_back_ratchet_and_every_consequence() { + const NOW: u64 = 1_800_000_000; + + let mut rng = StdRng::seed_from_u64(0xa70c); + let dir = tempfile::tempdir().unwrap(); + let store = + Store::create(&dir.path().join("receive.db"), b"pass", TEST_KDF, &mut rng).unwrap(); + + let alice = Identity::generate(&mut rng); + let bob = Identity::generate(&mut rng); + let spk = SignedPrekeySecret::generate(&mut rng, 1); + let pqspk = PqPrekeySecret::generate(&mut rng, 2); + let opk = OneTimePrekeySecret::generate(&mut rng, 3); + let bundle = PrekeyBundle::build(&bob, &spk, &pqspk, Some(&opk), NOW + 86_400, vec![]) + .verify(NOW) + .unwrap(); + let (mut alice_session, initial) = + initiate(&alice, &bundle, &pad(b"first").unwrap(), NOW, &mut rng).unwrap(); + let (bob_session, first) = + respond(&bob, &spk, &pqspk, Some(&opk), &initial, NOW, &mut rng).unwrap(); + assert_eq!(unpad(&first).unwrap(), b"first"); + + let peer_device = alice.public().ed; + store + .put_session(&peer_device, &bob_session, &mut rng) + .unwrap(); + let ratchet = alice_session.encrypt(&mut rng, NOW + 1, &pad(b"second").unwrap(), &[]); + let envelope = Envelope::new(EnvelopeKind::Message, [4; 32], ratchet.encode()); + let content_id = envelope.content_id(); + let pending_sequence = store.pending_push(&envelope, NOW + 1, &mut rng).unwrap(); + let unrelated = Envelope::new(EnvelopeKind::Receipt, [6; 32], vec![7]); + let unrelated_sequence = store.pending_push(&unrelated, NOW + 2, &mut rng).unwrap(); + let message = MessageRecord { + id: [5; 16], + peer: peer_device, + direction: Direction::Inbound, + state: DeliveryState::Received, + timestamp: NOW + 1, + body: b"second".to_vec(), + wire_id: None, + }; + + let decoded = RatchetMessage::decode(&envelope.body).unwrap(); + let mut failed_candidate = store.get_session(&peer_device).unwrap().unwrap(); + assert_eq!( + unpad( + &failed_candidate + .decrypt(&mut rng, NOW + 1, &decoded, &[]) + .unwrap() + ) + .unwrap(), + b"second" + ); + + // Force the third statement to fail after the candidate session and + // message insert. SQLite must roll both back and retain the source. + store + .conn + .execute_batch( + "CREATE TRIGGER fail_pairwise_receive + BEFORE INSERT ON seen + BEGIN + SELECT RAISE(ABORT, 'injected receive failure'); + END;", + ) + .unwrap(); + assert!(store + .commit_pairwise_receive( + PairwiseReceivePlan { + peer_device: &peer_device, + session: &failed_candidate, + message: Some(&message), + content_id: &content_id, + received_at: NOW + 1, + source_pending_sequence: Some(pending_sequence), + }, + &mut rng, + ) + .is_err()); + store + .conn + .execute_batch("DROP TRIGGER fail_pairwise_receive") + .unwrap(); + + assert!(store.all_messages().unwrap().is_empty()); + assert!(!store.is_seen(&content_id).unwrap()); + assert_eq!( + store.pending_all().unwrap(), + vec![ + (pending_sequence, envelope.clone(), NOW + 1), + (unrelated_sequence, unrelated.clone(), NOW + 2), + ] + ); + assert_eq!(store.receipt_replay_peer(&content_id).unwrap(), None); + + // The original durable ratchet still decrypts the same ciphertext, + // proving the failed candidate was not persisted. + let mut wrong_source_candidate = store.get_session(&peer_device).unwrap().unwrap(); + assert_eq!( + unpad( + &wrong_source_candidate + .decrypt(&mut rng, NOW + 1, &decoded, &[]) + .unwrap() + ) + .unwrap(), + b"second" + ); + assert!(matches!( + store.commit_pairwise_receive( + PairwiseReceivePlan { + peer_device: &peer_device, + session: &wrong_source_candidate, + message: Some(&message), + content_id: &content_id, + received_at: NOW + 1, + source_pending_sequence: Some(unrelated_sequence), + }, + &mut rng, + ), + Err(StoreError::InvalidTransition) + )); + assert!(store.all_messages().unwrap().is_empty()); + assert!(!store.is_seen(&content_id).unwrap()); + assert_eq!( + store.pending_all().unwrap(), + vec![ + (pending_sequence, envelope, NOW + 1), + (unrelated_sequence, unrelated.clone(), NOW + 2), + ] + ); + assert_eq!(store.receipt_replay_peer(&content_id).unwrap(), None); + + // A final retry from the still-unchanged durable session commits all + // consequences and consumes exactly the named pending source. + let mut retry_candidate = store.get_session(&peer_device).unwrap().unwrap(); + assert_eq!( + unpad( + &retry_candidate + .decrypt(&mut rng, NOW + 1, &decoded, &[]) + .unwrap() + ) + .unwrap(), + b"second" + ); + store + .commit_pairwise_receive( + PairwiseReceivePlan { + peer_device: &peer_device, + session: &retry_candidate, + message: Some(&message), + content_id: &content_id, + received_at: NOW + 1, + source_pending_sequence: Some(pending_sequence), + }, + &mut rng, + ) + .unwrap(); + + assert_eq!(store.all_messages().unwrap(), vec![message]); + assert!(store.is_seen(&content_id).unwrap()); + assert_eq!( + store.pending_all().unwrap(), + vec![(unrelated_sequence, unrelated, NOW + 2)] + ); + assert_eq!( + store.receipt_replay_peer(&content_id).unwrap(), + Some(peer_device) + ); + let mut committed = store.get_session(&peer_device).unwrap().unwrap(); + assert!(committed.decrypt(&mut rng, NOW + 1, &decoded, &[]).is_err()); + } } diff --git a/crates/kult-store/tests/m2_sneakernet.rs b/crates/kult-store/tests/m2_sneakernet.rs index c35cc76..c35a4d8 100644 --- a/crates/kult-store/tests/m2_sneakernet.rs +++ b/crates/kult-store/tests/m2_sneakernet.rs @@ -156,7 +156,7 @@ fn sneakernet_end_to_end_with_restart() { let queued = alice.queue_all().unwrap(); assert_eq!(queued.len(), 2, "queue must survive restart"); let envs: Vec = queued.iter().map(|(_, i)| i.envelope.clone()).collect(); - std::fs::write(&bundle_a_to_b, bundle_export(&envs)).unwrap(); + std::fs::write(&bundle_a_to_b, bundle_export(&envs).unwrap()).unwrap(); for (seq, _) in queued { alice.queue_ack(seq).unwrap(); // handed to the courier } @@ -265,7 +265,7 @@ fn sneakernet_end_to_end_with_restart() { let queued = bob.queue_all().unwrap(); assert_eq!(queued.len(), 1); let envs: Vec = queued.iter().map(|(_, i)| i.envelope.clone()).collect(); - std::fs::write(&bundle_b_to_a, bundle_export(&envs)).unwrap(); + std::fs::write(&bundle_b_to_a, bundle_export(&envs).unwrap()).unwrap(); } { let alice = Store::open(&alice_db, b"alice-pass").unwrap(); diff --git a/crates/kult-store/tests/single_writer.rs b/crates/kult-store/tests/single_writer.rs new file mode 100644 index 0000000..1d18d38 --- /dev/null +++ b/crates/kult-store/tests/single_writer.rs @@ -0,0 +1,134 @@ +use std::process::Command; + +use kult_crypto::KdfProfile; +use kult_store::{Store, StoreError}; +use rand::rngs::StdRng; +use rand::SeedableRng; + +const TEST_KDF: KdfProfile = KdfProfile { + m_cost_kib: 8, + t_cost: 1, + p_cost: 1, +}; +const CHILD_PATH: &str = "KOMMS_SINGLE_WRITER_CHILD_PATH"; +const CHILD_OPERATION: &str = "KOMMS_SINGLE_WRITER_CHILD_OPERATION"; +const CHILD_EXPECTATION: &str = "KOMMS_SINGLE_WRITER_CHILD_EXPECTATION"; +const CHILD_MARKER: &str = "KOMMS_SINGLE_WRITER_CHILD_MARKER"; + +fn run_child(path: &std::path::Path, operation: &str, expectation: &str) { + let mut marker = path.as_os_str().to_os_string(); + marker.push(format!(".{operation}.{expectation}.marker")); + let marker = std::path::PathBuf::from(marker); + let output = Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("child_store_attempt") + .arg("--nocapture") + .env(CHILD_PATH, path) + .env(CHILD_OPERATION, operation) + .env(CHILD_EXPECTATION, expectation) + .env(CHILD_MARKER, &marker) + .output() + .unwrap(); + assert!( + output.status.success(), + "child store attempt failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(marker.exists(), "filtered child helper did not run"); + std::fs::remove_file(marker).unwrap(); +} + +#[test] +fn child_store_attempt() { + let Some(path) = std::env::var_os(CHILD_PATH) else { + return; + }; + let mut rng = StdRng::seed_from_u64(0x10cc); + let result = match std::env::var(CHILD_OPERATION).unwrap().as_str() { + "open" => Store::open(std::path::Path::new(&path), b"pass"), + "create" => Store::create(std::path::Path::new(&path), b"pass", TEST_KDF, &mut rng), + operation => panic!("unknown child operation {operation}"), + }; + match std::env::var(CHILD_EXPECTATION).unwrap().as_str() { + "already-open" => assert!(matches!(result, Err(StoreError::AlreadyOpen))), + "success" => drop(result.unwrap()), + expectation => panic!("unknown child expectation {expectation}"), + } + std::fs::write(std::env::var_os(CHILD_MARKER).unwrap(), b"completed").unwrap(); +} + +#[test] +fn writer_lock_excludes_another_process_and_releases_on_drop() { + let mut rng = StdRng::seed_from_u64(0x10cd); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("node.db"); + let store = Store::create(&database, b"pass", TEST_KDF, &mut rng).unwrap(); + + run_child(&database, "open", "already-open"); + run_child(&database, "create", "already-open"); + + drop(store); + run_child(&database, "open", "success"); +} + +#[cfg(unix)] +#[test] +fn writer_sidecar_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let mut rng = StdRng::seed_from_u64(0x10ce); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("private.db"); + let store = Store::create(&database, b"pass", TEST_KDF, &mut rng).unwrap(); + let mut sidecar = database.into_os_string(); + sidecar.push(".lock"); + let mode = std::fs::metadata(std::path::PathBuf::from(sidecar)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + drop(store); +} + +#[cfg(unix)] +#[test] +fn writer_lock_excludes_a_hardlink_alias() { + let mut rng = StdRng::seed_from_u64(0x10d0); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("node.db"); + let alias = directory.path().join("same-inode.db"); + let store = Store::create(&database, b"pass", TEST_KDF, &mut rng).unwrap(); + std::fs::hard_link(&database, &alias).unwrap(); + + run_child(&alias, "open", "already-open"); + + drop(store); + run_child(&database, "open", "success"); +} + +#[cfg(unix)] +#[test] +fn writer_sidecar_refuses_a_symlink_without_touching_its_target() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let mut rng = StdRng::seed_from_u64(0x10d1); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("node.db"); + let target = directory.path().join("unrelated"); + std::fs::write(&target, b"preserve").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap(); + let sidecar = directory.path().join("node.db.lock"); + symlink(&target, &sidecar).unwrap(); + + assert!(matches!( + Store::create(&database, b"pass", TEST_KDF, &mut rng), + Err(StoreError::Io(_)) + )); + assert_eq!(std::fs::read(&target).unwrap(), b"preserve"); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o644 + ); + assert!(!database.exists()); +} diff --git a/crates/kult-transport/src/internet.rs b/crates/kult-transport/src/internet.rs index da480ea..d82d6e3 100644 --- a/crates/kult-transport/src/internet.rs +++ b/crates/kult-transport/src/internet.rs @@ -2,7 +2,7 @@ //! as the primary link protocol and TCP+Noise+Yamux as the fallback. //! //! Envelopes travel over a dedicated request-response protocol -//! (`/komms/envelope/1`); the response is an empty acknowledgment, so a +//! (`/komms/envelope/2`); the response is an acceptance acknowledgment, so a //! successful send honestly reports [`SendReceipt::AckedByNextHop`] — never //! end-to-end delivery (only encrypted receipts prove that). //! @@ -64,13 +64,17 @@ use libp2p::request_response::{self, ProtocolSupport}; use libp2p::swarm::dial_opts::{DialOpts, PeerCondition}; use libp2p::swarm::{ConnectionId, DialError, NetworkBehaviour, SwarmEvent}; use libp2p::{ - autonat, dcutr, identify, noise, relay, tcp, yamux, Multiaddr, PeerId, StreamProtocol, + autonat, connection_limits, dcutr, identify, noise, relay, tcp, yamux, Multiaddr, PeerId, + StreamProtocol, }; use tokio::sync::{mpsc, oneshot, watch, Mutex as AsyncMutex}; -use kult_protocol::Envelope; +use kult_protocol::{Envelope, MAX_ENVELOPE_BYTES}; -use crate::mailbox::{MailboxContents, MailboxRequest, MailboxResponse, MailboxStore}; +use crate::mailbox::{ + MailboxContents, MailboxRequest, MailboxResponse, MailboxStore, MAX_MAILBOX_CHECKIN_ENVELOPES, + MAX_MAILBOX_CHECKIN_TOKENS, +}; use crate::mdns::{self, DiscoveredPeer}; use crate::{ CostClass, DeliveryHint, Discovery, LatencyClass, LinkProfile, MailboxConfig, Reachability, @@ -108,10 +112,47 @@ const CALL_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// transport; authentication and encryption live in `kult-crypto`. const CALL_PROTOCOL: StreamProtocol = StreamProtocol::new("/komms/call/1"); +/// Direct-envelope v2 adds an explicit acceptance bit so a full receiver can +/// refuse work without acknowledging an envelope it did not retain. +const ENVELOPE_PROTOCOL: StreamProtocol = StreamProtocol::new("/komms/envelope/2"); + /// Bound unauthenticated inbound media handshakes. A caller must consume and /// prove the call-media hello before accepting any audio. const CALL_INBOX_MAX: usize = 16; +/// Maximum unsolicited direct envelopes retained until the delivery engine +/// drains the internet carrier. +const DIRECT_INBOX_MAX_ITEMS: usize = 256; + +/// Maximum encoded bytes of unsolicited direct envelopes retained in memory. +/// Item and byte axes are independent: neither many tiny envelopes nor a few +/// maximal envelopes can grow the queue without bound. +const DIRECT_INBOX_MAX_BYTES: usize = 8 * 1024 * 1024; +/// One daemon lifecycle pass checks at most eight relays. Matching that +/// aggregate of count-bounded v1 pages prevents honest relays from overflowing +/// the local collection queue before the node task drains it. +const COLLECTED_INBOX_MAX_ITEMS: usize = 8 * MAX_MAILBOX_CHECKIN_ENVELOPES; +const COLLECTED_INBOX_MAX_BYTES: usize = 16 * 1024 * 1024; + +/// CBOR currently represents `Vec` as an integer array, so the carrier +/// frame can take just over twice the encoded-envelope bytes. +const ENVELOPE_CBOR_REQUEST_MAX_BYTES: u64 = (2 * MAX_ENVELOPE_BYTES + 64) as u64; +/// A v2 envelope response is one CBOR boolean. +const ENVELOPE_CBOR_RESPONSE_MAX_BYTES: u64 = 16; +/// Mailbox deposits and bounded token check-ins fit below this ceiling. +const MAILBOX_CBOR_REQUEST_MAX_BYTES: u64 = 320 * 1024; +/// A 2 MiB raw check-in batch can approach 4 MiB in the current CBOR array +/// representation; leave bounded framing overhead without retaining the +/// library's 10 MiB default. +const MAILBOX_CBOR_RESPONSE_MAX_BYTES: u64 = 5 * 1024 * 1024; +const ENVELOPE_MAX_CONCURRENT_STREAMS: usize = 16; +const MAILBOX_MAX_CONCURRENT_STREAMS: usize = 8; +const MAX_PENDING_INCOMING_CONNECTIONS: u32 = 32; +const MAX_PENDING_OUTGOING_CONNECTIONS: u32 = 32; +const MAX_ESTABLISHED_INCOMING_CONNECTIONS: u32 = 64; +const MAX_ESTABLISHED_CONNECTIONS_PER_PEER: u32 = 8; +const MAX_ESTABLISHED_CONNECTIONS: u32 = 96; + /// Idle connections linger briefly so a message burst reuses one connection. const IDLE_TIMEOUT: Duration = Duration::from_secs(60); @@ -160,7 +201,8 @@ pub enum NatStatus { #[derive(NetworkBehaviour)] struct KultBehaviour { - envelopes: request_response::cbor::Behaviour, ()>, + limits: connection_limits::Behaviour, + envelopes: request_response::cbor::Behaviour, bool>, mailbox: request_response::cbor::Behaviour, kad: kad::Behaviour, identify: identify::Behaviour, @@ -171,12 +213,22 @@ struct KultBehaviour { streams: libp2p_stream::Behaviour, } +/// Internal settlement of an envelope request. This deliberately preserves +/// the distinction between an explicit remote refusal and failure to obtain +/// any response from the hop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HopOutcome { + Accepted, + Refused, + LinkFailed, +} + /// A request aimed at a specific peer, parked while its connection dials. enum PendingOp { /// Direct envelope delivery; reports next-hop ack. - Envelope(Vec, oneshot::Sender), + Envelope(Vec, oneshot::Sender), /// Mailbox deposit of encoded envelope bytes; reports acceptance. - Deposit(Vec, oneshot::Sender), + Deposit(Vec, oneshot::Sender), /// Mailbox check-in; reports collected-envelope count, `None` on /// refusal or link failure. Checkin(Vec<[u8; 32]>, oneshot::Sender>), @@ -187,7 +239,7 @@ impl PendingOp { fn fail(self) { match self { Self::Envelope(_, ack) | Self::Deposit(_, ack) => { - let _ = ack.send(false); + let _ = ack.send(HopOutcome::LinkFailed); } Self::Checkin(_, done) => { let _ = done.send(None); @@ -238,7 +290,7 @@ enum Cmd { /// In-flight mailbox requests awaiting their response. enum MailboxWaiter { - Deposit(oneshot::Sender), + Deposit(oneshot::Sender), Checkin(oneshot::Sender>), } @@ -247,7 +299,7 @@ impl MailboxWaiter { fn fail(self) { match self { Self::Deposit(ack) => { - let _ = ack.send(false); + let _ = ack.send(HopOutcome::LinkFailed); } Self::Checkin(done) => { let _ = done.send(None); @@ -324,6 +376,73 @@ impl BridgeBuffer { } } +/// Internet receive queue with independent direct and collected-mail budgets. +/// Mailbox collection is locally initiated and bounded per response as well +/// as across responses retained before the delivery engine drains this queue. +struct InternetInbox { + queue: Vec, + direct_items: usize, + direct_bytes: usize, + collected_items: usize, + collected_bytes: usize, +} + +impl InternetInbox { + fn new() -> Self { + Self { + queue: Vec::new(), + direct_items: 0, + direct_bytes: 0, + collected_items: 0, + collected_bytes: 0, + } + } + + /// Admit an unsolicited direct envelope or refuse it without mutation. + fn push_direct(&mut self, envelope: Envelope) -> bool { + let encoded_len = envelope.encoded_len(); + if encoded_len > MAX_ENVELOPE_BYTES { + return false; + } + let Some(next_bytes) = self.direct_bytes.checked_add(encoded_len) else { + return false; + }; + if self.direct_items >= DIRECT_INBOX_MAX_ITEMS || next_bytes > DIRECT_INBOX_MAX_BYTES { + return false; + } + self.direct_items += 1; + self.direct_bytes = next_bytes; + self.queue.push(envelope); + true + } + + /// Add a solicited mailbox result without allowing repeated local + /// check-ins to grow the shared receive queue without bound. + fn push_collected(&mut self, envelope: Envelope) -> bool { + let encoded_len = envelope.encoded_len(); + let Some(next_bytes) = self.collected_bytes.checked_add(encoded_len) else { + return false; + }; + if self.collected_items >= COLLECTED_INBOX_MAX_ITEMS + || next_bytes > COLLECTED_INBOX_MAX_BYTES + { + return false; + } + self.collected_items += 1; + self.collected_bytes = next_bytes; + self.queue.push(envelope); + true + } + + fn drain(&mut self) -> Vec { + self.direct_items = 0; + self.direct_bytes = 0; + self.collected_items = 0; + self.collected_bytes = 0; + std::mem::take(&mut self.queue) + } +} + struct Shared { local_peer_id: PeerId, listen_addrs: Mutex>, @@ -411,7 +530,7 @@ impl AsyncWrite for CallStream { /// Internet carrier: QUIC (primary) and TCP (fallback) via rust-libp2p. pub struct Libp2pTransport { cmds: mpsc::UnboundedSender, - inbox: Arc>>, + inbox: Arc>, shared: Arc, mailbox: Option>>, bridge: Option>>, @@ -468,19 +587,30 @@ impl Libp2pTransport { .with_relay_client(noise::Config::new, yamux::Config::default) .map_err(io_other)? .with_behaviour(|key, relay_client| { - let envelopes = request_response::cbor::Behaviour::new( - [( - StreamProtocol::new("/komms/envelope/1"), - ProtocolSupport::Full, - )], - request_response::Config::default(), + let envelope_codec = + request_response::cbor::codec::Codec::, bool>::default() + .set_request_size_maximum(ENVELOPE_CBOR_REQUEST_MAX_BYTES) + .set_response_size_maximum(ENVELOPE_CBOR_RESPONSE_MAX_BYTES); + let envelopes = request_response::Behaviour::with_codec( + envelope_codec, + [(ENVELOPE_PROTOCOL, ProtocolSupport::Full)], + request_response::Config::default() + .with_max_concurrent_streams(ENVELOPE_MAX_CONCURRENT_STREAMS), ); - let mailbox = request_response::cbor::Behaviour::new( + let mailbox_codec = request_response::cbor::codec::Codec::< + MailboxRequest, + MailboxResponse, + >::default() + .set_request_size_maximum(MAILBOX_CBOR_REQUEST_MAX_BYTES) + .set_response_size_maximum(MAILBOX_CBOR_RESPONSE_MAX_BYTES); + let mailbox = request_response::Behaviour::with_codec( + mailbox_codec, [( StreamProtocol::new("/komms/mailbox/1"), ProtocolSupport::Full, )], - request_response::Config::default(), + request_response::Config::default() + .with_max_concurrent_streams(MAILBOX_MAX_CONCURRENT_STREAMS), ); let peer_id = key.public().to_peer_id(); let kad = kad::Behaviour::with_config( @@ -516,7 +646,16 @@ impl Libp2pTransport { let relay = relay::Behaviour::new(peer_id, relay::Config::default()); let dcutr = dcutr::Behaviour::new(peer_id); let streams = libp2p_stream::Behaviour::new(); + let limits = connection_limits::Behaviour::new( + connection_limits::ConnectionLimits::default() + .with_max_pending_incoming(Some(MAX_PENDING_INCOMING_CONNECTIONS)) + .with_max_pending_outgoing(Some(MAX_PENDING_OUTGOING_CONNECTIONS)) + .with_max_established_incoming(Some(MAX_ESTABLISHED_INCOMING_CONNECTIONS)) + .with_max_established_per_peer(Some(MAX_ESTABLISHED_CONNECTIONS_PER_PEER)) + .with_max_established(Some(MAX_ESTABLISHED_CONNECTIONS)), + ); Ok(KultBehaviour { + limits, envelopes, mailbox, kad, @@ -555,7 +694,7 @@ impl Libp2pTransport { connections: Mutex::new(HashMap::new()), lan_peers: Mutex::new(HashMap::new()), }); - let inbox = Arc::new(Mutex::new(Vec::new())); + let inbox = Arc::new(Mutex::new(InternetInbox::new())); let mailbox = mailbox.map(|config| Arc::new(Mutex::new(MailboxStore::new(config)))); let bridge = bridge_deposits.then(|| Arc::new(Mutex::new(BridgeBuffer::new()))); let (cmds, cmd_rx) = mpsc::unbounded_channel(); @@ -692,16 +831,20 @@ impl Libp2pTransport { } /// Check in with a mailbox relay (a multiaddr with `/p2p/…`): register - /// `tokens` as this node's accept-filters and collect everything queued - /// under them into the normal receive path ([`Transport::recv`]). - /// Returns how many envelopes were collected; a large backlog may take - /// several check-ins, so call until it returns 0. Errors are honest: the - /// relay was unreachable, or does not serve mailboxes. + /// `tokens` as this node's accept-filters and collect one bounded page + /// queued under them into the normal receive path ([`Transport::recv`]). + /// Returns how many envelopes were collected in one bounded v1 page. + /// Callers schedule later pages instead of looping in one lifecycle pass. + /// Errors are honest: the relay was unreachable, or does not serve + /// mailboxes. /// /// Build the token set with `kult-node`'s `mailbox_tokens` — every token /// in it is scoped to the caller as recipient (ADR-0007), which is what /// makes collect-and-delete safe on relays shared with one's peers. pub async fn mailbox_checkin(&self, relay: &str, tokens: &[[u8; 32]]) -> Result { + if tokens.len() > MAX_MAILBOX_CHECKIN_TOKENS { + return Err(io_other("mailbox check-in token limit exceeded")); + } let (addr, peer) = parse_addr(relay).ok_or(TransportError::UnsupportedHint)?; let (done, rx) = oneshot::channel(); self.cmds @@ -1003,6 +1146,7 @@ impl Transport for Libp2pTransport { _ => return Err(TransportError::UnsupportedHint), }; let (addr, peer) = parse_addr(s).ok_or(TransportError::UnsupportedHint)?; + let encoded = envelope.try_encode()?; // A deposit aimed at our own mailbox goes straight into the local // store instead of self-dialing — how a bridge that serves the // community mailbox hands mesh-heard transit to its internet-side @@ -1012,36 +1156,37 @@ impl Transport for Libp2pTransport { let Some(store) = &self.mailbox else { return Err(io_other("own relay hint but no mailbox service")); }; - let accepted = - store - .lock_unpoisoned() - .deposit(envelope.token, envelope.encode(), unix_now()); + let accepted = store + .lock_unpoisoned() + .deposit(envelope.token, encoded, unix_now()); return if accepted { Ok(SendReceipt::AckedByNextHop) } else { - Err(io_other("local mailbox refused the deposit")) + Err(TransportError::RefusedByNextHop) }; } let (ack_tx, ack_rx) = oneshot::channel(); let op = if deposit { - PendingOp::Deposit(envelope.encode(), ack_tx) + PendingOp::Deposit(encoded, ack_tx) } else { - PendingOp::Envelope(envelope.encode(), ack_tx) + PendingOp::Envelope(encoded, ack_tx) }; self.cmds .send(Cmd::Op { peer, addr, op }) .map_err(|_| io_other("transport task stopped"))?; match tokio::time::timeout(SEND_TIMEOUT, ack_rx).await { - // Both outcomes are the same honest signal: the next hop — the - // peer itself, or its mailbox relay — acknowledged receipt. - Ok(Ok(true)) => Ok(SendReceipt::AckedByNextHop), - Ok(_) => Err(io_other("peer unreachable or refused the envelope")), + // Only explicit acceptance means the peer or mailbox relay + // retained the envelope. Refusal is typed for retry scheduling. + Ok(Ok(HopOutcome::Accepted)) => Ok(SendReceipt::AckedByNextHop), + Ok(Ok(HopOutcome::Refused)) => Err(TransportError::RefusedByNextHop), + Ok(Ok(HopOutcome::LinkFailed)) => Err(io_other("next-hop request failed")), + Ok(Err(_)) => Err(io_other("transport task stopped")), Err(_) => Err(io_other("send timed out")), } } async fn recv(&self) -> Result> { - Ok(self.inbox.lock_unpoisoned().drain(..).collect()) + Ok(self.inbox.lock_unpoisoned().drain()) } async fn recv_transit(&self) -> Result> { @@ -1088,7 +1233,7 @@ struct Services { /// by the request id it gets back. fn issue_op( swarm: &mut libp2p::Swarm, - inflight: &mut HashMap>, + inflight: &mut HashMap>, mb_inflight: &mut HashMap, peer: &PeerId, op: PendingOp, @@ -1168,7 +1313,7 @@ fn dial_call( async fn run_swarm( mut swarm: libp2p::Swarm, mut cmd_rx: mpsc::UnboundedReceiver, - inbox: Arc>>, + inbox: Arc>, shared: Arc, services: Services, addr_tx: watch::Sender>, @@ -1180,7 +1325,7 @@ async fn run_swarm( let mut mdns_open = true; // Requests waiting for a connection to come up, then for the response. let mut pending: Parked = HashMap::new(); - let mut inflight: HashMap> = + let mut inflight: HashMap> = HashMap::new(); let mut mb_inflight: HashMap = HashMap::new(); @@ -1476,22 +1621,34 @@ async fn run_swarm( SwarmEvent::Behaviour(KultBehaviourEvent::Envelopes(ev)) => match ev { request_response::Event::Message { message, .. } => match message { request_response::Message::Request { request, channel, .. } => { - // Parse failures are dropped silently: transports - // carry sealed envelopes, nothing else. - if let Ok(env) = Envelope::decode(&request) { - inbox.lock_unpoisoned().push(env); - } - let _ = swarm.behaviour_mut().envelopes.send_response(channel, ()); + // Decode enforces the project wire cap before it + // allocates the envelope body. A valid request is + // acknowledged only when the bounded inbox kept + // it; malformed or overflow work is an explicit + // refusal that the sender can retry elsewhere. + let accepted = Envelope::decode(&request) + .is_ok_and(|env| inbox.lock_unpoisoned().push_direct(env)); + let _ = swarm + .behaviour_mut() + .envelopes + .send_response(channel, accepted); } - request_response::Message::Response { request_id, .. } => { + request_response::Message::Response { + request_id, + response, + } => { if let Some(ack) = inflight.remove(&request_id) { - let _ = ack.send(true); + let _ = ack.send(if response { + HopOutcome::Accepted + } else { + HopOutcome::Refused + }); } } }, request_response::Event::OutboundFailure { request_id, .. } => { if let Some(ack) = inflight.remove(&request_id) { - let _ = ack.send(false); + let _ = ack.send(HopOutcome::LinkFailed); } } _ => {} @@ -1538,7 +1695,9 @@ async fn run_swarm( tracing::debug!(accepted, "mailbox deposit"); MailboxResponse::Deposit { accepted } } - (MailboxRequest::Checkin { tokens }, Some(store)) => { + (MailboxRequest::Checkin { tokens }, Some(store)) + if tokens.len() <= MAX_MAILBOX_CHECKIN_TOKENS => + { MailboxResponse::Checkin { serving: true, envelopes: store @@ -1546,6 +1705,12 @@ async fn run_swarm( .checkin(&tokens, unix_now()), } } + (MailboxRequest::Checkin { .. }, Some(_)) => { + MailboxResponse::Checkin { + serving: false, + envelopes: Vec::new(), + } + } // Not serving: honest refusals. (MailboxRequest::Checkin { .. }, None) => { MailboxResponse::Checkin { @@ -1562,7 +1727,11 @@ async fn run_swarm( Some(MailboxWaiter::Deposit(ack)), MailboxResponse::Deposit { accepted }, ) => { - let _ = ack.send(accepted); + let _ = ack.send(if accepted { + HopOutcome::Accepted + } else { + HopOutcome::Refused + }); } ( Some(MailboxWaiter::Checkin(done)), @@ -1572,14 +1741,29 @@ async fn run_swarm( // path; parse failures are dropped, as on // any link. let mut count = 0; + let mut admitted_all = true; let mut inbox = inbox.lock_unpoisoned(); for bytes in envelopes { - if let Ok(env) = Envelope::decode(&bytes) { - inbox.push(env); - count += 1; + match Envelope::decode(&bytes) { + Ok(env) => { + if inbox.push_collected(env) { + count += 1; + } else { + admitted_all = false; + tracing::warn!( + "mailbox-v1 page was not fully admitted" + ); + } + } + Err(_) => { + admitted_all = false; + tracing::warn!( + "mailbox-v1 page was not fully admitted" + ); + } } } - let _ = done.send(serving.then_some(count)); + let _ = done.send((serving && admitted_all).then_some(count)); } // A response of the wrong shape: fail the // waiter rather than hang its caller. @@ -1642,3 +1826,59 @@ async fn run_swarm( } } } + +#[cfg(test)] +mod tests { + use super::*; + use kult_protocol::{EnvelopeKind, ENVELOPE_V1_HEADER_LEN}; + + fn small_envelope(fill: u8) -> Envelope { + Envelope::new(EnvelopeKind::Message, [fill; 32], vec![fill; 8]) + } + + #[test] + fn direct_inbox_item_cap_refuses_without_mutation_and_recovers_after_drain() { + let mut inbox = InternetInbox::new(); + for i in 0..DIRECT_INBOX_MAX_ITEMS { + assert!(inbox.push_direct(small_envelope(i as u8))); + } + let items = inbox.direct_items; + let bytes = inbox.direct_bytes; + assert!(!inbox.push_direct(small_envelope(255))); + assert_eq!(inbox.direct_items, items); + assert_eq!(inbox.direct_bytes, bytes); + + assert_eq!(inbox.drain().len(), DIRECT_INBOX_MAX_ITEMS); + assert!(inbox.push_direct(small_envelope(1))); + } + + #[test] + fn direct_inbox_byte_cap_bounds_maximal_envelopes() { + let mut inbox = InternetInbox::new(); + let oversized = Envelope::new(EnvelopeKind::Message, [6; 32], vec![0; MAX_ENVELOPE_BYTES]); + assert!(!inbox.push_direct(oversized)); + + let maximal = Envelope::new( + EnvelopeKind::Message, + [7; 32], + vec![0; MAX_ENVELOPE_BYTES - ENVELOPE_V1_HEADER_LEN], + ); + let count = DIRECT_INBOX_MAX_BYTES / MAX_ENVELOPE_BYTES; + for _ in 0..count { + assert!(inbox.push_direct(maximal.clone())); + } + assert_eq!(inbox.direct_bytes, DIRECT_INBOX_MAX_BYTES); + assert!(!inbox.push_direct(small_envelope(8))); + } + + #[test] + fn collected_inbox_has_independent_item_cap_and_recovers_after_drain() { + let mut inbox = InternetInbox::new(); + for i in 0..COLLECTED_INBOX_MAX_ITEMS { + assert!(inbox.push_collected(small_envelope(i as u8))); + } + assert!(!inbox.push_collected(small_envelope(3))); + assert_eq!(inbox.drain().len(), COLLECTED_INBOX_MAX_ITEMS); + assert!(inbox.push_collected(small_envelope(4))); + } +} diff --git a/crates/kult-transport/src/lib.rs b/crates/kult-transport/src/lib.rs index 4d431f8..8258b16 100644 --- a/crates/kult-transport/src/lib.rs +++ b/crates/kult-transport/src/lib.rs @@ -32,7 +32,9 @@ mod mesh; mod sneakernet; pub use internet::{CallStream, Libp2pTransport, NatStatus, TransportOptions}; -pub use mailbox::{MailboxConfig, MailboxContents}; +pub use mailbox::{ + MailboxConfig, MailboxContents, MAX_MAILBOX_CHECKIN_ENVELOPES, MAX_MAILBOX_CHECKIN_TOKENS, +}; #[cfg(feature = "meshtastic")] #[doc(hidden)] pub use mesh::testutil as mesh_testutil; @@ -50,6 +52,9 @@ pub enum TransportError { Protocol(kult_protocol::ProtocolError), /// The delivery hint is not addressable by this transport. UnsupportedHint, + /// The addressed hop was reached but explicitly refused to retain the + /// envelope, for example because its bounded receive queue is full. + RefusedByNextHop, /// The link's shared-medium budget (LoRa duty cycle) is exhausted; /// retrying after the given duration can succeed. An honest refusal /// (docs/05-transports.md §4.2 rule 3) — the envelope was not sent. @@ -65,6 +70,7 @@ impl std::fmt::Display for TransportError { Self::Io(e) => write!(f, "link i/o error: {e}"), Self::Protocol(e) => write!(f, "link protocol error: {e}"), Self::UnsupportedHint => f.write_str("delivery hint not supported by this transport"), + Self::RefusedByNextHop => f.write_str("next hop refused the envelope"), Self::AirtimeExhausted { retry_after } => write!( f, "airtime duty-cycle budget exhausted; retry in {retry_after:?}" diff --git a/crates/kult-transport/src/mailbox.rs b/crates/kult-transport/src/mailbox.rs index 2d85d21..28ac67d 100644 --- a/crates/kult-transport/src/mailbox.rs +++ b/crates/kult-transport/src/mailbox.rs @@ -8,10 +8,10 @@ //! (docs/04-cryptography.md §7) and draining anything queued under them. //! Senders **deposit** sealed envelopes; a deposit is accepted only for a //! registered token. The relay sees rotating 32-byte tokens and sealed -//! envelopes — no identities, no plaintext, no conversation graph — and -//! collection deletes, which is only safe because tokens are -//! recipient-scoped (ADR-0007): a check-in can never drain mail addressed -//! to someone else. +//! envelopes — no identities, no plaintext, no conversation graph. Tokens are +//! recipient-scoped (ADR-0007), so a check-in cannot drain another recipient's +//! mail. Mailbox-v1 still deletes before endpoint acknowledgement and is not +//! crash-safe; ADR-0032 defines the leased replacement. use std::collections::HashMap; @@ -58,13 +58,23 @@ pub type MailboxContents = Vec<([u8; 32], Vec>)>; /// Byte budget per check-in response, kept comfortably under the /// request-response codec's response cap. A backlog larger than this is /// drained across successive check-ins. -const CHECKIN_BATCH_BYTES: usize = 4 * 1024 * 1024; +pub(crate) const CHECKIN_BATCH_BYTES: usize = 2 * 1024 * 1024; +/// Maximum delivery-token filters accepted in one check-in request. +/// +/// Registrations can span repeated check-ins. Bounding one request prevents a +/// remote peer from forcing an unbounded token walk before mailbox admission. +pub const MAX_MAILBOX_CHECKIN_TOKENS: usize = 4_096; +/// Maximum envelope rows returned by one mailbox-v1 check-in page. +/// +/// This is an interim compatibility bound. ADR-0032 replaces destructive v1 +/// collection with leased pages acknowledged after durable endpoint staging. +pub const MAX_MAILBOX_CHECKIN_ENVELOPES: usize = 512; /// Wire request on `/komms/mailbox/1`. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub(crate) enum MailboxRequest { /// Register these tokens as accept-filters (refreshing their TTL) and - /// drain everything queued under them. + /// return one count/byte-bounded destructive v1 page queued under them. Checkin { tokens: Vec<[u8; 32]> }, /// Deposit one sealed envelope; its delivery token must be registered. Deposit { envelope: Vec }, @@ -118,7 +128,11 @@ impl MailboxStore { let expiry = now + self.config.registration_ttl_secs; let mut out = Vec::new(); let mut budget = CHECKIN_BATCH_BYTES; - for token in tokens { + let mut items_left = MAX_MAILBOX_CHECKIN_ENVELOPES; + for token in tokens.iter().take(MAX_MAILBOX_CHECKIN_TOKENS) { + if items_left == 0 { + break; + } if let Some(current) = self.registered.get_mut(token) { *current = expiry; } else if self.registered.len() < self.config.max_tokens { @@ -133,6 +147,7 @@ impl MailboxStore { }; let take = queue .iter() + .take(items_left) .scan(0usize, |used, q| { *used += q.bytes.len(); (*used <= budget).then_some(()) @@ -143,6 +158,7 @@ impl MailboxStore { self.total_bytes -= q.bytes.len(); out.push(q.bytes); } + items_left -= take; if queue.is_empty() { self.queued.remove(token); } @@ -322,10 +338,29 @@ mod tests { }); s.checkin(&[[1; 32]], NOW); for i in 0..3 { - assert!(s.deposit([1; 32], vec![i; 2 * 1024 * 1024], NOW)); + assert!(s.deposit([1; 32], vec![i; 1024 * 1024], NOW)); } - // 6 MiB queued, 4 MiB budget: two now, one on the next check-in. + // 3 MiB queued, 2 MiB budget: two now, one on the next check-in. assert_eq!(s.checkin(&[[1; 32]], NOW).len(), 2); assert_eq!(s.checkin(&[[1; 32]], NOW).len(), 1); } + + #[test] + fn checkin_pages_are_bounded_by_row_count() { + let mut s = MailboxStore::new(MailboxConfig { + max_total_bytes: 32 * 1024 * 1024, + max_per_token: MAX_MAILBOX_CHECKIN_ENVELOPES + 100, + ..MailboxConfig::default() + }); + let token = [2; 32]; + s.checkin(&[token], NOW); + for i in 0..(MAX_MAILBOX_CHECKIN_ENVELOPES + 100) { + assert!(s.deposit(token, (i as u64).to_le_bytes().to_vec(), NOW)); + } + assert_eq!( + s.checkin(&[token], NOW).len(), + MAX_MAILBOX_CHECKIN_ENVELOPES + ); + assert_eq!(s.checkin(&[token], NOW).len(), 100); + } } diff --git a/crates/kult-transport/src/sneakernet.rs b/crates/kult-transport/src/sneakernet.rs index 38a4344..f68f595 100644 --- a/crates/kult-transport/src/sneakernet.rs +++ b/crates/kult-transport/src/sneakernet.rs @@ -12,8 +12,9 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use async_trait::async_trait; +use tokio::io::AsyncReadExt; -use kult_protocol::{bundle_export, bundle_import, Envelope}; +use kult_protocol::{bundle_export, bundle_import, Envelope, MAX_BUNDLE_BYTES, MAX_ENVELOPE_BYTES}; use crate::{ CostClass, DeliveryHint, LatencyClass, LinkProfile, Reachability, Result, SendReceipt, @@ -26,6 +27,11 @@ pub struct SneakernetTransport { counter: AtomicU64, } +/// Bound directory work and aggregate allocation for one receive pass. +const MAX_FILES_PER_RECV: usize = 256; +const MAX_DIRECTORY_ENTRIES_PER_RECV: usize = 1_024; +const MAX_BYTES_PER_RECV: usize = MAX_BUNDLE_BYTES; + impl SneakernetTransport { /// Create a transport that receives from `inbox` (created if missing). pub fn new(inbox: impl Into) -> std::io::Result { @@ -42,6 +48,32 @@ impl SneakernetTransport { pub fn inbox(&self) -> &Path { &self.inbox } + + async fn quarantine(&self, path: &Path) -> std::io::Result { + for attempt in 0..64u64 { + let candidate = if attempt == 0 { + let mut candidate = path.to_path_buf(); + candidate.set_extension("kkb.bad"); + candidate + } else { + let sequence = self.counter.fetch_add(1, Ordering::Relaxed); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + self.inbox.join(format!("{name}.{sequence}.bad")) + }; + match tokio::fs::symlink_metadata(&candidate).await { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + tokio::fs::rename(path, &candidate).await?; + return Ok(candidate); + } + Ok(_) => continue, + Err(error) => return Err(error), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate a unique quarantine name", + )) + } } #[async_trait] @@ -49,7 +81,7 @@ impl Transport for SneakernetTransport { fn profile(&self) -> LinkProfile { LinkProfile { // Files impose no frame limit; cap at the bundle envelope cap. - mtu: 128 * 1024, + mtu: MAX_ENVELOPE_BYTES, latency: LatencyClass::HumanScale, cost: CostClass::Free, broadcast: false, @@ -68,6 +100,10 @@ impl Transport for SneakernetTransport { let DeliveryHint::Spool(dir) = peer else { return Err(crate::TransportError::UnsupportedHint); }; + // Validate and serialize before touching the destination filesystem: + // an oversized envelope is a typed protocol refusal, never a partial + // or empty courier artifact. + let bundle = bundle_export(std::slice::from_ref(envelope))?; tokio::fs::create_dir_all(dir).await?; // Unique, collision-safe name: content id + local counter. Write to // a temp name first so readers never observe partial files. @@ -80,13 +116,16 @@ impl Transport for SneakernetTransport { ); let tmp = dir.join(format!(".{name}.part")); let fin = dir.join(name); - tokio::fs::write(&tmp, bundle_export(std::slice::from_ref(envelope))).await?; + tokio::fs::write(&tmp, bundle).await?; tokio::fs::rename(&tmp, &fin).await?; Ok(SendReceipt::HandedToLink) } async fn recv(&self) -> Result> { let mut out = Vec::new(); + let mut entries_examined = 0usize; + let mut files_examined = 0usize; + let mut bytes_admitted = 0usize; let mut dir = tokio::fs::read_dir(&self.inbox).await?; while let Some(entry) = dir.next_entry().await? { let path = entry.path(); @@ -97,7 +136,47 @@ impl Transport for SneakernetTransport { if !is_bundle { continue; } - let bytes = tokio::fs::read(&path).await?; + if entries_examined >= MAX_DIRECTORY_ENTRIES_PER_RECV { + break; + } + entries_examined += 1; + + let file_type = entry.file_type().await?; + if !file_type.is_file() { + // A persistent directory or symlink with a bundle suffix + // must not consume every future file budget. Move the entry + // out of the candidate namespace without following it. + self.quarantine(&path).await?; + continue; + } + if files_examined >= MAX_FILES_PER_RECV { + break; + } + files_examined += 1; + + let metadata = entry.metadata().await?; + let file_len = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if file_len > MAX_BUNDLE_BYTES { + self.quarantine(&path).await?; + continue; + } + let remaining = MAX_BYTES_PER_RECV.saturating_sub(bytes_admitted); + if file_len > remaining { + break; + } + + // Read through a hard ceiling even if another process grows the + // file after metadata inspection. + let mut file = tokio::fs::File::open(&path).await?; + let mut bytes = Vec::with_capacity(file_len); + (&mut file) + .take((remaining + 1) as u64) + .read_to_end(&mut bytes) + .await?; + if bytes.len() > remaining { + break; + } + bytes_admitted += bytes.len(); match bundle_import(&bytes) { Ok(envelopes) => { out.extend(envelopes); @@ -106,9 +185,7 @@ impl Transport for SneakernetTransport { // Corrupt or foreign file: leave it in place for inspection, // never loop on it forever — rename it aside. Err(_) => { - let mut quarantined = path.clone(); - quarantined.set_extension("kkb.bad"); - tokio::fs::rename(&path, &quarantined).await?; + self.quarantine(&path).await?; } } } diff --git a/crates/kult-transport/tests/internet.rs b/crates/kult-transport/tests/internet.rs index 5e5d4b2..5798622 100644 --- a/crates/kult-transport/tests/internet.rs +++ b/crates/kult-transport/tests/internet.rs @@ -3,8 +3,10 @@ use std::time::Duration; -use kult_protocol::{Envelope, EnvelopeKind}; -use kult_transport::{DeliveryHint, Libp2pTransport, Reachability, SendReceipt, Transport}; +use kult_protocol::{Envelope, EnvelopeKind, ProtocolError, MAX_ENVELOPE_BYTES}; +use kult_transport::{ + DeliveryHint, Libp2pTransport, Reachability, SendReceipt, Transport, TransportError, +}; fn test_envelope(fill: u8) -> Envelope { Envelope::new(EnvelopeKind::Message, [fill; 32], vec![fill; 300]) @@ -81,5 +83,57 @@ async fn unreachable_peer_fails_honestly() { let ghost_id = ghost.local_peer_id(); drop(ghost); let hint = DeliveryHint::Multiaddr(format!("/ip4/127.0.0.1/tcp/1/p2p/{ghost_id}")); - assert!(a.send(&hint, &test_envelope(4)).await.is_err()); + assert!(matches!( + a.send(&hint, &test_envelope(4)).await.unwrap_err(), + TransportError::Io(_) + )); +} + +#[tokio::test] +async fn oversized_outbound_envelope_fails_with_typed_protocol_error() { + let sender = Libp2pTransport::new(&["/ip4/127.0.0.1/tcp/0"]) + .await + .unwrap(); + let receiver = Libp2pTransport::new(&["/ip4/127.0.0.1/tcp/0"]) + .await + .unwrap(); + let hint = DeliveryHint::Multiaddr(receiver.wait_listen_addr().await.unwrap()); + let oversized = Envelope::new(EnvelopeKind::Message, [9; 32], vec![0; MAX_ENVELOPE_BYTES]); + + assert!(matches!( + sender.send(&hint, &oversized).await.unwrap_err(), + TransportError::Protocol(ProtocolError::EnvelopeTooLarge) + )); + assert!(receiver.recv().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn saturated_direct_inbox_refuses_then_recovers_after_drain() { + let sender = Libp2pTransport::new(&["/ip4/127.0.0.1/tcp/0"]) + .await + .unwrap(); + let receiver = Libp2pTransport::new(&["/ip4/127.0.0.1/tcp/0"]) + .await + .unwrap(); + let hint = DeliveryHint::Multiaddr(receiver.wait_listen_addr().await.unwrap()); + let envelope = test_envelope(10); + + let mut accepted = 0usize; + loop { + match sender.send(&hint, &envelope).await { + Ok(SendReceipt::AckedByNextHop) => accepted += 1, + Ok(receipt) => panic!("unexpected send receipt: {receipt:?}"), + Err(TransportError::RefusedByNextHop) => break, + Err(error) => panic!("unexpected send failure: {error}"), + } + assert!(accepted < 1_000, "direct inbox did not apply backpressure"); + } + assert!(accepted > 0); + assert_eq!(receiver.recv().await.unwrap().len(), accepted); + + assert_eq!( + sender.send(&hint, &envelope).await.unwrap(), + SendReceipt::AckedByNextHop + ); + assert_eq!(receiver.recv().await.unwrap(), vec![envelope]); } diff --git a/crates/kult-transport/tests/mailbox.rs b/crates/kult-transport/tests/mailbox.rs index 44beb03..b947259 100644 --- a/crates/kult-transport/tests/mailbox.rs +++ b/crates/kult-transport/tests/mailbox.rs @@ -6,6 +6,7 @@ use kult_protocol::{Envelope, EnvelopeKind}; use kult_transport::{ DeliveryHint, Libp2pTransport, MailboxConfig, Reachability, SendReceipt, Transport, + TransportError, }; fn envelope(token: [u8; 32], body: &[u8]) -> Envelope { @@ -36,7 +37,10 @@ async fn deposit_collect_roundtrip_gated_by_registration() { // No registration yet: the relay refuses, the sender sees a failed send // (and its delivery engine would keep the envelope queued). - assert!(sender.send(&hint, &env).await.is_err()); + assert!(matches!( + sender.send(&hint, &env).await.unwrap_err(), + TransportError::RefusedByNextHop + )); // The recipient checks in — registering its filter — and the same // deposit now lands. @@ -88,9 +92,12 @@ async fn node_without_mailbox_service_refuses_honestly() { .await .unwrap(); assert!(client.mailbox_checkin(&addr, &[[1u8; 32]]).await.is_err()); - assert!(client - .send(&DeliveryHint::Relay(addr), &envelope([1u8; 32], b"x")) - .await - .is_err()); + assert!(matches!( + client + .send(&DeliveryHint::Relay(addr), &envelope([1u8; 32], b"x")) + .await + .unwrap_err(), + TransportError::RefusedByNextHop + )); assert!(bystander.mailbox_contents().is_none()); } diff --git a/crates/kult-transport/tests/sneakernet.rs b/crates/kult-transport/tests/sneakernet.rs index 89e4158..668faa9 100644 --- a/crates/kult-transport/tests/sneakernet.rs +++ b/crates/kult-transport/tests/sneakernet.rs @@ -1,7 +1,7 @@ //! Sneakernet transport tests: two peers exchanging sealed envelopes through //! spool directories, honest receipts, and corrupt-file quarantine. -use kult_protocol::{Envelope, EnvelopeKind}; +use kult_protocol::{Envelope, EnvelopeKind, ProtocolError, MAX_BUNDLE_BYTES, MAX_ENVELOPE_BYTES}; use kult_transport::{ DeliveryHint, Reachability, SendReceipt, SneakernetTransport, Transport, TransportError, }; @@ -55,6 +55,28 @@ async fn corrupt_files_are_quarantined_not_looped() { assert!(t.inbox().join("note.txt").exists()); } +#[tokio::test] +async fn quarantine_never_overwrites_an_existing_artifact() { + let dir = tempfile::tempdir().unwrap(); + let t = SneakernetTransport::new(dir.path().join("inbox")).unwrap(); + std::fs::write(t.inbox().join("junk.kkb"), b"not a bundle").unwrap(); + std::fs::write(t.inbox().join("junk.kkb.bad"), b"operator evidence").unwrap(); + + assert!(t.recv().await.unwrap().is_empty()); + assert_eq!( + std::fs::read(t.inbox().join("junk.kkb.bad")).unwrap(), + b"operator evidence" + ); + assert!(!t.inbox().join("junk.kkb").exists()); + assert!(std::fs::read_dir(t.inbox()) + .unwrap() + .filter_map(std::result::Result::ok) + .any( + |entry| entry.file_name().to_string_lossy().starts_with("junk.kkb.") + && entry.file_name() != "junk.kkb.bad" + )); +} + #[tokio::test] async fn wrong_hint_kind_is_refused() { let dir = tempfile::tempdir().unwrap(); @@ -70,3 +92,55 @@ async fn wrong_hint_kind_is_refused() { Reachability::Unreachable ); } + +#[tokio::test] +async fn oversized_envelope_is_typed_and_touches_no_destination_files() { + let dir = tempfile::tempdir().unwrap(); + let sender = SneakernetTransport::new(dir.path().join("sender-inbox")).unwrap(); + let destination = dir.path().join("not-created"); + let oversized = Envelope::new(EnvelopeKind::Message, [8; 32], vec![0; MAX_ENVELOPE_BYTES]); + + assert!(matches!( + sender + .send(&DeliveryHint::Spool(destination.clone()), &oversized) + .await + .unwrap_err(), + TransportError::Protocol(ProtocolError::EnvelopeTooLarge) + )); + assert!(!destination.exists()); +} + +#[tokio::test] +async fn oversized_bundle_file_is_quarantined_without_whole_file_read() { + let dir = tempfile::tempdir().unwrap(); + let transport = SneakernetTransport::new(dir.path().join("inbox")).unwrap(); + let path = transport.inbox().join("oversized.kkb"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len((MAX_BUNDLE_BYTES + 1) as u64).unwrap(); + drop(file); + + assert!(transport.recv().await.unwrap().is_empty()); + assert!(!path.exists()); + assert!(transport.inbox().join("oversized.kkb.bad").exists()); +} + +#[tokio::test] +async fn non_regular_bundle_candidates_cannot_starve_a_valid_file() { + let dir = tempfile::tempdir().unwrap(); + let sender = SneakernetTransport::new(dir.path().join("sender")).unwrap(); + let receiver = SneakernetTransport::new(dir.path().join("receiver")).unwrap(); + for i in 0..256 { + std::fs::create_dir(receiver.inbox().join(format!("{i:04}.kkb"))).unwrap(); + } + let expected = env(7); + sender + .send( + &DeliveryHint::Spool(receiver.inbox().to_path_buf()), + &expected, + ) + .await + .unwrap(); + + assert_eq!(receiver.recv().await.unwrap(), vec![expected]); + assert!(receiver.inbox().join("0000.kkb.bad").is_dir()); +} diff --git a/crates/kultd/Cargo.toml b/crates/kultd/Cargo.toml index c364b1d..458d6a9 100644 --- a/crates/kultd/Cargo.toml +++ b/crates/kultd/Cargo.toml @@ -23,6 +23,8 @@ kult-store = { version = "0.3.0", path = "../kult-store" } # The daemon always ships the Meshtastic carrier: attaching a ~30€ radio to # the box the daemon runs on is exactly the off-grid deployment M4 targets. kult-transport = { version = "0.3.0", path = "../kult-transport", features = ["meshtastic"] } +fs2 = "0.4" +libc = "0.2" rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/kultd/src/daemon.rs b/crates/kultd/src/daemon.rs index 9945701..20fe86a 100644 --- a/crates/kultd/src/daemon.rs +++ b/crates/kultd/src/daemon.rs @@ -14,8 +14,9 @@ //! - **RPC server**: newline-delimited JSON on a mode-0600 Unix socket //! (see [`crate::wire`]). +use std::fs::{File, OpenOptions}; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime}; @@ -30,11 +31,32 @@ use kult_crypto::KdfProfile; use kult_node::{DeviceLinkSelection, FolderSelection, LabelMatchMode, Node, NodeError}; use kult_transport::{ DeliveryHint, Discovery, Libp2pTransport, MailboxConfig, MeshtasticOptions, - MeshtasticTransport, NatStatus, Transport, TransportOptions, + MeshtasticTransport, NatStatus, Transport, TransportOptions, MAX_MAILBOX_CHECKIN_TOKENS, }; use crate::wire::{self, Hint, Op, Request}; +/// Bound one lifecycle pass even when an operator configures many or hostile +/// mailbox endpoints. Remaining work waits for the next check-in interval. +const MAX_MAILBOXES_PER_CHECKIN_TICK: usize = 8; + +/// Return one contiguous bounded page and advance a persistent cursor. +/// +/// The final short page resets to the front for the following call. This +/// avoids both front-of-list starvation and needless duplicates while a full +/// mailbox/token set is being refreshed over multiple lifecycle intervals. +fn rotating_batch(items: &[T], cursor: &mut usize, limit: usize) -> Vec { + if items.is_empty() || limit == 0 { + *cursor = 0; + return Vec::new(); + } + *cursor %= items.len(); + let end = cursor.saturating_add(limit).min(items.len()); + let batch = items[*cursor..end].to_vec(); + *cursor = if end == items.len() { 0 } else { end }; + batch +} + /// Everything the daemon needs to run. Built by the CLI in `bin/kultd.rs`, /// or directly by tests. #[derive(Clone)] @@ -223,6 +245,7 @@ pub struct Daemon { pub net: Arc, shutdown: watch::Sender, tasks: Vec>, + socket_guard: RpcSocketGuard, } impl Daemon { @@ -323,14 +346,18 @@ impl Daemon { let (node_tx, node_rx) = mpsc::channel::(64); let (events_tx, _) = broadcast::channel::(256); - // Replace a stale socket from an unclean shutdown; a live daemon on - // the same path would have to be stopped first anyway. - let _ = std::fs::remove_file(&cfg.socket_path); - let listener = UnixListener::bind(&cfg.socket_path)?; + // Refuse to displace a live daemon. Only a socket that cannot accept + // a connection is treated as stale and removed. + let (listener, socket_guard) = bind_rpc_socket(&cfg.socket_path).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&cfg.socket_path, std::fs::Permissions::from_mode(0o600))?; + if let Err(error) = + std::fs::set_permissions(&cfg.socket_path, std::fs::Permissions::from_mode(0o600)) + { + socket_guard.remove_owned(); + return Err(DaemonError::Io(error)); + } } let mut tasks = Vec::new(); @@ -398,7 +425,7 @@ impl Daemon { for task in tasks { let _ = task.await; } - let _ = std::fs::remove_file(&cfg.socket_path); + socket_guard.remove_owned(); return Err(DaemonError::Io(error)); } @@ -409,6 +436,7 @@ impl Daemon { net, shutdown, tasks, + socket_guard, }) } @@ -418,7 +446,149 @@ impl Daemon { for task in self.tasks { let _ = task.await; } - let _ = std::fs::remove_file(&self.socket_path); + self.socket_guard.remove_owned(); + } +} + +#[derive(Debug)] +struct RpcSocketGuard { + path: PathBuf, + device: u64, + inode: u64, + _lock: File, +} + +impl RpcSocketGuard { + /// Remove only the socket inode this daemon originally bound. + fn remove_owned(&self) { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + let Ok(metadata) = std::fs::symlink_metadata(&self.path) else { + return; + }; + if metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode + { + let _ = std::fs::remove_file(&self.path); + } + } +} + +fn rpc_lock_path(path: &Path) -> io::Result { + let file_name = path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "RPC socket path has no file name", + ) + })?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut lock_name = file_name.to_os_string(); + lock_name.push(".lock"); + Ok(std::fs::canonicalize(parent)?.join(lock_name)) +} + +fn acquire_rpc_lock(path: &Path) -> io::Result { + let lock_path = rpc_lock_path(path)?; + if std::fs::symlink_metadata(&lock_path).is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("RPC lock path is a symlink: {}", lock_path.display()), + )); + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + let lock = options.open(lock_path)?; + lock.set_permissions(std::fs::Permissions::from_mode(0o600))?; + match fs2::FileExt::try_lock_exclusive(&lock) { + Ok(()) => Ok(lock), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!("another daemon owns RPC socket {}", path.display()), + )), + Err(error) => Err(error), + } +} + +fn socket_identity(path: &Path) -> io::Result<(u64, u64)> { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_socket() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("refusing to replace non-socket path {}", path.display()), + )); + } + Ok((metadata.dev(), metadata.ino())) +} + +fn guarded_listener( + path: &Path, + lock: File, + listener: UnixListener, +) -> io::Result<(UnixListener, RpcSocketGuard)> { + let (device, inode) = socket_identity(path)?; + Ok(( + listener, + RpcSocketGuard { + path: path.to_path_buf(), + device, + inode, + _lock: lock, + }, + )) +} + +/// Bind the RPC socket without unlinking a live daemon's pathname. +/// +/// A no-follow socket-specific sidecar lock serializes cooperative stale +/// probing, unlink, bind, and the daemon lifetime. Observed non-socket path +/// replacements fail closed. Deployment still requires a daemon-owned parent +/// directory because portable Unix APIs cannot atomically recheck and unlink a +/// hostile replacement. +async fn bind_rpc_socket(path: &Path) -> io::Result<(UnixListener, RpcSocketGuard)> { + let lock = acquire_rpc_lock(path)?; + match UnixListener::bind(path) { + Ok(listener) => guarded_listener(path, lock, listener), + Err(bind_error) if bind_error.kind() == io::ErrorKind::AddrInUse => { + let stale_identity = socket_identity(path)?; + match tokio::time::timeout(Duration::from_millis(500), UnixStream::connect(path)).await + { + Err(_) => Err(bind_error), + Ok(Ok(_)) => Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!("another daemon is listening on {}", path.display()), + )), + Ok(Err(connect_error)) + if matches!( + connect_error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) => + { + match socket_identity(path) { + Ok(current) if current == stale_identity => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + _ => return Err(bind_error), + } + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let listener = UnixListener::bind(path)?; + guarded_listener(path, lock, listener) + } + Ok(Err(_)) => Err(bind_error), + } + } + Err(error) => Err(error), } } @@ -1831,6 +2001,9 @@ async fn lifecycle( // Kept in lockstep with kult-ffi's lifecycle. let mut lan_seen: std::collections::HashSet = std::collections::HashSet::new(); let mut lan_tick = tokio::time::interval(Duration::from_secs(15)); + let mut mailbox_cursor = 0usize; + let mut mailbox_token_cursors: std::collections::HashMap = + std::collections::HashMap::new(); loop { tokio::select! { @@ -1869,17 +2042,27 @@ async fn lifecycle( break; } let Ok(tokens) = rx.await else { break }; - for mailbox in &cfg.mailboxes { - // Drain the backlog: a check-in returns at most one - // batch; repeat until empty. - loop { - match net.mailbox_checkin(mailbox, &tokens).await { - Ok(0) => break, - Ok(_) => continue, - Err(e) => { - tracing::warn!(error = %e, %mailbox, "mailbox check-in failed"); - break; - } + let mailboxes = rotating_batch( + &cfg.mailboxes, + &mut mailbox_cursor, + MAX_MAILBOXES_PER_CHECKIN_TICK, + ); + for mailbox in mailboxes { + let token_cursor = mailbox_token_cursors + .entry(mailbox.clone()) + .or_default(); + let token_batch = + rotating_batch(&tokens, token_cursor, MAX_MAILBOX_CHECKIN_TOKENS); + // One bounded page per mailbox and lifecycle interval. + // A relay that never returns empty cannot monopolize this + // task or grow the local receive queue without a limit. + match net.mailbox_checkin(&mailbox, &token_batch).await { + Ok(count) if count > 0 => { + tracing::debug!(count, %mailbox, "mailbox page collected"); + } + Ok(_) => {} + Err(e) => { + tracing::warn!(error = %e, %mailbox, "mailbox check-in failed"); } } } @@ -1980,3 +2163,125 @@ async fn recv_event(subscription: &mut Option>) -> O None => std::future::pending().await, } } + +#[cfg(test)] +mod socket_tests { + use super::*; + + #[tokio::test] + async fn live_rpc_socket_is_never_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("kultd.sock"); + let (listener, guard) = bind_rpc_socket(&path).await.unwrap(); + + let error = bind_rpc_socket(&path).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + + // The original listener remains reachable at the same pathname. + let client = UnixStream::connect(&path).await.unwrap(); + drop(client); + drop(listener); + guard.remove_owned(); + } + + #[tokio::test] + async fn stale_rpc_socket_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("kultd.sock"); + let stale = std::os::unix::net::UnixListener::bind(&path).unwrap(); + drop(stale); + assert!(path.exists()); + + let (listener, guard) = bind_rpc_socket(&path).await.unwrap(); + let client = UnixStream::connect(&path).await.unwrap(); + drop(client); + drop(listener); + guard.remove_owned(); + } + + #[tokio::test] + async fn regular_file_at_rpc_path_is_preserved() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("kultd.sock"); + std::fs::write(&path, b"operator-owned").unwrap(); + + assert!(bind_rpc_socket(&path).await.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"operator-owned"); + } + + #[tokio::test] + async fn concurrent_stale_recovery_has_one_reachable_owner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("kultd.sock"); + let stale = std::os::unix::net::UnixListener::bind(&path).unwrap(); + drop(stale); + + let (left, right) = tokio::join!(bind_rpc_socket(&path), bind_rpc_socket(&path)); + assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1); + let (listener, guard) = left.or(right).unwrap(); + let client = UnixStream::connect(&path).await.unwrap(); + drop(client); + drop(listener); + guard.remove_owned(); + } + + #[test] + fn rpc_lock_refuses_a_symlink_and_normalizes_existing_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("kultd.sock"); + let lock_path = dir.path().join("kultd.sock.lock"); + let target = dir.path().join("unrelated"); + std::fs::write(&target, b"preserve").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap(); + symlink(&target, &lock_path).unwrap(); + assert!(acquire_rpc_lock(&socket).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"preserve"); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o644 + ); + + std::fs::remove_file(&lock_path).unwrap(); + std::fs::write(&lock_path, b"").unwrap(); + std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o666)).unwrap(); + let lock = acquire_rpc_lock(&socket).unwrap(); + assert_eq!(lock.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + + #[test] + fn rotating_batches_cover_large_mailbox_and_token_sets_without_starvation() { + let mailboxes: Vec = (0..10).collect(); + let mut mailbox_cursor = 0; + assert_eq!( + rotating_batch( + &mailboxes, + &mut mailbox_cursor, + MAX_MAILBOXES_PER_CHECKIN_TICK + ), + (0..8).collect::>() + ); + assert_eq!( + rotating_batch( + &mailboxes, + &mut mailbox_cursor, + MAX_MAILBOXES_PER_CHECKIN_TICK + ), + vec![8, 9] + ); + + let tokens: Vec = (0..(MAX_MAILBOX_CHECKIN_TOKENS + 17)).collect(); + let mut token_cursor = 0; + let first = rotating_batch(&tokens, &mut token_cursor, MAX_MAILBOX_CHECKIN_TOKENS); + let second = rotating_batch(&tokens, &mut token_cursor, MAX_MAILBOX_CHECKIN_TOKENS); + assert_eq!(first.len(), MAX_MAILBOX_CHECKIN_TOKENS); + assert_eq!(second, tokens[MAX_MAILBOX_CHECKIN_TOKENS..]); + assert_eq!(token_cursor, 0); + } +} diff --git a/docs/00-start-here.md b/docs/00-start-here.md index a3949a7..874a8d1 100644 --- a/docs/00-start-here.md +++ b/docs/00-start-here.md @@ -4,25 +4,33 @@ ## What is this? -Komms is a messenger being built so that **nobody between you and the person -you're writing to can read, scan, or block your messages**: not a company, not a -government scanner, not the network itself. Not because a policy promises it, but -because the messages protect themselves and no mandatory provider can open them. +Komms is a messenger being built so that **people carrying your messages are +not given the keys to read or scan their contents**. It also offers more than +one route, so no mandatory exclusive provider is the only way to communicate. +Encryption cannot promise delivery against every network block, device seizure, +radio jammer, or compromised endpoint; the goal is to preserve useful +alternatives when at least one supported path remains. Three things make it different from the messengers you know: -1. **There is no mandatory company in the middle.** WhatsApp, Telegram, even - Signal depend on servers operated by one organization. Komms messages travel - directly between devices, through volunteers, or over radio. Optional - convenience services can help wake a sleeping phone or find a paired friend, - but they cannot read messages and communication still works without them. -2. **It works when the internet doesn't.** Messages can travel over small, - ~€30 [Meshtastic](https://meshtastic.org) radios (kilometres of range, no SIM card, - no infrastructure), between phones nearby, or even on a USB stick carried in a - pocket. If someone switches the internet off, communication continues. +1. **There is no mandatory exclusive provider.** Komms messages may travel + directly, through chosen volunteer mailbox operators holding sealed + ciphertext, or over local and radio links. A future Standard mode may offer + disclosed, replaceable defaults for easy setup. Optional post-pairing + rendezvous and phone wake services cannot read message content or hold + identity private keys; removing them leaves the pure-core routes available. +2. **It is designed for more than one kind of network.** Messages can use + [Meshtastic](https://meshtastic.org) radios, local links, or a `.kkb` courier + file carried on removable media. The software paths have automated tests; + the physical two-radio and real-world mobile matrices remain Alpha + qualification work. During a shutdown, communication still needs a + supported route, working devices, power, configuration, and sometimes radio + hardware. 3. **You are not a phone number.** No number, no email, no account, no sign-up. Your identity is a cryptographic key created on your own device. Nobody can ban your - account, because there is no account. + Komms account, because there is no centrally administered Komms account. + Networks, app stores, and device owners can still deny access to their own + resources. ## What do the crypto words mean? @@ -31,16 +39,17 @@ You'll see five terms around the project. This is all you need: | Term | Plain meaning | |---|---| | **End-to-end encryption** | Your message is locked on your device and only your contact's device can unlock it. Everyone in between sees scrambled bytes. | -| **Post-quantum** | The locks are designed to survive even the codebreaking computers expected in the future. Messages recorded today stay private tomorrow. | -| **kult address** (`kk1…`) | Your ID, like a phone number you invented yourself and nobody can take away. Share it as a QR code, sticker, or text. | -| **Safety number** | A 30-digit number you and a friend compare (in person or over a call) to be *certain* no one is impersonating either of you; scanning compares the full 256-bit value. | +| **Post-quantum** | The handshake combines classical and standardized post-quantum key agreement to reduce “record now, decrypt later” risk. It is not a guarantee against endpoint compromise, implementation bugs, or future cryptanalysis. | +| **kult address** (`kk1…`) | Your cryptographic ID, created on your device rather than assigned by a central registry. Share it as a QR code, sticker, or text. | +| **Safety number** | A 30-digit number you and a friend compare (in person or over a trusted call) to check that the identity keys match; scanning compares the full 256-bit value. It cannot make a compromised endpoint trustworthy. | | **Courier file / bundle** | Your encrypted messages packed into a `.kkb` file that can travel on a USB stick or another file channel: messaging with no network at all. Animated message-bundle QR is planned; current QR flows are for pairing and verification. | ## What does it protect me from, honestly? -**It protects**: the content of your messages; who you talk to (as far as -technically possible); your message history on a lost or stolen (locked) device; your -ability to communicate during internet shutdowns. +**It is designed to protect**: the content of your messages; who you talk to (as +far as the selected routes and threat model allow); your message history on a +lost or stolen locked device; and communication options during internet +shutdowns when a supported alternate path remains. **It cannot protect**: a phone that is already hacked or taken from you unlocked; the fact that a radio transmission physically happened (radio can be detected); you, @@ -57,7 +66,10 @@ checksum, install it, and report what you find. The desktop packages are not production-signed or notarized, the Android APK is debug-signed, and iOS remains source/Simulator-only. Hands-on device qualification, signed and store distribution, the physical radio bench, and an external audit remain before a -stable release. +stable release. Fresh internet installs also need deliberate bootstrap/mailbox +configuration today; the replaceable-default Standard journey and the +separately demonstrated pure-core journey are P0 work, not a current +plug-and-play claim. Messages may use a small safe formatting subset for emphasis, strong text, quotes, lists, and code. The exact readable source stays encrypted in history @@ -74,8 +86,9 @@ warning and user action before operating-system handoff. See You can edit canonical text you authored in a pairwise or group conversation. Komms sends that change as a new encrypted event, keeps an **edited** marker and inspectable version history, and derives the same winner even when offline -carriers deliver edits out of order. Editing does not erase what another device -already received or copied. See +carriers deliver edits out of order. Pairwise authorship is authenticated; in +the current Alpha, a malicious group member can forge another member's apparent +edit. Editing does not erase what another device already received or copied. See [Authenticated Message Editing](18-message-editing.md). You can also choose disappearing text or a view-once attachment. Komms removes @@ -87,23 +100,26 @@ not the exact deadline or content. See [Disappearing Messages and View-Once Attachments](19-ephemeral-messages.md). Groups can also create encrypted single-choice polls. Votes and voter identities -are visible to members—Komms does not call them anonymous—and the creator closes -the exact vote snapshot they have received. Offline, duplicate, and reordered -events still converge locally. See [Group Polls](20-group-polls.md). +are visible to members—Komms does not call them anonymous—and the apparent +creator closes the exact vote snapshot they have received. Offline, duplicate, +and reordered events still converge locally. In the current Alpha, another +group member can forge an apparent voter or creator; ADR-0029 is required before +stable. See [Group Polls](20-group-polls.md). Groups can upgrade to signed owner, admin, and member roles. There is always one owner. Admins can request common work while the owner is offline, but the owner still commits one ordered change and refreshes the group's encryption keys. Ownership can be transferred; the owner must transfer before leaving. A signed -owner moderation close is visibly different from the poll creator's ordinary +owner moderation close is visibly different from an apparent creator's ordinary close. There is no server account or hidden moderator behind these roles. See [Group Roles, Ownership, and Moderation](21-group-roles.md). One Komms identity can authorize up to eight independently keyed devices through a mutually confirmed QR or paste ceremony. Sync is explicit and encrypted -between those devices; there is no cloud account, and revoking one exact device -does not revoke or silently clone another. Recovery creates fresh device -credentials rather than reviving credentials from a backup. See +between those devices; there is no cloud account. Current Alpha revocation +excludes one known id but cannot permanently contain a compromised device that +retained the copied account root. Recovery creates fresh device credentials +rather than reviving credentials from a backup. See [Linked Devices](22-linked-devices.md). Already paired contacts can also make alpha live-audio calls when both devices @@ -149,9 +165,10 @@ Platform build instructions: | what disappearing/view-once means—and what it cannot erase | [Disappearing Messages and View-Once Attachments](19-ephemeral-messages.md) | | how encrypted group polls converge and why votes are visible | [Group Polls](20-group-polls.md) | | how signed group roles, ownership transfer, and moderation work | [Group Roles, Ownership, and Moderation](21-group-roles.md) | -| how one account safely authorizes, syncs, and revokes physical devices | [Linked Devices](22-linked-devices.md) | +| how one account currently authorizes and syncs devices—and why revocation still needs redesign | [Linked Devices](22-linked-devices.md) | | when live audio calls work—and when they deliberately do not | [Live Audio Calls](23-live-audio-calls.md) | | how a release is validated locally before any hosted run | [Local Release Gate](24-local-release-gate.md) | +| what evidence is required before stable or wire v1 | [Stabilization Program](29-stabilization-program.md) | | why a technical decision was made | [ADR Index](adr/README.md) | ## How can I help? @@ -167,6 +184,7 @@ Platform build instructions: ## Why does this exist? -Because private conversation is a human right, and rights need infrastructure, not -just arguments. The longer version (including our answer to the EU's ChatControl -law) is in [Why Komms](01-why.md). +Because private conversation is a human right, and rights need infrastructure, +not just arguments. The longer version—including Komms's position on European +policy proposals and temporary rules commonly discussed as +“Chat Control”—is in [Why Komms](01-why.md). diff --git a/docs/01-why.md b/docs/01-why.md index c82f025..80a7d72 100644 --- a/docs/01-why.md +++ b/docs/01-why.md @@ -2,24 +2,35 @@ ## The moment -The EU's "ChatControl" legislation (the CSA Regulation's mandatory-detection provisions) -requires communication services to scan private messages, including, in practice, -end-to-end encrypted ones via client-side scanning. Whatever its stated aims, its -mechanism is the same one every mass-surveillance system uses: a checkpoint between you -and the person you're talking to, operated by someone who is not either of you. - -The technical community's assessment has been consistent for decades and was repeated, -loudly, about this bill: **there is no such thing as a scanning mechanism that only works -for the good guys.** A backdoor is a backdoor; a scanner is a wiretap; infrastructure -built for one purpose is repurposed by the next government, the next breach, the next -mission-creep amendment. +*Legal-status note, last checked 2026-07-26: this is project motivation, not +legal advice.* + +European policy commonly discussed as “Chat Control” is not one already-enacted +law that universally requires private-message scanning. The proposed permanent +Child Sexual Abuse Regulation remains in negotiation between EU institutions. +Separately, on 2026-07-23 the Council gave final approval to a new temporary +measure allowing providers to resume certain voluntary detection activity. +The [Council's current overview](https://www.consilium.europa.eu/en/policies/prevent-child-sexual-abuse-online/) +distinguishes those interim rules from the permanent framework still being +negotiated. This page must be updated when that status changes. + +Komms is motivated by the broader and durable risk: proposals or laws that make +private communication scannable create a checkpoint between people who are +talking, operated by someone who is not either of them. + +Security experts have repeatedly warned that a privileged scanning mechanism +creates a capability that can be abused, breached, expanded, or repurposed. +Komms therefore treats content confidentiality as a technical boundary rather +than a promise that a scanner will always be used as intended. Komms's answer is architectural rather than rhetorical: build a messenger with -**no mandatory content-bearing service provider to compel**. Optional -reachability and wake services may be pressured to log or deny their own work, -but they never receive the message keys or plaintext needed for server-side -scanning, and communication survives without them. See -[02: Threat Model](02-threat-model.md), adversary A1. +**no mandatory exclusive content-bearing provider**. DHT first contact and +durable mailbox delivery remain core roles whose operators can be chosen or +self-hosted. Optional post-pairing rendezvous and wake services may be pressured +to log or deny their own work, but they must never receive message plaintext or +identity private keys. Removing them leaves pure-core routes available, although +an adversary may still block every usable route. See +[02: Threat Model](02-threat-model.md), adversaries A1 and A3. ## The position @@ -43,16 +54,18 @@ serverless mesh messaging is possible but stops at the phone's own radios. The empty niche Komms targets: -1. **A server-independent core**, not a provider promise: DHT + friend relays + - mesh, with no project service required to communicate +1. **A server-independent core**, not an exclusive-provider promise: DHT first + contact + chosen durable mailbox operators + direct/local/mesh paths, with no + optional project service required to communicate ([03: Architecture](03-architecture.md)). 2. **Off-grid as a first-class transport**, not a demo: commodity Meshtastic LoRa radios give kilometers of range and multi-hop store-and-forward when networks are shut down or shut off ([05: Transports](05-transports.md)). -3. **Bleeding-edge cryptography, conservatively assembled**: hybrid post-quantum key +3. **Modern cryptographic constructions, conservatively assembled**: hybrid post-quantum key agreement (X25519 + ML-KEM-768), Double Ratchet with encrypted headers, - XChaCha20-Poly1305 everywhere, sealed-sender delivery: every construction from the - published state of the art, no invented primitives + XChaCha20-Poly1305, and sealed-sender delivery. The primitives and + constructions are published; their combination in Komms is not yet + independently audited or independently interoperable ([04: Cryptography](04-cryptography.md)). 4. **No identifiers**: identity is a keypair you mint yourself ([06: Identity & Trust](06-identity-trust.md)). @@ -68,12 +81,22 @@ and the privacy comes with it. ## The commitments -1. Every line of code public, AGPLv3, forkable forever. -2. No mandatory service, account, phone number, email, or telemetry. Optional - convenience services are public, replaceable, content-blind, and honest about - the bounded metadata they can be compelled to disclose or deny +1. Project-owned software remains public under AGPL-3.0-only and forkable + forever. Qualifying modified network versions owe their interacting users a + Corresponding Source offer; the license does not prohibit government or + commercial use. +2. No mandatory exclusive service, account, phone number, email, or telemetry. + Standard mode may use disclosed and replaceable bootstrap or mailbox + defaults. Optional post-pairing convenience services are public, + replaceable, content-blind, and honest about the bounded metadata they can be + compelled to disclose or work they can deny ([ADR-0017](adr/0017-optional-hybrid-modes.md)). 3. No custom crypto primitives; published constructions only; external audit before any "stable" label ([08: Roadmap](08-roadmap.md), M6). 4. Honest limits, in writing: what Komms cannot protect against is documented as carefully as what it can ([02: Threat Model §4](02-threat-model.md)). +5. Official project activity and default services follow a nonprofit + public-benefit mission. Funding sustains access, infrastructure, security, + accessibility, maintenance, and development rather than data monetization or + private profit distribution + ([ADR-0033](adr/0033-nonprofit-founder-stewardship.md)). diff --git a/docs/02-threat-model.md b/docs/02-threat-model.md index 8418ab1..4f7613d 100644 --- a/docs/02-threat-model.md +++ b/docs/02-threat-model.md @@ -85,7 +85,10 @@ Malware, forensic seizure of an unlocked device, or a coerced unlock, against a target. **Defense (bounded)**: at-rest encryption under an Argon2id-derived key protects a -powered-off/locked device. Forward secrecy means a captured device does not reveal +powered-off/locked device's sealed record bodies. The current Alpha database +still exposes contact/group identifiers in plaintext lookup columns and does +not bind every ciphertext to its logical row; ADR-0027 is an open P0 storage +migration. Forward secrecy means a captured device does not reveal messages deleted before capture; post-compromise security means a *transient* compromise is healed by the next DH ratchet step. A persistently compromised endpoint sees everything its user sees; no messenger can prevent that (§5). @@ -136,20 +139,20 @@ viewer, or defeat A7. Exact behavior is in | Goal | Meaning | Mechanism | |---|---|---| | **Confidentiality** | Only intended recipients read content. | XChaCha20-Poly1305 AEAD under Double Ratchet keys. | -| **Integrity & authenticity** | Messages cannot be altered or forged. | AEAD tags; identity-key-signed handshakes. | +| **Integrity & authenticity** | Pairwise messages and signed authority state cannot be altered or forged by an outsider. | AEAD tags and identity-key-signed handshakes protect pairwise lanes; signed group authority state is attributable. Current sender-key group content has only membership-level authenticity, so a malicious member can impersonate another member until [ADR-0029](adr/0029-recipient-authenticated-groups.md) is implemented. | | **Forward secrecy** | Key compromise doesn't expose past messages. | Symmetric + DH ratchets; keys zeroized after use. | | **Post-compromise security** | Security self-heals after transient compromise. | DH ratchet steps on every round trip. | | **Post-quantum confidentiality** | A6 resistance for content. | Hybrid PQXDH-style handshake (ML-KEM-768). | | **Metadata minimization** | Network learns as little as possible about who/when/how much. | Sealed sender, encrypted headers, size-bucket padding, no mandatory identity-indexed rendezvous; optional pairwise capabilities. | | **Deniability** | Transcripts are not cryptographic proof of authorship to third parties. | No signatures over message content; authentication via shared MAC keys (Signal-style). | | **No mandatory identifiers** | No phone number, email, or real name, ever. | Keypair-as-identity ([06: Identity & Trust](06-identity-trust.md)). | -| **Availability off-grid** | Communication survives infrastructure loss. | Transport abstraction with LoRa mesh + sneakernet fallbacks. | +| **Availability off-grid** | Communication may continue when ordinary infrastructure is lost and at least one supported alternate path remains usable. | Transport abstraction with LoRa mesh + sneakernet fallbacks; no guarantee against simultaneous blocking, jamming, device loss, or power loss. | | **Local display minimization** | Reduce accidental disclosure from capture and task/app-switcher previews where native APIs permit. | Always-on B14 shell protections and explicit unsupported states; not an endpoint-compromise defense. | | **Local input minimization** | Reduce keyboard learning, correction, spellcheck, autofill, and secret-field exposure where native APIs permit. | Always-on B15 field controls and explicit best-effort/unavailable states; not an endpoint-compromise defense. | | **Identity-text safety** | Keep mutable human labels from becoming identity or silently hiding spoofing risk. | Exact peer-key targeting, NFC normalization, duplicate/confusable/bidi/invisible warnings, and explicit review for warned B5 renames. | | **Active-content isolation** | Authenticated message text must not become executable or network-active content. | B9 keeps exact source, applies a bounded local parser, exports only inert block/run tokens, literal-falls back on complexity, and never interprets HTML, links, images, or URL schemes. | -| **Edit provenance** | A peer must not rewrite another author's message or make offline endpoints disagree about the visible version. | C3 immutable edit events bind exact author/content ids inside authenticated content; wrong-author/wrong-scope events never apply, and maximum `(revision, edit id)` converges without clocks. | -| **Poll convergence and honesty** | A peer must not cast another member's vote or make replicas tally the same received events differently. | C5 binds each vote to the authenticated group sender, fixes the creator-attested electorate, selects maximum `(revision, event id)`, and freezes a creator-authenticated close snapshot; the UI says votes are visible and never claims anonymity or election fairness. | +| **Edit provenance** | A pairwise peer must not rewrite another author's message, and offline endpoints must agree about the visible version. | C3 immutable edit events bind exact author/content ids inside authenticated content; wrong-author/wrong-scope events never apply, and maximum `(revision, edit id)` converges without clocks. Current group edits still inherit sender-key member impersonation until ADR-0029. | +| **Poll convergence and honesty** | Replicas must tally the same received events; stable release must also prevent one member from casting another member's vote. | C5 fixes the creation-declared electorate, selects maximum `(revision, event id)`, and freezes a close snapshot claimed by the creator. Convergence is implemented, but voter, creator, and close-origin authentication remain open under ADR-0029; the UI says votes are visible and never claims anonymity or election fairness. | | **Group authority convergence** | A stale admin or losing ownership fork must not regain authority or future group secrets. | C6 signs canonical full role state and transfer certificates, binds admin requests to one generation, serializes mutations at one owner, rejects non-extending transfer chains, and re-keys every accepted transition. | | **Sovereignty** | Users hold their own keys and data; anyone can run every component. | Local-first storage, AGPLv3, no privileged nodes. | @@ -163,15 +166,18 @@ non-colluding OHTTP relay, but it does not promise anonymity against collusion or a global passive observer. Service compromise can suppress convenience work but cannot decrypt or forge an accepted Komms message. -Message editing does not erase evidence or extend sender authority. The original -and accepted versions remain sealed locally and in backups; only the exact -authenticated author can target canonical text in the same conversation. -Display names, local timestamps, and arrival order cannot authorize or select a -winner. A malicious peer can send many authenticated attempts and consume its -own conversation storage, so local authors are capped at 64 edits per target; -authenticated inbound events remain durable to preserve convergence. Recipients -may retain, copy, capture, or export any prior version, and the UI never describes -editing as remote deletion. Exact limits are in +Message editing does not erase evidence or extend pairwise sender authority. The +original and accepted versions remain sealed locally and in backups; in a +pairwise conversation only the exact authenticated author can target canonical +text in that conversation. Current sender-key groups validate the claimed author +against membership but cannot stop a malicious member from forging another +member's edit; ADR-0029 is required before extending the pairwise authorship +claim to groups. Display names, local timestamps, and arrival order cannot +authorize or select a winner. A malicious peer can send many authenticated +attempts and consume its own conversation storage, so local authors are capped +at 64 edits per target; authenticated inbound events remain durable to preserve +convergence. Recipients may retain, copy, capture, or export any prior version, +and the UI never describes editing as remote deletion. Exact limits are in [18: Authenticated Message Editing](18-message-editing.md). C4 ephemeral content narrows local retention; it does not create remote @@ -185,17 +191,19 @@ Komms's ordinary preview/export/playback paths but is not DRM or universal screenshot prevention. See [19: Disappearing Messages and View-Once Attachments](19-ephemeral-messages.md). -C5 group polls protect content from intermediaries and authenticate which group -identity authored each event; they do not provide secret ballots. Every holder -of the poll can inspect current voter identities and choices. The creator -attests the fixed electorate and final observed vote-head snapshot, so a -malicious creator can close before an offline vote arrives or omit an observed -head in a forged close event; deterministic convergence is not proof of fair or -complete counting. A voter cannot impersonate another member, and outsider, -unknown-option, duplicate, delayed, and reordered events do not change the -defined result. Removed members retain what they already received. See -[20: Group Polls](20-group-polls.md) and -[ADR-0022](adr/0022-convergent-group-polls.md). +C5 group polls protect content from intermediaries and deterministically converge, +but the current shared sender-key construction authenticates only that an event +came from some member. A malicious member can therefore forge another member's +apparent vote until ADR-0029 adds recipient-verifiable origin authentication. +Every holder of the poll can inspect current apparent voter identities and +choices. The creator attests the fixed electorate and final observed vote-head +snapshot, so a malicious creator can close before an offline vote arrives or +omit an observed head in a forged close event; deterministic convergence is not +proof of fair or complete counting. Outsider, unknown-option, duplicate, +delayed, and reordered events do not change the defined result. Removed members +retain what they already received. See [20: Group Polls](20-group-polls.md), +[ADR-0022](adr/0022-convergent-group-polls.md), and +[ADR-0029](adr/0029-recipient-authenticated-groups.md). C6 group authority is attributable rather than deniable durable state. This is an intentional exception to ordinary deniable message content: identity @@ -215,13 +223,15 @@ but it does not make an authorized device harmless. Every physical endpoint has an account-signed certificate and independent delivery cryptography; manifests, link transcripts, sync events, counters, and revocations are authenticated and rollback/replay checked. A compromised authorized device can retain plaintext, -emit account-authorized events, or race a same-generation manifest fork until -another surviving device permanently revokes it. Revocation protects future -delivery and accepted sync, not content already seen. Explicit bounded sync has -no server log and excludes live queues/ratchets, active ephemeral content, -downloaded media, drafts, and scheduled outbox rows. See +emit account-authorized events, race manifests, or—because ADR-0024 currently +copies the account root—mint a fresh certificate even after its known device id +is revoked. Current revocation excludes that exact id from honest future +delivery and sync; it is not permanent containment of a compromised device. +ADR-0026 moves the root offline and requires prior-device majority authority. +Explicit bounded sync has no server log and excludes live queues/ratchets, +active ephemeral content, downloaded media, drafts, and scheduled outbox rows. See [22: Linked Devices](22-linked-devices.md) and -[ADR-0024](adr/0024-account-authorized-linked-devices.md). +[ADR-0026](adr/0026-revocable-device-authority.md). Private folders, conversation pins, and labels are endpoint organization, never communications metadata. Their definitions, single-folder assignments, @@ -298,8 +308,8 @@ Honesty here is a security feature. Komms does **not** claim to provide: |---|---| | A1 | No server-side content-scanning point exists; malicious or compelled endpoint software remains A7. | | A2 | Coarse traffic patterns on internet transport and enabled convenience services until cover-traffic/Tor mitigations apply. | -| A3 | Determined national censor can degrade internet transport; off-grid transports remain. | -| A4 | Regional mesh partitions until a bridge node appears; optional-service outage loses convenience, not core communication. | +| A3 | Determined national censor can degrade or block internet transport; off-grid options still require usable hardware, power, configuration, and an unjammed path. | +| A4 | Regional mesh partitions until a bridge node appears; optional-service outage leaves pure-core capabilities available but may still prevent delivery when no core path is currently reachable. | | A5 | Targeted denial and service-use correlation by a well-placed component; denial is mitigated by multipath core fallback. | -| A6 | Broken only if *both* X25519 and ML-KEM-768 fail. | +| A6 | The hybrid handshake is intended to retain key-agreement security if either X25519 or ML-KEM-768 remains secure under the composition's assumptions; protocol, implementation, endpoint, and future-cryptanalysis failures remain possible. | | A7 | Persistent endpoint compromise is out of scope; transient compromise is healed. | diff --git a/docs/03-architecture.md b/docs/03-architecture.md index f0798c9..6920102 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -97,13 +97,16 @@ choices and honest local-only language. See [19: Disappearing Messages and View-Once Attachments](19-ephemeral-messages.md). C5 polls follow the immutable replicated-state shape: `kult-protocol` owns -content-v1 kind 6 create/vote/close frames; `kult-node` authenticates the group -sender and derives fixed-electorate vote heads and final tallies; existing +content-v1 kind 6 create/vote/close frames; `kult-node` validates the claimed +group member and derives fixed-electorate vote heads and final tallies; existing sealed group rows and `KKR7` carry the source events; RPC/UniFFI expose typed snapshots; shells render and refresh cards without resolving votes. The sender-key path hides poll content from transports, while authenticated -capability intersection keeps old clients off the typed send path. See -[20: Group Polls](20-group-polls.md). +capability intersection keeps old clients off the typed send path. Because +sender keys are shared, another member can forge the claimed voter until +ADR-0029 adds recipient-verifiable origins. See +[20: Group Polls](20-group-polls.md) and +[ADR-0029](adr/0029-recipient-authenticated-groups.md). C6 authority adds a deliberately separate signed control plane without moving policy into shells. `kult-protocol` owns content-v1 kind 7, the canonical full @@ -123,11 +126,13 @@ cryptography. `kult-crypto` owns certificates, manifests, link transcripts, and sync sealing; `kult-protocol` owns bounded endpoint bundles and sync events; `kult-store` seals device authority, per-endpoint delivery state, sync counters, and deterministic winners; `kult-node` enforces fan-out, capability intersection, -revocation, convergence, and recovery. RPC/UniFFI expose opaque ceremony bytes +known-id exclusion, convergence, and recovery. Current Alpha authority still +copies the account root and cannot permanently contain a compromised device; +ADR-0026 replaces that boundary. RPC/UniFFI expose opaque ceremony bytes and strict render-safe device models; shells compare codes and collect explicit approvals without implementing authority rules. See [22: Linked Devices](22-linked-devices.md) and -[ADR-0024](adr/0024-account-authorized-linked-devices.md). +[ADR-0026](adr/0026-revocable-device-authority.md). ## 3. Message lifecycle @@ -140,8 +145,12 @@ what makes pre-activation edit/cancel safe. Clock rollback keeps the row held; clock advance activates it on the next tick; time-zone changes are display-only. 1. **App** calls `send(conversation, content)` through `kult-ffi`. -2. **kult-node** persists the outbound message locally (`kult-store`) with state `queued`: - the UI is truthful about delivery, and nothing is lost on crash. +2. **kult-node** persists the outbound message locally (`kult-store`) with state + `queued`, so ordinary restart preserves the durable queue and the UI can + report delivery honestly. Current Alpha outbound ratchet, history, and queue + writes are not yet one universal atomic transition; the remaining crash + windows are tracked by [ADR-0028](adr/0028-atomic-protocol-commits.md) and + must close before a general “nothing is lost on crash” claim. 3. **kult-protocol** serializes content, pads it to the next size bucket, and hands it to the conversation's ratchet. 4. **kult-crypto** advances the sending chain, encrypts with XChaCha20-Poly1305, and @@ -237,13 +246,18 @@ decrypting it. The exact deadline and whether the content is text or view-once media remain encrypted. The bucket is a bounded deletion hint, not proof that a relay erased every copy. -No sender identity, no recipient identity, no exact content deadline beyond arrival time, no -conversation linkage. This is the **sealed sender** property; the construction is specified -in [04: Cryptography §7](04-cryptography.md). +The envelope encodes no sender identity, recipient identity, exact content +deadline beyond the coarse bucket, or conversation identifier. This is the +**sealed sender** content property; it does not hide network addresses, opaque +delivery tokens, timing, sizes, volume, or cross-request correlation from every +carrier or observer. The construction is specified in +[04: Cryptography §7](04-cryptography.md). This paragraph does not describe an enabled optional rendezvous or native-wake service. Their bounded but non-zero metadata surfaces are listed in -[02: Threat Model](02-threat-model.md) and ADR-0017. +[02: Threat Model](02-threat-model.md), +[ADR-0017](adr/0017-optional-hybrid-modes.md), and +[ADR-0034](adr/0034-operator-minimized-reference-discovery.md). ## 6. Groups diff --git a/docs/04-cryptography.md b/docs/04-cryptography.md index bfcaba9..0db5abe 100644 --- a/docs/04-cryptography.md +++ b/docs/04-cryptography.md @@ -142,12 +142,16 @@ fits typical LoRa payloads after fragmentation into ≤2 frames. C3 `Edit` is content-v1 kind `0x0004` inside the plaintext described above. Its exact author/content reference, revision, and replacement UTF-8 are protected by the same Double Ratchet or group sender-key AEAD as the original. Nothing in the -outer envelope identifies an edit. Authorization uses the authenticated content -sender and exact target bytes; visible names and local timestamps are excluded. -Resolution by maximum `(revision, edit_content_id)` is application convergence, -not a new cryptographic primitive or signature. The normative encoding and -compatibility contract are [ADR-0020](adr/0020-authenticated-message-edits.md) -and [18: Authenticated Message Editing](18-message-editing.md). +outer envelope identifies an edit. Pairwise authorization uses the authenticated +content sender and exact target bytes; visible names and local timestamps are +excluded. In sender-key groups the same check is only membership-level because +every member holds the content key; ADR-0029 is required for individual group +origins. Resolution by maximum `(revision, edit_content_id)` is application +convergence, not a new cryptographic primitive or signature. The normative +encoding and compatibility contract are +[ADR-0020](adr/0020-authenticated-message-edits.md), +[ADR-0029](adr/0029-recipient-authenticated-groups.md), and +[18: Authenticated Message Editing](18-message-editing.md). ### 5.2 Authenticated ephemeral content @@ -281,8 +285,10 @@ comparison value. Rationale and UX: ## 10. Explicit exclusions -- No custom primitives, ever. Constructions may be composed here; primitives come from - audited crates pinned by exact version and checksum. +- No custom primitives, ever. Constructions may be composed here; primitives + come from published, externally maintained crates pinned by exact version and + checksum. Dependency provenance is not a substitute for independent review of + Komms's composition and implementation. - No compression before encryption (compression-oracle class attacks). - No protocol-level plaintext timestamps; time lives inside the AEAD. - `kult-crypto` is `no_std`-compatible (alloc-only) to keep the door open for diff --git a/docs/05-transports.md b/docs/05-transports.md index 45b3d1e..3f649ae 100644 --- a/docs/05-transports.md +++ b/docs/05-transports.md @@ -24,8 +24,10 @@ pub trait Transport: Send + Sync { Rules every implementation must obey: 1. **Ciphertext only.** A transport never sees plaintext or key material. -2. **No identity leakage.** Transports address peers by `DeliveryHint` (multiaddr, mesh - node id, mailbox token), never by Komms identity keys. +2. **No Komms identity in transport addressing.** Transports address peers by + `DeliveryHint` (multiaddr, mesh node id, mailbox token), never by Komms + identity keys. The link or its operator may still observe network addresses, + libp2p PeerIds, multiaddrs, opaque tokens, timing, sizes, and volume. 3. **Link encryption is additive, not load-bearing.** Noise/TLS on the link protects against A2/A3 traffic tampering, but all security guarantees hold even over a plaintext link: the envelope is self-protecting. @@ -62,12 +64,65 @@ receipt, the queue copy is removed and retained history becomes | Link protocols | QUIC (primary), TCP+Noise+Yamux (fallback) | | Discovery | Kademlia DHT; bootstrap from a *user-editable* list of community nodes + manual peer addresses + rendezvous points shared out-of-band (QR) | | NAT traversal | AutoNAT + Circuit Relay v2 + DCUtR hole punching | -| Prekey bundles | Signed bundles ([06: Identity & Trust](06-identity-trust.md)) published as DHT records under `H(IK_pub)`; signatures make records self-authenticating regardless of which DHT node serves them | +| Prekey bundles | Current Alpha: signed bundles ([06: Identity & Trust](06-identity-trust.md)) published under stable `H(IK_pub)` locators. This authenticates contents but exposes a polling and route-correlation oracle; [ADR-0031](adr/0031-capability-scoped-dht-discovery.md) proposes capability-scoped rotating locators before wire v1. | | Mailbox relays | Ordinary nodes advertising a relay protocol; recipients pick relays and list them (as hints) in their bundle | -Bootstrap deserves emphasis: hardcoded bootstrap nodes are a seizure target (A4), so the -list ships as *defaults, not dependencies*: any reachable peer can bootstrap the DHT, and -two users who exchange a QR code need no bootstrap at all. +Bootstrap deserves emphasis: a fresh internet-only install still needs one +reachable bootstrap peer or explicit hint to join the DHT. The current Alpha +ships with no default bootstrap peers, so internet discovery requires deliberate +configuration. Any future default nodes would be censorship points for the +first attempt and must therefore remain user-editable and replaceable rather +than becoming a protocol dependency. Any reachable peer can bootstrap the DHT, +and two users who exchange a QR code need no project bootstrap at all. Before +stable, release tests must blackhole every configured default and exercise an +alternate peer and an out-of-band path. + +Direct sealed-envelope delivery negotiates `/komms/envelope/2`. One encoded +envelope is capped at **128 KiB** across carriers. The receiver keeps at most +256 unsolicited direct envelopes and 8 MiB of their encoded bytes between +delivery-engine drains. Its response has only two meanings: + +- `accepted`: the bounded in-process next-hop inbox retained the envelope for + the delivery engine; +- `refused`: the request was understood but the next hop did not retain it, + for example because that inbox was full. + +A timeout, dial error, malformed response, or response-write failure is neither +answer and never becomes an acknowledgement. The sender keeps the durable +envelope retryable and may try another supported path. Version 2 deliberately +does not negotiate the older `/komms/envelope/1` unit response, which could not +distinguish retention from refusal; Alpha peers must be upgraded together. +These bounds protect one ingress surface. They do not replace first-contact +admission, identity blocking, mailbox durability, or operator abuse controls in +the [stabilization program](29-stabilization-program.md). In particular, the +current `accepted` boundary is volatile RAM rather than ADR-0030's target +transactional admission boundary, so it is Alpha next-hop evidence—not a +durability or end-to-end receipt. + +The libp2p swarm also caps pending inbound/outbound connections at 32 each, +established inbound connections at 64, established connections at 96 total, +and connections per peer at 8. Envelope and mailbox protocols independently +cap active streams per connection. These are memory/concurrency containment, +not first-contact rate limits or Sybil resistance. + +Unknown-token envelopes that survive that volatile boundary enter an encrypted +deferred inbox only when their exact content id is not already present. The +interim store ceiling is 2,048 envelopes and 64 MiB of sealed rows, with the +older of exact multipath duplicates retained under one stable row id. Reaching +that ceiling prevents further persistence, but current `/komms/envelope/2` +cannot relay the late refusal back after it has already answered `accepted`. +That semantic gap is why these quotas are containment rather than closure of +ADR-0030. + +Mailbox-v1 collection is likewise bounded while its crash-safe replacement is +designed: one check-in carries at most 4,096 token filters and returns at most +512 envelopes / 2 MiB; the daemon rotates larger token sets, rotates beyond +eight configured mailboxes across later lifecycle ticks, and admits at most +eight pages (4,096 rows / 16 MiB) before the node drains the carrier. These +bounds prevent honest large contact or relay sets from failing or starving. +They do **not** make v1 durable: the relay still removes a page before the +endpoint transactionally stages and acknowledges it. [ADR-0032](adr/0032-leased-mailbox-delivery.md) +replaces that delete-before-response behavior with leased pages. **Censorship posture (A3)**: QUIC-on-443 blends adequately against casual blocking. Full DPI resistance (pluggable obfuscated transports, arti/Tor onion services as a transport) @@ -90,6 +145,12 @@ delivery state. Sovereign mode registers with neither service. Private mode uses Tor or a non-colluding Oblivious HTTP ingress; Standard mode uses direct HTTPS. Complete failure falls back to the unchanged transports in this document. +[ADR-0034](adr/0034-operator-minimized-reference-discovery.md) proposes the +initial founder-operated Hetzner profile: Standard-mode bootstrap/DHT caching +and post-pairing rendezvous with RAM-backed mutable state. It is not a mailbox +or wake gateway and cannot claim zero metadata, Private-mode non-collusion, or +plural operation. + ### 2.2 Ephemeral retention at intermediaries C4 mailbox, bridge, queue, and fragment records preserve envelope v2's coarse @@ -169,7 +230,7 @@ flowchart LR ``` - The Meshtastic client API is standardized over BLE, serial, and TCP. The - shipped Komms carrier attaches over USB-serial or the radio's TCP API to a + implemented Komms carrier attaches over USB-serial or the radio's TCP API to a stock Meshtastic device: **no custom firmware required**. Owning any supported ~30€ board is the only hardware requirement. - Komms envelopes are carried as Meshtastic packets on a **dedicated private app @@ -191,7 +252,12 @@ Consequences, all normative: 1. **Fragmentation**: envelopes above the frame budget split into type-`0x04` fragments ([04: Cryptography §5](04-cryptography.md)); a padded 192 B-bucket text message = - **≤ 2 LoRa frames**. Reassembly window: 24 h, per-peer cap, fail-closed on overflow. + **≤ 2 LoRa frames**. Reassembly window: 24 h, 1,024 fragments per envelope, + 256 concurrent partials, fail-closed on overflow. One receipt requests at + most 4,096 missing indices across 32 partials so hostile fragment metadata + cannot create an oversized NACK. Outer fragments are not made permanently + seen before their completed inner envelope clears bounded admission, so a + sender's full retry remains recoverable when the deferred inbox was full. 2. **Selective retransmission**: receiver NACKs missing fragment indices (in a receipt envelope) rather than the sender re-flooding whole messages: airtime is the scarcest resource in the system. @@ -223,23 +289,30 @@ includes knowing your exposure. The zero-RF, zero-network fallback and the simplest transport to implement: -- Any set of queued envelopes exports as a **bundle file** (`.kkb`): magic, version, then - concatenated envelopes: already sealed, already padded; the bundle adds no metadata. +- Up to 4,096 queued envelopes and 16 MiB export as a **bundle file** (`.kkb`): + magic, version, then concatenated envelopes: already sealed, already padded; + the bundle format adds no identity or routing fields. The filesystem or + courier channel may still expose filename, size, timestamps, handling, and + location. Each envelope retains the canonical 128 KiB limit. - Carried by USB stick, SD card, or any file channel; imported bundles feed the normal receive path (dedup makes double-import harmless). Bundles are also relay-able by - people who can't read them: a courier learns only bundle size. + people who can't read them: a courier learns only bundle size. One receive + pass scans at most 1,024 candidate directory entries, processes at most 256 + regular bundle files and 16 MiB, and leaves remaining work for the next pass. + Oversized files and non-regular `.kkb` entries are moved out of the candidate + namespace so they cannot starve valid bundles. - Animated QR sequences for **message** bundle transfer remain planned. Pairing already uses a bounded animated sequence because the ML-KEM-768 public key makes a complete post-quantum prekey bundle too dense for one - reliably scanned symbol; shipped message sneakernet uses `.kkb` files. + reliably scanned symbol; implemented message sneakernet uses `.kkb` files. ## 6. Transport comparison | Transport | MTU | Latency | Reach | Infrastructure needed | Milestone | |---|---|---|---|---|---| -| libp2p QUIC/TCP | ~64 KiB practical | ms–s | Global | Internet access | M3 | +| libp2p QUIC/TCP | 128 KiB/envelope | ms–s | Global | Internet access | M3 | | Freenet contracts | Prototype must measure | seconds–offline | Global store-and-forward | Local Freenet Core; explicit opt-in | Proposed (M6, ADR-0025) | -| mDNS/LAN | ~64 KiB | ms | Site | Shared LAN | M3 | +| mDNS/LAN | 128 KiB/envelope | ms | Site | Shared LAN | M3 | | BLE direct | ~0.2–0.5 KiB/frame | s | ~10–100 m | None | Planned (M6) | | Meshtastic/LoRa | ~0.2 KiB/frame | s–hours | km–100 km (multi-hop) | ~30€ radio per user | M4 | -| Sneakernet file / animated QR | Unbounded / ~2 KiB target | Human-scale | Anywhere humans go | None | M2 files shipped; animated QR planned | +| Sneakernet file / animated QR | 128 KiB/envelope; 16 MiB bundle / ~2 KiB target | Human-scale | Anywhere humans go | None | M2 files implemented; animated QR planned | diff --git a/docs/06-identity-trust.md b/docs/06-identity-trust.md index dcfc35e..4d06aeb 100644 --- a/docs/06-identity-trust.md +++ b/docs/06-identity-trust.md @@ -61,7 +61,7 @@ device owned by the same account. Global usernames require a global authority, excluded by design. Instead, **petnames**: every contact's display name is a private, local label chosen by *you*. B5 lets the -user rename an exact peer in every shipped interface. Names are NFC-normalized and +user rename an exact peer in every implemented interface. Names are NFC-normalized and bounded; duplicates are valid because the peer key, never display text, is the identity. Duplicate, mixed-script/confusable, bidirectional-control, and invisible- character risks are shown for explicit review before a warned rename. The label is @@ -86,31 +86,48 @@ compatibility path. See [15: Private Contact Names](15-contact-petnames.md). - **Revocation**: a signed revocation statement propagates through sessions and DHT; contacts mark the identity dead and refuse new sessions to it. -## 6. Linked devices (C2, shipped) +## 6. Linked devices (C2, Alpha with an open authority flaw) Each physical device holds its own certified device keypair. The stable account identity signs a bounded device manifest, while PQXDH/Double Ratchet sessions, capabilities, delivery rows, and group sender chains remain per physical device. Linking a pristine installation requires a time-bounded offer, explicit -confirmation on both sides, and matching six-digit comparison codes. Permanent -exact-device revocation excludes future delivery and sync. +confirmation on both sides, and matching six-digit comparison codes. Current +exact-id revocation excludes that known id from honest future delivery and +sync, but every linked device currently receives the stable account private +key. A compromised revoked device can mint a replacement certificate, so +permanent adversarial revocation is not implemented. Authenticated explicit device-to-device bundles converge contacts and verification, private organization, ordinary history, edits, polls, group authority, and terminal expiry tombstones. Drafts, scheduled outbox rows, live queues/ratchets, active ephemeral content, downloaded media, and most shell preferences remain installation-local. See [22: Linked Devices](22-linked-devices.md) -and [ADR-0024](adr/0024-account-authorized-linked-devices.md). +and the required offline-root replacement in +[ADR-0026](adr/0026-revocable-device-authority.md). ## 7. First-contact abuse controls -Open reachability invites spam (threat model non-goal #4). Local, user-controlled -mitigations (no central moderator exists): - -- **Contact gating** (default): unknown-sender messages land in a request queue showing - only a size-bounded intro; the ratchet session completes only on accept. -- **Introduction cost**: senders attach a small proof-of-work over (their `IK` ‖ recipient - token ‖ day) to first-contact envelopes; free for humans, expensive at spam scale. - Contacts-of-contacts can include a signed introduction voucher instead. -- **Local blocklists**, exportable/shareable as signed lists users may *choose* to - subscribe to: community moderation without central authority. +> **Proposed, not current Alpha behavior.** The current receive path accepts a +> valid cryptographic introduction and creates a normal contact without a +> request inbox, admission puzzle, or local blocking state. Do not treat the +> controls below as implemented. + +[ADR-0030](adr/0030-first-contact-admission.md) defines the pre-stable +replacement: + +- a signed, expiring recipient policy advertises a bounded client puzzle for + unsolicited public-address contact; +- authenticated QR/link/file invitations may bypass visible puzzle work; +- carrier and node byte, item, concurrency, KEM, disk, notification, and + per-tick budgets reject excess work before it becomes an unbounded queue; +- a valid stranger enters a sealed, fixed-size provisional request inbox and + becomes a normal contact only after explicit acceptance; +- reject, block, group-invite consent, and optional signed reputation lists are + local, inspectable state transitions rather than central moderation; and +- ordinary UI presents this as a familiar message request while technical + admission details remain in diagnostics. + +The proposal raises unsolicited-sender cost but does not claim proof-of-work can +defeat a distributed adversary; fixed resource quotas remain the controlling +safety boundary. diff --git a/docs/07-storage.md b/docs/07-storage.md index 5e74837..95c2859 100644 --- a/docs/07-storage.md +++ b/docs/07-storage.md @@ -2,24 +2,36 @@ Local-first is a security property, an availability property, and a political statement: your history lives on your hardware, encrypted under your keys, exportable at will, and -deletable for real. +locally deletable within explicit endpoint and copy-retention limits. ## 1. Principles -1. **Owned devices are the source of truth.** No cloud copy exists unless the user creates an - encrypted export. Shipped C2 sync is explicit device-to-device, end-to-end encrypted, - and accepted only between account-authorized physical devices. -2. **Durable state at rest is sealed.** The core database never persists plaintext: - messages, drafts, media chunks/previews, metadata, and search terms are independently - sealed. Bounded protected transients required by OS picker, recording, editing, - playback, or explicit export workflows are temporary exceptions with lifecycle cleanup, - never durable sources of truth. +1. **Owned devices are the source of truth.** There is no authoritative + provider-hosted history database. Optional mailbox and delayed-delivery + carriers may temporarily retain bounded sealed envelopes, and users may + create encrypted exports, but neither becomes readable message history for + an operator. Implemented C2 sync is explicit device-to-device, + end-to-end encrypted, and accepted only between account-authorized physical + devices. +2. **Durable record bodies at rest are sealed.** The core database does not + persist message, draft, or media plaintext. The current Alpha schema still + has plaintext equality and ordering columns that expose some exact + relationship metadata in a copied database; these are documented below and + replaced by the proposed ADR-0027 design. Bounded protected transients + required by OS picker, recording, editing, playback, or explicit export + workflows are temporary exceptions with lifecycle cleanup, never durable + sources of truth. 3. **Export is a right.** Full history exports to a documented, versioned format at any time. Lock-in is a bug. -4. **Deletion is real.** Deleting a message deletes the ciphertext row and its keys; - retention policies (per-conversation disappearing messages) are enforced locally. - We are honest that the *recipient's* copy is theirs, no fake "remote delete" theater - beyond a polite delete-request the peer may honor. +4. **Local deletion has a precise boundary.** Deleting a message removes the + live logical row and application references from that Komms history; + retention policies (per-conversation disappearing messages) are enforced on + that endpoint. SQLite cleanup can reduce remnants but does not establish + per-record cryptographic or forensic erasure. Komms cannot erase a + recipient's copy, screenshot, export, operating-system artifact, previously + copied backup, carrier ciphertext, filesystem snapshot, or data recovered + from a compromised endpoint. A deletion hint is a request another endpoint + may honor, not proof of remote erasure. ## 2. Layout @@ -30,22 +42,30 @@ HKDF per-domain keys. | Domain | Contents | Notes | |---|---|---| | `identity` | Own keys (wrapped), device settings | Smallest, most sensitive; extra wrap layer | -| `sessions` | Serialized ratchet states, skipped-key store | Rewrapped on every persist; zeroized in memory after | -| `contacts` | Peer keys, verification state, petnames, relay hints | Sealed locally; selected fields may enter authenticated own-device sync | +| `sessions` | Serialized ratchet states, skipped-key store | Body sealed and rewrapped on persist; current peer-key lookup column remains plaintext metadata | +| `contacts` | Peer keys, verification state, petnames, relay hints | Body sealed; current peer-key lookup column exposes an exact identifier in a copied database; selected fields may enter authenticated own-device sync | | `devices` | Own and contact device certificates, signed manifests, revocation tombstones | Bounded authority state; exact physical identities | | `device_sync` | Per-device channels, counters, Lamport winners, terminal convergence tombstones | Direction-bound, replay-protected, never a cloud log | | `messages` | Envelope plaintexts post-decrypt, delivery state | Per-blob AEAD, random nonces | | `queue` | Outbound envelopes pending delivery per transport | Ciphertext only, survives crash/restart | | `scheduled_messages` | Pairwise/group text held until an absolute UTC instant | Plaintext fields exist only inside independently sealed blobs; no ratchet or envelope is created early | | `prekeys` | Own signed/PQ/one-time prekey secrets | One-time prekeys deleted on use | -| `pending` | Inbound envelopes not yet readable (arrived before their session) | Ciphertext only; TTL-bounded | +| `pending` | Inbound envelopes not yet readable (arrived before their session) | Individually sealed; stable row ids; exact-duplicate suppression in the node; 2,048-row / 64 MiB sealed-byte ceiling; TTL-bounded | | `media` | Attachment blobs, chunked | Each chunk sealed; keys stored in `messages` | | `ephemeral` | Exact local deadlines, mode, transfer references, active/terminal lifecycle | Sealed separately; terminal tombstones block resurrection after plaintext/media deletion | | `local_metadata` | Conversation types, folders, pins, labels, drafts, UI preferences, custom icons | Endpoint-private; only the C2 allowlist can sync to another owned device | -Every blob is individually AEAD-sealed (XChaCha20-Poly1305, random 24-byte nonce, table -name + row purpose as associated data), a copied database file leaks only row counts and -approximate sizes; rows can't be transplanted across tables or databases. +Every record body is individually AEAD-sealed (XChaCha20-Poly1305, random +24-byte nonce, table name + row purpose as associated data). The current Alpha +schema is **not** an opaque-index store: contact/device public keys, group ids, +and related equality columns remain plaintext, so a copied locked database can +reveal exact relationship identifiers in addition to row counts and sizes. +Constant per-table associated data prevents cross-table/cross-key opening but +does not bind every ciphertext to its logical row, so a writer can substitute +some valid rows within one table without guaranteed detection. +[ADR-0027](adr/0027-opaque-indexed-store.md) defines the keyed indexes, +row-bound associated data, versioned migration, and deletion wording required +before stronger at-rest metadata claims. B9 formatting creates no additional durable state. The `messages`, `scheduled_messages`, group history, and note-to-self rows retain exact source @@ -56,15 +76,19 @@ the same source it already carried and needs no format or migration change. C3 edits also add no mutable plaintext projection. Canonical originals and edit events remain separate individually sealed pairwise/group history rows; derived history hides edit rows and returns the winning text, marker, revision, and -ordered versions. The winner is rebuilt from authenticated rows after restart or -restore, including edit-before-original order. `KKR7` carries those +ordered versions. The winner is rebuilt from accepted rows after restart or +restore, including edit-before-original order. Pairwise rows have authenticated +individual origins; current group rows have only membership-level origin until +ADR-0029. `KKR7` carries those history rows, so no backup version or migration changes. The node caps locally -authored edits at 64 per target; it retains every authenticated inbound edit so +authored edits at 64 per target; it retains every accepted inbound edit so admission order cannot change convergence. See [18: Authenticated Message Editing](18-message-editing.md). C4 keeps lifecycle state separate from history. Every disappearing/view-once id -is keyed by exact conversation, authenticated author, and content id. The node +is keyed by exact conversation, accepted author field, and content id. Pairwise +authors are individually authenticated; current sender-key group authors are +only membership-authenticated until ADR-0029. The node sweeps due rows before receive, scheduled activation, attachment work, or queue flush. Expiry/first reveal deletes exact history and queue rows plus every associated media object/chunk, then retains only a sealed terminal tombstone. @@ -76,10 +100,11 @@ byte. See [19: Disappearing Messages and View-Once Attachments](19-ephemeral-mes C5 polls add no mutable tally or plaintext projection. Creation, vote, and closure remain separate individually sealed group-history rows. The node rebuilds the fixed electorate, maximum `(revision, event id)` vote per member, -winning creator closure, and tally on read, so restart and reordered admission -cannot change the result. Local authors are capped at 64 vote revisions per -poll; authenticated inbound history is retained for convergence. See -[20: Group Polls](20-group-polls.md). +winning creator-claimed closure, and tally on read, so restart and reordered +admission cannot change the result. Local authors are capped at 64 vote +revisions per poll; membership-authenticated inbound history is retained for +convergence. That retention does not repair member-forged origins; ADR-0029 is +required before stable. See [20: Group Polls](20-group-polls.md). C7 live calls add no durable domain. Decoded call control, call/device arbitration, master secrets, derived media keys, replay state, Opus queues, and @@ -174,12 +199,14 @@ unread truth, notifications, queues, cryptographic state, or transport work. Message pins remain deferred until stable message-reference semantics are designed separately. -## 3. Search +## 3. Search (planned) -Full-text search runs over a **sealed local index**: tokenized terms are HMAC'd under a -search-domain key before insertion, so the index file leaks no vocabulary. Query = -HMAC the query terms, look up. (Trades fuzzy matching for sealed storage, the right -trade for this project.) +The current Alpha does not ship a durable full-text index. ADR-0027 proposes a +separately keyed, bounded local index in which tokenized terms are HMAC'd under +a search-domain key before insertion. That design avoids plaintext vocabulary +at rest but still leaks repeated-term equality and access patterns inside one +database; those trade-offs require implementation, migration, and measurement +before search is described as available. ## 4. Backup & portability @@ -219,10 +246,11 @@ trade for this project.) delivery queue, it is device runtime state rather than conversation history; it survives ordinary process/app restarts on that device but is not resurrected by a later identity restore. -- **C3 edit backup behavior**: originals and authenticated edit records ride - with ordinary sealed history. Restore recomputes the authorized deterministic +- **C3 edit backup behavior**: originals and accepted edit records ride with + ordinary sealed history. Restore recomputes the deterministic winner and prior-version list; it never imports a mutable current-body cache - or discards stale losing revisions. + or discards stale losing revisions. Pairwise origin authorization survives + restore; current group origin remains membership-level pending ADR-0029. - **C4 ephemeral backup behavior**: no live disappearing plaintext, view-once manifest, or associated media enters KKR6. Terminal tombstones do, so restore cannot resurrect a removed content id. Active ephemeral content is @@ -239,8 +267,11 @@ trade for this project.) - **C2 linked-device backup behavior**: `KKR7` carries the stable account, signed manifest, local device id, certified contact endpoints, convergence winners, and terminal tombstones, but never exports ratchets or a reusable physical - device private credential. Recovery permanently revokes every device that was - active in the backup and mints a fresh sole active device. + device private credential. Current recovery excludes the known device ids + active in the backup and mints a fresh device. It does **not** establish + permanent adversarial revocation because an already linked device may retain + a copied account root and mint a new id. ADR-0026's offline-root authority + reset is required before that stronger claim. - **C7 call backup behavior**: no offer/answer/terminal row, call id, device arbitration, secret, media key, Opus packet, or decoded audio enters any KKR version. Restore never resumes or reveals a prior call. @@ -252,9 +283,15 @@ trade for this project.) ## 5. What never becomes durable or remote state -- Plaintext in the core database, backups, logs, analytics, crash metadata, or - notification metadata. Protected application transients are the narrow lifecycle-bound - exception described above; logs remain structured and content-free by policy. +- Message, draft, contact-name, and media plaintext outside sealed record + bodies in the core database or encrypted Komms backups, or intentionally in + logs, analytics, crash metadata, or notification metadata. Explicit warned + plaintext export and protected lifecycle-bound application transients are the + narrow exceptions described above. The current plaintext relationship indexes + remain the separately disclosed Alpha limitation; logs remain structured and + content-free by policy. - Message keys after use; chain keys after advancing (zeroize-on-drop). -- Contact graphs on any remote system. Relay queues hold only sealed envelopes under - rotating tokens with TTLs. +- Plaintext contact graphs intentionally uploaded to a remote system. Optional + relay queues hold only sealed envelopes under rotating tokens with TTLs, but + their operators may still observe network and token-access metadata described + in the transport threat model. diff --git a/docs/08-roadmap.md b/docs/08-roadmap.md index 21a1e55..7bc50ed 100644 --- a/docs/08-roadmap.md +++ b/docs/08-roadmap.md @@ -1,18 +1,24 @@ # 08: Roadmap -Milestones express dependency order, not a rule that all work in an earlier -milestone must stop before a later foundation can land. M0–M3 are complete; -M4, M5, and M6 each have shipped slices plus explicit remaining acceptance -work. Build order details per crate: [09: Implementation Guide](09-implementation-guide.md). +Milestones express dependency order and implementation history. They are not a +stable-release scorecard. The +[stabilization program](29-stabilization-program.md) is now authoritative for +priority, evidence language, and P0/P1/P2 gates; it freezes nonessential feature +expansion until the everyday messaging path and trust gates are proven. +“Implemented” below means a production path exists, usually with automated +evidence. It does not mean field-qualified, independently interoperable, +independently reviewed, or stable. Build order details per crate: +[09: Implementation Guide](09-implementation-guide.md). | Milestone | Status | Principal remaining gate | |---|---|---| -| M0–M3 | Complete | Permanent regression and assurance work only | -| M4 | In progress | Stand up the physical two-radio nightly bench | -| M5 | In progress | Hands-on mobile qualification and installable distribution | -| M6 | In progress | Remaining runtime hardening, reproducible signed artifacts, store delivery, and external audit | +| M0–M2 | Implemented + automated evidence | Independent vectors/review and stabilization regressions | +| M3 | Implemented + partial automated evidence | Clean-install distinct-NAT journey, first-contact abuse admission, durable mailbox qualification | +| M4 | Implemented + partial automated evidence | Physical two-radio field qualification | +| M5 | Implemented Alpha surfaces | Hands-on mobile, lifecycle, accessibility, localization, and install qualification | +| M6 | Partial | Signed/reproducible updates, external review, operator readiness; expansion work deferred | -## M0: Design framework *(done)* +## M0: Design framework *(implemented; review remains)* **Deliverable**: the documentation set in `docs/`: threat model, architecture, crypto spec, transport spec, identity model, storage model, ADRs, implementation guide. @@ -21,7 +27,7 @@ spec, transport spec, identity model, storage model, ADRs, implementation guide. implementation guide sufficient for a competent Rust developer (or coding agent) to start M1 without design questions. -## M1: Cryptographic core (`kult-crypto`) *(done)* +## M1: Cryptographic core (`kult-crypto`) *(implemented; assurance open)* Workspace scaffolding + the full crypto layer: primitives wiring, hybrid PQXDH handshake, Double Ratchet with header encryption, fingerprints, key serialization. @@ -33,7 +39,7 @@ Double Ratchet with header encryption, fingerprints, key serialization. - Two in-memory parties complete handshake and exchange 10 000 messages under random loss/reorder within `MAX_SKIP`. -## M2: Protocol & storage (`kult-protocol`, `kult-store`) *(done)* +## M2: Protocol & storage (`kult-protocol`, `kult-store`) *(implemented; assurance open)* Envelope codec, padding buckets, fragmentation/reassembly, delivery tokens, sealed sender; encrypted SQLite storage with the full key hierarchy; sneakernet bundle @@ -46,7 +52,7 @@ import/export (first working transport, needs no networking). - Fuzzers on envelope + bundle parsers; storage passes "copied DB file leaks nothing but sizes" review checklist. -## M3: Internet transport & headless node (`kult-transport`, `kult-node`) *(done)* +## M3: Internet transport & headless node (`kult-transport`, `kult-node`) *(implemented; stabilization open)* The `kult-node` runtime is implemented per the build order in [09: Implementation Guide §2](09-implementation-guide.md): delivery engine @@ -90,7 +96,13 @@ publish/lookup) with zero bootstrap configuration and no internet at all. libp2p integration (QUIC, TCP fallback, Kademlia, relay v2, DCUtR), prekey bundles on DHT, mailbox relays, transport scheduler, headless daemon with local RPC. -**Acceptance**: +The existing localhost, LAN, configured-peer, and automated NAT/relay evidence +does not close the everyday clean-install claim. Fresh application defaults +currently require deliberate bootstrap/mailbox configuration, mailbox +persistence is not operator-qualified, and first-contact abuse admission remains +a P0 gate. + +**Stable acceptance (open)**: - Two nodes behind distinct NATs exchange messages with no manual configuration beyond sharing kult addresses. - Recipient offline → message deposited at relay → delivered on reconnect; relay @@ -242,7 +254,7 @@ delivery alive in the background. Native libraries cross-compile via cargo-ndk; the local release matrix runs the `:core` e2e and assembles/lints the debug APK when the SDK/NDK is installed, while per-push CI assembles a real debug APK in addition to the SDK-free host suite. Android -sender-key group UX is also shipped: a distinct group list/create flow, +sender-key group UX is also implemented: a distinct group list/create flow, dedicated history/chat/member surface, truthful per-recipient outbound delivery rows, and a JVM acceptance scenario with a real offline member. The iOS alpha is in (application A2, `apps/ios`): a Swift shell over the same @@ -262,7 +274,7 @@ key-change surfacing, transport indicators, hint editing, secret-free `settings.json` (same file format as the other shells), and mnemonic-shown-once backup export via the share sheet with the data directory excluded from iCloud backup. The sender-key group front door is -also shipped: a distinct group list/create flow, dedicated history/chat and +also implemented: a distinct group list/create flow, dedicated history/chat and member-management surfaces, truthful per-recipient outbound delivery rows, and a host acceptance scenario with a real offline member. QR rendering is CoreImage and scanning is AVFoundation. The app has zero third-party dependencies; @@ -277,7 +289,7 @@ SwiftUI initializer and build. Remaining: a full hands-on SwiftUI messaging pass and an on-device run; background delivery and store distribution stay M6. -B14 screen security is shipped across the shared capability contract and all +B14 screen security is implemented across the shared capability contract and all three shells. Protection starts before unlock and is not user-disableable: Android applies `FLAG_SECURE` to every activity, iOS obscures inactive snapshots and live-captured scenes while documenting the still-screenshot limit, and @@ -287,7 +299,7 @@ gates are in; the remaining M5 hands-on qualification records real device, OS, window-server, and compositor results per [13: Screen Security](13-screen-security.md). -B15 incognito keyboard behavior is shipped across the shared capability +B15 incognito keyboard behavior is implemented across the shared capability contract and every textual input. Android applies the documented no-personalized-learning request to every editor; iOS disables correction on all SwiftUI editors and uses secure passphrase/mnemonic entry; desktop applies the @@ -296,7 +308,7 @@ field inventories and native build gates are in. Manual first-/third-party keyboard evidence follows [14: Incognito Keyboard](14-incognito-keyboard.md) without treating absence of later suggestions as proof of non-retention. -C7 live audio calls are shipped through the bounded content-v1 `CallControl` +C7 live audio calls are implemented through the bounded content-v1 `CallControl` shape, transient account/device-aware node state, and one authenticated `/komms/call/1` substream on an observed fresh direct QUIC connection. The transport and every shell refuse TCP, relay-only, mailbox, sneakernet, and @@ -312,7 +324,7 @@ M5 release qualification; video begins only after the audio matrix passes. See [23: Live Audio Calls](23-live-audio-calls.md) and [ADR-0013](adr/0013-real-time-calls.md). -**Acceptance**: a non-technical user can install desktop + mobile builds, exchange QR +**Stable acceptance (open)**: a non-technical user can install desktop + mobile builds, exchange QR verification with a friend, and message over internet, LAN, and mesh with truthful delivery/security indicators. Backup/restore round-trips. @@ -324,7 +336,7 @@ reproducible builds; **external security audit** of `kult-crypto` + `kult-protocol`; F-Droid and store distribution. -A production-readiness slice is shipped. Runtime synchronization in +A production-readiness slice is implemented with automated evidence. Runtime synchronization in `kult-transport` and `kult-ffi` recovers poisoned locks rather than cascading a panic. `kultd` owns structured `tracing` output under the content-free logging policy in [09 §4b](09-implementation-guide.md), and passphrases/restore mnemonics @@ -340,10 +352,13 @@ production signing key, updater, reproducible-artifact claim, or store release is claimed. See [24: Local Release Gate](24-local-release-gate.md) and [27: Alpha Testing](27-alpha-testing.md). -C2 multi-device is shipped: the stable account signs bounded device manifests, +C2 multi-device is implemented with automated evidence: the stable account signs bounded device manifests, every physical endpoint keeps independent pairwise/group cryptographic state, and explicit authenticated bundles converge an allowlisted set of owned-device -state without cloud infrastructure. See [22: Linked Devices](22-linked-devices.md). +state without cloud infrastructure. The current Alpha copies the account root +to linked devices, so revoking a known device id is not permanent against a +compromised former device; ADR-0026's offline-root authority redesign is a P0 +stable-release requirement. See [22: Linked Devices](22-linked-devices.md). The optional Hybrid Infrastructure Layer is proposed as an independent M6 adoption track under ADR-0017 through ADR-0019: explicit Sovereign/Private/ @@ -379,7 +394,7 @@ The ADR must pin: - native Komms integration through the local or explicitly bundled Freenet Core API, while keeping a browser-distributed Freenet UI and delegate port as a separate possible future product rather than silently replacing the - audited native boundary; + reviewable native boundary; - reproducible contract/delegate artifacts, stable key-derived application identity, a registered predecessor lineage, and a tested state/secret migration path before any public Freenet contract is treated as durable; @@ -428,11 +443,11 @@ per-member delivery ladders, newcomer-reads-no-history, and removed-member exclusion. The shared front door is also in: `kultd` RPC, the `kult` CLI, and `kult-ffi` expose group records, history, events, membership operations, and honest per-member delivery state, pinned by `rpc_e2e.rs` and `ffi_e2e.rs`. -Desktop, Android, and iOS group UX are shipped, including truthful +Desktop, Android, and iOS group UX are implemented, including truthful per-recipient partial-delivery rows and shell-level acceptance coverage. Remaining for groups is the M6 list above. -The versioned message-content foundation is shipped: +The versioned message-content foundation is implemented: [ADR-0014](adr/0014-versioned-message-content.md) is accepted and implemented with a permanent legacy-text decode path, encrypted capability negotiation, bounded typed `Text` frames, stable encrypted content ids, scoped deduplication, @@ -454,7 +469,8 @@ native caller-selected paths, Android uses Storage Access Framework streams, and iOS uses security-scoped document-provider URLs; both mobile shells stage only bounded app-private copies. All three expose pairwise/group send, per-object verified-byte progress and state, lifecycle controls, and protected -caller-selected export. F3 shell delivery is complete: generic files use explicit +caller-selected export. F3 shell delivery is implemented across the three +Alpha surfaces: generic files use explicit local confirmation, older sealed previews remain renderable, and canonical edited PNG primaries are validated and rendered only through protected transient paths. Each shell exposes its actual interruption/resume policy. @@ -463,7 +479,7 @@ foreground service continues data-sync work while backgrounded, desktop continues while open or minimized, and iOS resumes on foreground without claiming unsupported continuous execution. -B2 recorded audio is shipped end to end on top of that unchanged F3/F4 path. +B2 recorded audio is implemented end to end on top of that unchanged F3/F4 path. Desktop, Android, and iOS record only while foregrounded, stop into a local review with no autoplay, show locally derived duration/waveform and the current carrier explanation, and require explicit send or discard. All three canonicalize to one @@ -474,7 +490,7 @@ lock, restart, and orphan cleanup are covered. The ADR-0015 invariant remains absolute: mesh-only recorded audio waits for a faster link and emits zero bulk airtime frames. -B16 still-image editing is shipped end to end without changing F3, F4, wire +B16 still-image editing is implemented end to end without changing F3, F4, wire metadata, crypto, or transport behavior. One path-based Rust/UniFFI helper owns the 32 MiB / 4096-edge / 12-megapixel decode limits, EXIF-orientation normalization, integer crop/quarter-turn/region semantics, metadata-free RGBA @@ -490,7 +506,7 @@ receiver rendering/export, and zero manifest/chunk/range or other bulk mesh airtime. Video, cloud/generative editing, filters, face recognition, project files, and protocol changes remain out of scope. -C1 generic non-image presentation is now shipped without changing ADR-0015's +C1 generic non-image presentation is now implemented without changing ADR-0015's wire or carrier contract. One shared Rust policy classifies untrusted filename and media-type hints, forces active, mismatched, unknown, or nameless objects to export-only, and permits only explicit warned OS handoff for reviewed matching @@ -500,7 +516,7 @@ resume, no auto-open, protected temporary cleanup, and zero new delivery work. Hands-on Android/iOS interaction remains an M5 qualification gate. See [17: Safe File Presentation](17-safe-file-presentation.md). -B17 group mentions are shipped end to end under +B17 group mentions are implemented end to end under [ADR-0016](adr/0016-group-mention-content.md). The immutable kind `0x0003` preserves exact fallback UTF-8 plus canonical sorted, non-overlapping UTF-8 byte ranges targeting stable group peers; the whole shape remains authenticated, @@ -516,7 +532,7 @@ There is no server push or online-delivery guarantee, and no mention data was added to envelopes, transports, DHT records, delivery tokens, or public OS previews. -B9 safe text formatting is shipped without changing ADR-0014 content, storage, +B9 safe text formatting is implemented without changing ADR-0014 content, storage, backups, capabilities, envelopes, or transports. One bounded `kult-node` formatter derives emphasis, strong, inline/fenced code, quote, and list blocks from exact authenticated source and composes B17 mention ranges as inert @@ -527,15 +543,15 @@ remote fetches stay literal. The shared corpus pins malicious input, bidi, complexity fallback, readable old-client source, and plain-text copy. See [16: Safe Text Formatting](16-safe-text-formatting.md). -The F5 sealed local-metadata foundation is shipped in `kult-store`: typed and +The F5 sealed local-metadata foundation is implemented in `kult-store`: typed and bounded conversation, folder, pin, label, draft, preference, and custom-icon records use an isolated storage key and reveal no local organization keys in a copied database. User-authored metadata and sealed note-to-self history are -included in current `KKR7` backups. Note-to-self text is shipped through every shell under one +included in current `KKR7` backups. Note-to-self text is implemented through every shell under one reserved identity; folders, conversation pins, labels, appearance, and bounded metadata-free custom icons now ship as separate local experiences. -B5 private contact rename is shipped end to end through `kult-node`, strict +B5 private contact rename is implemented end to end through `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, and iOS. The peer key remains the only identity and mutation target. Petnames are private local contact-record fields, NFC-normalized and bounded to 256 UTF-8 bytes; duplicate names are permitted. @@ -546,7 +562,7 @@ survives restart and `KKR7`, and produces zero discovery, notification, queue, envelope, capability, or transport work. Optional signed self-display suggestions remain a separate unimplemented bundle-format/compatibility program. -B13 private custom icons are shipped end to end across the existing F5 record, +B13 private custom icons are implemented end to end across the existing F5 record, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Exact contact, group, folder, and note-to-self targets render generated initials when absent or after a safe read failure. Eight bundled glyphs and selected local JPEG/PNG inputs become @@ -555,7 +571,7 @@ re-encoding. Per-record, count, and 64 MiB aggregate quotas are enforced at the sealed-store boundary; `KKR7` preserves canonical records. Icons create no remote lookup, peer sync, envelope, capability, queue, notification, or transport work. -B10 private local conversation folders are shipped end to end across the +B10 private local conversation folders are implemented end to end across the unchanged F5 record contract, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Cryptorandom stable IDs remain separate from exact duplicate-capable UTF-8 names and durable manual order. Pairwise, group, and note-to-self targets @@ -567,7 +583,7 @@ UTF-8 bytes per name. `KKR7` preserves exact identity, order, membership, and stale behavior. Folders never synchronize to contacts or services; C2 can converge them only between authorized devices of the same account. -B11 private local conversation pins are shipped end to end across the unchanged +B11 private local conversation pins are implemented end to end across the unchanged F5 record contract, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Pins use exact typed pairwise, group, and note-to-self identities, with one pin per conversation and a fixed limit of 8,192. Idempotent append/unpin, atomic @@ -579,7 +595,7 @@ Every operation creates zero network, transport, notification, or cryptographic work. Portability is limited to `KKR7` and authenticated own-device C2 sync; message pins remain separate work. -B12 private appearance is shipped end to end across the unchanged F5 UI +B12 private appearance is implemented end to end across the unchanged F5 UI preference record, `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, and iOS. The exact `system`, `light`, and `dark` vocabulary defaults safely to System, persists at `appearance.theme`, emits one local change event only on mutation, @@ -590,7 +606,7 @@ resources, and iOS adaptive system colors. Native high-contrast/reduced-motion signals remain live, shared reference palettes meet WCAG text contrast, and security or delivery meaning always retains non-color cues. -B18 private labels are shipped end to end across the unchanged F5 record +B18 private labels are implemented end to end across the unchanged F5 record contract, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Labels target stable pairwise, group, and note-to-self conversation IDs; message labels remain deferred. Definitions use cryptorandom 16-byte IDs, exact bounded UTF-8 names, @@ -604,14 +620,14 @@ work. `KKR7` preserves exact identity, ordering, and membership. There is no shared taxonomy or contact/service synchronization; C2 can converge labels only between authorized devices of the same account. -Durable scheduled pairwise and group text is shipped end to end. The sealed +Durable scheduled pairwise and group text is implemented end to end. The sealed scheduled outbox does not advance a ratchet or create transport work early; RPC/CLI, UniFFI, desktop, Android, and iOS expose create/list/edit/cancel and the activation lifecycle. Every shell renders scheduled rows separately from the ordinary queued, sent, and delivered ladder while converting only the display/editor to local time. -C4 disappearing text and view-once attachments are shipped for pairwise and +C4 disappearing text and view-once attachments are implemented for pairwise and sender-key groups across protocol, sealed lifecycle storage, node, relay/bridge/ queue/fragment retention, strict RPC/CLI, UniFFI, desktop, Android, and iOS. ADR-0021 binds an exact local deadline to an hour-aligned envelope-v2 relay @@ -623,14 +639,17 @@ prevention. Automated Android APK and iOS simulator builds cover compilation; real-device interaction on both platforms remains part of the hands-on M5 gate. See [19: Disappearing Messages and View-Once Attachments](19-ephemeral-messages.md). -C5 fixed-electorate group polls are shipped across protocol, node, RPC/CLI, -UniFFI, desktop, Android, and iOS. Authenticated immutable creation, visible vote -heads, and creator closure converge deterministically after delay, duplicates, -reorder, membership change, restart, and KKR1–KKR7 restore. Poll events render as -cards rather than empty chat rows and never claim anonymity. See -[20: Group Polls](20-group-polls.md). - -C6 owner/admin/member authority is shipped across the same complete surface. +C5 fixed-electorate group polls are implemented across protocol, node, RPC/CLI, +UniFFI, desktop, Android, and iOS. Group-AEAD-protected immutable creation, +visible vote heads, and creator-claimed closure converge deterministically after +delay, duplicates, reorder, membership change, restart, and KKR1–KKR7 restore. +Current sender-key groups do not resist member-forged voter or creator origins; +ADR-0029 is required before stable. Poll events render as cards rather than +empty chat rows and never claim anonymity. See +[20: Group Polls](20-group-polls.md) and +[ADR-0029](adr/0029-recipient-authenticated-groups.md). + +C6 owner/admin/member authority is implemented across the same complete surface. Capability-gated legacy upgrade creates a canonical signed full state; one owner serializes direct actions and generation-bound signed admin requests. Ownership certificates form a verified chain, conflicting same-generation states use the @@ -644,10 +663,10 @@ and consumed request ids while KKR1-KKR5 restore as legacy groups. See **Acceptance**: audit findings triaged with public report; reproducible-build attestation for all release artifacts. -## Shipped alpha: real-time audio calls +## Alpha implementation: real-time audio calls -The C7 audio implementation is complete across the shared core and all shipped -front doors under the strict direct-QUIC and transient-state contract above. +The C7 audio path is implemented across the shared core and Alpha application +surfaces under the strict direct-QUIC and transient-state contract above. Video remains unimplemented until real-network and physical-device audio qualification passes. Details and constraints: [11: Feature Scope](11-feature-scope.md) and @@ -656,7 +675,7 @@ qualification passes. Details and constraints: ## Explicitly not scheduled Cryptocurrency anything, federation with other networks, and any feature that -requires mandatory project-operated infrastructure. Optional, replaceable, +requires mandatory exclusive project-operated infrastructure. Optional, replaceable, content-blind convenience services remain subject to ADR-0017 through ADR-0019. Each broader exception would need a compelling ADR. diff --git a/docs/09-implementation-guide.md b/docs/09-implementation-guide.md index 898ac24..9dd9db2 100644 --- a/docs/09-implementation-guide.md +++ b/docs/09-implementation-guide.md @@ -1,7 +1,7 @@ # 09: Implementation Guide The implementation handoff and maintenance contract. M1–M5 now have substantial -shipped implementations, so this guide applies both to new features and to changes +working implementations, so this guide applies both to new features and to changes that must preserve existing boundaries. The design documents say *what*; this says *how to build it without drifting*. When this guide and a design doc conflict, the design doc wins and the conflict is a bug—file it. @@ -241,7 +241,10 @@ resolver hides edit events, retains ordered versions, and selects maximum `edit_message` and `group_edit_message`; CLI commands are `edit` and `group-edit`; UniFFI mirrors them and the typed refresh events. The complete wire, storage, shell, and qualification contract is -[18: Authenticated Message Editing](18-message-editing.md). +[18: Authenticated Message Editing](18-message-editing.md). Do not describe the +current group sender field as individual-origin authentication: a member can +forge another member's apparent edit until +[ADR-0029](adr/0029-recipient-authenticated-groups.md) is implemented. C4 is a replicated lifecycle feature, not a timer implemented by each shell. Only the dedicated pair/group disappearing and view-once APIs may create @@ -260,13 +263,14 @@ C5 polls are replicated immutable group content, never a shell-owned counter. Only the dedicated create/vote/close APIs may emit content-v1 kind `0x0006`; generic pairwise and group send reject it. Resolve each open voter head by maximum `(revision, event id)`, then replace the open view with the winning -creator-attested close snapshot. The electorate is the fixed sorted creation +creator-claimed close snapshot. The electorate is the fixed sorted creation list and votes are visible, not anonymous. RPC uses `group_poll_create`, `group_polls`, `group_poll_vote`, and `group_poll_close`; the CLI uses matching hyphenated commands; UniFFI exposes `GroupPoll` and `PollUpdated`. Shells render the node snapshot and never resolve raw events. The complete contract is [20: Group Polls](20-group-polls.md) and -[ADR-0022](adr/0022-convergent-group-polls.md). +[ADR-0022](adr/0022-convergent-group-polls.md). These rules prove convergence, +not malicious-member origin; ADR-0029 is required for that property. C6 authority is a signed control plane over the existing sender-key group, not mutable role flags in a shell. Use only content-v1 kind `0x0007` for canonical diff --git a/docs/11-feature-scope.md b/docs/11-feature-scope.md index 0e47cef..e23fd9e 100644 --- a/docs/11-feature-scope.md +++ b/docs/11-feature-scope.md @@ -19,17 +19,17 @@ Each item notes where it lands: which crate or milestone already covers it, or what it would take. Nothing here loosens a security or scope commitment in [01: Why](01-why.md) or the [roadmap](08-roadmap.md); where a feature touches the protocol, transports, or crypto, it lands only behind an ADR that shows it -surviving the threat model and the mesh bandwidth floor (the shipped C7 audio +surviving the threat model and the mesh bandwidth floor (the implemented C7 audio alpha is the current example: direct internet/LAN QUIC only under accepted ADR-0013, with physical-platform qualification still required). ## Build (fits the architecture as-is) These are either already carried by the core crates, stay local to a device, or -fit the architecture without changing its security model. Their shipped/planned +fit the architecture without changing its security model. Their implemented/planned status and prerequisites are tracked in the delivery plan. -- **Text and audio messages.** Both are shipped. Recorded audio is an +- **Text and audio messages.** Both are implemented. Recorded audio is an asynchronous encrypted F3 attachment, never a live call: every shell records the same bounded metadata-free mono PCM WAV profile, requires local review and explicit send/discard, and derives duration/waveform only on the endpoint. @@ -43,36 +43,36 @@ status and prerequisites are tracked in the delivery plan. because users ask for it by name. - **Usernames / contact names.** Identity is a keypair and the authoritative human label is a local petname, never a phone number or central-registry name - (see [06: Identity & Trust](06-identity-trust.md)). B5 local rename is shipped + (see [06: Identity & Trust](06-identity-trust.md)). B5 local rename is implemented through every interface with NFC normalization, duplicate/confusable/bidi/ invisible review, exact peer targeting, restart durability, and zero network work. An optional signed self-display name may later be advertised as a non-unique suggestion, but it is not implemented and could never silently override the recipient's petname. -- **Secure backups.** Shipped: the `KKR7` mnemonic-sealed backup (Argon2id under a +- **Secure backups.** Implemented: the `KKR7` mnemonic-sealed backup (Argon2id under a 24-word BIP-39 phrase, ADR-0011/ADR-0012), including sealed local metadata and note-to-self history, terminal ephemeral tombstones, and signed group authority plus linked-device recovery state; `KKR1` through `KKR6` remain restorable. Stored locally or moved by sneakernet; no cloud. -- **Note to self.** Shipped as a sealed local conversation in `kult-store`, with +- **Note to self.** Implemented as a sealed local conversation in `kult-store`, with the reserved `note_to_self` identity across every shell and no peer, envelopes, receipts, queue entries, or transport activity. Text is supported; attachments follow the attachment shell work. -- **Scheduled / queued messages.** Shipped. Ordinary queued delivery waits +- **Scheduled / queued messages.** Implemented. Ordinary queued delivery waits honestly for a carrier; scheduled delivery adds a durable absolute-UTC gate in core storage and the node scheduler, plus shared RPC/CLI/UniFFI operations for create/list/edit/cancel, so app exit or suspension cannot send early. Desktop, Android, and iOS now provide local-time composer controls plus distinct editable/cancellable scheduled rows before the ordinary queued, sent, and delivered states. -- **Text formatting.** Shipped through every front door and shell as one bounded +- **Text formatting.** Implemented through every front door and shell as one bounded CommonMark-style source subset: emphasis, strong, inline/fenced code, quotes, and lists. Exact source remains the authenticated stored/transmitted value; shells render only the shared inert block/run model and copy a readable plain- text projection. Raw HTML, links, images, URL schemes, remote fetches, and scriptable content are never interpreted. See [16: Safe Text Formatting](16-safe-text-formatting.md). -- **Conversation pins.** Shipped for pairwise contacts, groups, and note-to-self +- **Conversation pins.** Implemented for pairwise contacts, groups, and note-to-self through the sealed F5 store and every wrapper and shell. Exact typed identity, manual order, idempotent append/unpin, complete-set reorder including stale targets, deterministic activity tie-breaking, cleanup, and reactivation stay @@ -81,7 +81,7 @@ status and prerequisites are tracked in the delivery plan. portability paths, and every operation creates zero network, notification, crypto, or transport work. Message pins remain a separate design because they require stable message references. -- **Dark mode.** Shipped as the exact `system` / `light` / `dark` preference +- **Dark mode.** Implemented as the exact `system` / `light` / `dark` preference under the sealed F5 `appearance.theme` key, exposed through node, RPC/CLI, UniFFI, and every shell. System is the first-run default and follows native changes live; desktop uses semantic CSS roles, Android uses DayNight resources, @@ -90,7 +90,7 @@ status and prerequisites are tracked in the delivery plan. is never the only security or delivery signal. `KKR7` is the authoritative portability path; a small non-sensitive device cache exists only to style the pre-unlock gate without a flash. -- **Custom icons.** Shipped for contacts, groups, folders, and note-to-self over +- **Custom icons.** Implemented for contacts, groups, folders, and note-to-self over the sealed F5 record and every wrapper/shell. Missing records render generated initials; eight bundled glyphs and selected local JPEG/PNG inputs become exact metadata-free 256×256 RGBA PNGs after orientation normalization and square @@ -100,7 +100,7 @@ status and prerequisites are tracked in the delivery plan. own-device C2 sync. Icons never enter avatar URLs, peer sync, envelopes, capabilities, queues, notifications, DHT state, or transport work. -- **Screen security.** Shipped as an always-on pre-unlock policy. The shared +- **Screen security.** Implemented as an always-on pre-unlock policy. The shared node/RPC/CLI/UniFFI contract names exact native capability levels and limits; shells enforce them locally. Android applies `FLAG_SECURE` to every activity. iOS obscures inactive/app-switcher and live-captured scenes but cannot @@ -108,7 +108,7 @@ status and prerequisites are tracked in the delivery plan. content protection, obscures on focus loss, and provides `Ctrl/Cmd+Shift+L` rapid lock. It is not stored, backed up, synchronized, notified, or sent and creates zero transport work. See [13: Screen Security](13-screen-security.md). -- **Incognito keyboard.** Shipped as an always-on pre-unlock policy across the +- **Incognito keyboard.** Implemented as an always-on pre-unlock policy across the shared node/RPC/CLI/UniFFI contract and every shell text editor. Android sets the documented no-personalized-learning request on all editors; iOS disables correction and uses secure passphrase/mnemonic fields; desktop applies @@ -117,19 +117,19 @@ status and prerequisites are tracked in the delivery plan. classes. It is not stored, synchronized, notified, or sent and creates zero transport work. Keyboard/OS/webview compliance remains explicitly best effort. See [14: Incognito Keyboard](14-incognito-keyboard.md). -- **Local still-image editing.** Shipped across desktop, Android, and iOS through +- **Local still-image editing.** Implemented across desktop, Android, and iOS through one bounded Rust helper: JPEG/PNG orientation normalization, free/preset crop, 90-degree rotation, and manual blur/pixelation are applied *before* encryption. The exact metadata-free PNG is reviewed and is the only asset sealed; protected originals and intermediates are cleaned locally. No protocol involvement. -- **Mentions.** Group mentions are shipped through explicit current-roster +- **Mentions.** Group mentions are implemented through explicit current-roster pickers and canonical typed content, with exact readable fallback text and stable encrypted peer references rather than ambiguous free-form `@name` parsing. Semantic send fails closed unless every current co-member has fresh authenticated support; an explicit plain-text fallback never notifies. Mention notifications are endpoint-local and opportunistic, with no server-push guarantee. -- **Private labels.** Shipped for pairwise contacts, groups, and note-to-self +- **Private labels.** Implemented for pairwise contacts, groups, and note-to-self through the sealed F5 metadata store and every wrapper and shell. Stable random IDs remain separate from exact names and canonical colors; duplicates use color plus deterministic order rather than raw IDs in human-facing UI. Accessible @@ -142,7 +142,7 @@ status and prerequisites are tracked in the delivery plan. C2 may converge them only between authorized devices of the same account. Message labels and shared tags remain separate work; B11 conversation pins compose independently after label filtering. -- **Private conversation folders.** Shipped for pairwise contacts, groups, and +- **Private conversation folders.** Implemented for pairwise contacts, groups, and note-to-self through F5 and every wrapper and shell. One stable typed conversation belongs to at most one folder; All and Unfiled are virtual views. Exact duplicate-capable names use stable random IDs plus durable manual order, @@ -161,7 +161,7 @@ Realistic, but only if they respect carrier bandwidth or tolerate offline/delaye peers. The recurring rule: the app must know which carrier a peer is reachable on and degrade honestly, exactly as the delivery ladder already does. -- **File sharing.** The bounded F3 pipeline is shipped across desktop, Android, +- **File sharing.** The bounded F3 pipeline is implemented across desktop, Android, and iOS: independently sealed resumable chunks, explicit consent and lifecycle controls, protected export, exact progress, and pairwise/encrypt-once group transfer. Safe generic rows now share a fail-closed filename/media-type policy, @@ -169,21 +169,27 @@ and degrade honestly, exactly as the delivery ladder already does. mismatched, unknown, or nameless files. A hard no-airtime class still holds every bulk object for a faster link; no scanner, remote preview, or new transport behavior is implied. -- **Linked devices.** Shipped across the core, strict RPC/CLI, UniFFI, and every +- **Linked devices.** Implemented across the core, strict RPC/CLI, UniFFI, and every shell. One account identity uses separately authenticated device - keys, per-device sessions, revocation, and deterministic sync. Linking happens - proximately through a mutually confirmed QR/paste ceremony, never by copying - live ratchet databases or depending on cloud sync. See + keys, per-device sessions, exact-id exclusion, and deterministic sync. Linking + happens proximately through a mutually confirmed QR/paste ceremony, never by + copying live ratchet databases or depending on cloud sync. The current Alpha + does copy the account root, so it cannot permanently revoke a compromised + former device; ADR-0026 is a P0 replacement, not an optional enhancement. See [22: Linked Devices](22-linked-devices.md) and - [ADR-0024](adr/0024-account-authorized-linked-devices.md). -- **Message editing.** Shipped for canonical pairwise and group Text through - every front door and shell. Immutable authenticated events target exact - author/content ids, retain inspectable versions, and converge under offline - reorder by maximum `(revision, edit id)` without clocks. Pairwise capability - and complete current-group capability are required before send; legacy text, - attachments, mentions, and other edits remain non-editable. Editing is not - erasure. See [18: Authenticated Message Editing](18-message-editing.md). -- **Disappearing messages / view-once media.** Shipped for pairwise and groups + [ADR-0026](adr/0026-revocable-device-authority.md). +- **Message editing.** Implemented for canonical pairwise and group Text through + every front door and shell. Immutable events target exact author/content ids, + retain inspectable versions, and converge under offline reorder by maximum + `(revision, edit id)` without clocks. Pairwise authorship is authenticated; + current sender-key group events provide only membership-level authenticity, so + a malicious member can forge another member's edit until ADR-0029. Pairwise + capability and complete current-group capability are required before send; + legacy text, attachments, mentions, and other edits remain non-editable. + Editing is not erasure. See + [18: Authenticated Message Editing](18-message-editing.md) and + [ADR-0029](adr/0029-recipient-authenticated-groups.md). +- **Disappearing messages / view-once media.** Implemented for pairwise and groups through every front door and shell. Exact authenticated local deadlines, terminal sealed tombstones, KKR6 plaintext/media exclusion, and first-output view-once consumption compose with a coarse hour-aligned envelope-v2 deletion @@ -191,14 +197,17 @@ and degrade honestly, exactly as the delivery ladder already does. and recipients may retain copies, and Komms does not promise remote erasure or screenshot prevention. See [19: Disappearing Messages and View-Once Attachments](19-ephemeral-messages.md). -- **Group polls.** Shipped through every front door and shell as content-v1 +- **Group polls.** Implemented through every front door and shell as content-v1 kind 6 over sender-key groups. Stable poll/option IDs, fixed creation-time - electorates, authenticated visible vote heads, creator-attested closure, and - local deterministic tallies converge after duplicate, delayed, reordered, + electorates, visible vote heads, creator-claimed closure, and local + deterministic tallies converge after duplicate, delayed, reordered, removed-member, restart, and restore paths. Votes are explicitly not - anonymous. See [20: Group Polls](20-group-polls.md) and - [ADR-0022](adr/0022-convergent-group-polls.md). -- **Admin / role controls.** Shipped through every front door and shell as a + anonymous. Their apparent voter is not yet protected against forgery by + another member; ADR-0029 is required before stable. See + [20: Group Polls](20-group-polls.md), + [ADR-0022](adr/0022-convergent-group-polls.md), and + [ADR-0029](adr/0029-recipient-authenticated-groups.md). +- **Admin / role controls.** Implemented through every front door and shell as a fixed owner/admin/member model. Canonical generation-bound full state, ownership-transfer certificates, admin requests, and moderation snapshots are identity-signed; the sole owner serializes transitions and every accepted @@ -207,7 +216,7 @@ and degrade honestly, exactly as the delivery ladder already does. restore as legacy groups. See [21: Group Roles, Ownership, and Moderation](21-group-roles.md) and [ADR-0023](adr/0023-group-roles-and-owner-authority.md). -- **Live voice and video calls.** The audio alpha is shipped across transport, +- **Live voice and video calls.** The audio alpha is implemented across transport, node, RPC/CLI, UniFFI, desktop, Android, and iOS. It is strictly confined to a fresh direct QUIC path reached through internet libp2p or LAN discovery, never a relay-only, TCP, mailbox, sneakernet, or radio-mesh route. DCUtR may upgrade diff --git a/docs/12-feature-delivery-plan.md b/docs/12-feature-delivery-plan.md index 1125268..f76dd53 100644 --- a/docs/12-feature-delivery-plan.md +++ b/docs/12-feature-delivery-plan.md @@ -12,13 +12,17 @@ not. ## 1. Status vocabulary +The canonical evidence levels are in +[29: Stabilization Program §2](29-stabilization-program.md#2-evidence-vocabulary). +The short labels below describe implementation inventory only: + | Status | Meaning | |---|---| -| **Shipped** | Present through the relevant core and application surfaces, with tests. | +| **Implemented** | The relevant production path exists. Automated, field, interoperability, independent-review, and stable evidence are stated separately. | | **Partial** | A usable foundation exists, but some promised behavior or application surface is missing. | | **Planned** | In scope but not implemented. | -| **Design-only** | A proposed ADR or design track exists, but product implementation is not authorized or shipped. | -| **Assurance** | Shipped security behavior that remains a permanent release gate rather than a feature backlog item. | +| **Design-only** | A proposed ADR or design track exists, but product implementation is not authorized or implemented. | +| **Assurance** | An implemented security behavior with a permanent evidence and review track rather than a finite feature backlog item. | ## 2. Current baseline @@ -30,32 +34,32 @@ behind their three proposed ADRs. | Feature from scope | Current status | Main gap | |---|---|---| -| Text messages | Shipped | Product polish and accessibility only. | -| Recorded audio messages | Shipped | Keep the canonical profile, lifecycle cleanup, F3/F4 behavior, and cross-platform acceptance gates stable. | -| End-to-end encryption | Assurance | Continuous audit, KAT, fuzz, and regression gates. | +| Text messages | Implemented | Product polish and accessibility only. | +| Recorded audio messages | Implemented | Keep the canonical profile, lifecycle cleanup, F3/F4 behavior, and cross-platform acceptance gates stable. | +| End-to-end encryption | Assurance | Continuous review, KAT, fuzz, regression, external-vector, and independent-audit gates. | | Post-quantum handshake | Assurance | Crypto-agility and downgrade-safe future upgrades. | -| Contact names / usernames | Partial | B5 local petname rename is shipped end to end; optional signed self-display suggestions remain deferred. | -| Secure backups | Shipped | Future feature data must be added without leaking or silently omitting it. | -| Note to self | Shipped (text) | Attachments follow F3 shell integration. | -| Queued messages | Shipped | Already part of the honest delivery engine. | -| Scheduled messages | Shipped | Preserve the sealed absolute-UTC gate, edit/cancel-before-activation semantics, and distinct cross-shell lifecycle. | -| Text formatting | Shipped | Preserve exact source, bounded inert rendering, malicious-input parity, mention composition, and plain-text copy across every shell. | -| Folders | Shipped | Preserve single-folder membership, All/Unfiled views, deterministic order, stale cleanup, label composition, and zero-network behavior. | -| Pins | Shipped (conversation) | Preserve exact typed targets, complete durable reorder, stale reactivation/cleanup, folder → label → pin composition, and zero-network behavior; message pins remain separate. | -| Dark mode | Shipped | Sealed system/light/dark preference, shared semantic roles, and native live switching in every shell. | -| Custom icons | Shipped | Preserve exact typed targets, strict local image canonicalization, sealed quotas, initials fallback, `KKR7`/C2 own-device portability, and zero-network behavior. | -| Screen security | Shipped | Always-on shared policy, native shell protections, rapid desktop lock, and explicit platform limitations. | -| Incognito keyboard | Shipped | Always-on field inventory, Android no-learning request, secure secret fields, and honest iOS/desktop limits. | -| Local still-image editing | Shipped | Keep shared deterministic semantics, cleanup, exact-review, and metadata-removal gates stable; video remains out of scope. | -| Mentions | Shipped | ADR-0016 canonical peer targets, current-roster composers, conservative group capability gating, and local navigation/notification. | -| Labels | Shipped (contact/conversation) | Private pairwise, group, and note-to-self labels with fixed limits, stale cleanup, and accessible any/all filtering; message labels remain deferred. | -| File sharing | Shipped | Bounded F3/F4 delivery plus shared fail-closed file rows, explicit warned open/export, mismatch handling, lifecycle cleanup, and cross-language parity. | -| Linked devices | Shipped | Preserve ADR-0024 confirmed linking, independent per-device cryptography, deterministic sync, revocation, and KKR7 recovery across every surface. | -| Message editing | Shipped | ADR-0020 immutable authenticated revisions, deterministic offline reconciliation, retained versions, and every front door/shell. | -| Disappearing/view-once messages | Shipped | ADR-0021 exact local deadlines, envelope-v2 coarse relay deletion, tombstones, KKR6 exclusion, terminal reveal, and honest local-only promises. | -| Group polls | Shipped | ADR-0022 fixed-electorate visible votes, deterministic heads/tallies, creator snapshot closure, and every front door/shell. | -| Admin/role controls | Shipped | ADR-0023 owner-serialized signed roles, transfer, re-keying, and poll moderation through every shell. | -| Live voice/video calls | Shipped (audio alpha) | Preserve direct-QUIC-only gating, transient ratcheted control, authenticated Opus media, and zero history/backup/mesh work; real-network/device qualification precedes stable enablement and video. | +| Contact names / usernames | Partial | B5 local petname rename is implemented end to end; optional signed self-display suggestions remain deferred. | +| Secure backups | Implemented | Future feature data must be added without leaking or silently omitting it. | +| Note to self | Implemented (text) | Attachments follow F3 shell integration. | +| Queued messages | Implemented | Already part of the honest delivery engine. | +| Scheduled messages | Implemented | Preserve the sealed absolute-UTC gate, edit/cancel-before-activation semantics, and distinct cross-shell lifecycle. | +| Text formatting | Implemented | Preserve exact source, bounded inert rendering, malicious-input parity, mention composition, and plain-text copy across every shell. | +| Folders | Implemented | Preserve single-folder membership, All/Unfiled views, deterministic order, stale cleanup, label composition, and zero-network behavior. | +| Pins | Implemented (conversation) | Preserve exact typed targets, complete durable reorder, stale reactivation/cleanup, folder → label → pin composition, and zero-network behavior; message pins remain separate. | +| Dark mode | Implemented | Sealed system/light/dark preference, shared semantic roles, and native live switching in every shell. | +| Custom icons | Implemented | Preserve exact typed targets, strict local image canonicalization, sealed quotas, initials fallback, `KKR7`/C2 own-device portability, and zero-network behavior. | +| Screen security | Implemented | Always-on shared policy, native shell protections, rapid desktop lock, and explicit platform limitations. | +| Incognito keyboard | Implemented | Always-on field inventory, Android no-learning request, secure secret fields, and honest iOS/desktop limits. | +| Local still-image editing | Implemented | Keep shared deterministic semantics, cleanup, exact-review, and metadata-removal gates stable; video remains out of scope. | +| Mentions | Implemented | ADR-0016 canonical peer targets, current-roster composers, conservative group capability gating, and local navigation/notification. | +| Labels | Implemented (contact/conversation) | Private pairwise, group, and note-to-self labels with fixed limits, stale cleanup, and accessible any/all filtering; message labels remain deferred. | +| File sharing | Implemented | Bounded F3/F4 delivery plus shared fail-closed file rows, explicit warned open/export, mismatch handling, lifecycle cleanup, and cross-language parity. | +| Linked devices | Implemented Alpha; authority redesign required | Preserve confirmed linking, independent per-device cryptography, deterministic sync, and KKR7 recovery, but do not claim permanent revocation while ADR-0024 copies the account root. Implement and migrate to ADR-0026 before stable. | +| Message editing | Pairwise implemented; groups security-limited | ADR-0020 immutable revisions, pairwise authorship, deterministic offline reconciliation, retained versions, and every front door/shell. Current group origin is forgeable by another member until ADR-0029. | +| Disappearing/view-once messages | Implemented | ADR-0021 exact local deadlines, envelope-v2 coarse relay deletion, tombstones, KKR6 exclusion, terminal reveal, and honest local-only promises. | +| Group polls | Convergence implemented; origin security-limited | ADR-0022 fixed-electorate visible votes, deterministic heads/tallies, creator snapshot closure, and every front door/shell. Another member can forge the apparent voter until ADR-0029. | +| Admin/role controls | Implemented | ADR-0023 owner-serialized signed roles, transfer, re-keying, and poll moderation through every shell. | +| Live voice/video calls | Implemented (audio alpha) | Preserve direct-QUIC-only gating, transient ratcheted control, authenticated Opus media, and zero history/backup/mesh work; real-network/device qualification precedes stable enablement and video. | | Optional hybrid reachability/wake | Design-only | ADR-0017 through ADR-0019 remain Proposed; acceptance precedes mode boundaries, rotating rendezvous, and best-effort native wake. | ## 3. Shared foundations @@ -65,7 +69,7 @@ These are prerequisites, not new user-facing scope. ### F1. Finish the group front door The sender-key group core and its shared `kultd` RPC, CLI, and `kult-ffi` front -doors are shipped. Desktop, Android, and iOS group UX are shipped, completing +doors are implemented. Desktop, Android, and iOS group UX are implemented, completing the shared group front door before polls, mentions, or roles. Deliver: @@ -78,13 +82,13 @@ Deliver: ### F2. Versioned content model -**State:** shipped. [ADR-0014](adr/0014-versioned-message-content.md) is accepted +**State:** implemented. [ADR-0014](adr/0014-versioned-message-content.md) is accepted and implemented: the compatibility frame, permanent legacy-text path, encrypted capability negotiation, scoped stable content ids, bounded unknown-content behavior, sealed capability state, and render-safe RPC/UniFFI outcomes are shared across pairwise and sender-key group messages. -The shipped codec keeps legacy raw text readable and carries the accepted, +The implemented codec keeps legacy raw text readable and carries the accepted, bounded `Text`, `Attachment`, `Mention`, `Edit`, `Ephemeral`, `Poll`, `GroupAuthority`, and `CallControl` kinds. C7 call control is ordinary encrypted pairwise content—there is deliberately no relay-visible `CallSignal` envelope. @@ -102,7 +106,7 @@ The ADR must define: ### F3. Attachment and media pipeline -**State:** shipped through core, shared RPC/CLI/UniFFI front doors, and the +**State:** implemented through core, shared RPC/CLI/UniFFI front doors, and the desktop, Android, and iOS shells. [ADR-0015](adr/0015-encrypted-attachment-pipeline.md) now has bounded manifest/bulk codecs, deterministic chunk cryptography, sealed quota-bound @@ -145,7 +149,7 @@ Required properties: ### F4. Per-peer carrier capabilities -**State:** shipped through node, RPC/CLI, and UniFFI. The node probes stored +**State:** implemented through node, RPC/CLI, and UniFFI. The node probes stored delivery hints on each heartbeat, publishes a 60-second snapshot and verdict change event, and safely downgrades expired positive observations to `offline_or_unknown`. Attachment activation consumes this same snapshot, so @@ -168,14 +172,14 @@ paths do not, even when they can carry ordinary messages. ### F5. Local metadata store -**State:** sealed store foundation shipped. `kult-store` provides versioned, +**State:** sealed store foundation implemented. `kult-store` provides versioned, bounded records and stable replacement keys for conversation types, folders, single-folder membership, pins, labels and multi-label membership, drafts, UI preferences, and custom icons. The table exposes only row count and approximate sealed sizes in a copied database; `KKR7` backs up every non-ephemeral user-authored record and note-to-self history while `KKR1` through `KKR6` remain restorable. Feature behavior and shell UX remain separate B7/B13 slices. B10 folders, -B11 conversation pins, B12 appearance, and B18 labels use the shipped record +B11 conversation pins, B12 appearance, and B18 labels use the implemented record shapes and the ordinary-history `KKR7` contract unchanged. Add sealed endpoint-private records for conversation type, folders, pins, labels, @@ -194,7 +198,7 @@ must not be represented as folders/drafts/preferences or B8 scheduled messages. ### B1. Text messages -**State:** shipped. Treat as the compatibility baseline for every content-model +**State:** implemented. Treat as the compatibility baseline for every content-model change. Remaining work: @@ -212,7 +216,7 @@ Acceptance: ### B2. Recorded audio messages -**State:** shipped across desktop, Android, and iOS. +**State:** implemented across desktop, Android, and iOS. **Depends on:** F2, F3, F4. @@ -244,7 +248,7 @@ Permission denial leaves the ordinary composer usable. Microphone capture stops and plaintext is discarded on interruption, background/lock, view teardown, or shutdown; recording never continues in the background. Review and playback use app-private/protected transients, clean failure paths, and startup orphan cleanup. -Desktop continues F3 transfers while open/minimized, Android uses the shipped +Desktop continues F3 transfers while open/minimized, Android uses the implemented data-sync foreground service, and iOS resumes durable verified progress when the OS returns the app to the foreground. @@ -256,7 +260,7 @@ ADR-0015 regression proves audio on a mesh-only route emits zero airtime frames. ### B3. End-to-end encryption -**State:** shipped; permanent assurance track. +**State:** implemented; permanent assurance track. Every new content variant must travel inside the existing pairwise ratchet or sender-key group body. New control data must not create a weaker side channel. @@ -269,7 +273,7 @@ tests, plus negative tests proving intermediaries see only permitted metadata. ### B4. Post-quantum upgrades -**State:** hybrid X25519 + ML-KEM-768 handshake shipped; permanent assurance +**State:** hybrid X25519 + ML-KEM-768 handshake implemented; permanent assurance track. Create a crypto-agility ADR before introducing another primitive or parameter @@ -286,10 +290,10 @@ Acceptance: ### B5. Contact names and usernames -**State:** local petname rename shipped end to end; optional remote suggestion +**State:** local petname rename implemented end to end; optional remote suggestion deferred. -The shipped B5 slice reaches `kult-node`, strict RPC/CLI, UniFFI, desktop, +The implemented B5 slice reaches `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, and iOS. Rename always targets the exact peer key, NFC-normalizes and bounds the proposed name, permits duplicates, and assesses duplicate, mixed-script/confusable, bidirectional-control, and invisible-character risks. @@ -304,11 +308,11 @@ non-unique suggestion in the prekey bundle/DHT record. A recipient may choose to accept it initially, but it can never silently override their local petname. That is not a global username registry and must not imply uniqueness. The bundle-format change still requires its own ADR, compatibility path, and tests -for remote-suggestion changes. It is not part of shipped B5. +for remote-suggestion changes. It is not part of implemented B5. ### B6. Secure backups -**State:** KKR7 shipped; permanent KKR1–KKR6 compatibility track. +**State:** KKR7 implemented; permanent KKR1–KKR6 compatibility track. For every feature in this plan, decide explicitly whether its state is identity critical, conversation history, local preference, secret ephemeral state, or @@ -326,7 +330,7 @@ Acceptance: **Depends on:** F5. -**State:** text note-to-self shipped through `kult-store`, `kult-node`, RPC/CLI, +**State:** text note-to-self implemented through `kult-store`, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Every surface uses the reserved `note_to_self` identity. `KKR7` includes the sealed history; exact KKR1–KKR6 restore compatibility remains. Attachments follow F3 shell integration. @@ -341,7 +345,7 @@ backup/restore, and all shells use the same reserved conversation identity. ### B8. Scheduled and queued messages -**State:** shipped end to end. `kult-store` seals pairwise/group scheduled text +**State:** implemented end to end. `kult-store` seals pairwise/group scheduled text separately from the encrypted delivery queue, and `kult-node` activates it only when the absolute UTC instant is reached. RPC/CLI and UniFFI expose create/list/edit/cancel, with the same scheduled lifecycle events. Desktop, @@ -372,7 +376,7 @@ render the four states distinctly. ### B9. Text formatting -**State:** shipped end to end across `kult-node`, strict RPC/CLI, UniFFI, +**State:** implemented end to end across `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, and iOS without a store, backup, content-kind, capability, envelope, or transport-format change. @@ -386,7 +390,7 @@ Acceptance uses a shared conformance corpus across desktop, Android, and iOS, including malicious input, huge nesting, bidirectional text, and copy-as-plain- text behavior. -The shipped shared formatter accepts at most 64 KiB source, 1,024 blocks, 4,096 +The implemented shared formatter accepts at most 64 KiB source, 1,024 blocks, 4,096 runs, inline/list depth 4, and 64 canonical UTF-8 semantic ranges. Complexity falls back to the whole exact source. RPC and UniFFI expose only text, block roles, and inert style tokens; desktop, Android, and iOS map them to native text @@ -399,7 +403,7 @@ See [16: Safe Text Formatting](16-safe-text-formatting.md). **Depends on:** F5. -**State:** shipped end to end across `kult-store`, `kult-node`, RPC/CLI, +**State:** implemented end to end across `kult-store`, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Folders are local views over conversation IDs. Support create, rename, reorder, @@ -410,7 +414,7 @@ Acceptance covers restart, backup/restore, deleted contacts/groups, and the same conversation appearing in at most one folder unless multi-folder behavior is explicitly chosen before implementation. -The shipped contract chooses single-folder membership. Exact names retain their +The implemented contract chooses single-folder membership. Exact names retain their UTF-8 bytes and may duplicate; cryptorandom 16-byte IDs and persisted manual order disambiguate them. All and Unfiled are virtual views. Complete-set reorder, move/unfile, delete cascade, and stale cleanup are atomic, and folder selection @@ -423,7 +427,7 @@ envelope, queue, receipt, capability, or transport work. **Depends on:** F5. -**State:** conversation pins shipped end to end across `kult-store`, `kult-node`, +**State:** conversation pins implemented end to end across `kult-store`, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. Message pins remain deferred. Pins use exact typed pairwise peer, group, or note-to-self `ConversationId` @@ -444,7 +448,7 @@ notification, capability, crypto, or transport work. ### B12. Dark mode -**State:** shipped end to end. The canonical `system`, `light`, and `dark` +**State:** implemented end to end. The canonical `system`, `light`, and `dark` choice is stored in the existing independently sealed F5 UI-preference record at `appearance.theme`. Missing or unknown legacy values safely render as System; idempotent writes emit only the endpoint-local `ThemeChanged` event and create @@ -471,7 +475,7 @@ delivery states retain text, icons, or accessible labels and never rely on color **Depends on:** F5. -**Shipped.** Contacts, sender-key groups, private folders, and note-to-self each +**Implemented.** Contacts, sender-key groups, private folders, and note-to-self each have one exact typed private icon identity. No record renders deterministic generated initials. Users can instead choose one of eight bundled glyphs (`person`, `group`, `folder`, `note`, `star`, `heart`, `shield`, `compass`) or a @@ -500,7 +504,7 @@ missing fallback, and zero delivery work. ### B14. Screen security -**Shipped.** Platform controls have honest, always-on guarantees: +**Implemented.** Platform controls have honest, always-on guarantees: - Android: always-on secure-window protection for screenshots/screen recording and task previews, with the exact policy visible in settings; @@ -526,7 +530,7 @@ and remains a release-evidence task rather than an inflated cross-platform claim ### B15. Incognito keyboard -**State:** shipped across `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, +**State:** implemented across `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, and iOS as an immutable always-on policy plus exhaustive native field controls. The shared contract distinguishes `platform_enforced`, `platform_requested`, @@ -538,7 +542,7 @@ mnemonic, and name. Android routes every XML and programmatic text editor through one class that sets `IME_FLAG_NO_PERSONALIZED_LEARNING` and no-suggestions metadata on the final -input connection. iOS applies one audited no-correction modifier to every +input connection. iOS applies one shared, inventory-tested no-correction modifier to every SwiftUI editor. Desktop classifies every editable textual HTML control and applies autocomplete, autocorrect, autocapitalization, and spellcheck hints at startup and after modal cloning. Passphrases and recovery mnemonics use masked @@ -546,7 +550,7 @@ secret entry on every shell. Automated acceptance inventories 21 Android construction paths, 20 iOS SwiftUI editors, and 24 desktop editable textual controls, and checks shared fixture, -FFI, strict RPC/CLI, pre-unlock, and zero-delivery parity. No shipped search box +FFI, strict RPC/CLI, pre-unlock, and zero-delivery parity. No implemented search box exists yet; its required class prevents a future search surface from bypassing the policy. Android explicitly states that its documented flag is a request; iOS and desktop expose no per-field personalized-learning guarantee. Manual @@ -554,7 +558,7 @@ keyboard qualification follows [14: Incognito Keyboard](14-incognito-keyboard.md ### B16. Local media editing -**State:** shipped for still JPEG/PNG across desktop, Android, and iOS. +**State:** implemented for still JPEG/PNG across desktop, Android, and iOS. **Depends on:** F3. @@ -586,7 +590,7 @@ preview/export, F4 reconfirmation, and zero mesh airtime. ### B17. Mentions -**State:** shipped across protocol, node, storage/backup, RPC/CLI, UniFFI, +**State:** implemented across protocol, node, storage/backup, RPC/CLI, UniFFI, desktop, Android, and iOS. **Governed by:** [ADR-0016](adr/0016-group-mention-content.md). **Depends on:** F1, F2. @@ -596,7 +600,7 @@ display text so every client can highlight the intended member despite different local petnames. Mention notifications remain local and opportunistic: there is no server push guarantee. -The shipped kind `0x0003` uses exact authenticated fallback UTF-8 and canonical +The implemented kind `0x0003` uses exact authenticated fallback UTF-8 and canonical sorted, non-overlapping UTF-8 byte ranges into a bounded target table. It never normalizes Unicode or exposes kind, target, or range fields outside the existing encrypted padded content. Historic resolution remains scoped to the exact group @@ -621,7 +625,7 @@ policy; they provide no server-push or online-delivery guarantee. **Depends on:** F5. -**State:** shipped through `kult-store`, `kult-node`, RPC/CLI, UniFFI, desktop, +**State:** implemented through `kult-store`, `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. PR #43/B17 was only the administrative branch base; labels have no semantic dependency on Mention content. B18 stays inside the accepted F5 `LabelRecord` and `LabelAssignment` shapes and `KKR7`, so it requires no new @@ -668,15 +672,15 @@ outside B18. ### C1. File sharing -**State:** shipped. Bounded attachments and the generic pre-send F4 explanation, +**State:** implemented. Bounded attachments and the generic pre-send F4 explanation, fresh verdict recheck, changed-verdict reconfirmation, and explicit send/discard -flow are shipped across desktop, Android, and iOS. Generic non-image rows use one +flow are implemented across desktop, Android, and iOS. Generic non-image rows use one shared fail-closed filename/media-type policy with explicit warned open/export, protected temporary lifecycle, and no auto-open or scanning claim. **Depends on:** F2, F3, F4. **Governed by:** ADR-0015. -The shipped tiers are: +The implemented tiers are: 1. small files over internet/LAN with explicit user download; 2. resumable transfer over mailbox/sneakernet within local quotas; @@ -694,8 +698,8 @@ proof that an oversized transfer emits zero mesh frames. ### C2. Linked devices -**State:** shipped. **Decision:** -[ADR-0024](adr/0024-account-authorized-linked-devices.md). +**State:** implemented Alpha with a P0 authority flaw. **Replacement decision:** +[ADR-0026](adr/0026-revocable-device-authority.md). Use one account identity with separately authenticated device keys rather than copying live ratchet databases. Linking is proximate through a QR handshake or a @@ -718,34 +722,43 @@ revocation, replay/rollback rejection, KKR1–KKR7 migration/recovery, no cloud service, strict RPC/CLI, UniFFI, and the confirmed QR/paste ceremony in every shell. Per-push CI assembles the Android debug APK and the local full-SDK gate adds lint; full iOS app/simulator validation remains gated by full Xcode. -Hands-on device behavior remains a separate M5 gate on both platforms. +Hands-on device behavior remains a separate M5 gate on both platforms. Existing +tests demonstrate cooperative exact-id exclusion, not permanent revocation: +acceptance before stable additionally requires offline-root migration, +majority-authorized manifest transitions, stolen-device attempts to mint a new +certificate, forks, old backups, and root-recovery conflicts under ADR-0026. ### C3. Message editing -**State:** shipped. **Depends on:** F2. **Decision:** +**State:** implemented. **Depends on:** F2. **Decision:** [ADR-0020](adr/0020-authenticated-message-edits.md). -Model an edit as a new authenticated event referencing the original message ID; +Model an edit as a new protected event referencing the original message ID; never mutate history invisibly. Use a monotonic per-author revision plus a deterministic tie-breaker for rare concurrent same-author device edits. Preserve an "edited" marker; decide before implementation whether prior versions remain -locally inspectable. A user may edit only content they authored. +locally inspectable. The supported UI lets a user edit only content attributed +to them; pairwise cryptography enforces that origin, while current group +cryptography does not yet resist a malicious member. Offline peers apply edits when they arrive, including edit-before-original ordering. Group edits use ordinary sender-key fan-out and the same authorship -checks. Shipped C2 sync carries immutable edit rows and their deterministic -winners between authorized owned devices. +checks, but those checks currently validate only a claimed member identity: +because every member holds the sender key, they do not resist malicious-member +forgery. ADR-0029 is required before stable. Implemented C2 sync carries +immutable edit rows and their deterministic winners between authorized owned +devices. Acceptance covers reorder, duplication, partitions, malicious cross-author edits, edits after group removal, old-client fallback, and eventual convergence. -The shipped implementation additionally covers strict raw-send bypass refusal, +The implementation additionally covers strict raw-send bypass refusal, restart/`KKR7` restore, shared parity fixtures, dedicated fuzzing, exact RPC/CLI and UniFFI events/models, and accessible retained-version UI on all three shells. See [18: Authenticated Message Editing](18-message-editing.md). ### C4. Disappearing messages and view-once media -**State:** shipped. **Decision:** accepted +**State:** implemented. **Decision:** accepted [ADR-0021](adr/0021-ephemeral-retention.md). Define separate promises: @@ -769,7 +782,7 @@ Acceptance covers offline delivery near expiry, clock skew, relay restart, expiry-before-original ordering, backup exclusion/tombstones, linked devices, quoted/replied content, and honest limitation copy. -The shipped implementation uses content-v1 kind 5 plus envelope v2. The exact +The implementation uses content-v1 kind 5 plus envelope v2. The exact deadline and canonical hour-ceiling hint are authenticated together; relays apply the hint only to deletion, while endpoints enforce the exact deadline. Sealed lifecycle rows and terminal tombstones prevent restart, duplicate, and @@ -785,26 +798,28 @@ See [19: Disappearing Messages and View-Once Attachments](19-ephemeral-messages. ### C5. Group polls -**Shipped. Depends on:** F1, F2. **Decision:** +**Implemented. Depends on:** F1, F2. **Decision:** [ADR-0022](adr/0022-convergent-group-polls.md). Content-v1 kind 6 carries immutable creation, vote, and creator-close events with stable IDs. The creation-time roster is fixed; votes and identities are visible to members and explicitly not anonymous. Maximum `(revision, event id)` selects each open vote head, while closure freezes the -creator-attested sorted snapshot; tallies are derived locally. Complete current- -roster capability gating and raw-send refusal protect old clients. +creator-claimed sorted snapshot; tallies are derived locally. Complete current- +roster capability gating and raw-send refusal protect old clients. The current +sender-key lane does not protect an apparent voter from forgery by another +member; ADR-0029 is a stable-release prerequisite. Acceptance covers canonical/arbitrary decoding, partitions, changed, duplicate, and reordered votes, outsiders, additions/removals, conflicting closure, convergence, KKR1–KKR7 restore, C2 owned-device sync, RPC/CLI, UniFFI, desktop, Android host core, and iOS host/app contracts. Android debug-APK assembly is automated; hands-on Android/iOS device evidence remains in the common M5 platform release gate -rather than weakening the shipped protocol contract. +rather than weakening the implemented protocol contract. ### C6. Admin and role controls -**State:** Shipped. **Depends on:** F1. **Decision:** +**State:** Implemented. **Depends on:** F1. **Decision:** [ADR-0023](adr/0023-group-roles-and-owner-authority.md). Extend the current single creator-managed roster with signed, generation-bound @@ -821,7 +836,7 @@ Acceptance includes forged/stale capability rejection, concurrent admin actions, owner transfer, last-owner safeguards, offline members, removed-device exclusion, and deterministic convergence. -The shipped design keeps exactly one owner as sequencer. Admin invite, ordinary +The implemented design keeps exactly one owner as sequencer. Admin invite, ordinary member removal, rename, and poll moderation are signed generation-bound requests; role grants, admin removal, and ownership transfer remain owner-only. Canonical content-v1 kind 7 full states and ordered transfer certificates make authority @@ -843,9 +858,9 @@ implementation parity. **Depends on:** F4 and accepted ADR-0013. -**State:** audio alpha shipped. ADR-0013 is accepted. The pinned +**State:** audio alpha implemented. ADR-0013 is accepted. The pinned localhost/loss spike selected one reliable ordered `/komms/call/1` substream on -a fresh direct QUIC connection; the shipped libp2p QUIC transport disables +a fresh direct QUIC connection; the implemented libp2p QUIC transport disables datagrams. Relay-only and TCP paths do not qualify as realtime. Distinct-NAT, DCUtR, mobile network, CPU, battery, native audio-route, background, and lock measurements remain release gates rather than unmeasured design claims. @@ -925,17 +940,20 @@ Standard mode can be recommended to non-test users. ## 6. Delivery sequence The order below maximizes usable increments while keeping protocol dependencies -honest. Parallel work is safe only where rows do not share a foundation. +honest. Parallel work is safe only where rows do not share a foundation. These +rows describe repository implementation, not stable release evidence. The +[stabilization program](29-stabilization-program.md) takes priority and defines +the gates that must close before broader feature expansion. | Wave | Progress | Outcome and features | |---|---|---| -| **0: Shared foundations** | Complete | F1–F5 are implemented; ADR-0015 remains formally Proposed despite the shipped attachment pipeline. | +| **0: Shared foundations** | Implemented + automated evidence | F1–F5 have implementation paths; ADR-0015 remains formally Proposed despite the implemented attachment pipeline. | | **Parallel: mobile reachability** | Design-only | Accept ADR-0017–0019, then implement C8 behind reversible feature gates. | -| **1: Local-first product polish** | Complete | B5, B7–B15, and B18 are shipped; optional signed self-display suggestions remain a separate format-gated extension to B5. | -| **2: Typed content and asynchronous media** | Complete | F2/F3, B2, B16, B17, and C1 are shipped across the shared core and all three shells; hands-on device evidence remains an M5 release gate. | -| **3: Replicated conversation features** | Complete | C3, C4, C5, and C6 are shipped through every surface. | -| **4: Multi-device** | Complete | ADR-0024 and C2 are shipped, including cross-device hardening of Wave 3. | -| **5: Real-time media** | Complete (audio alpha) | ADR-0013 and C7 audio are implemented through every surface, restricted to observed direct QUIC; real-network/device qualification gates stable enablement and video. | +| **1: Local-first product polish** | Implemented + automated evidence | B5, B7–B15, and B18 have Alpha paths; optional signed self-display suggestions remain a separate format-gated extension to B5. Localization and external accessibility evidence remain open. | +| **2: Typed content and asynchronous media** | Implemented + automated evidence | F2/F3, B2, B16, B17, and C1 have core and shell paths; hands-on device evidence remains an M5 release gate. | +| **3: Replicated conversation features** | Implemented + automated evidence | C3, C4, C5, and C6 have paths through the documented surfaces; field and independent evidence remain separate. | +| **4: Multi-device** | Implemented + automated evidence | ADR-0024 and C2 have implementation paths, including cross-device hardening of Wave 3; physical-device qualification remains open. | +| **5: Real-time media** | Implemented Alpha path | ADR-0013 and C7 audio are implemented through the documented surfaces, restricted to observed direct QUIC; real-network/device qualification gates stable enablement and video. | Scheduled messages (B8) completed as the intended isolated core-plus-shell delivery. Its durable gate remains in the shared queue/storage schema rather @@ -947,18 +965,18 @@ Do not combine these into one oversized design decision. | Order | Decision | Unlocks | |---|---|---| -| 1 (done) | ADR-0014: versioned typed message content and compatibility | Audio, files, edits, polls, structured mentions. | +| 1 (accepted) | ADR-0014: versioned typed message content and compatibility | Audio, files, edits, polls, structured mentions. | | 2 (proposed; implemented) | ADR-0015: encrypted attachment/chunk transfer and carrier policy | Audio, files, media editing; formal ADR acceptance remains. | -| 3 (done) | ADR-0016: canonical group-mention content | B17 stable encrypted targets, range semantics, compatibility, and local notification. | +| 3 (accepted) | ADR-0016: canonical group-mention content | B17 stable encrypted targets, range semantics, compatibility, and local notification. | | 4 (proposed) | ADR-0017: optional hybrid modes and threat boundary | C8 mode guarantees and honest product claims. | | 5 (proposed) | ADR-0018: rotating pairwise rendezvous | C8 private post-pairing route refresh. | | 6 (proposed) | ADR-0019: capability-gated native wake | C8 APNs/FCM acceleration and bounded collection. | -| 7 (done) | ADR-0021: expiry/retention metadata and deletion semantics | C4 disappearing and view-once content. | -| 8 (done) | ADR-0020: immutable edit events, authorization, ordering, and retained versions | Message editing and multi-device convergence. | -| 9 (done) | ADR-0022: fixed-electorate visible-vote polls and creator snapshot closure | Convergent encrypted group polls. | -| 10 (done) | ADR-0023: group roles/capabilities and authority transfer | Admin controls and moderated polls. | -| 11 (done) | ADR-0024: multi-device identity, device certificates, sync, revocation | Linked devices. | -| 12 (done) | ADR-0013: measured direct-QUIC call signaling/media contract | C7 audio alpha; physical qualification gates video and stable enablement. | +| 7 (accepted) | ADR-0021: expiry/retention metadata and deletion semantics | C4 disappearing and view-once content. | +| 8 (accepted) | ADR-0020: immutable edit events, authorization, ordering, and retained versions | Message editing and multi-device convergence. | +| 9 (accepted) | ADR-0022: fixed-electorate visible-vote polls and creator snapshot closure | Convergent encrypted group polls. | +| 10 (accepted) | ADR-0023: group roles/capabilities and authority transfer | Admin controls and moderated polls. | +| 11 (accepted) | ADR-0024: multi-device identity, device certificates, sync, cooperative exact-id exclusion | Linked devices; copied-root authority remains Alpha-only and ADR-0026 must replace it before stable. | +| 12 (accepted) | ADR-0013: measured direct-QUIC call signaling/media contract | C7 audio alpha; physical qualification gates video and stable enablement. | | As needed | Signed optional self-display name in bundle records | Non-global username suggestion. | | Before next PQ suite | Downgrade-safe crypto agility | Future post-quantum upgrades. | @@ -968,7 +986,9 @@ review. ## 8. Cross-feature release gates -No feature is done until all applicable gates pass: +No feature is **Stable** until all applicable gates pass. “Implemented” or +“automated evidence” may be used earlier according to +[29: Stabilization Program §2](29-stabilization-program.md#2-evidence-vocabulary): 1. **Security:** plaintext and secrets never leave their intended boundary; intermediaries learn no unapproved metadata; parsers are bounded and fuzzed. @@ -984,7 +1004,8 @@ No feature is done until all applicable gates pass: either support the feature or show an honest unsupported state. 7. **Accessibility and localization:** semantic labels, keyboard navigation, scalable text, contrast, reduced motion, bidirectional text, and localizable - strings are covered. + strings have repository evidence; field qualification and a cross-shell + localization system are reported separately rather than inferred. 8. **Resource bounds:** storage, memory, CPU, battery, bandwidth, and attachment quotas fail safely and visibly. 9. **Documentation:** user promise, limitations, threat-model effect, and manual @@ -995,10 +1016,12 @@ No feature is done until all applicable gates pass: simulator evidence where host tests cannot prove it. Hosted CI is a later, explicitly authorized repetition, not the development loop. -## 9. Completed foundation program and next priorities +## 9. Implemented foundation inventory +This is historical implementation inventory, not the current priority order. +Current work follows the [stabilization program](29-stabilization-program.md). Keep each numbered item, and each shell named within an item, in a separate -reviewable PR: +reviewable PR when maintenance changes it: 1. completed: expose group operations through RPC, CLI, and UniFFI, with an end-to-end bindings test; @@ -1018,20 +1041,22 @@ reviewable PR: and bounded safe text formatting through every front door and shell; ADR-0015's formal status remains Proposed. -The C1 non-image file presentation slice is shipped over the unchanged F3/F4 +The C1 non-image file presentation slice is implemented over the unchanged F3/F4 pipeline: safe generic rows, explicit open/export affordances, stronger filename/media-type mismatch handling, accessibility/lifecycle behavior, and malicious-file/large-file/resume qualification add no auto-open, remote scanning, -preview, or mesh behavior. C3 authenticated immutable message editing is now -shipped across protocol, node, storage, RPC/CLI, UniFFI, desktop, Android, and -iOS with retained versions and deterministic offline convergence. C4 -disappearing text and view-once attachments are likewise shipped end to end +preview, or mesh behavior. C3 immutable message editing is now implemented +across protocol, node, storage, RPC/CLI, UniFFI, desktop, Android, and iOS with +pairwise authorship, retained versions, and deterministic offline convergence; +group origin remains security-limited pending ADR-0029. C4 +disappearing text and view-once attachments are likewise implemented end to end with exact local deadlines, coarse authenticated relay deletion, sealed -tombstones, and KKR6 exclusion. C5 encrypted group polls are now shipped with -visible authenticated votes, fixed electorates, deterministic convergence, and -creator snapshot closure. C6 signed owner/admin/member roles, ownership transfer, -mandatory re-keying, and poll moderation are now shipped through every shell. -C7 live audio calls are now shipped through direct QUIC, transient ratcheted +tombstones, and KKR6 exclusion. C5 encrypted group polls are now implemented with +visible votes, fixed electorates, deterministic convergence, and creator +snapshot closure; malicious-member voter forgery remains open under ADR-0029. +C6 signed owner/admin/member roles, ownership transfer, +mandatory re-keying, and poll moderation are now implemented through every shell. +C7 live audio calls are now implemented through direct QUIC, transient ratcheted signaling, authenticated media, RPC/CLI, UniFFI, and all three shells. Real-NAT, mobile handoff, battery, route, background/lock, and physical-device evidence remain release qualification; video remains gated on that audio evidence. diff --git a/docs/13-screen-security.md b/docs/13-screen-security.md index fdf1f5f..c8b5e5f 100644 --- a/docs/13-screen-security.md +++ b/docs/13-screen-security.md @@ -1,6 +1,6 @@ # 13: Screen Security -B14 is shipped as an **always-on application-shell boundary**. It reduces +B14 is implemented as an **always-on application-shell boundary**. It reduces accidental disclosure through screenshots, recordings, app-switcher snapshots, and recent/task previews where the operating system offers a relevant API. It is not DRM and does not change Komms' end-to-end encryption or endpoint-compromise diff --git a/docs/14-incognito-keyboard.md b/docs/14-incognito-keyboard.md index d5e0044..cd00aa1 100644 --- a/docs/14-incognito-keyboard.md +++ b/docs/14-incognito-keyboard.md @@ -7,7 +7,7 @@ protocol capability, or remote promise. ## 1. Exact user promise -Komms marks every shipped text-entry surface for the strongest relevant input +Komms marks every implemented text-entry surface for the strongest relevant input privacy the platform exposes. Passphrases and recovery mnemonics use masked secret fields. Message composers, scheduled text, names, filenames, addresses, and other technical text disable personalized learning, correction, prediction, @@ -36,7 +36,7 @@ trait or web attribute without an enforcement API. `unavailable` means no honest per-field control exists. These levels must not be upgraded in shell copy. The shared contract covers semantic field classes `message`, `search`, -`passphrase`, `mnemonic`, and `name`. There is no search box in the shipped +`passphrase`, `mnemonic`, and `name`. There is no search box in the implemented shells today; the class is included so a future search field cannot silently bypass the policy. @@ -55,7 +55,8 @@ from the shared B15 policy. ### iOS -Every `TextField`, `TextEditor`, and `SecureField` uses one audited SwiftUI +Every `TextField`, `TextEditor`, and `SecureField` uses one shared, +inventory-tested SwiftUI modifier that disables autocorrection and selects explicit capitalization semantics. Passphrases and recovery mnemonics use `SecureField`; iOS substitutes the system keyboard for secure text entry. Non-secure fields remain best effort: diff --git a/docs/15-contact-petnames.md b/docs/15-contact-petnames.md index 213d560..246aa4d 100644 --- a/docs/15-contact-petnames.md +++ b/docs/15-contact-petnames.md @@ -44,7 +44,7 @@ the mutation until the caller presents the returned risks and explicitly retries with warning acceptance. Interfaces must retain peer-key-derived or other stable context wherever duplicate names could otherwise be ambiguous. -## 3. Shipped interfaces +## 3. Implemented interfaces Strict daemon operations are `contact_name_assessment` and `rename_contact`. Unknown fields are rejected. The CLI equivalents are: diff --git a/docs/16-safe-text-formatting.md b/docs/16-safe-text-formatting.md index e51fb9f..ec73029 100644 --- a/docs/16-safe-text-formatting.md +++ b/docs/16-safe-text-formatting.md @@ -1,6 +1,6 @@ # 16: Safe Text Formatting -B9 is shipped as a local display feature across `kult-node`, strict RPC/CLI, +B9 is implemented as a local display feature across `kult-node`, strict RPC/CLI, UniFFI, desktop, Android, and iOS. Komms stores and transmits the exact UTF-8 source a person wrote. Formatting is derived only on the receiving endpoint and is never a second message representation. @@ -79,7 +79,7 @@ objects in the model. `kult format-text TEXT...` and prints the stable JSON model. - Kotlin and Swift call `KultNode.formatText` through UniFFI; their `Session` wrappers expose the same operation. -- Every shipped bubble path—pairwise, group, note-to-self, and scheduled—uses +- Every implemented bubble path—pairwise, group, note-to-self, and scheduled—uses this model. Native selection remains enabled for scalable, plain-text copy. ## Qualification diff --git a/docs/18-message-editing.md b/docs/18-message-editing.md index e161676..18be098 100644 --- a/docs/18-message-editing.md +++ b/docs/18-message-editing.md @@ -1,13 +1,21 @@ # 18: Authenticated Message Editing -C3 message editing is shipped for canonical pairwise and sender-key group text +C3 message editing is implemented for canonical pairwise and sender-key group text across `kult-protocol`, `kult-node`, `kultd` RPC/CLI, UniFFI, desktop, Android, and iOS. It follows [ADR-0020](adr/0020-authenticated-message-edits.md): an edit is a new encrypted authenticated event, never an invisible rewrite of history. +> **Alpha group-authorship limit:** pairwise edit authorship is authenticated. +> In the current sender-key group design, every member knows the shared content +> key and can forge another member's apparent edit. Group editing is therefore +> security-limited until +> [ADR-0029](adr/0029-recipient-authenticated-groups.md) is implemented. + ## User promise -- Only the author of a canonical Komms `Text` event can edit it. +- In a pairwise conversation, only the authenticated author of a canonical + Komms `Text` event can edit it. The current Alpha cannot make that promise + against a malicious member inside a sender-key group. - A successful edit keeps the original message row, shows an **edited** marker, and offers the original plus every valid version for inspection. - Pairwise and group edits work through the ordinary queued → sent → delivered @@ -44,9 +52,12 @@ inside the existing pairwise Double Ratchet or group sender-key ciphertext and the existing padding buckets. Relays, mailboxes, bridges, mesh repeaters, and sneakernet carriers cannot distinguish an edit from another encrypted message. -An accepted edit must satisfy all of these conditions: +An accepted edit must satisfy all of these conditions. In current groups, +condition 1 is membership attribution rather than cryptographic proof of the +individual origin: -1. its authenticated event sender equals `target_author`; +1. its event sender field equals `target_author` (individually authenticated + pairwise; only a claimed member in current groups); 2. target author and content id identify an exact canonical `Text` in the same pairwise or group conversation; 3. revision and replacement text obey the canonical bounds; and @@ -79,10 +90,12 @@ so would make endpoints disagree. ## Storage, backup, search, and events The existing sealed pairwise/group history rows retain exact originals and edit -events. No plaintext `current_body` column, mutable source row, or new backup -format exists. `KKR7` carries those sealed history records unchanged and the -derived winner is rebuilt after open or restore. A copied database continues to -leak only the already accepted sealed-row count and approximate sizes. +events. No plaintext `current_body` column, mutable source row, or new +edit-specific equality index exists. `KKR7` carries those sealed history records +unchanged and the derived winner is rebuilt after open or restore. The broader +locked-database metadata and row-binding limitations are documented in +[07: Local Storage](07-storage.md) and +[ADR-0027](adr/0027-opaque-indexed-store.md). C2 own-device sync carries immutable originals and edit events under their exact conversation/content ids. Each destination recomputes the same winning tuple; @@ -143,6 +156,11 @@ Automated coverage includes: - proof that raw edit records remain durable while rendered histories contain only the resolved original row. +That matrix proves deterministic processing and pairwise authorization; it does +not establish malicious-member origin authentication for sender-key group +events. ADR-0029 and its adversarial member-forgery tests are a stable-release +prerequisite for the group authorship promise. + Manual release qualification must still exercise screen readers, keyboard-only operation, Dynamic Type/font scaling, Unicode and bidirectional replacement text, lifecycle interruption, old-client interop, and real Android/iOS devices. diff --git a/docs/19-ephemeral-messages.md b/docs/19-ephemeral-messages.md index 0959d47..2995bbc 100644 --- a/docs/19-ephemeral-messages.md +++ b/docs/19-ephemeral-messages.md @@ -45,7 +45,10 @@ session and advertised support. Content format v1 kind `0x0005` contains a random content id, mode, exact UTC `expires_at`, canonical coarse `retention_until`, and either UTF-8 text or an attachment manifest. `retention_until` is the one-hour ceiling of `expires_at`. -Both values are inside the Double Ratchet or sender-key authenticated plaintext. +Both values are inside Double Ratchet authenticated plaintext or sender-key +membership-authenticated plaintext. The latter excludes outsiders but does not +prove an individual group origin against another member; ADR-0029 is required +for that stronger property. Envelope v2 adds the same hour-aligned `retention_until` in cleartext. Mailboxes, bridges, queues, and fragments treat it only as a bounded deletion hint: they diff --git a/docs/20-group-polls.md b/docs/20-group-polls.md index 53d2cce..ff2c5d4 100644 --- a/docs/20-group-polls.md +++ b/docs/20-group-polls.md @@ -5,6 +5,11 @@ the poll travels only inside the sender-key group conversation; it does **not** mean an anonymous ballot. Every member who has the poll can see who voted and which choice their current vote selects. +> **Alpha integrity limit:** the replicated poll state converges, but the current +> shared sender-key construction cannot prove which member originated an event. +> A malicious member can forge another member's apparent vote until +> [ADR-0029](adr/0029-recipient-authenticated-groups.md) is implemented. + ## Product promise - Any current member can create a poll when every current co-member supports @@ -37,7 +42,9 @@ current owner's `Komms-group-poll-moderation-v1` signature, binding the group id poll author/id, generation, and heads. It is accepted only against a valid signed authority state. If conflicting valid closures arrive, the smallest close event ID wins. The tally is always derived locally from -these authenticated immutable events, never from a server or mutable counter. +these group-AEAD-protected immutable events, never from a server or mutable +counter. That protection excludes outsiders but does not authenticate the +apparent voter against another malicious group member. Limits are deliberately fixed: 1,024 UTF-8 bytes for the question, 2–12 choices, 256 UTF-8 bytes per choice, 64 voters, and 64 locally authored vote @@ -72,7 +79,13 @@ host-mobile bindings, signed owner moderation, exact KKR1–KKR7 restore, and C2 owned-device convergence. Android debug-APK assembly is automated; real-device poll interaction remains part of the platform release gate. +This evidence establishes deterministic convergence, not resistance to +member-forged origins. ADR-0029 and adversarial member-forgery coverage must land +before Komms claims authenticated group voters. + The normative replicated-state and wire decision is [ADR-0022](adr/0022-convergent-group-polls.md). Signed moderation and owner/admin authority are specified separately in [ADR-0023](adr/0023-group-roles-and-owner-authority.md). +Recipient-verifiable group event origins are specified in +[ADR-0029](adr/0029-recipient-authenticated-groups.md). diff --git a/docs/21-group-roles.md b/docs/21-group-roles.md index fcbdc9b..a9f8ba5 100644 --- a/docs/21-group-roles.md +++ b/docs/21-group-roles.md @@ -1,7 +1,7 @@ # Group Roles, Ownership, and Moderation C6 adds private, cryptographically attributable group administration without a -server. It is shipped through `kult-node`, RPC/CLI, UniFFI, desktop, Android, +server. It is implemented through `kult-node`, RPC/CLI, UniFFI, desktop, Android, and iOS. [ADR-0023](adr/0023-group-roles-and-owner-authority.md) is the normative decision; this document is the product and operator contract. @@ -66,11 +66,12 @@ portable live chain state. ## Poll moderation -Ordinary closure remains the poll creator's visible vote-head snapshot. Owner -moderation is a distinct operation. An admin may request it, but the owner -sequences the authority generation and emits the closure. The exact group id, -poll author/id target, authority generation, and final visible vote heads are -signed under `Komms-group-poll-moderation-v1`. +Ordinary closure remains the apparent poll creator's visible vote-head snapshot +and is vulnerable to sender-key member forgery until ADR-0029. Owner moderation +is a distinct operation. An admin may request it, but the owner sequences the +authority generation and emits the closure. The exact group id, poll author/id +target, authority generation, and final visible vote heads are signed under +`Komms-group-poll-moderation-v1`. Resolution accepts moderation only when the signature matches the owner in the referenced valid authority generation. Every shell labels the result as owner diff --git a/docs/22-linked-devices.md b/docs/22-linked-devices.md index ac7b2d0..eef6fe6 100644 --- a/docs/22-linked-devices.md +++ b/docs/22-linked-devices.md @@ -6,6 +6,15 @@ a cloud account or a promise that every device is continuously online. The normative design is [ADR-0024](adr/0024-account-authorized-linked-devices.md). +> **Alpha authority warning:** the current implementation copies the stable +> account private key to every linked device. A known device id can be excluded +> from honest future delivery and sync, but a compromised former device still +> has authority to mint a new certificate and higher manifest. Permanent +> adversarial revocation is therefore **not** implemented. Do not rely on the +> current linked-device feature after device compromise. The required +> offline-root and majority-authorized replacement is +> [ADR-0026](adr/0026-revocable-device-authority.md). + ## What users can rely on - Every physical device has its own certified identity, PQXDH/Double Ratchet @@ -16,9 +25,10 @@ The normative design is [ADR-0024](adr/0024-account-authorized-linked-devices.md private organization, and non-ephemeral history. - Up to eight devices can be active. Names are account-signed; last-seen is a coarse observation, not presence. -- Revocation targets one exact device, is permanent, requires a destructive - confirmation, excludes future delivery/sync, and rotates surviving group - sender chains. +- Revocation targets one exact known device, requires a destructive + confirmation, excludes that id from honest future delivery/sync, and rotates + surviving group sender chains. It is not yet permanent against a holder of + the copied account root. - Pairwise delivery exposes honest per-device queued/sent/delivered state. The account-level state remains an aggregate and never implies every device is online. @@ -66,9 +76,12 @@ recipient. ## Loss and recovery -If one linked device is lost, revoke it from another active device as soon as -possible. The lost device can retain content already decrypted; revocation -prevents new delivery and accepted sync. +If one linked device is lost, revoke its known id from another active device as +soon as possible and treat the account as needing an Alpha authority reset. +The lost device can retain content already decrypted and, because it received +the current account root, can create a new authorized-looking device. Current +revocation prevents honest peers from delivering or syncing to the named id; it +does not contain a compromised root holder. `KKR7` recovery intentionally does not resurrect any backed-up device credential. Restore keeps the stable account and ordinary data, revokes every @@ -83,4 +96,7 @@ convergence, malformed/replay/rollback rejection, revocation, restart, KKR7 recovery, strict RPC/CLI, UniFFI, desktop, Android host-core source parity, and iOS host-core source parity. Per-push CI assembles the Android debug APK; full SwiftUI app type-check/simulator testing requires full Xcode. Real-device -ceremony, revocation, and recovery behaviors remain hands-on qualification. +ceremony and recovery behaviors remain hands-on qualification. Those tests +exercise cooperative exact-id exclusion only; stolen-root, permanent +revocation, manifest-fork, and authority-reset acceptance remain open under +ADR-0026. diff --git a/docs/23-live-audio-calls.md b/docs/23-live-audio-calls.md index 9af0f8a..a68c64b 100644 --- a/docs/23-live-audio-calls.md +++ b/docs/23-live-audio-calls.md @@ -6,7 +6,7 @@ only when both endpoints have a fresh direct QUIC route. There is no Komms call server, signaling service, SFU, STUN/TURN service, or reusable call link. This document describes the implemented C7 audio contract. Video is not part of -the shipped path. [ADR-0013](adr/0013-real-time-calls.md) is normative for the +the implemented path. [ADR-0013](adr/0013-real-time-calls.md) is normative for the transport and cryptographic decisions. ## 1. Availability and honest limits diff --git a/docs/24-local-release-gate.md b/docs/24-local-release-gate.md index 43aebb0..dac5b24 100644 --- a/docs/24-local-release-gate.md +++ b/docs/24-local-release-gate.md @@ -1,13 +1,15 @@ # 24: Local Release Gate -Komms development uses one long-lived local branch and one complete local -release matrix. Feature work is not pushed merely to ask hosted CI whether it -compiles. Publication, a draft pull request, and any hosted repetition happen -only after the roadmap implementation is complete, local evidence is green, and -the maintainer explicitly authorizes the remote action. - -This policy reduces private-repository runner cost without weakening the test -bar. The commands are pinned in +Komms uses one complete local release matrix for publication candidates. +Ordinary contributions may open a focused pull request after the scoped checks +in [CONTRIBUTING.md](../CONTRIBUTING.md); CI is a verifier, not a substitute for +running the relevant local check. Publishing binaries, containers, or a stable +claim requires the full matrix, explicit maintainer authorization, and the +applicable P0 evidence in the +[stabilization program](29-stabilization-program.md). + +This keeps the publication bar high without making every documentation or +bounded code contribution reproduce every platform. The commands are pinned in [`scripts/local-release-matrix.sh`](../scripts/local-release-matrix.sh). ## 1. Toolchains and platform prerequisites diff --git a/docs/26-self-hosting.md b/docs/26-self-hosting.md index 27ea641..8f32ea0 100644 --- a/docs/26-self-hosting.md +++ b/docs/26-self-hosting.md @@ -13,6 +13,14 @@ supports `linux/amd64` and `linux/arm64`. Pull the immutable release tag with: docker pull ghcr.io/andrigitdev/komms-kultd:0.3.0 ``` +> **Alpha mailbox warning:** `--serve-mailbox` currently keeps accepted mailbox +> ciphertext in process memory, and mailbox fetch removes a returned page before +> endpoint-level acknowledgement. Restart, process loss, or a receiver that +> cannot admit the fetched page can therefore lose that relay copy. This role is +> suitable for interoperability testing, not durable or production mailbox +> custody. ADR-0032 defines the leased persistent design required before that +> claim. + The `0.3-alpha` and `alpha` tags are moving Alpha aliases; the committed Compose file tracks `0.3-alpha`, while automation should pin `0.3.0` or an image digest. The image runs the daemon as numeric user/group `10001`, stores its sealed @@ -60,6 +68,15 @@ host LAN. Komms operates no mandatory bootstrap service: add trusted bootstrap or relay addresses, or distribute explicit reachable peer hints, when the node must discover peers beyond its container network. +This `kultd` profile is **not** the proposed RAM-only reference +discovery/rendezvous service. It is a full identity-bearing Komms endpoint with a +persistent encrypted database and passphrase. Mounting its entire data directory +on tmpfs would rotate or destroy endpoint state on restart and would not +establish the least-authority claim in +[ADR-0034](adr/0034-operator-minimized-reference-discovery.md). The reference +service requires a dedicated daemon, container, and runbook that cannot enable +endpoint, mailbox, or native-wake roles. + To add daemon flags, replace the Compose service's command while retaining both listen addresses. For example, a volunteer mailbox with an explicit bootstrap peer can use: diff --git a/docs/28-brand-system.md b/docs/28-brand-system.md index eca74b6..450c651 100644 --- a/docs/28-brand-system.md +++ b/docs/28-brand-system.md @@ -1,5 +1,16 @@ # Komms product brand system +**Name status:** `Komms` is the current project and product name. The project +does not represent it as a registered or legally cleared trademark. Potential +overlap—including `komms.app`—is monitored and documented, but an observed +similar name is not by itself a legal conclusion, automatic rename requirement, +or engineering stop. Under +[stabilization gate P0-02](29-stabilization-program.md), the founder records a +keep, adjust, or rename decision before stable brand and wire identifiers are +frozen, using qualified advice when actual confusion or expansion makes it +proportionate. The product character and accessibility tokens remain reusable +if that decision ever changes. + The application shells use the same visual language as the public Komms site. The light theme follows `komms.org`; the dark theme follows the technical `how-it-works` page. This file is the cross-shell contract, not a second brand. @@ -15,6 +26,33 @@ The light theme follows `komms.org`; the dark theme follows the technical - Conversation content is primary. Folders, labels, addresses, NAT details and transport controls are secondary tools. +## Message hierarchy + +The front door speaks to ordinary messaging needs before it explains the +network: + +1. **Promise:** “Private messaging that keeps working.” +2. **Everyday benefit:** familiar conversations, clear delivery state, easy + pairing, and recovery a non-specialist can complete. +3. **Reason to believe:** messages are end-to-end encrypted and can use more + than one supported route. +4. **Advanced proof:** user-owned identity, replaceable infrastructure, + local/radio/courier fallbacks, published code, a nonprofit public-benefit + mission, reciprocal source obligations for modified covered software, and + explicit threat limits. + +Do not lead consumer pages with “sovereign,” “DHT,” “relay,” “PQXDH,” “node,” +or transport-selection language. Those are valuable proof for people who want +it, not homework required before sending a message. Do not market fear, +invulnerability, guaranteed delivery, universal anonymity, remote erasure, or +an independent audit that has not happened. The product should win on quality; +privacy and resilience explain why it remains trustworthy. + +Use **nonprofit public-benefit mission**, not **registered nonprofit**, +**charity**, or **tax-exempt**, unless a legal entity and jurisdiction-specific +status support that claim. Do not imply that the mission forbids independent +commercial AGPL use. + ## Semantic tokens | Role | Light | Dark | diff --git a/docs/29-stabilization-program.md b/docs/29-stabilization-program.md new file mode 100644 index 0000000..d83b35b --- /dev/null +++ b/docs/29-stabilization-program.md @@ -0,0 +1,242 @@ +# Komms stabilization program + +**Status:** active +**Scope:** Alpha to a trustworthy stable release and protocol wire v1 +**Accountable owner:** lead maintainer until ownership is delegated in +[MAINTAINERS.md](../MAINTAINERS.md) + +Komms should feel like an everyday messenger while strong privacy, user-owned +identity, and resilient fallbacks stay underneath. This program freezes that +product direction and turns the remaining trust gaps into release gates. + +It is the canonical source for stabilization priority. The +[engineering roadmap](08-roadmap.md) and +[feature delivery plan](12-feature-delivery-plan.md) remain useful inventories, +but a completion label there does not override a gate here. + +Founder-directed, tool-assisted implementation may continue throughout +stabilization. It is a means of production, not independent evidence. +Stabilization freezes what qualifies for a stable release; it does not prevent +isolated roadmap work needed to make Komms broadly capable, reliable, +accessible, and polished. Experimental breadth must not silently expand the +audited v1 profile or block closure of its trust gates. + +## 1. Product and architecture contract + +The stabilization work must preserve these boundaries: + +1. **Everyday messaging comes first.** Installation, pairing, sending, receiving, + recovery, and honest delivery state must be understandable without knowing + transport or cryptography terminology. +2. **There is no mandatory exclusive provider.** The pure core can be operated + without an optional project service. Standard mode may include replaceable, + clearly disclosed defaults to make first use practical; users can replace or + remove them. +3. **Optional services cannot read message plaintext or hold user Komms + identity private keys.** Runtime service identities, TLS keys, or provider + credentials are separately scoped and disclosed. Removing rendezvous or + native wake may reduce convenience, but it must not change the message + format, user identity, or cryptographic trust root. +4. **DHT first contact and durable store-and-forward mailboxes remain core + protocol roles.** Bootstrap peers and mailbox operators may be chosen or + self-hosted. Current Alpha provider configuration and mailbox persistence + still require qualification. +5. **Post-pairing rendezvous and content-free native wake remain optional.** + They may improve mobile reachability after a relationship exists, as proposed + in ADR-0017 through ADR-0019, but are not prerequisites for pure-core + communication. +6. **No mode may silently weaken a guarantee.** Standard, Private, or Sovereign + presentation may change defaults and convenience, not message + confidentiality, identity ownership, or the meaning of delivery receipts. +7. **Official operation serves a nonprofit public-benefit mission.** + Project-operated or officially designated default services use revenue and + capacity to sustain access, infrastructure, security, accessibility, + maintenance, and development. Independent AGPL operators may operate + commercially and are not represented as official unless they accept the + applicable service and trademark policy. See + [ADR-0033](adr/0033-nonprofit-founder-stewardship.md). + +## 2. Evidence vocabulary + +Public status claims use the strongest level actually demonstrated: + +| Level | Meaning | +|---|---| +| **Designed** | A reviewable requirement, threat boundary, or ADR exists. | +| **Implemented** | The relevant production path exists in the repository. | +| **Automated evidence** | Repeatable tests exercise the claimed path and are recorded in CI or a release evidence bundle. | +| **Field-qualified** | Named physical devices, operating systems, radios, and real network conditions pass a recorded matrix. | +| **Independently interoperable** | A separately produced implementation or external test fixture exchanges the normative format successfully. | +| **Independently reviewed** | A qualified person outside the implementation authorship has reviewed the scoped design or code and published a disposition. | +| **Stable** | The applicable P0 gates are closed, compatibility is declared, support and update paths exist, and the release evidence is published. | + +These levels are not interchangeable. A simulator build is automated evidence, +not device qualification. A self-round-trip test is not independent +interoperability. Use **available in Alpha** for a feature users can exercise in +an Alpha package. Do not use unqualified **shipped**, **complete**, **audited**, +or **production-ready** as substitutes for evidence. + +Each gate closes with links to durable evidence: tests and logs tied to a +revision, a signed review report, a field matrix, a decision record, or a +published release artifact. Screenshots and assertions without revision or +environment details are supporting material, not closure. + +## 3. Accountability and evidence roles + +| Code | Owner category | Responsibility | +|---|---|---| +| **FND** | Founder / lead maintainer | Product boundary, priority, final release accountability, delegation | +| **SEC** | Core security | Cryptography, identity, storage, threat model, abuse resistance | +| **NET** | Network and services | Discovery, NAT traversal, mailbox durability, radio, operator behavior | +| **PROD** | Product and clients | Onboarding, ordinary messaging, accessibility, localization, recovery | +| **REL** | Release engineering | Builds, signing, updates, provenance, reproducibility, evidence archive | +| **COM** | Community and governance | Contribution path, conduct, review coverage, transparent decisions | +| **LEG** | Legal and brand | Name-risk assessment, trademarks, licensing boundaries, policy wording | +| **EXT** | Independent evidence providers | External security review, interoperability, field, or accessibility evidence; no product, merge, or release authority unless separately delegated | + +One person may temporarily fill several categories, but evidence is not +independent when author and reviewer are the same person. The founder remains +accountable for unassigned gates and must identify the actual individual next +to each gate before work begins. + +## 4. P0 — trust and release blockers + +Every applicable P0 gate must close before a stable release. P0-02 requires a +documented brand-risk decision, not an automatic rename. As of 2026-07-26, +[komms.app](https://komms.app/) uses “Komms Protocol” for a different +communication-infrastructure project. That observation is a monitoring input, +not a legal conclusion or engineering veto. Work continues while the founder +records a proportionate keep, adjust, or rename decision before stable brand +and wire identifiers are frozen. + +| Gate | Owner | Required evidence | Unlocks | +|---|---|---|---| +| **P0-01 Honest claims and evidence ledger** | FND + SEC + PROD | Public pages use the vocabulary above; security, deletion, blocking, legal-policy, platform, and feature-status claims cite their limits and evidence. A release-scoped ledger links every stable claim to a revision and artifact. | Stable marketing and release notes | +| **P0-02 Name-risk assessment and recorded decision** | LEG + FND | A dated search records relevant marks, categories, jurisdictions, domains, package identifiers, observed confusion, and migration cost. The founder records a keep, adjust, or rename decision and monitoring cadence; qualified trademark advice is used when actual confusion, enforcement contact, or expansion makes it proportionate. | Stable naming and long-lived identifiers | +| **P0-03 Stabilized core product profile** | FND + PROD + SEC | A frozen v1 profile covers install, contact establishment, pairwise text, groups at a stated bound, attachments at stated limits, backup/recovery, blocking, and honest delivery. Additional roadmap work remains isolated from the stable profile until its applicable evidence closes. | Coherent beta scope | +| **P0-04 Clean-install and real-network golden path** | NET + PROD + EXT | On fresh supported devices, two users on distinct ordinary NATs can install, establish first contact, exchange messages, go offline, and receive later without editing addresses or configuration. Standard defaults are disclosed and replaceable; default blackhole, alternate-bootstrap, replacement-operator, and pure-core/self-hosted journeys are exercised separately. | Credible everyday internet use | +| **P0-05 Unsolicited-contact abuse admission** | SEC + NET + PROD + EXT | Before a first payload creates durable contact/session state or consumes scarce prekeys, the accepted contact gate, bounded proof-of-work or equivalent cost, rate limits, block controls, storage quotas, and recovery behavior pass adversarial and usability tests. | Safe public discovery | +| **P0-06 Independent crypto and protocol assurance** | SEC + EXT | Normative external vectors cover PQXDH, Double Ratchet state transitions, sealed envelopes, downgrade behavior, backup/recovery, and malformed inputs; an independent reviewer publishes scope, findings, fixes, and residual risks. Self-tests remain useful but are labelled accordingly. | Stable security claims | +| **P0-07 Signed and recoverable distribution** | REL + SEC + EXT | Production-signed supported artifacts, protected release keys, provenance/SBOM, reproducible-build measurements, verified install/upgrade/rollback, and an authenticated update or clearly bounded manual-update path are exercised from a clean device. | Stable binary distribution | +| **P0-08 Durable mailbox and operator qualification** | NET + SEC + EXT | Mailbox ciphertext survives restart/crash within declared retention and quota rules; expiry, deletion hints, overload, abuse, upgrade, backup, observability, and multi-operator failure behavior are tested. RAM-only discovery/rendezvous nodes do not count as mailbox-durability evidence. A maintained self-hosting path identifies costs and responsibilities. | Trustworthy asynchronous delivery | +| **P0-09 Field qualification across supported claims** | PROD + NET + REL + EXT | A published matrix covers named Android and iOS devices, desktop systems, background/lock lifecycle, NAT classes, network handoff, accessibility, recovery, and the two-radio hardware bench. Unsupported combinations are stated instead of inferred from simulator or CI results. | Supported-platform declaration | +| **P0-10 Accountable founder authority, review, and incidents** | FND + COM + SEC | Founder authority, delegation, code ownership, conduct and recusal, security intake, incident handling, release accountability, and succession are public. Independent review requirements are assurance gates rather than transfers of product authority; missing evidence is reported instead of implied. | Trustworthy release authority | + +Work in the stabilization branch toward P0-05 includes a canonical 128 KiB +envelope limit, an explicit accept/refuse contract on `/komms/envelope/2`, and a +direct inbox bounded to 256 items and 8 MiB. The encrypted deferred inbox is +also capped at 2,048 rows / 64 MiB and suppresses exact multipath duplicates. +Global libp2p connection counts, fragment/NACK work, courier bundles, directory +ingress, and mailbox-v1 page/token/lifecycle work now have explicit interim +bounds; large mailbox and token lists rotate without front-of-list starvation. +These paths have local automated tests, but the evidence is not yet tied to a +published revision or independently reviewed, so P0-05 remains open. They bound +carrier and disk surfaces but do not by themselves provide pre-acknowledgement +first-contact admission, identity blocking, or mailbox abuse controls. + +## 5. P1 — adoption and ecosystem readiness + +P1 work follows a coherent P0 beta and should not delay corrections to P0 +claims or safety. + +| Gate | Owner | Required evidence | Outcome | +|---|---|---|---| +| **P1-01 Fast contributor path** | COM + REL | A newcomer can build one target, run a bounded required test set, find a suitable issue, and submit a focused change without completing the entire release matrix. Maintainer-only publication remains protected. | Sustainable contribution funnel | +| **P1-02 Localization and accessibility system** | PROD + COM + EXT | User-facing strings leave source code, at least one non-English locale exercises every shell, bidi/Unicode and pluralization tests run, and an external accessibility pass records findings. | Reach beyond early technical adopters | +| **P1-03 Stand-alone protocol and conformance kit** | SEC + NET + EXT | Versioned wire/state specifications, fixtures, compatibility policy, reference traces, and an independently run conformance suite exist outside implementation prose. | Credible third-party implementations | +| **P1-04 Operator program and sustainable capacity** | NET + COM + FND | A dedicated service image and runbook, version/support policy, resource model, abuse response, telemetry boundary, and funding assumptions are tested with at least two independently operated nodes. | Replaceable, plural infrastructure | +| **P1-05 License, trademark, and asset policy** | LEG + COM | AGPL-3.0-only scope, section-13 source-offer obligations, commercial-use rights, contribution terms, documentation/specification/artwork licenses, trademark use, package names, and third-party assets are documented without implying that the nonprofit mission narrows downstream AGPL rights. | Safe reuse without brand confusion | +| **P1-06 Nonprofit funding and transparency** | FND + COM | Official project and service income, expenditure, infrastructure cost, surplus use, conflicts, paid work, and sponsor independence are reported on a predictable cadence. | Mission-aligned durability | +| **P1-07 Privacy, legal, and incident runbooks** | SEC + LEG + COM | Data-flow inventory covers cloud/provider visibility, optional-service retention, lawful requests, service-key compromise, cross-role correlation, advisory publication, and user notification; the response paths are rehearsed. | Operational trust under pressure | + +## 6. P2 — expansion outside the stable profile + +The founder may research or implement P2 work during Alpha when it is isolated +from the stable profile. It is not enabled or represented as stable until the +everyday messenger and the feature's own evidence are proven: + +- live video, very large groups, advanced moderation, and high-bandwidth media; +- additional delay-tolerant networks such as Freenet-style carriers; +- cross-protocol federation or standards participation beyond the P1 + conformance work; +- richer optional discovery/wake services, provided the boundaries in section 1 + remain intact; +- governance evolution considered by the founder after sustained adoption has + created a real community able to carry delegated responsibility. + +Each P2 proposal needs a user problem, privacy impact, operational cost, +compatibility plan, and evidence budget. Feature count is not a stability +signal. + +## 7. First 90 days + +### Days 0–30: reset the trust surface + +- Freeze the stable-v1 contract and name accountable people for every P0 gate; + keep broader roadmap work isolated so it cannot silently expand that contract. +- Correct public claims and publish the first evidence ledger. +- Record an initial name-risk search, monitoring cadence, and founder + keep/adjust/rename decision; do not halt engineering solely because another + project uses a similar name. +- Scope an independent cryptography/protocol review and external vectors. +- Decide first-contact abuse admission and the stabilized v1 product profile. +- Specify the clean-install Standard and pure-core acceptance journeys. +- Publish release signing/update/reproducibility and mailbox-durability plans. + +### Days 31–60: make the golden path testable + +- Exercise clean installs with replaceable Standard defaults and separately with + pure-core/self-hosted configuration. +- Land and adversarially test first-contact admission before enabling broad + public discovery. +- Qualify persistent mailbox restart, retention, quotas, and operator failure. +- Exercise production signing, upgrade/rollback, and evidence capture. +- Extract localizable strings and open a bounded newcomer contribution path. + +### Days 61–90: prove it outside the author’s machine + +- Run the named-device, real-NAT, background-lifecycle, accessibility, recovery, + and physical-radio matrix. +- Begin independent review, publish findings and dispositions, and add external + interoperability fixtures. +- Reproduce release artifacts in a second controlled environment. +- Run a small, consent-based pilot with explicit Alpha limitations and + measurable install-to-delivery success. +- Publish the gate ledger: closed, open, owner, evidence, and next review date. + +## 8. Audit-finding crosswalk + +This table prevents a prior concern from disappearing into roadmap prose. + +| Finding | Gate | +|---|---| +| Intentional founder-led construction creates continuity and independent-assurance gaps until external evidence and recovery stewardship exist | P0-06, P0-10 | +| Potential similar-name overlap, including komms.app, requires monitoring and a documented founder risk decision; it is not by itself a legal conclusion or automatic rename requirement | P0-02, P1-05 | +| Feature breadth presented ahead of audit, distribution, field qualification, and stable core profile | P0-01, P0-03, P0-09 | +| Fresh installs have no practical internet bootstrap/mailbox defaults; hybrid reachability is design-only | P0-04 | +| First payload can create state while contact-gating and abuse cost are only promised elsewhere | P0-05 | +| Unsigned/debug packages, no updater, incomplete reproducibility and store distribution | P0-07 | +| Absolute blocking, erasure, cryptographic-audit, and current-law wording exceeds demonstrated guarantees | P0-01, P0-06, P1-07 | +| Mailbox state and the operator path are not yet qualified as durable production infrastructure | P0-08, P1-04 | +| Simulator/CI evidence is described beside unresolved device, NAT, radio, background, and accessibility work | P0-01, P0-09 | +| Localization is claimed without a shared localization system or cross-shell locale evidence | P1-02 | +| Contribution rules require release-scale validation and maintainer authorization for ordinary work | P1-01 | +| AGPL reciprocity and the nonprofit mission are clear, but software/documentation/artwork scope, trademark use, contribution rights, and exact section-13 obligations still need a policy | P0-02, P1-05 | +| No durable nonprofit funding, plural-operator, incident, or transparency program is established | P1-04, P1-06, P1-07 | +| No stand-alone conformance suite or independent implementation yet supports a durable ecosystem claim | P0-06, P1-03 | +| Video, large groups, new carriers, federation, and governance expansion could distract from everyday reliability | P0-03, P2 | +| Direct transport currently acknowledges volatile RAM before bounded durable admission can accept or refuse an unknown token | P0-05 | +| Stable identity-derived DHT locators and public route hints permit polling and network-location correlation | P0-04, P0-05, P0-06 | +| Several stateful receive paths still persist ratchets, history, replay state, and source acknowledgements in separate crash windows | P0-03, P0-06 | +| Mailbox collection deletes relay custody before the endpoint durably stages and acknowledges a leased page | P0-04, P0-08 | +| Unix store writer exclusion now combines a no-follow sidecar with a database-inode lock; equivalent alias resistance and hostile-filesystem qualification remain open on other supported platforms | P0-06, P0-09 | +| The Unix RPC sidecar is no-follow and owner-only, but portable stale-socket replacement still requires a daemon-owned parent directory to exclude hostile rename/unlink races | P0-07, P0-09 | +| Current linked devices copy the account root, so a compromised revoked device can mint a replacement credential; ADR-0026 requires offline-root migration and majority-authorized manifests | P0-03, P0-06 | +| Current SQLite equality columns reveal exact contact/group identifiers in a locked copy and constant row AD permits same-table substitution; ADR-0027 requires opaque indexes and row binding | P0-03, P0-06 | +| Current sender-key group content proves membership, not individual origin, so a malicious member can forge another member's text, edit, vote, or ephemeral event; ADR-0029 requires recipient-verifiable group origins | P0-03, P0-06 | +| RAM-only storage, disabled logs, and aggregate metrics reduce retention but remain deployment controls; a cloud operator can still observe network metadata, running memory, and availability | P0-01, P0-04, P1-07 | + +Until the relevant gates close, Komms is an ambitious public Alpha with +implemented and automated evidence in many areas—not an audited stable +messenger. That distinction protects users and gives contributors a concrete +path to earning stronger claims. diff --git a/docs/adr/0006-agplv3.md b/docs/adr/0006-agplv3.md index c772bbc..2db891f 100644 --- a/docs/adr/0006-agplv3.md +++ b/docs/adr/0006-agplv3.md @@ -2,6 +2,8 @@ - **Status**: Accepted - **Date**: 2026-07-11 +- **Clarified by**: + [ADR-0033](0033-nonprofit-founder-stewardship.md) ## Context @@ -10,7 +12,11 @@ failure mode is becoming the engine of a proprietary, telemetry-laden fork. ## Decision -GNU Affero General Public License v3.0 for the entire repository. +GNU Affero General Public License v3.0 (`AGPL-3.0-only`) for all +project-authored software. Documentation, specifications, screenshots, fonts, +logos, artwork, and third-party assets require the explicit inventory and policy +tracked by stabilization gate P1-05; their presence in the repository is not +silently treated as proof of ownership or compatible relicensing. ## Alternatives considered @@ -24,7 +30,14 @@ GNU Affero General Public License v3.0 for the entire repository. ## Consequences -Anyone running a modified relay/gateway/client must publish source; the codebase stays -open by force of law (Signal precedent shows AGPL does not prevent adoption of a -messenger). Some corporate contributors will abstain, acceptable. Library reuse in -proprietary apps is foreclosed; that is the point, not a bug. +Anyone may use Komms, including companies and governments. When AGPL section 13 +applies, an operator of a modified covered version that supports remote network +interaction must prominently offer the interacting users that version's +Corresponding Source. Distribution obligations apply under the exact license +terms. + +The license does not prohibit surveillance or commercial use, automatically +cover every separate interoperating service, guarantee general-public +publication, or prove that a deployed binary matches offered source. Some +corporate contributors and proprietary integrators may abstain because of the +strong reciprocal terms; preserving that reciprocity is the intended trade. diff --git a/docs/adr/0014-versioned-message-content.md b/docs/adr/0014-versioned-message-content.md index 07136c7..4eb143d 100644 --- a/docs/adr/0014-versioned-message-content.md +++ b/docs/adr/0014-versioned-message-content.md @@ -159,9 +159,10 @@ Every future content event that refers to another event (edit, reply context, poll vote, attachment relation) carries `target_author(32) || target_content_id(16)` inside its own encrypted payload. Resolution is restricted to the same conversation and exact author. Including -the author prevents a malicious group member from making a colliding id -ambiguous. A reference never uses an envelope `content_id`, ciphertext hash, -store row id, timestamp, or plaintext hash. +the author disambiguates independent honest authors, but current sender-key +groups do not stop a malicious member from claiming that same author and id; +ADR-0029 is required for that property. A reference never uses an envelope +`content_id`, ciphertext hash, store row id, timestamp, or plaintext hash. Legacy messages have local store ids but no interoperable content id and cannot be the target of a network-replicated edit or vote. A later reply-UX slice may diff --git a/docs/adr/0015-encrypted-attachment-pipeline.md b/docs/adr/0015-encrypted-attachment-pipeline.md index 3892e37..f8a2a7a 100644 --- a/docs/adr/0015-encrypted-attachment-pipeline.md +++ b/docs/adr/0015-encrypted-attachment-pipeline.md @@ -191,14 +191,17 @@ payload(payload_len) The complete unpadded bulk body is at most 65,535 bytes and must be consumed exactly. Unknown versions, operations, flags, or scopes are terminally ignored after authentication; malformed lengths are rejected before allocation. The -session peer, scope, author, manifest id, and object id must resolve to one -stored Attachment manifest before any operation changes state. +session peer, scope, claimed author, manifest id, and object id must resolve to +one stored Attachment manifest before any operation changes state. An inbound `Chunk` or `Cancel` that acts as the serving side is accepted only -from the manifest author's pairwise session. In a group, `RequestMissing`, -`Complete`, `Cancel`, or `Reject` from a receiver is accepted only from a member -who was entitled to the manifest when it was sent. These checks are durable -transfer metadata, not inferred from the current roster alone. +from the apparent manifest author's pairwise session. In a group, +`RequestMissing`, `Complete`, `Cancel`, or `Reject` from a receiver is accepted +only from a member who was entitled to the manifest when it was sent. These +checks are durable transfer metadata, not inferred from the current roster +alone. Current sender-key group manifests do not prove their individual origin: +a malicious member can forge another member as the apparent author and misroute +chunk requests until ADR-0029 is implemented. For a pairwise scope, `scope_id` is `BLAKE3("KAT-pairwise-scope-v1" || min(IK_A, IK_B) || max(IK_A, IK_B))`, with @@ -250,12 +253,12 @@ group message and fanned out under [ADR-0012](0012-sender-key-groups.md). The attachment key is therefore available to every member entitled to that group message, just like its text. Each member consents and requests missing chunks independently over its pairwise -ratchet with the original manifest author. The author reuses the exact same -sealed chunk bytes for every member; neither the file nor a chunk is encrypted -again per recipient. Pairwise wrappers, delivery tokens, and retry state differ, -but the bulk ciphertext does not. +ratchet with the apparent manifest author. An honest author reuses the exact +same sealed chunk bytes for every member; neither the file nor a chunk is +encrypted again per recipient. Pairwise wrappers, delivery tokens, and retry +state differ, but the bulk ciphertext does not. -Only the original manifest author serves v1 chunks. Peer-assisted group seeding +Only the apparent manifest author serves v1 chunks. Peer-assisted group seeding would reveal possession and needs a separate authorization and abuse design. Adding a member does not replay old manifests or attachment keys. Removing a member cannot revoke bytes or keys already delivered to that member; a member @@ -458,9 +461,9 @@ Implementation is not accepted until it includes: encryptions and N distinct stores. Pairwise request wrappers are small; sealed chunk bytes remain identical. - **Automatic download based only on MIME or filename.** Rejected: both are - authenticated sender claims, not safety verdicts, and carrier/storage state - can change immediately. Consent, byte caps, sandboxing, and F4 gating remain - authoritative. + pairwise-authenticated or group-membership-protected sender claims, not safety + verdicts, and carrier/storage state can change immediately. Consent, byte + caps, sandboxing, and F4 gating remain authoritative. - **Store media as SQLite BLOBs or plaintext files.** Rejected: large BLOBs amplify database copies and backups; plaintext files violate the storage promise. Sealed files plus transactional sealed metadata bound both risks. @@ -481,9 +484,10 @@ Implementation is not accepted until it includes: chunk carries fixed-record and double-encryption overhead and small control records use a 4 KiB padding bucket; this intentionally trades bandwidth on bulk-capable links for restart safety, metadata protection, and a mesh guard. -- Group manifests and file bytes are encrypted once, but the original author +- Group manifests and file bytes are encrypted once, but the apparent author maintains independent per-member request/progress state and must remain - available to serve v1 downloads. + available to serve v1 downloads. ADR-0029 is required to make that group + attribution resistant to another member. - The store gains filesystem lifecycle and crash-consistency obligations beyond SQLite. Backups remain small and compatible at the cost of media being re-downloadable rather than restored. diff --git a/docs/adr/0016-group-mention-content.md b/docs/adr/0016-group-mention-content.md index a894c22..cf686a1 100644 --- a/docs/adr/0016-group-mention-content.md +++ b/docs/adr/0016-group-mention-content.md @@ -254,10 +254,12 @@ payloads remain excluded from indexing. The shared node API, RPC, CLI, and UniFFI expose a decoded Mention record containing the ADR-0014 content id, exact fallback text, and ordered spans with `start`, `end`, -and the full target peer id. Conversation and authenticated author remain fields -of the surrounding group message. Raw authenticated payload bytes, group secrets, -sender chains, capability controls, and encryption state never cross those -render-safe APIs. +and the full target peer id. Conversation and claimed author remain fields of +the surrounding group message. Current sender-key protection authenticates that +some member produced it, not which member; ADR-0029 defines the stronger origin +required before stable. Raw protected payload bytes, group secrets, sender +chains, capability controls, and encryption state never cross those render-safe +APIs. Send APIs accept the group id, exact UTF-8 text, and explicit spans whose targets are full peer ids. RPC and CLI offsets are UTF-8 byte offsets. UniFFI uses the same diff --git a/docs/adr/0017-optional-hybrid-modes.md b/docs/adr/0017-optional-hybrid-modes.md index ef70929..9e130ca 100644 --- a/docs/adr/0017-optional-hybrid-modes.md +++ b/docs/adr/0017-optional-hybrid-modes.md @@ -2,6 +2,8 @@ - **Status**: Proposed - **Date**: 2026-07-15 +- **Reference deployment**: + [ADR-0034](0034-operator-minimized-reference-discovery.md) ## Context @@ -39,22 +41,48 @@ optional discovery privacy and wake-up behavior: | Mode | Pairwise rendezvous | Native wake | Intended disclosure | |---|---|---|---| -| **Sovereign** | Disabled; existing DHT, out-of-band, LAN, mailbox, mesh, and sneakernet paths only | Disabled | No optional Komms-operated service | -| **Private** | Recipient-selected rendezvous through Tor or a non-colluding [Oblivious HTTP](https://www.rfc-editor.org/rfc/rfc9458.html) relay | Optional native wake through anonymized ingress | Wake gateway and APNs/FCM still learn the destination and delivery time | | **Standard** | Direct HTTPS to recipient-selected rendezvous providers | APNs on Apple platforms; FCM in the Google Play Android flavor | Provider sees the connecting address, opaque target, timing, and volume | +| **Private** | Recipient-selected rendezvous through Tor or a non-colluding [Oblivious HTTP](https://www.rfc-editor.org/rfc/rfc9458.html) relay | Optional native wake through anonymized ingress | Wake gateway and APNs/FCM still learn the destination and delivery time | +| **Sovereign** | Disabled; existing DHT, out-of-band, LAN, mailbox, mesh, and sneakernet paths only | Disabled | No optional Komms-operated service | -Enabling Private or Standard mode requires an explicit, reversible choice with -a concise disclosure. A build may recommend a mode during onboarding, but it -must not silently enable a convenience service, and the applications must show -which mode is active. Changing mode never rotates or replaces the user's Komms +The official consumer distribution recommends and may preselect **Standard**: +an everyday user should complete onboarding without understanding DHTs, relays, +or mobile operating-system scheduling. Before the first optional-service +registration it presents one concise, reversible disclosure: message content +and identity keys stay end-to-end protected, while convenience providers and +APNs/FCM can observe limited connection/timing metadata. One confirmation +activates the recommended setup. + +Private and Sovereign are available from the same onboarding review and later +under an advanced privacy/network control. They are not framed as “expert +mode” or as more virtuous choices; each has a clear reliability/privacy +tradeoff. The conversation screen shows useful state such as **Private**, +**Connected**, **Waiting for a route**, and **Fallback ready**, not internal +service names. Changing mode never rotates or replaces the user's Komms identity. +### 1a. Public first-contact records do not publish a direct route by default + +The DHT remains the self-authenticating first-contact index. In Standard and +Private modes its signed bundle carries prekeys, capabilities, and one or more +bounded introduction paths such as recipient-selected mailbox/relay +descriptors. It does not publish a current direct IP multiaddress under the +stable public account lookup by default. + +After pairing, ADR-0018 supplies rotating pairwise reachability. QR/file +invites, LAN discovery, mesh, and sneakernet may carry context-specific direct +hints because they are not a globally polled identity record. Sovereign mode +may explicitly publish a direct DHT route when the user or operator needs +internet reachability without a mailbox/relay; the UI warns that anyone holding +the public address can poll that route. + ### 2. Optional services are accelerators, never authorities No optional service may: - receive message plaintext, attachment keys, ratchet state, sender-key state, - identity private keys, contact petnames, group membership, or local metadata; + user Komms identity private keys, contact petnames, group membership, or local + metadata; - authenticate a peer, establish trust, mint a Komms identity, decide message ordering, or advance a delivery state; - make a message depend on service availability after it has entered the @@ -67,6 +95,11 @@ emits a wake request only after a direct peer or mailbox relay has accepted the sealed envelope. The encrypted delivery receipt remains the only transition to `delivered`; a push-provider acknowledgement is never a message receipt. +Service operation still requires narrowly scoped service credentials such as a +libp2p identity, TLS key, or native-provider credential. Those keys grant no +user identity or message authority, remain separate from offline directory and +release-signing keys, and require explicit rotation and compromise procedures. + ### 3. The threat model distinguishes content safety from metadata exposure The following is the maximum honest claim for the optional layer: @@ -91,6 +124,19 @@ route or wake target. They do not make a peer, a service operator, or a global observer incapable of traffic analysis. Registrations made together may also be correlated operationally unless the client separates and anonymizes them. +The guarantees and controls must be labelled by their enforcement source: + +| Property | Enforced by | Residual operator ability | +|---|---|---| +| No message plaintext or user identity keys | End-to-end formats and APIs that never transmit those secrets | Observe network metadata and bounded ciphertext | +| No accepted message or user-record forgery | Client-side AEAD and account/device signature verification | Suppress, replay still-valid state, return garbage, or deny service | +| Bounded content leakage | Fixed-size records and capability-derived identifiers | Observe opaque slots/locators, timing, volume, and addresses | +| Reduced retention | RAM-backed state, disabled logs, short TTLs, and aggregate metrics | Change the deployment, inspect live memory, or be compelled at the host/network layer | + +Project control of client signing and updates is a separate supply-chain +boundary. A content-blind service cannot protect a user running a malicious +client build. + ### 4. Rendezvous is federated; native push egress has platform limits Recipients choose zero or more rendezvous providers and convey provider @@ -99,6 +145,14 @@ self-hostable, use provider-specific capabilities, and are never placed in a mandatory global list. Clients retain static/out-of-band and signed DHT hints alongside expiring rendezvous hints and may query redundant providers. +The official Standard profile ships a signed, versioned, user-editable +directory containing multiple bootstrap, mailbox, relay, and rendezvous +operators under different administrative domains. Directory signatures prove +configuration provenance, not trustworthiness or message authenticity. A +client retains the last valid directory, supports user-supplied providers, and +never deletes sovereign routes when a directory changes. No one listed +provider is required for identity, history, or protocol validity. + Native push is different. APNs and FCM credentials are bound to the distributed application identity and cannot safely be handed to arbitrary community operators. The official application may therefore use one or more controlled @@ -122,6 +176,13 @@ termination, kernel buffers, allocator copies, and a hostile host remain outside that guarantee. Native push state follows ADR-0019 and may use durable protected gateway keys or encrypted token mappings where availability requires it. +The initial founder-operated Hetzner pilot is limited to Standard-mode +bootstrap/DHT caching and ADR-0018 rendezvous under +[ADR-0034](0034-operator-minimized-reference-discovery.md). It is not a durable +mailbox, native-wake gateway, Private-mode non-colluding deployment, or +plural-operator proof. Its administrative domain, provider, source revision, +image digest, configuration, retention policy, and incidents are public. + ### 6. Failure always collapses toward the sovereign core Every optional client has bounded exponential backoff, jitter, a circuit diff --git a/docs/adr/0018-pairwise-rendezvous.md b/docs/adr/0018-pairwise-rendezvous.md index e44a97d..8bb0da0 100644 --- a/docs/adr/0018-pairwise-rendezvous.md +++ b/docs/adr/0018-pairwise-rendezvous.md @@ -7,10 +7,13 @@ Komms currently publishes signed prekey bundles under `H(IK)` in the Kademlia DHT. That path is necessary for first contact by kult address and remains -self-authenticating, but the signed bundle can contain current delivery hints -associated with the public identity. Once two peers have an authenticated -session, they can discover each other's changing internet routes through a -pairwise capability that a public-key scraper cannot calculate. +self-authenticating. A public bundle may contain recipient-selected +mailbox/relay introduction paths, but ADR-0017 no longer permits Standard or +Private mode to publish a current direct IP route under the stable account +lookup by default. Sovereign users may make that explicit tradeoff. Once two +peers have an authenticated session, they can discover each other's changing +internet routes through a pairwise capability that a public-key scraper cannot +calculate. A naive fixed slot `H(shared_secret || "locator")` is insufficient. It remains linkable for the life of the relationship, lets the service correlate repeated @@ -247,6 +250,9 @@ ADR-0017. - Established contacts gain private, rapidly expiring route discovery without exposing a public identity lookup to the provider. +- Standard first contact normally reaches a recipient-selected + mailbox/introduction path from the signed DHT bundle; rendezvous becomes + available only after that first authenticated handshake. - Each relationship costs multiple registrations across adjacent epochs; clients must stagger and coalesce work rather than burst every contact at launch. diff --git a/docs/adr/0020-authenticated-message-edits.md b/docs/adr/0020-authenticated-message-edits.md index 3ef4358..aaac216 100644 --- a/docs/adr/0020-authenticated-message-edits.md +++ b/docs/adr/0020-authenticated-message-edits.md @@ -52,11 +52,14 @@ local scheduled-message operation until activation creates an ordinary message. ### Authorization and resolution -The authenticated event sender must equal `target_author`. Resolution is scoped -to the same exact pairwise or group conversation. The target must decode as -canonical v1 `Text` and have that author and content id. A cross-author, -cross-conversation, self-referential, or wrong-kind edit is retained as an -invalid edit event for diagnostics but never changes presentation. +The event's sender field must equal `target_author`. In a pairwise lane that +sender has an authenticated individual origin. In the current shared sender-key +group lane it proves only that some member produced the event, so another member +can forge the claimed author. Resolution is scoped to the same exact pairwise or +group conversation. The target must decode as canonical v1 `Text` and have that +author and content id. A cross-author, cross-conversation, self-referential, or +wrong-kind edit is retained as an invalid edit event for diagnostics but never +changes presentation. An edit may arrive before its original. Clients store it durably, acknowledge it after storage, and resolve it if the exact target later arrives. Duplicate @@ -77,6 +80,11 @@ kind. Group edit is refused until every current co-member advertises it and the author is still a current member. One canonical edit plaintext is encrypted once through the existing sender-key fan-out. +This Accepted ADR defines the immutable edit state machine and pairwise +authorization. Its original individual-authorship goal is not met for groups by +the current sender-key construction. ADR-0029 must add recipient-verifiable +group origins before stable group-edit authorship is claimed. + A pre-edit client never receives an edit through the supported send path. If a capability race or future source delivers one anyway, ADR-0014 requires durable `Unsupported` handling; it must not display the replacement as a standalone @@ -117,13 +125,15 @@ and prior-version inspection. Display names never authorize or resolve edits. ## Consequences Edits converge under loss, duplication, reordering, partitions, restore, and -linked-device concurrency while retaining exact provenance. Storage grows -with edit count, so node APIs enforce at most 64 locally authored edit events per -target and reject further local sends. Every authenticated edit received from a -peer remains durable and participates in the same deterministic maximum; a local -admission limit cannot change convergence based on arrival order. History -derivation scans sealed local conversation records at alpha scale and can later -gain a sealed rebuildable index without changing the wire rule. +linked-device concurrency while retaining exact event history. Pairwise records +retain individual provenance; current sender-key group records retain only +membership-level provenance. Storage grows with edit count, so node APIs enforce +at most 64 locally authored edit events per target and reject further local +sends. Every accepted edit received from a peer remains durable and participates +in the same deterministic maximum; a local admission limit cannot change +convergence based on arrival order. History derivation scans sealed local +conversation records at alpha scale and can later gain a sealed rebuildable +index without changing the wire rule. Acceptance requires golden/fuzz/proptest coverage; edit-before-original, duplicate, stale, same-revision tie, cross-author, wrong-kind, removed-member, diff --git a/docs/adr/0022-convergent-group-polls.md b/docs/adr/0022-convergent-group-polls.md index cca7797..71de877 100644 --- a/docs/adr/0022-convergent-group-polls.md +++ b/docs/adr/0022-convergent-group-polls.md @@ -32,9 +32,10 @@ repeated sorted voter_peer_id(32) The common content id is the stable poll id. Option ids are random and unique within the poll; presentation order is the encoded order. The supported local API snapshots the exact sorted current roster and its generation. The payload -is creator-attested because a receiver cannot reconstruct an old roster after -arbitrary offline membership changes. The authenticated creator must appear in -the electorate. +declares a creator and electorate because a receiver cannot reconstruct an old +roster after arbitrary offline membership changes. The claimed creator must +appear in the electorate. Under the current sender-key construction, another +member can forge that creator claim. Vote (`operation = 2`) carries: @@ -43,12 +44,14 @@ version=1 || operation=2 || reserved(2)=0 poll_author(32) || poll_id(16) || option_id(16) || revision(8) ``` -The enclosing sender-key event authenticates the voter. A vote is valid only -for a listed electorate member and a stable option id. For each voter, the -current head is the maximum `(revision, vote content id)`. Revisions are -positive; supported local authors increment their own maximum and are capped at -64 vote events per poll. Duplicate and reordered records therefore converge -without clocks. Votes and voter identities are visible to every member that +The enclosing sender-key event protects the vote from outsiders and binds a +claimed voter, but it provides only membership-level authenticity: another +member can forge that claim. Structurally, a vote is valid only for a listed +electorate member and a stable option id. For each apparent voter, the current +head is the maximum `(revision, vote content id)`. Revisions are positive; +supported local authors increment their own maximum and are capped at 64 vote +events per poll. Duplicate and reordered records therefore converge without +clocks. Votes and apparent voter identities are visible to every member that holds the poll. Polls are single-choice and explicitly **not anonymous**. Closure (`operation = 3`) carries: @@ -59,13 +62,20 @@ poll_author(32) || poll_id(16) || head_count(1) || reserved(3)=0 repeated sorted voter(32) || vote_event_id(16) || option_id(16) || revision(8) ``` -Only the authenticated poll creator can close. Closure is an irreversible, -creator-attested snapshot of the visible vote heads the creator accepted at -that moment. This makes the final tally converge even when another replica -never received an underlying vote before a member was removed or a partition -ended. If multiple structurally valid creator closures exist, the smallest -closure content id wins. Closure is not proof that the creator observed every -vote, and no server fairness claim is made. +The sender field must claim the poll creator to close. Under the current shared +sender key, a malicious member can forge that claim; signed owner-moderated +closures are a distinct attributable control path. Closure is an irreversible +snapshot of the visible vote heads the apparent creator accepted at that moment. +This makes the final tally converge even when another replica never received an +underlying vote before a member was removed or a partition ended. If multiple +structurally valid creator closures exist, the smallest closure content id wins. +Closure is not proof that the creator authored it, observed every vote, or +counted fairly. + +This Accepted ADR defines deterministic replicated poll state. It does not +satisfy individual-origin authentication under the current sender-key group +construction. ADR-0029 must add recipient-verifiable origins before stable +claims about authenticated voters or creator closures. Question text is exact non-empty UTF-8 up to 1,024 bytes. A poll has 2–12 non-empty exact UTF-8 choices of at most 256 bytes each and 1–64 sorted unique @@ -104,18 +114,19 @@ same result without a backup version or database migration. rewrite history and additions could vote without receiving creation. - **Closure marker without vote heads.** Rejected because replicas missing a pre-close vote could permanently disagree on the final tally. -- **Anonymous label on authenticated votes.** Rejected as privacy theater. A - separate cryptographic anonymous-voting protocol would be required. +- **Anonymous label on membership-authenticated votes.** Rejected as privacy + theater. A separate cryptographic anonymous-voting protocol would be required. - **Creator-only private votes.** Rejected for v1 because sender-key fan-out delivers the same group event to all current members and the product needs an honest, inspectable rule. ## Consequences -Poll creators determine when to close and attest the final observed snapshot; -Komms guarantees deterministic convergence, not election fairness or secret -ballots. The fixed electorate and visible identities must be shown before -creation and voting. Parser fuzzing, changed/duplicate/reordered vote tests, -membership and old-client gates, closure conflicts, KKR1–KKR7 restore, C2 -owned-device convergence, strict -RPC/CLI, UniFFI, and all shell contracts are release requirements. +The apparent poll creator determines when to close and declares the final +observed snapshot; Komms guarantees deterministic convergence, not +malicious-member origin, election fairness, or secret ballots. The fixed +electorate and visible identities must be shown before creation and voting. +Parser fuzzing, changed/duplicate/reordered vote tests, membership and old-client +gates, closure conflicts, KKR1–KKR7 restore, C2 owned-device convergence, strict +RPC/CLI, UniFFI, and all shell contracts are release requirements. ADR-0029 and +member-forgery tests are additionally required for stable origin claims. diff --git a/docs/adr/0023-group-roles-and-owner-authority.md b/docs/adr/0023-group-roles-and-owner-authority.md index d4a7914..3b0cc44 100644 --- a/docs/adr/0023-group-roles-and-owner-authority.md +++ b/docs/adr/0023-group-roles-and-owner-authority.md @@ -133,10 +133,11 @@ and removal notices cannot regress a newer generation or owner epoch. ### Poll moderation -Ordinary poll closure remains creator-authored under ADR-0022. An admin may -request moderation; the owner emits a separately typed owner-signed moderation -closure containing the exact group id, poll author/id target, authority -generation, and final visible vote-head snapshot, signed under +Ordinary poll closure remains creator-claimed under ADR-0022 and is vulnerable +to sender-key member forgery until ADR-0029. An admin may request moderation; +the owner emits a separately typed owner-signed moderation closure containing +the exact group id, poll author/id target, authority generation, and final +visible vote-head snapshot, signed under `Komms-group-poll-moderation-v1`. Poll resolution accepts it only when the signature matches the owner in the referenced valid authority generation. The UI identifies it as owner moderation, never as the poll diff --git a/docs/adr/0024-account-authorized-linked-devices.md b/docs/adr/0024-account-authorized-linked-devices.md index 07e5d3c..e00a911 100644 --- a/docs/adr/0024-account-authorized-linked-devices.md +++ b/docs/adr/0024-account-authorized-linked-devices.md @@ -1,8 +1,16 @@ # ADR-0024: Account-authorized linked devices and convergent local sync -- **Status**: Accepted +- **Status**: Accepted for Alpha data/sync mechanics; permanent-revocation + claim withdrawn - **Date**: 2026-07-16 +> **Security correction (2026-07-26):** distributing the account private key +> to every linked device lets a compromised revoked device mint a new +> certificate and higher manifest. Exact known-id exclusion works among honest +> participants, but the “permanent revocation” statements below are not a +> security guarantee. [ADR-0026](0026-revocable-device-authority.md) specifies +> the required offline-root and majority-authorized replacement before stable. + ## Context Komms previously equated the stable account identity with one physical @@ -29,8 +37,9 @@ certificate containing the complete account/device public keys, a random The account also signs a complete, monotonically generated device manifest. Each row contains the immutable certificate, a bounded signed display name, a -coarse last-seen hint, and optional permanent revocation plus its last accepted -sync counter. Rows sort by device id. At most eight devices may be active and at +coarse last-seen hint, and optional exact-id exclusion plus its last accepted +sync counter. This is not permanent adversarial revocation while devices retain +the account root. Rows sort by device id. At most eight devices may be active and at most 64 lifetime certificate/tombstone rows are retained. A revoked certificate can never be rewritten, deleted, or made active again. @@ -118,10 +127,11 @@ every device active in the backup at the backup creation time. The recovered installation is the sole active row until another device is explicitly linked. `KKR1` through `KKR7` remain readable and migrate to the same model. -Revocation is permanent, exact-id targeted, explicitly confirmed in every -shell, and cannot revoke the current or last active device. A lost device may -retain plaintext it already saw; revocation protects future delivery and sync, -not retrospective erasure. +Revocation is exact-id targeted, explicitly confirmed in every shell, and +cannot revoke the current or last active device. Among honest participants it +excludes that known id from future delivery and sync. It is not permanent +against a device that retained the account root, and it never erases plaintext +already seen. ## Consequences @@ -131,10 +141,10 @@ not retrospective erasure. online account service. - Initial history transfer and later sync are bounded explicit operations; the current UI does not claim continuous background cloud sync. -- Account private-key availability on each linked device is a deliberate - authority tradeoff: it permits offline manifest progress and stable message - authorship. Independent device credentials still make delivery separation, - revocation, and ratchet safety enforceable. +- Account private-key availability on each linked device was an unsafe Alpha + authority tradeoff. Independent device credentials still separate delivery + and ratchets, but cannot make adversarial revocation enforceable while every + device can mint a replacement credential. - Same-generation manifest forks converge deterministically, not fairly. A malicious account-authorized device can race authority changes until revoked. - Last-seen, sync completion, and per-device delivery indicators must retain diff --git a/docs/adr/0026-revocable-device-authority.md b/docs/adr/0026-revocable-device-authority.md new file mode 100644 index 0000000..2e623e8 --- /dev/null +++ b/docs/adr/0026-revocable-device-authority.md @@ -0,0 +1,157 @@ +# ADR-0026: Offline account authority and quorum-authorized devices + +- **Status**: Proposed +- **Date**: 2026-07-26 +- **Supersedes on acceptance**: the device-authority and recovery rules in + [ADR-0024](0024-account-authorized-linked-devices.md); its per-device + ratchet, delivery, group-chain, and convergent-sync boundaries remain. + +## Context + +ADR-0024 gives every linked device the stable account private key. That makes +offline linking simple, but it also makes permanent revocation impossible: a +lost device retains the authority needed to issue a new device certificate and +sign a higher-generation manifest after its old certificate is revoked. + +The stable account address must survive ordinary device changes. Each physical +device must still have independent ratchets and sender chains, and linking must +remain possible without a project account service. At the same time, a +single compromised secondary device must not be able to add a replacement +credential, remove honest devices, or override a later recovery. + +This is a pre-stable alpha protocol. Preserving an insecure authority layout +for silent compatibility is less important than establishing a recoverable +model before people rely on it. + +## Decision + +### 1. The account root is an offline recovery authority + +The stable account Ed25519 key remains the address and trust anchor. Its private +key signs the genesis device manifest and is then removed from the live Komms +store. It is represented only by the user's encrypted recovery material and +may be opened transiently for an explicit recovery operation. + +No device-link package, backup intended for routine device migration, sync +event, or online device state contains the account private key. Platform secure +storage may protect device keys, but an exportable copy of the account root is +not retained as a convenience credential. + +Ordinary messages and state changes are signed or authenticated by independent +device keys. A device certificate and the accepted manifest chain bind those +keys to the stable account. Verifiers never require an online account-root +signature for ordinary messaging. + +### 2. Manifest transitions are authorized by the previous active set + +The manifest becomes an append-only transition chain. Every transition commits +to: + +- the account public key; +- the exact parent manifest hash and generation; +- the complete next active/revoked device set; +- any newly introduced immutable device certificates; +- a transition kind and random transition id; and +- signatures from a strict majority of the parent's active devices. + +A sole active device can link a second device. Once two or more devices are +active, no single device can add, revoke, rename, or replace credentials. +Losing enough devices to satisfy the majority requires account-root recovery; +availability is not purchased by giving every device unilateral account +authority. + +Concurrent valid transitions from one parent are forks, not mergeable sets. +Clients retain the first accepted branch, surface a safety event for a +conflicting branch, and require account-root recovery to converge them. +Lexicographic state-id ordering must not silently choose account authority. + +### 3. Recovery is a root-authorized epoch change + +An explicit recovery opens the account root, names the last manifest known to +the recovering user, and signs a recovery transition with a monotonically +stored recovery epoch and a random recovery id. The transition: + +- revokes every previously active device; +- introduces exactly one fresh recovery device; +- rotates sync, rendezvous, wake, group-sender, and per-device delivery state; + and +- makes device-quorum transitions descending from an older recovery epoch + invalid, regardless of their numeric manifest generation. + +Clients persist the greatest accepted recovery epoch and its transition hash. +A conflicting root-signed transition at the same epoch is a visible recovery +conflict and requires safety-number re-verification; it is never resolved +silently. Old root transitions, device-only descendants of an old epoch, and +attempted un-revocation fail closed. + +The account root remains the ultimate takeover secret. Recovery UI therefore +states that anyone with the recovery material controls the account, and rate +limits mnemonic attempts without sending recovery material to a service. + +### 4. Linking remains an ordinary-user ceremony + +The visible flow remains scan, compare, and confirm. Underneath it: + +1. the candidate creates an independent device key; +2. the active devices sign one canonical proposed transition; +3. the candidate activates only after the strict-majority proof and manifest + chain verify; and +4. selected history is transferred over the existing transcript-bound sealed + channel. + +The UI asks for additional active-device approval only when the threshold +requires it. Recovery words are required when too many devices are unavailable. +The network has no account server and no operator can approve a device. + +### 5. Alpha migration fails honestly + +An account that never exported its root to another device may migrate in place: +it issues a genesis device transition, exports recovery material, verifies that +the live database no longer contains the root, and retains its address. + +An ADR-0024 account that distributed the root to another device cannot prove +that every copy was erased. It must perform an explicit authority reset. Before +stable wire v1, the safe default is a new account identity with guided contact +re-verification. A compatibility tool may preserve local history and petnames, +but it must not label the old safety number or device revocations as preserved. + +No automatic migration claims to repair an already copied root. + +## Alternatives considered + +### Keep the account root on every device and remember revoked ids + +Rejected. A revoked holder can mint a new id that does not appear in the +revocation set and sign the next manifest. + +### Designate one permanently privileged online owner device + +Rejected. It converts theft or compromise of that device into silent account +takeover and makes loss of that device a special availability failure. + +### Require the offline root for every link or revocation + +Rejected. It is cryptographically simple but makes normal multi-device use +unnecessarily hostile. Majority transitions handle routine changes; the root +handles loss and conflict. + +### Let deterministic ordering choose between authority forks + +Rejected. Deterministic convergence is suitable for ordinary replicated data, +not for choosing which attacker-controlled device set owns an identity. + +## Consequences + +- Linked devices no longer receive the one secret that can recreate account + authority after revocation. +- Normal linking remains service-independent and usually needs only the + devices already in the user's hands. +- Losing half or more of the active devices requires recovery material. This + is an intentional safety boundary and must be rehearsed in onboarding. +- Manifests carry a bounded proof chain or checkpoint from the last state a + contact accepted; codecs and storage need explicit versioning and limits. +- Existing multi-device alpha identities require a visible authority reset and + possibly a new safety number. +- Acceptance requires stolen-device, offline-majority, fork, replay, old + backup, and recovery-conflict tests before ADR-0024's permanent-revocation + claim can return. diff --git a/docs/adr/0027-opaque-indexed-store.md b/docs/adr/0027-opaque-indexed-store.md new file mode 100644 index 0000000..c3d196e --- /dev/null +++ b/docs/adr/0027-opaque-indexed-store.md @@ -0,0 +1,180 @@ +# ADR-0027: Versioned opaque indexes and row-bound sealed storage + +- **Status**: Proposed +- **Date**: 2026-07-26 + +## Context + +Komms seals record bodies, but the current SQLite schema uses plaintext account +public keys and group ids as primary keys. A copied locked database therefore +reveals exact contact and group relationships. Several tables also seal every +row under constant associated data, so a local database writer can transplant +valid ciphertext between identifiers without the store proving that the inner +record belongs to the requested row. + +The schema has no versioned migration ledger. Additive `CREATE TABLE IF NOT +EXISTS` calls cannot safely express index replacement, authenticated row +identity, or large-history query plans. Updating or deleting a message scans +and decrypts the full history. + +SQLite row deletion and flash storage do not support an honest guarantee that +deleted bytes are physically unrecoverable. The design must protect a locked +copy, resist row substitution, scale to ordinary histories, and state deletion +limits accurately without inventing forensic erasure. + +## Decision + +### 1. Every database has a random identity and explicit schema version + +Creation writes a random 32-byte `database_id`, `schema_version`, and completed +migration ledger inside authenticated metadata. The database id is not a user +identifier and is never copied during restore. Backups contain logical records, +not database ids, indexes, SQLite pages, or wrapped row ciphertext. + +Opening refuses unknown future schemas, incomplete migrations, duplicate +migration ids, or metadata whose authenticated version disagrees with the +physical schema. Released migration fixtures cover every public schema +version. + +### 2. Sensitive equality keys become domain-separated keyed indexes + +The storage master derives a non-exported index root. Each table derives a +separate key and computes: + +```text +table_index = HMAC-SHA-256( + K_index_table, + "Komms-Store-Index-v2" || canonical_logical_key +) +``` + +Canonical keys include a type/domain byte and fixed-width fields. Examples are +account public key, group id, `(group id, account key)`, message id, and +conversation direction. The same logical value produces unrelated indexes in +different tables and unrelated databases. + +SQLite stores only these 32-byte indexes, random row ids, sealed blobs, and the +minimum integer ordering needed for pagination. Static schema metadata and +approximate row counts/sizes remain observable. No contact key, group id, +delivery token, message id, media id, or search term is a plaintext index. + +### 3. AEAD binds a row to its database, schema, table, and index + +Every sealed row uses canonical associated data: + +```text +"Komms-Store-Row-v2" || +database_id || +u32_be(schema_version) || +table_domain || +row_locator +``` + +`row_locator` is the keyed index for equality tables and a random 16-byte row id +for append-only tables. A decoded record must reproduce the expected canonical +logical key before it is returned. Index mismatch, inner-key mismatch, unknown +record version, duplicate unique index, or cross-database/table transplant is +corrupt state. + +Record plaintext starts with its own version and logical key. This permits +bounded record migrations without asking the caller to infer which historical +layout happened to decode. + +This prevents undetected substitution and transplantation. It does not prevent +an attacker with write access from deleting the newest database, restoring an +older complete snapshot, or denying access. Rollback detection needs an +optional platform monotonic anchor and remains a separate capability. + +### 4. Migration builds a new sibling database + +The plaintext-index schema is not migrated in place. After unlocking the old +store, Komms: + +1. creates a private sibling database with a fresh database id; +2. validates and decrypts each old row under strict limits; +3. verifies every inner logical identity; +4. writes the v2 representation in bounded transactions; +5. validates counts, referential rules, and a complete reopen; +6. fsyncs the new database and containing directory; and +7. atomically replaces the active path while retaining an explicitly named, + user-removable rollback copy until the new version has opened successfully. + +WAL/SHM files are checkpointed and excluded from the replacement. The old file +may still exist in filesystem snapshots, backups, SSD remapping, or recovered +blocks; UI and documentation do not describe migration as secure deletion. + +Restore uses the same new-sibling, validate, fsync, and atomic-rename process. +It never leaves a partially restored path that looks like a valid store. + +### 5. Query indexes are private and bounded + +Messages and group history gain keyed conversation and message-id indexes plus +cursor pagination. Exact update/delete is one indexed lookup, not a full-table +decrypt. Lists decrypt only the requested bounded page and use a stable opaque +cursor. + +A future full-text index uses separately keyed HMAC terms, fixed limits, and an +explicit leakage statement. Until that implementation ships, documentation +labels sealed search as planned. + +Benchmarks cover at least 100,000 and 1,000,000 message rows with budgets for +unlock, conversation-page latency, exact update/delete, migration memory, and +database growth. + +### 6. Deletion claims are logical and best-effort locally + +Deleting a record removes the live logical row and all application references. +Komms enables SQLite secure-delete behavior where supported, checkpoints and +truncates its WAL at bounded maintenance points, protects database/WAL/SHM and +lock files with owner-only permissions, and excludes them from platform cloud +backup unless the user explicitly exports an encrypted Komms backup. + +These measures reduce remnants; they do not promise forensic erasure from +flash, filesystem snapshots, an adversary's prior copy, relay ciphertext, or a +recipient device. User copy therefore says “removed from this Komms history” +rather than “deleted for real.” + +## Alternatives considered + +### Encrypt only the whole SQLite file + +Rejected as the sole control. Page encryption is useful defense in depth, but +does not by itself define authenticated logical identities, portable record +versions, safe migrations, or query leakage. A reviewed page-encryption layer +may be added underneath this design. + +### Store SHA-256 of public identifiers + +Rejected. Account keys and group ids are known or guessable to an attacker, so +an unkeyed hash preserves the social-graph oracle. + +### Keep plaintext indexes because record bodies are sealed + +Rejected. The social graph is an explicit protected asset, not harmless +database metadata. + +### Drop old tables and `VACUUM` in place + +Rejected. It creates complex failure states and cannot establish that old +plaintext keys have left WAL, freelist, snapshot, or physical storage. + +### Promise cryptographic erasure through per-record keys + +Rejected for v2. Deleting a wrapped per-record key from the same copy-on-write +storage has the same remanence problem. A future hardware-backed key ledger may +offer stronger local guarantees, but it must be measured and narrowly stated. + +## Consequences + +- A locked copied database no longer exposes exact account and group + identifiers through its schema. +- Equality queries and history pagination become efficient without plaintext + social-graph indexes. +- Every store operation and backup/restore path must handle explicit record and + schema versions. +- Migration requires temporary space approximately equal to another database + and must surface that requirement before starting. +- Database replacement and old-file cleanup need platform-specific + qualification. +- Marketing loses an absolute deletion slogan and gains a claim the + implementation can defend. diff --git a/docs/adr/0028-atomic-protocol-commits.md b/docs/adr/0028-atomic-protocol-commits.md new file mode 100644 index 0000000..993c85a --- /dev/null +++ b/docs/adr/0028-atomic-protocol-commits.md @@ -0,0 +1,183 @@ +# ADR-0028: Atomic protocol-state commits + +- **Status**: Proposed +- **Date**: 2026-07-26 + +## Context + +Double Ratchet, handshake, and sender-key steps destroy or replace secrets as +they progress. The current node persists that advanced cryptographic state in +separate SQLite autocommits from message history, replay markers, receipt +routes, delivery rows, and outbound queue entries. A process stop or disk error +between writes can make a valid inbound ciphertext permanently undecryptable or +advance an outbound chain without retaining the only ciphertext produced by +that step. + +Deferred inbox rows also need at-least-once processing: removing a row before +all durable consequences commit turns an ordinary crash into message loss. + +The runtime is intentionally single-writer, but that property is not enough. +One logical protocol transition must become one durable transaction, and +in-memory state and user-visible events must never get ahead of that commit. + +## Decision + +### 1. The node prepares immutable typed commit plans + +Before changing live state, `kult-node` clones the relevant session, prekey +vault, group chain, group record, and delivery state. It performs cryptographic +work only on those candidate values and creates one bounded typed commit plan: + +- `PairwiseSend`; +- `PairwiseReceive`; +- `HandshakeReceive`; +- `GroupSend`; +- `GroupReceive`; +- `ReceiptReceive`; or +- `Maintenance`. + +Each plan contains the complete before-state identity/version, resulting sealed +logical records, exact queue mutations, replay/seen mutations, source pending +row if any, and events to emit after success. A generic collection of arbitrary +SQL statements is not a public node API. + +The plan validates internal invariants before opening a SQLite transaction: +every produced envelope has one queue/delivery owner, every advanced chain has +its ciphertext or accepted plaintext consequence, and every consumed one-time +secret has the session whose establishment consumed it. + +### 2. One `BEGIN IMMEDIATE` transaction owns every durable consequence + +The store applies a plan in one `BEGIN IMMEDIATE` transaction. + +`PairwiseSend` commits together: + +- the advanced sending session; +- immutable local history, when the operation creates history; +- one durable envelope per target device; +- per-device and aggregate delivery state; and +- any capability or scheduled-message transition consumed by the send. + +`PairwiseReceive` commits together: + +- the advanced receiving session; +- accepted immutable history or typed control state; +- seen/replay state; +- the encrypted receipt and its corresponding sending-session advancement; +- expiry/tombstone state; and +- acknowledgement of the exact deferred-inbox row, if present. + +`HandshakeReceive` additionally commits one-time-prekey removal and the new +session. An unknown sender enters the bounded contact-request quarantine; it +does not become a trusted contact merely because the cryptographic handshake +was valid. + +Group plans commit the group sender/receiver chain, group generation or pending +announcement state, immutable history/control state, all fan-out envelopes and +delivery rows, replay state, and deferred-row acknowledgement together. + +Transport I/O, native wake, rendezvous lookup, filesystem export, UI callbacks, +and event fan-out never occur inside the database transaction. + +### 3. Memory and events change only after commit + +The live node replaces its in-memory candidate state only after the store +returns a successful commit receipt containing the committed transaction id and +record ids. User-visible events are emitted from that receipt. + +Any serialization, sealing, quota, constraint, disk, or commit error discards +candidate state and produces no success event. Retrying the original ciphertext +starts from the unchanged durable state. + +The store transaction may commit successfully while the process stops before +memory/event update. On restart, durable state is authoritative. A bounded +post-commit event outbox or snapshot resynchronization reproduces presentation +without replaying a ratchet step. + +### 4. Deferred work is leased and acknowledged after consequence commit + +Reading a pending envelope never removes or rewrites it. Processing names its +stable row id in the commit plan, and the same transaction that stores the +message/session consequences deletes that one row. + +Retryable rows remain durable under their original id. Expired or permanently +invalid rows use a bounded maintenance plan that records the terminal reason +where diagnostics require it and deletes only the named row. Pending work is +read in bounded pages; one corrupt row is quarantined rather than preventing +later valid rows from being considered. + +### 5. The store is one writer across processes + +Opening or creating a store requires a non-blocking exclusive advisory lock +held for the complete `Store` lifetime. A second daemon or embedded runtime +receives a typed already-open failure before it loads mutable protocol state. +The daemon also refuses to unlink a socket that accepts a live connection. + +The advisory lock complements SQLite transactions; it does not replace them. +On Unix, the implementation combines the canonical no-follow sidecar with a +lock on the opened database inode, so a hardlink alias cannot create a second +cooperative writer. Equivalent file-identity qualification remains required on +other supported platforms. + +### 6. Crash injection is release evidence + +Tests insert deterministic failures: + +- before and after every candidate cryptographic step; +- before each transaction statement; +- before commit, after commit, and before in-memory replacement; +- during disk-full/constraint failures; and +- during restart with deferred, duplicated, reordered, or partially delivered + carrier input. + +For every injected point, restart must produce exactly one of two states: + +1. the complete transition is absent and the original input remains safely + retryable; or +2. the complete transition is durable and replay is idempotently absorbed. + +No accepted state may contain a ratchet/chain step without its only ciphertext +or plaintext consequence. + +The first implementation slice covers ordinary retained pairwise text: the +candidate receiving ratchet, optional history row, seen marker, sealed receipt +replay route, and exact deferred-row acknowledgement commit together. It is +deliberately not evidence that handshake, receipt, attachment, ephemeral, +call-control, group-control, group-message, or outbound transitions are atomic; +each still needs a typed plan and crash matrix. + +## Alternatives considered + +### Repair individual failure windows with compensating writes + +Rejected. A compensating write cannot reconstruct a destroyed message key after +a crash, and each new feature would create another unreviewed ordering graph. + +### Persist the advanced session before doing anything else + +Rejected. It prevents key reuse but loses messages. Safety requires the state +advance and its consequence to be one commit. + +### Queue ciphertext in memory and rely on transport retries + +Rejected. A process stop loses the only ciphertext produced by an advanced +sending chain. + +### Put the entire node tick in one database transaction + +Rejected. Network and carrier work can block, transaction latency becomes +unbounded, and unrelated conversations interfere. Transactions are scoped to +one logical protocol transition. + +## Consequences + +- Store APIs become transition-oriented rather than a collection of unrelated + row setters. +- Node code must separate candidate state from live state and delay events. +- Receipt generation is part of receive-state planning instead of a later + best-effort write. +- Group fan-out may create larger but explicitly bounded transactions. +- Deferred processing, restore, and presentation recovery gain deterministic + crash semantics. +- Acceptance requires failpoint and restart evidence, not only happy-path + round trips. diff --git a/docs/adr/0029-recipient-authenticated-groups.md b/docs/adr/0029-recipient-authenticated-groups.md new file mode 100644 index 0000000..9ad7b41 --- /dev/null +++ b/docs/adr/0029-recipient-authenticated-groups.md @@ -0,0 +1,155 @@ +# ADR-0029: Recipient-authenticated sender-key groups + +- **Status**: Proposed +- **Date**: 2026-07-26 +- **Supersedes on acceptance**: the membership-level-authenticity tradeoff in + [ADR-0012](0012-sender-key-groups.md). Sender-key encryption, one shared + ciphertext, recipient-scoped delivery, and bounded groups remain. + +## Context + +The current group construction gives every member each sender's symmetric +chain key and deliberately omits a signature. Any member can therefore create +a valid ciphertext under another member's chain. The delivery token usually +identifies the actual pairwise sender, but it is a routing capability rather +than cryptographic origin authentication and can be observed and copied on +shared carriers. + +Membership-level authenticity is insufficient for author-sensitive state such +as edits, votes, role requests, moderation, and disappearing-message +authorship. At the same time, Komms should retain one group ciphertext per +message, recipient-deniable transcripts, and the existing per-recipient +envelopes needed by mailbox collection and receipts. + +## Decision + +### 1. Each sender chain has a distinct origin key per recipient device + +When a device distributes or rotates its group sender chain, every recipient +device receives an additional random 32-byte `origin_key` over that device's +authenticated pairwise session. Origin keys are unique to: + +- group id; +- sender account and physical device; +- sender-chain key id; and +- recipient account and physical device. + +The shared sender chain and group ciphertext remain unchanged. Origin keys are +never shared with another recipient, included in a group broadcast, exported +in backups, or reused after membership/device/session reset. + +The recipient knows its own origin key and can fabricate a transcript addressed +only to itself. It cannot forge the same sender to another recipient. This +preserves the ordinary deniability property that a receiver cannot +cryptographically prove its own transcript while preventing one malicious +member from changing honest members' group state as somebody else. + +### 2. Every per-recipient envelope authenticates the shared ciphertext + +For each existing recipient-scoped envelope, the sender computes: + +```text +origin_tag = HMAC-SHA-256( + origin_key, + "Komms-Group-Origin-v1" || + group_id || + sender_account || + sender_device || + recipient_account || + recipient_device || + sender_chain_key_id || + envelope_content_id || + authenticated_retention || + SHA-256(shared_group_ciphertext) +) +``` + +The exact fixed-width encoding is versioned and contains no ambiguous +concatenation. The wire body is a bounded wrapper containing the shared group +ciphertext and 32-byte tag. The group id and identities remain inside +authenticated calculations; they are not added as plaintext routing metadata. + +The receiver first maps the delivery token to one pairwise sender device, opens +the group header without advancing a chain, selects the exact receiving chain +and origin key, verifies the tag in constant time, and only then advances and +decrypts the sender chain. Unknown keys are retryable while an authenticated +announce may still be in flight. A bad tag, wrong recipient/device, replay, or +identity mismatch is a terminal invalid envelope and never mutates group state. + +### 3. Author-sensitive content requires origin authentication + +Text and attachments use the same authenticated wrapper for one uniform group +path. Edits, polls/votes, expiry events, role/admin requests, moderation, +ownership operations, and device-sync imports additionally refuse legacy +membership-authenticated group messages. + +The stored author is taken only from the verified pairwise device certificate, +never from a plaintext content field, sender-chain key id, delivery token +alone, petname, or group roster position. + +### 4. Rotation and removal erase origin capability + +Sender-chain rotation creates fresh per-recipient origin keys. Removing an +account or device deletes its outgoing key at senders and its incoming keys at +the removed device's honest local state. Surviving members rotate their sender +chains and origin keys under the same generation transition. + +A removed or compromised member may retain old group and origin keys it already +saw. Generation, chain-id, roster, recipient-device, and replay binding prevent +those keys from authenticating new-generation traffic. + +### 5. Existing alpha groups upgrade visibly + +The sender-key announce and group-message wrapper receive explicit new +versions. Existing groups enter a bounded “security upgrade required” state: +all active members redistribute fresh chains and origin keys before new +author-sensitive content is accepted. + +Legacy messages remain readable as historical membership-authenticated content +and are labelled accurately. They are never rewritten to appear +individually origin-authenticated after the fact. + +## Alternatives considered + +### Keep delivery-token matching as the author check + +Rejected. A delivery token is observable routing data and was not designed to +prove that the member holding a shared sender chain created a ciphertext. + +### Add an Ed25519 signature to every group message + +Rejected for this profile. It is compact and publicly verifiable, but creates a +transferable sender proof for every ordinary message. Recipient-specific MACs +fit the existing per-recipient envelope fan-out and retain receiver +deniability. + +### Encrypt the complete message separately for every recipient + +Rejected. It discards the sender-key bandwidth benefit and multiplies group +encryption and radio payload cost. This decision adds only one bounded tag to +each envelope while retaining one shared ciphertext. + +### Keep membership-level authenticity but disable polls and edits + +Rejected. It removes useful everyday features without repairing misleading +ordinary message authorship. + +### Move all current groups directly to MLS + +Deferred. MLS is the preferred standards path for larger and more dynamic +groups, but a constrained multipath/radio profile and migration require +separate interoperability work. Current small groups still need honest author +authentication. + +## Consequences + +- A group ciphertext is still produced once, then wrapped with one 32-byte tag + per recipient envelope. +- Group announce state grows by one 32-byte secret per sender-chain/recipient + device pair and must stay within existing group/device bounds. +- Recipients can forge their own local transcripts, but cannot forge another + member's vote/edit/message to honest third-party recipients. +- Device identity becomes part of group author verification and must follow the + revocable authority model in ADR-0026. +- Codec, fuzz, reorder, malicious-member, shared-mesh, device-removal, and + legacy-upgrade tests are release requirements. diff --git a/docs/adr/0030-first-contact-admission.md b/docs/adr/0030-first-contact-admission.md new file mode 100644 index 0000000..808fd40 --- /dev/null +++ b/docs/adr/0030-first-contact-admission.md @@ -0,0 +1,172 @@ +# ADR-0030: Bounded first-contact admission and consent + +- **Status**: Proposed +- **Date**: 2026-07-26 + +## Context + +A Komms address intentionally lets somebody obtain a signed prekey bundle and +calculate its introduction delivery token. The current receive path performs +Ed25519, X25519, ML-KEM, storage, and session work for that first contact and +then creates a normal contact automatically. The documentation instead +promises a request inbox, proof-of-work, blocking, and optional reputation +inputs. + +Open discovery without an admission boundary lets strangers spend endpoint +CPU, battery, memory, one-time prekeys, disk, notifications, and operator relay +capacity. Decentralization removes a mandatory moderator; it does not remove +the recipient's consent boundary. + +Admission must work through direct internet, mailbox, mesh, and delayed file +delivery. It must be cheap to reject before expensive cryptography where +possible, bounded after decryption, local-first, and invisible during an +ordinary accepted invitation flow. + +## Decision + +### 1. The signed bundle advertises a target-specific admission policy + +Every public prekey bundle contains a signed, expiring admission descriptor: + +- descriptor version and bundle digest; +- validity epoch and maximum clock-skew window; +- recipient-selected puzzle profile and difficulty; +- maximum first-message ciphertext size; +- optional invitation capability commitment; and +- supported anonymous admission-token issuers, if any. + +The default public profile requires a bounded SHA-256 client puzzle tied to the +recipient account, exact bundle digest, validity epoch, introduction envelope +content id, and random nonce. Verification is constant-memory and occurs before +ML-KEM decapsulation. Difficulty changes only in a newly signed descriptor; +relays and senders cannot raise it for the recipient. + +An authenticated, time-bounded invitation capability conveyed by QR/link/file +may replace the public puzzle. It is single-recipient, rate-bounded, and reveals +no contact petname or future session material. Ordinary invite onboarding +therefore feels immediate while unsolicited public-address contact pays the +admission cost. + +IP cookies and subnet limits may protect a direct listener, but they are +defense in depth: they cannot be the protocol admission rule because mailboxes, +carrier NATs, Tor, mesh, and sneakernet do not preserve a useful source IP. + +### 2. Resource budgets precede expensive work + +Every carrier enforces the canonical envelope byte limit. The node additionally +has fixed global budgets for concurrent introduction verification, puzzle work, +KEM work, provisional rows, total provisional bytes, notification rate, and +per-tick admission time. Mailboxes and bridges have independent byte/item/rate +budgets and never promise acceptance after a quota refusal. + +For an interactive carrier, a next-hop `accepted` response is sent only after +the node has either consumed the envelope successfully or transactionally +staged it in the bounded durable admission inbox. A transport-level RAM queue +may prefilter bytes and apply backpressure, but it is not the final acceptance +boundary. The carrier therefore hands the candidate and a response channel to +the node, or writes it to an authenticated durable spool the node owns; it does +not acknowledge an unknown token and discover the durable quota later. + +Syntactically invalid, expired, under-difficulty, oversized, duplicate, or +over-budget introductions are rejected before KEM work and never become +pending generic envelopes. Failure responses have bounded uniform shapes and +do not reveal whether a target has free request-inbox capacity. + +### 3. A valid stranger becomes a provisional request, not a contact + +After a proof passes, the node processes the hybrid handshake into candidate +state and decrypts only the bounded request preview. In one atomic store +transition it: + +- consumes the exact one-time prekey, if used; +- seals a provisional session isolated from normal send/group APIs; +- records the verified account/device identity and safety number; +- stores the request id, arrival time, transport class, and bounded preview; + and +- inserts one sealed request-inbox row. + +It does not create a trusted contact, expose normal history, advertise +capabilities, accept group membership, start media, send a delivery receipt, or +make the provisional session available to unrelated content. + +The request inbox is fixed-count and fixed-byte. One identity may hold at most +one live request; a replacement follows deterministic newest-valid rules +without increasing capacity. Expiry removes provisional keys and content. + +### 4. Accept, reject, block, and invite are explicit state transitions + +Accept atomically promotes the provisional identity/session, creates the local +contact petname, stores the first message in normal history, and sends the +encrypted acceptance/delivery result. The user can verify the safety number +before or after acceptance under the existing trust states. + +Reject deletes provisional state and creates only a short bounded replay +tombstone. Block creates a sealed local rule keyed by the exact account/device +identity and rotates public invitation capability material where required. +Blocking also removes wake/rendezvous capability, queued copies, group invites, +and provisional state without claiming remote deletion. + +Group invitations use the same consent model. Receipt of an authenticated +invite never silently adds a user to a group, exposes its membership, downloads +media, or creates mesh airtime before acceptance. + +### 5. Safety tools remain local and user-controlled + +Mute, delete, block, and evidence export are available without a project +account. Evidence export is an explicit local action that warns the user it +reveals selected plaintext and cryptographic context to whoever receives it. + +Clients may subscribe to signed block/reputation lists with provenance, expiry, +scope, and an inspectable local decision. Lists never form a hidden global ban +service, never revoke an identity, and never prevent a user from overriding a +non-local recommendation. + +### 6. Everyday UI hides admission mechanics + +An invitation opens as “Connect with …” and normally bypasses visible puzzle +work. An unsolicited valid introduction appears as a familiar **Message +request** with Accept, Delete, and Block. Technical network, proof, and provider +details stay in diagnostics. + +When the endpoint is busy, the sender sees an honest generic retry state rather +than a claim that the recipient read or rejected the request. + +## Alternatives considered + +### Automatically trust every valid cryptographic handshake + +Rejected. Cryptographic identity proves who controls a key; it does not express +the recipient's consent or grant unlimited endpoint resources. + +### Rely only on IP/subnet rate limits + +Rejected. They punish carrier NATs and Tor exits, are absent on delayed +carriers, and are cheap to distribute around. + +### Require a central CAPTCHA or account reputation service + +Rejected. It creates a first-contact authority, accessibility and censorship +failure, and a global social-graph observation point. + +### Perform KEM first so the puzzle policy can remain private + +Rejected. It gives attackers the expensive operation the admission layer is +supposed to protect. Policy privacy is less valuable than endpoint safety. + +### Silently drop all unknown senders + +Rejected. Public reachability is useful, and everyday users understand a +bounded message-request inbox. + +## Consequences + +- First contact becomes a separate provisional state machine and storage + domain. +- Public bundles and handshake/envelope versions change before stable wire v1. +- Invitations provide the fast consumer path; public unsolicited contact pays + adjustable anti-abuse cost. +- Proof-of-work raises attacker cost but cannot defeat a large distributed + adversary, so hard resource quotas remain mandatory. +- Acceptance, rejection, blocking, group-invite consent, prekey exhaustion, + flood, Sybil, battery, disk-full, replay, and delayed-carrier cases become + release tests. diff --git a/docs/adr/0031-capability-scoped-dht-discovery.md b/docs/adr/0031-capability-scoped-dht-discovery.md new file mode 100644 index 0000000..13294b5 --- /dev/null +++ b/docs/adr/0031-capability-scoped-dht-discovery.md @@ -0,0 +1,178 @@ +# ADR-0031: Capability-scoped DHT first-contact discovery + +- **Status**: Proposed +- **Date**: 2026-07-26 + +## Context + +The current DHT key is a stable hash of the account identity. Its signed value +is plaintext and may contain every current direct address and mailbox route. +Anybody who learns an account address can poll that stable key and recover an +identity-to-route timeline. Rotating a locator derived only from the public +identity changes the key but not that capability: every address holder can +derive the same rotation. + +Komms must retain DHT first contact and offline mailbox reachability without +making a public identity fingerprint a permanent global route lookup. Normal +users should still scan, tap, or paste one familiar connect artifact; the +network mechanism remains hidden. + +## Decision + +### 1. Identity and discovery capability are separate + +The existing account fingerprint remains the stable identity and safety-number +input. A new versioned **Connect code** additionally carries a random 32-byte +discovery capability and checksum. The capability is generated locally, stored +sealed, included in encrypted recovery, and normally exchanged by QR, link, or +file rather than typed. + +The capability is a bearer reachability secret, not an anonymity promise. +Publishing a Connect code publicly intentionally makes that identity publicly +contactable. It can nevertheless be rotated without changing identity or +safety numbers. + +### 2. Weekly DHT locators are capability-derived + +For weekly epoch `e`: + +```text +locator(e) = HMAC-SHA-256( + discovery_cap, + "Komms-DHT-Locator-v2" || u64_be(e) +) +``` + +The record key is `/kk/prekeys/2/ || locator(e)`. A distinct HKDF-SHA-256 key +derived from the capability, locator, and domain seals the value with +XChaCha20-Poly1305. Identity hashes, delivery tokens, mailbox keys, and +post-pairing rendezvous exporters are never reused for this purpose. + +For `e = floor(unix_time / 604800)`, clients publish exactly epochs +`e-1..=e+4`: one previous grace record, the current record, and four future +records. A record is client-valid only from 24 hours before its encoded epoch +through 24 hours after that epoch ends. A lookup tries only its local epoch and +the adjacent epoch on either side, so one operation requests at most three +locators. + +Daily jittered maintenance republishes the six-record window and republishes +immediately after prekey, device-authority, admission-policy, mailbox, or +capability changes. DHT record TTL must preserve a future record through its +encoded validity end. The four-week offline publication promise is qualified by +DHT availability: malicious peers may still suppress a record, and a client +uses introduction mailboxes, alternate bootstrap peers, or out-of-band exchange +when lookup fails. + +### 3. Records are fixed-size, signed, and bounded + +Every v2 value has one exact outer size and binds: + +- record version, locator, epoch, generation, issue and expiry time; +- account identity and the accepted ADR-0026 device-authority proof; +- at most two bounded ingress-device prekey bundles; +- at most three introduction-mailbox routes; +- the ADR-0030 admission descriptor; and +- zero padding plus an account/device-authority signature over the complete + canonical record digest. + +The exact size and authority encoding are frozen only after ADR-0026 is +accepted. Records carry no one-time prekey, detailed feature fingerprint, local +path, mesh node, spool path, or unrestricted address list. + +Kademlia peers cannot validate the sealed inner record. For each locator, a +lookup retains at most eight distinct candidate values and at most eight times +the frozen record size before decrypting or verifying. It rejects wrong-sized, +wrong-locator, expired, unauthenticated, or invalid-authority candidates without +mutating identity/session state. Among valid records it selects the highest +authority generation, then newest issue time, then smallest record digest. +Invalid candidates crowding out a valid value produce an unavailable lookup, +never acceptance of attacker state. + +Publishers store the same current record with multiple closest peers and query +through more than one bootstrap/routing path. Those measures improve +availability but cannot prevent an adversarial DHT region from overwriting, +eclipsing, delaying, or suppressing a record. + +### 4. Public and relationship routes are different products + +Standard and Private modes publish only recipient-selected introduction +mailboxes in public DHT records. Direct IP, LAN, relay-circuit, mesh, and spool +routes may appear in context-specific QR/file pairing or in authenticated +post-pairing route updates and ADR-0018 rendezvous. + +Sovereign mode may expose an explicit advanced switch to publish a direct +route, with a warning that every Connect-code holder can poll it. A default +consumer build never silently publishes one. + +After pairing, ordinary failed sends do not return to identity-indexed public +DHT lookup. They use stored mailboxes, authenticated route updates, optional +pairwise rendezvous, and the ordinary transport fallback ladder. Group state +distributes member connection capabilities through authenticated group +control; identity alone no longer implies global resolvability. + +### 5. Offline introductions use a separate capability + +A distinct HKDF key derived from the discovery capability creates rotating, +device-scoped introduction mailbox tokens. A Connect-code holder can address a +bounded provisional request; somebody holding only the public identity +fingerprint cannot. + +Recipients pre-register the bounded future token window at their chosen +mailboxes. Deposits enter ADR-0030's message-request flow and ADR-0032's leased +mailbox protocol. Native wake remains post-pairing unless a separate +capability-gated introduction-wake decision is accepted. + +### 6. Migration is explicit + +New clients accept both Connect-code v2 and legacy account addresses. Existing +identities generate a capability without changing identity. During a +time-bounded Alpha migration they may dual-publish: + +- capability-scoped v2 records; and +- legacy v1 records containing mailbox-only routes, never direct IP. + +New identities publish only v2. Existing paired contacts receive the +capability and routes through an authenticated upgrade. Existing groups receive +them through authenticated group control. + +There is no v1 redirect containing the new capability: publishing it below the +stable identity key recreates the tracking oracle. A printed legacy account +address must be replaced with a Connect code or knowingly retain legacy +reachability. + +## Alternatives considered + +### Rotate or encrypt records using only the public identity + +Rejected as the final design. It reduces blind crawling but every address +holder can still calculate and poll the record, and the capability cannot be +rotated independently. + +### Remove the DHT and require a project directory + +Rejected. It creates a first-contact authority and violates the replaceable +core architecture. + +### Publish direct routes everywhere for convenience + +Rejected. Introduction mailboxes can provide offline first contact without +turning the public discovery record into a current-IP oracle. + +## Consequences + +- DHT first contact remains a core protocol role; normal users see a Connect + code rather than DHT terminology. +- Identity fingerprints and safety numbers remain stable. +- Public Connect codes remain trackable by their holders, and DHT peers may + observe publisher/query network metadata unless another transport hides it. +- RAM-backed operation affects only one node's local cache. Records are + replicated to other DHT peers and may survive there until network expiry. +- Backup, linked-device, group, daemon, FFI, QR, and compatibility formats + change before wire v1. +- Acceptance requires fixed-record vectors, epoch/clock tests, capability + rotation/revocation, mailbox-offline tests, invalid-candidate crowding, + overwrite/flood/suppression, multi-bootstrap recovery, bounded DHT + storage/query behavior, and proof that Standard/Private records contain no + direct route. +- The project-operated reference deployment is separately bounded by + [ADR-0034](0034-operator-minimized-reference-discovery.md). diff --git a/docs/adr/0032-leased-mailbox-delivery.md b/docs/adr/0032-leased-mailbox-delivery.md new file mode 100644 index 0000000..24a0e71 --- /dev/null +++ b/docs/adr/0032-leased-mailbox-delivery.md @@ -0,0 +1,119 @@ +# ADR-0032: Leased, crash-safe mailbox delivery + +- **Status**: Proposed +- **Date**: 2026-07-26 + +## Context + +The current mailbox check-in removes queued ciphertext while constructing its +response. If the response cannot be written, the recipient process stops, or +local admission fails, neither relay nor endpoint necessarily retains the only +copy. Repeated locally initiated responses can also accumulate outside the +direct-inbox budget. + +Mailboxes are a core durable store-and-forward role. They remain content-blind +and replaceable, but “accepted” and “collected” must describe durable custody, +not a best-effort in-memory handoff. + +## Decision + +### 1. Mailbox v2 stores durable, idempotent deposits + +`/komms/mailbox/2` validates the canonical envelope bound and admission token +before writing a sealed mailbox row. The row has a relay-local random id, +opaque token index, ciphertext, expiry, and content-id dedup index. The relay +returns `accepted` only after that row commits durably. + +Per-token, per-client, global item, global byte, request-rate, and retention +limits are explicit. A refusal never claims custody. Relay storage follows the +opaque-index and row-binding rules in ADR-0027 and never receives plaintext or +identity private keys. + +### 2. Check-in leases; it does not delete + +A bounded check-in response contains one random lease id, an expiry, and a +bounded page of mailbox rows. Creating or retransmitting a lease does not +delete those rows. The same live lease is returned idempotently until it is +acknowledged or expires. + +The recipient transactionally stages each valid envelope in its durable inbound +admission inbox. Only after that commit does it send `AckLease` naming the lease +and exact accepted row ids. The relay deletes only those named rows in one +transaction. Rejected, unknown, over-quota, or locally corrupt rows remain +leased until policy expiry or receive an explicit protocol refusal that cannot +delete unrelated work. + +If the response, endpoint, acknowledgement, or relay stops, the rows return to +the available state after lease expiry. Duplicate pages and acknowledgements +are harmless. + +### 3. Every collection axis is bounded + +Protocol constants limit: + +- request and response bytes before CBOR or equivalent allocation; +- concurrent streams; +- filters per check-in; +- rows and ciphertext bytes per page; +- live leases per client/token; +- pages and bytes admitted during one endpoint lifecycle pass; and +- total durable endpoint admission rows. + +The daemon never loops until a hostile relay returns zero. It processes a fixed +page/time budget, then uses jittered backoff. More mail appears as ordinary +background progress, not an unbounded foreground loop. + +### 4. Retention and operator behavior are observable without content + +Relay restart preserves deposits, registrations, leases, quotas, and expiry. +Content-free metrics expose capacity, rejected deposits, lease age, expiry, +disk reserve, and schema version. Logs never contain tokens, ciphertext, +identity material, record locators, or peer social-graph labels. + +Operators publish retention, capacity, software version, and contact policy. +Clients use more than one selected operator when configured, deduplicate at the +endpoint, and keep the sender's original ciphertext until an encrypted +end-to-end receipt. + +### 5. Migration does not overclaim v1 + +Mailbox v1 remains Alpha-only during a coordinated transition. V2 clients +prefer v2 and may fall back to v1 only behind an explicit compatibility policy +that describes delete-before-response risk. Standard defaults qualify as +durable only after v2 restart, disk-full, crash, overload, lease-expiry, and +multi-operator tests pass. + +The interim v1 implementation limits a page to 512 rows / 2 MiB, limits a +filter request to 4,096 tokens, rotates larger token and mailbox lists, and +bounds one daemon pass to eight pages / 4,096 rows / 16 MiB. Those controls +close ordinary resource/starvation failures only; they cannot repair custody +loss between destructive relay collection and endpoint admission. + +## Alternatives considered + +### Delete when serializing the response + +Rejected. Serialization and socket write are not evidence that the recipient +durably retained the ciphertext. + +### Keep retrying from the sender + +Rejected as the sole safety mechanism. It eventually repairs some losses, but +cannot make a relay custody claim true and wastes battery, bandwidth, and +operator capacity. + +### Let collection bypass every local cap + +Rejected. A locally initiated request does not make a remote response trusted +or bounded. + +## Consequences + +- The mailbox wire protocol, relay schema, endpoint admission API, and + operator runbook change. +- Relay storage becomes persistent instead of an in-memory map. +- Delivery adds one acknowledgement round trip, but crash behavior becomes + deterministic and pages remain small. +- Release evidence must inject failure before/after lease creation, response + write, endpoint commit, acknowledgement, relay delete, restart, disk-full, + and lease expiry. diff --git a/docs/adr/0033-nonprofit-founder-stewardship.md b/docs/adr/0033-nonprofit-founder-stewardship.md new file mode 100644 index 0000000..8056997 --- /dev/null +++ b/docs/adr/0033-nonprofit-founder-stewardship.md @@ -0,0 +1,132 @@ +# ADR-0033: Nonprofit founder stewardship + +- **Status**: Accepted +- **Date**: 2026-07-26 + +## Context + +Komms is being built as public-benefit communications infrastructure rather +than as a venture-backed service or a data business. During the construction +and stabilization phase, Andri is the sole maintainer and directs a broad, +tool-assisted implementation program. Contributions and criticism are welcome, +but a governance structure should reflect the community that actually exists +rather than simulate plural ownership before adoption. + +The software is licensed under AGPL-3.0-only. The project's nonprofit mission, +the license's reciprocal source obligations, independent assurance, and product +authority are related but distinct: + +- a mission governs official project conduct; +- a copyright license governs downstream permissions and obligations; +- an independent review supplies evidence; and +- the founder remains accountable for product and release decisions unless + authority is explicitly delegated. + +## Decision + +### 1. Official Komms activity has a nonprofit public-benefit mission + +The project and any service it operates or designates as an official default +exist to make private, resilient communication broadly accessible. Official +activity does not sell user data, attention, access to surveillance, or +preferential protocol control. + +Donations, grants, sponsorship, paid work, and cost recovery may fund +infrastructure, accessibility, security review, maintenance, development, and +reasonable compensation. Surplus is reinvested in that mission rather than +distributed as private profit. Until an appropriate legal entity exists, this +is a project governance commitment, not a claim that Komms is a registered +charity, tax-exempt entity, or legally incorporated nonprofit. + +### 2. Founder direction is the intentional incubation model + +The founder holds final product, protocol, merge, release, delegation, and +roadmap authority during construction and stabilization. Automated research and +implementation systems are tools, not maintainers, reviewers, copyright holders +represented by the project, or decision-makers. The human maintainer remains +accountable for provenance, scope, testing, acceptance, and public claims. + +External reviewers may publish findings and decline to support an assurance +claim. Their work is evidence, not shared product governance or an automatic +transfer of release authority. Unresolved findings remain visible and cannot be +described as closed, audited, or independently approved. + +### 3. Community governance follows a real community + +Anyone may contribute code, design, testing, research, translation, operations, +or criticism now. Maintainer authority is delegated explicitly after sustained, +dependable participation. No contributor count, reviewer count, or calendar date +automatically transfers founder authority. + +When adoption has produced a durable community of users, contributors, +reviewers, and operators, the founder may propose additional maintainers, a +foundation, a steering body, or another accountable structure. Any transfer is +public, explicit, proportionate to the community that exists, and preserves the +nonprofit public-benefit mission. + +### 4. AGPL provides reciprocity, not an ethical-use prohibition + +AGPL-3.0-only permits use by individuals, companies, governments, and paid +operators. When AGPL section 13 applies, an operator of a modified covered +version that supports remote network interaction must prominently offer the +interacting users that version's Corresponding Source. + +This is deliberately stronger reciprocity than a permissive license, but it +does not: + +- prohibit surveillance, government, or commercial use; +- guarantee that every modification is published to the general public; +- prove that a deployed binary matches offered source; +- automatically cover separate interoperating software; or +- replace enforcement, trademark policy, reproducible builds, signed releases, + protocol verification, or user choice. + +Official Komms services follow the nonprofit mission. Independent operators +retain every right the AGPL grants, including commercial use, and are not +official merely because they run compatible software. + +### 5. Official infrastructure remains replaceable + +A project-operated bootstrap, discovery, mailbox, rendezvous, relay, wake, or +update service is a default operator, never a protocol authority. Its source, +deployment policy, retention behavior, funding, and material incidents are +public. Users may replace or remove it, and loss of every optional service +leaves the server-independent core intact. + +## Alternatives considered + +### Form a committee before adoption + +Rejected. It creates titles without a sustained contributor base and obscures +who is actually accountable for the product. + +### Treat external reviewers as product governors + +Rejected. Independent reviewers must be free to report findings without +becoming responsible for roadmap or release decisions. + +### Add a noncommercial or government-use prohibition + +Rejected. It would conflict with open-source freedom and would not provide a +reliable technical barrier against hostile use. Komms instead uses reciprocal +source obligations, a protected official identity, verifiable protocol rules, +and replaceable infrastructure. + +### Promise that AGPL prevents surveillance forks + +Rejected as an overclaim. AGPL can expose qualifying modified source to remote +users; it cannot make hostile behavior impossible or prove what binary an +operator deployed. + +## Consequences + +- Founder-led development is documented as intentional rather than disguised as + plural governance. +- Independent security and interoperability evidence remains a stable-release + requirement without granting product authority. +- Official services can accept mission-aligned funding and pay reasonable costs + or compensation without becoming profit-distribution vehicles. +- Commercial and government use remain legally possible under AGPL. +- A future legal entity, trademark policy, funding policy, and copyright/asset + inventory must implement this decision without narrowing downstream AGPL + rights. diff --git a/docs/adr/0034-operator-minimized-reference-discovery.md b/docs/adr/0034-operator-minimized-reference-discovery.md new file mode 100644 index 0000000..954f471 --- /dev/null +++ b/docs/adr/0034-operator-minimized-reference-discovery.md @@ -0,0 +1,172 @@ +# ADR-0034: Operator-minimized reference discovery + +- **Status**: Proposed +- **Date**: 2026-07-26 +- **Depends on**: + [ADR-0017](0017-optional-hybrid-modes.md), + [ADR-0018](0018-pairwise-rendezvous.md), and + [ADR-0031](0031-capability-scoped-dht-discovery.md) + +## Context + +Ordinary users need a working default path to join the internet discovery plane. +The initial reference deployment may run on founder-operated Hetzner +infrastructure, but it must remain a replaceable convenience rather than an +identity, trust, or availability authority. + +The desired deployment uses RAM-backed mutable state, receives no message +plaintext or user identity private keys, and minimizes retained metadata. Those +are valuable controls, but they have different strengths: + +- end-to-end cryptography can make content decryption and accepted-record + forgery unavailable to the service; +- client verification can make tampering detectable and rejectable; +- RAM-only state and disabled logging reduce operator retention; and +- a host administrator, cloud provider, or network observer can still inspect + live memory, addresses, timing, volume, and availability. + +A mailbox cannot use the same profile: durable delayed delivery requires +encrypted persistent custody under ADR-0032. A native-wake gateway also needs +protected durable service keys under ADR-0019. + +## Decision + +### 1. The first reference service has two bounded roles + +The initial Standard-mode Alpha deployment may provide: + +1. libp2p bootstrap and an ordinary Kademlia DHT cache; and +2. the short-lived post-pairing rendezvous role in ADR-0018. + +It does not provide a durable mailbox, native wake, user endpoint, account +directory, update authority, analytics service, or plaintext bridge. A later +mailbox is a separately deployed ADR-0032 service with encrypted persistent +storage. Native wake is a later separately keyed service. + +The existing `kultd` endpoint container is not this service: it owns a Komms +identity and persistent encrypted database. A dedicated daemon, image, and +runbook must enforce the narrower role. + +### 2. Guarantees and controls are labelled by enforcement source + +| Property | Enforcement | Honest limit | +|---|---|---| +| No message/media plaintext | End-to-end envelopes and encrypted rendezvous/DHT values | The operator still sees network metadata and bounded ciphertext | +| No user Komms identity private keys | Clients never transmit them; service APIs have no field for them | Runtime TLS, libp2p, and provider service keys still exist | +| No accepted user-record forgery | Complete client-side account/device signatures and context-bound AEAD verification | The service can replay still-valid data, suppress data, or return garbage | +| Bounded record contents | Fixed-size formats, strict candidate/byte limits, capability-derived locators | The operator observes queried or stored opaque locators | +| Reduced local retention | tmpfs, short TTLs, disabled logs/swap/dumps/snapshots | This is deployment policy, not cryptographic erasure | +| Replaceability | Editable bootstrap/provider configuration and self-hosted implementation | One default can censor or degrade a user's first attempt | + +Project control of official client signing and updates is a separate +supply-chain boundary. A content-blind service does not protect users if a +malicious client build exports their keys or plaintext. + +### 3. Mutable service state is RAM-backed and short-lived + +All DHT record cache, routing tables, rate-limit buckets, rendezvous slots, +temporary files, and application scratch state live on dedicated tmpfs mounts. +The service has: + +- a read-only root filesystem and unprivileged runtime user; +- no swap or hibernation; +- core dumps, host snapshots, backups, and crash-body capture disabled; +- TLS or Noise termination in the service process rather than a logging + reverse proxy, CDN, or WAF; +- no request/body/access logs, query identifiers, full client-address metrics, + capability metrics, locator metrics, or distributed traces; +- aggregate-only health and capacity metrics; +- fixed request/response sizes, strict memory/concurrency/rate caps, and short + protocol TTLs; and +- restart, clean-shutdown, crash, overload, and default-blackhole tests. + +Clean shutdown performs best-effort zeroization before tmpfs teardown. Abrupt +termination, allocator copies, kernel buffers, live memory inspection, provider +telemetry, and forensic host control remain outside the guarantee. + +DHT records replicate to other peers. RAM-only storage controls only the +project node's local cache; it cannot erase replicas held elsewhere before +their protocol expiry. + +### 4. Service keys are distinct from user keys + +“No identity private keys” means no **user Komms identity private keys**. A +stable libp2p PeerId needs a service identity key, HTTPS needs a TLS key, and +provider authentication may require other service credentials. These keys: + +- are domain-separated and grant no user identity or message authority; +- are stored separately from mutable runtime state; +- have documented rotation, revocation, and compromise procedures; and +- are never reused as offline directory-signing or software-release keys. + +The directory and release signing keys remain offline. A compromised runtime +service key may impersonate or disrupt that service, but cannot forge a +client-accepted account record or decrypt messages. + +### 5. The first Hetzner deployment is Standard Alpha evidence + +The operator publishes the administrative domain, hosting provider, enabled +roles, source revision, image digest, configuration, retention policy, service +key fingerprints, uptime history, and material incidents. The deployment is +explicitly a founder-operated convenience default. + +It does not demonstrate: + +- Private-mode non-collusion; +- plural independent infrastructure; +- anonymity from Hetzner or a network observer; +- durable mailbox delivery; +- forensic erasure; or +- inability of the operator to log, suppress, correlate, or selectively deny + requests after changing the deployment. + +Private mode requires non-colluding administrative domains and never +co-locates its OHTTP ingress with the protected gateway while claiming that +property. + +### 6. Clients retain sovereign alternatives + +The default provider list is signed and versioned but user-editable. A client +can add manual/community bootstrap peers, replace the reference rendezvous, +disable it, or use direct QR/file/LAN/mesh/sneakernet paths. The implementation +retains the last valid configuration and never removes sovereign routes because +the project service or directory is unavailable. + +Acceptance blackholes the project domain and proves alternate bootstrap, +self-hosted replacement, and pure-core operation. A second independently +operated service is required before any plural-operator claim. + +## Alternatives considered + +### Run every server role on one RAM disk + +Rejected. Mailbox custody and native-wake service keys have durability +requirements that conflict with a fully ephemeral host. + +### Use the existing full `kultd` container + +Rejected for this claim. It is an identity-bearing endpoint with a persistent +database and passphrase, not a least-authority discovery/rendezvous service. + +### Claim that root cannot behave maliciously + +Rejected. A root operator can replace code, inspect live state, log traffic, or +deny service. The design removes message-decryption and accepted-record-forgery +capabilities, minimizes everything else, and publishes the residual trust. + +### Make the project service mandatory + +Rejected. It would turn founder-operated infrastructure into a first-contact +availability authority and contradict the server-independent core. + +## Consequences + +- The project can provide a practical ordinary-user default without possessing + message plaintext or user identity keys. +- A dedicated service binary and deployment profile must be implemented before + making the RAM-only claim. +- Operator minimization, reproducible artifacts, public configuration, and + incident transparency supplement cryptography; they do not become + cryptographic guarantees. +- Durable mailboxes, native wake, Private-mode non-collusion, and plural + operation remain separate qualification tracks. diff --git a/docs/adr/README.md b/docs/adr/README.md index ba1c473..d1e4b7f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,12 +26,21 @@ product promise merely because the file exists. | [0017](0017-optional-hybrid-modes.md) | Proposed | Optional service modes and trust boundary | | [0018](0018-pairwise-rendezvous.md) | Proposed | Rotating pairwise rendezvous | | [0019](0019-native-wake-gateway.md) | Proposed | Capability-gated native wake | -| [0020](0020-authenticated-message-edits.md) | Accepted | Immutable authenticated message-edit events and deterministic convergence | +| [0020](0020-authenticated-message-edits.md) | Accepted | Immutable edit events, pairwise authorization, and deterministic convergence; group origin awaits ADR-0029 | | [0021](0021-ephemeral-retention.md) | Accepted | Authenticated local expiry, view-once consumption, and coarse relay retention | -| [0022](0022-convergent-group-polls.md) | Accepted | Visible-vote group polls, fixed electorates, deterministic vote heads, and creator closure | +| [0022](0022-convergent-group-polls.md) | Accepted | Visible-vote group polls and deterministic convergence; member origin awaits ADR-0029 | | [0023](0023-group-roles-and-owner-authority.md) | Accepted | Owner-serialized roles, signed generation-bound admin requests, and authority transfer | -| [0024](0024-account-authorized-linked-devices.md) | Accepted | Account-authorized physical devices, confirmed linking, per-device cryptography, deterministic sync, revocation, and recovery | +| [0024](0024-account-authorized-linked-devices.md) | Accepted Alpha; security-limited | Confirmed linking, per-device cryptography, sync, exact-id exclusion, and recovery; permanent-revocation claim withdrawn | | [0025](0025-optional-freenet-carrier.md) | Proposed | Optional epoch-scoped Freenet store-and-forward carrier and metadata boundary | +| [0026](0026-revocable-device-authority.md) | Proposed (P0) | Required offline account-root recovery and quorum-authorized manifests replacing ADR-0024 authority before stable | +| [0027](0027-opaque-indexed-store.md) | Proposed | Versioned keyed indexes, row-bound sealing, transactional migration, and honest local deletion limits | +| [0028](0028-atomic-protocol-commits.md) | Proposed | Transactional ratchet, handshake, group-chain, queue, replay, and deferred-inbox state transitions | +| [0029](0029-recipient-authenticated-groups.md) | Proposed | Per-recipient origin authentication for sender-key group messages and author-sensitive state | +| [0030](0030-first-contact-admission.md) | Proposed | Bounded public introductions, provisional message requests, blocking, and group-invite consent | +| [0031](0031-capability-scoped-dht-discovery.md) | Proposed | Capability-scoped, encrypted DHT first contact with mailbox-only public routes | +| [0032](0032-leased-mailbox-delivery.md) | Proposed | Durable deposits, leased collection pages, and acknowledgement-after-endpoint-commit | +| [0033](0033-nonprofit-founder-stewardship.md) | Accepted | Nonprofit public-benefit mission, founder-directed incubation, AGPL reciprocity limits, and adoption-triggered governance evolution | +| [0034](0034-operator-minimized-reference-discovery.md) | Proposed | RAM-backed, content-blind, replaceable reference bootstrap/DHT/rendezvous deployment with explicit operator limits | The attachment implementation follows ADR-0015 and its hard no-airtime rule, but the ADR file still carries Proposed status. This index reports that From a61fc19ca925e734035204ad69f25aa28f0a10bd Mon Sep 17 00:00:00 2001 From: Andri Date: Sun, 26 Jul 2026 10:54:09 +0000 Subject: [PATCH 2/3] Fix core Clippy warnings --- crates/kult-node/src/lib.rs | 45 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/kult-node/src/lib.rs b/crates/kult-node/src/lib.rs index ee6d74e..f34ab34 100644 --- a/crates/kult-node/src/lib.rs +++ b/crates/kult-node/src/lib.rs @@ -331,6 +331,12 @@ enum Consumed { Later, } +#[derive(Clone, Copy)] +struct ConsumeOrigin { + depth: u8, + pending_sequence: Option, +} + /// One third-party envelope in transit across the bridge (ADR-0009). struct TransitItem { envelope: Envelope, @@ -1856,8 +1862,10 @@ impl Node { match self.consume( &env, - 0, - pending_sequence, + ConsumeOrigin { + depth: 0, + pending_sequence, + }, now, rng, &mut acks, @@ -2470,8 +2478,7 @@ impl Node { fn consume( &mut self, env: &Envelope, - depth: u8, - pending_sequence: Option, + origin: ConsumeOrigin, now: u64, rng: &mut impl CryptoRngCore, acks: &mut Vec<([u8; 32], [u8; 16])>, @@ -2489,7 +2496,7 @@ impl Node { EnvelopeKind::Fragment => { // Fragments never nest (we only fragment whole envelopes); // treat nested ones as malformed. - if depth > 0 { + if origin.depth > 0 { self.store.mark_seen(&env.content_id())?; return Ok(Consumed::Done); } @@ -2514,8 +2521,17 @@ impl Node { // bounded, so exact fragment retries are safe. if let Ok(Some(payload)) = completed { if let Ok(inner) = Envelope::decode(&payload) { - if let Consumed::Later = - self.consume(&inner, 1, None, now, rng, acks, established)? + if let Consumed::Later = self.consume( + &inner, + ConsumeOrigin { + depth: 1, + pending_sequence: None, + }, + now, + rng, + acks, + established, + )? { // Reassembled before its session exists — stash // the inner envelope for later ticks. @@ -2530,7 +2546,14 @@ impl Node { } EnvelopeKind::Handshake => self.consume_handshake(env, now, rng, acks, established), EnvelopeKind::Message | EnvelopeKind::Receipt | EnvelopeKind::GroupControl => { - self.consume_ratchet(env, pending_sequence, now, rng, acks, established) + self.consume_ratchet( + env, + origin.pending_sequence, + now, + rng, + acks, + established, + ) } EnvelopeKind::GroupMessage => self.consume_group_message(env, now, rng, acks), } @@ -2863,7 +2886,7 @@ impl Node { } _ => unreachable!("ordinary text variants matched above"), }; - let message = (!duplicate).then(|| MessageRecord { + let message = (!duplicate).then_some(MessageRecord { id, peer, direction: Direction::Inbound, @@ -3485,9 +3508,7 @@ impl Node { if miss.is_empty() { return None; } - let Some(meta) = self.frag_meta.get(&id) else { - return None; - }; + let meta = self.frag_meta.get(&id)?; let due = now.saturating_sub(meta.first_seen) >= NACK_AFTER_SECS && meta .last_nack From a02b064ba7c57db112ef316e2bc6b120f7c7ef46 Mon Sep 17 00:00:00 2001 From: Andri Date: Sun, 26 Jul 2026 10:58:47 +0000 Subject: [PATCH 3/3] Format core Clippy fix --- crates/kult-node/src/lib.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/kult-node/src/lib.rs b/crates/kult-node/src/lib.rs index f34ab34..46a147e 100644 --- a/crates/kult-node/src/lib.rs +++ b/crates/kult-node/src/lib.rs @@ -2531,8 +2531,7 @@ impl Node { rng, acks, established, - )? - { + )? { // Reassembled before its session exists — stash // the inner envelope for later ticks. match self.store.pending_push(&inner, now, rng) { @@ -2546,14 +2545,7 @@ impl Node { } EnvelopeKind::Handshake => self.consume_handshake(env, now, rng, acks, established), EnvelopeKind::Message | EnvelopeKind::Receipt | EnvelopeKind::GroupControl => { - self.consume_ratchet( - env, - origin.pending_sequence, - now, - rng, - acks, - established, - ) + self.consume_ratchet(env, origin.pending_sequence, now, rng, acks, established) } EnvelopeKind::GroupMessage => self.consume_group_message(env, now, rng, acks), }