diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 21e4f49dab..6038af56e7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,7 +5,12 @@ Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should -contain implementations of Nostr specifications and utilities to help implement them. Commons stores +contain implementations of Nostr specifications and utilities to help implement them — NIPs under +`nipXX` packages, and whole non-NIP protocol families beside them: `marmot/` (MLS over Nostr, +`mipXX`), `cordn/` (MLS over an MCP coordinator, `specXX`), `contextvm/` (MCP over Nostr, `cepXX`), +`concord/` (`cordXX`), `buzz/`, plus the binding-agnostic RFC 9420 engine in `mls/`. A new protocol +over Nostr belongs here as a package, not as a Gradle module; `quic`/`nestsClient`/`marmotQuic` are +modules because they are transports with no Nostr in them. Commons stores shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a completely different screen and navigation architecture while sharing the back end components with the android counterpart. `cli` ships `amy`, diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md index 57a9e9a7d0..a98f901693 100644 --- a/.claude/skills/event-store-semantics/SKILL.md +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -37,7 +37,7 @@ Executable spec: the test suites in `quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`, `DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`, `MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`, -`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test, +`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, `InsertOutcomeClassificationTest`, …). If a rule here ever contradicts a test, the test wins — and this file has a bug to fix. ## Kind classes (used throughout) @@ -189,6 +189,21 @@ back alone and reports `Rejected(reason)`; the rest commit. If the **outer commi entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned in input order; OK frames pair by event id, not order. +**STORE-W09 — a failed row is classified against the database, not against the driver's +exception text.** `SQLiteEventStore.classifyRowError` rolls the row's savepoint back and then +asks the connection (which now shows pre-insert state): id already present → `DUPLICATE`; +a stored version that beats this one at the replaceable/addressable coordinate (the exact +complement of the supersession predicate in W01/W02) → `SUPERSEDED`; otherwise `Failed`. +Message text is only a fast path and a fallback for trigger RAISEs (`blocked:`, `not allowed`), +which leave no database-visible trace. This matters because the message is driver-specific — +the bundled JVM driver writes `UNIQUE constraint failed: event_headers.id`, Android's throws an +`android.database.SQLException` with a **null** message — so a text-only classifier answered +`OK false` on Android for events the store already held. Corollary: re-offering a stored +replaceable/addressable event **byte-for-byte** is `DUPLICATE`, not `SUPERSEDED` (it violates +both indexes and only the id answer is driver-independent); a stale *different* version is +still `SUPERSEDED`. Both carry the `duplicate:` prefix, so the relay reply is `OK true` either +way. + --- ## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D) @@ -323,6 +338,11 @@ non-itemizable cases). Add one line per behavior change, newest first: `YYYY-MM-DD — what changed`. +- 2026-09-18 (pending) W09, W01/W02 — insert-failure classification now queries the database + instead of parsing the driver's exception message (Android's is null, so duplicates were + reported as `Failed`/`OK false`). A byte-for-byte re-offer of a stored replaceable/addressable + event now reports `DUPLICATE` where the JVM driver previously reported `SUPERSEDED`; both are + `duplicate:` → `OK true`, so the wire answer is unchanged. - 2026-08-04 (baseline) — rules F01–F13, W01–W08, D01–D08, C01, S01–S06, N01 written from the code at the time this skill was introduced. Changes before this date are not itemized; archaeology starts at `git log` on `nip01Core/store/`. diff --git a/amethyst/plans/2026-09-19-cordn-ui.md b/amethyst/plans/2026-09-19-cordn-ui.md new file mode 100644 index 0000000000..db83c49efa --- /dev/null +++ b/amethyst/plans/2026-09-19-cordn-ui.md @@ -0,0 +1,527 @@ +# Cordn UI: every feature their client has, and what it costs us + +Status: proposed. No code yet. + +Companion to `quartz/plans/2026-09-17-cordn-interop.md`, which covers the protocol. That plan's +Stage 4 shipped the headless layer (`commons/…/cordn/`) and the §8 disclosure surface +(`commonsUI/…/cordn/ui/`, `Settings → Cordn group link`). This plan covers the rest: the group UI, +and every other user-facing feature the reference client has. + +Sources read on 2026-09-19: + +- `Cordn-msg/cordn-web` @ `c38e307` (2026-09-16), version `0.4.0` — **MIT**, the client at + . Read for its feature surface; nothing is translated from it. +- `Cordn-msg/cordn` @ `b465df0` — `spec/applications/*` for the normative half. + +Read the licensing correction in the interop plan's §7 before touching anything outside +`packages/core` and `packages/cli`: the rest of that repo is unlicensed. + +## 1. Executive summary + +**Marmot is frozen. Nothing in this plan changes how Marmot works.** The two protocols do not +interoperate, neither is a layer of the other, and the design requirement is that **either side +can walk away from the other** without the other noticing. That is a standing constraint, not a +stage, and §3 is what it means in practice. + +`amethyst/…/chats/marmotGroup/` is 3,308 lines covering the same *kinds* of screen cordn needs — +group list, chat view, group info, create-group. None of it is a reuse candidate. cordn gets its +own screens, and the only thing the two share is ordinary app furniture (§3.2). + +**The gating dependency is not a screen.** There is no account-level cordn runtime: nothing +constructs a `CordnGroupManager` for the logged-in account, wires it to a relay pool, publishes +key packages, or persists state. Until that exists, every group screen has nothing to render. +That is Stage A, and it is most of the risk. + +**One feature is an explicit non-goal** (multi-device, §5.3), and several are cordn.net product +features rather than protocol (§6). + +## 2. What their client actually has + +From `cordn-web`'s routes and components, not from the spec. Anything marked ✅ we already have in +some form; ⚠️ means we have a Marmot-shaped equivalent that does not transfer as-is. + +### 2.1 Coordinators (`/chat/coordinators`, `config/`) + +| Feature | Their file | Ours | +| ------- | ---------- | ---- | +| Coordinator list + per-coordinator page | `coordinators/[coordinatorKey]` | ✅ `CoordinatorConfig` model, no screen | +| Add a coordinator (pubkey + relays) | `CoordinatorAddForm` | model only | +| "Add default coordinator" | `coordinators/+page` | — | +| Health | `coordinatorHealth.svelte.ts` | ✅ `CoordinatorHealth` + `CoordinatorHealthRow` | +| Server info | `coordinatorServerInfo.svelte.ts` | ContextVM `initialize()` exists; unsurfaced | +| Purge a coordinator and its groups | `CoordinatorPurgeDialog` | — | + +### 2.2 Key packages (`config/key-packages`) + +| Feature | Their file | Ours | +| ------- | ---------- | ---- | +| Create a key package, with a label | `config/key-packages` | engine has `createKeyPackage`; no lifecycle | +| Publish to a coordinator | `chatCoordinatorActions` | ✅ `CordnGroupManager.publishKeyPackage` | +| Stored (local) key packages | `KeyPackageCard` | ⚠️ Marmot has `KeyPackageBundleStore` | +| Browse a coordinator's directory | `AvailableKeyPackageDirectory`, `VirtualKeyPackageList` | `kp_list` client exists; no screen | +| Last-resort conflict resolution | `LastResortConflictDialog` | — | + +Note §4.2 of the interop plan: cordn has **no key-package event kind**, so none of Marmot's +kind-443 machinery (rotation manager, relay list, publish obligations) applies. This is new work, +not an extraction. + +### 2.3 Group lifecycle + +| Feature | Their file | Ours | +| ------- | ---------- | ---- | +| Create group (name, description, icon, image, coordinator, key package) | `create-group` | ✅ `createGroup` + ⚠️ `CreateGroupScreen` (Marmot) | +| Group list / sidebar / tab bar | `ChatSidebar`, `ChatTabBar` | ⚠️ `MarmotGroupListScreen` | +| Group info: metadata, admins, participants, created, **current epoch**, coordinator, group id, **MLS snapshots**, **sync issues** | `chat/[id]/info` | ⚠️ `MarmotGroupInfoScreen` (1,154 lines) | +| Share link + QR | `share`, `QrShareDialog`, `QrScanner`, `GroupLinkInput` | ✅ `shareRef`, ✅ link screen; no QR | +| Join requests (accept / decline) | `JoinRequestCard` | client calls exist; no flow, no screen | +| Welcome notifications ("invited you", "accepted") | `WelcomeNotificationCard` | ✅ `joinPendingWelcomes`; no surface | +| New conversation (pick pubkeys, max 50) | `NewConversationDialog`, `ChatPubkeyMultiSelect` | ⚠️ Marmot equivalent | +| Admin policy gate | `chatAdminPolicy.ts` | ✅ `CordnGroupPolicy` (egalitarian-when-empty) | +| Delete local group / mark as read | `ChatGroupActions` | ⚠️ Marmot equivalent | + +### 2.4 Messaging + +Their kind catalog is `src/lib/chat/kinds.ts`, and it is the sharpest divergence in this document: + +| Purpose | cordn-web | Marmot (ours) | Spec says | +| ------- | --------- | ------------- | --------- | +| Chat message | 9 | ✅ 9 | NIP-C7, `spec/02.md` §6 | +| Threaded reply | 1111 | ✅ 1111 | NIP-22, §6 | +| Reaction | 7 | ✅ 7 | NIP-25, §6 | +| Edit | **1010** | **1009** | nothing | +| Delete | 5 | 5 | nothing | +| Pin / unpin | **1011** (`op` tag, LWW) | — | nothing | +| System rows | synthetic **client-side** `-1`, derived from Commits | kind **1210** on the wire | nothing | + +`spec/02.md` §6 is explicit that there is "no required set of `kind` values". So edits, pins and +system rows are **app conventions, not protocol** — and the two apps picked differently. §5.1 +decides what we do. + +Message-level features: reply, react (quick + custom emoji), edit, delete, copy, download, pin, +"message info", a pinned ribbon, unread chips, drafts, mentions, presence, and a per-message +route with a rich renderer beside the compact stream row (`src/lib/chat/README.md` documents that +two-renderer contract — worth reading; Amethyst's feed has the same shape implicitly). + +Media: `ChatMessageMedia`, `InlineMediaUrl`, `MediaLightbox`, Blossom upload, plus **voice notes** +(`voiceRecorder` / `voicePlayback`). Encrypted per `spec/applications/encrypted-media.md`, which +§4.5 of the interop plan already records as **diverging from our MIP-04 v2**: cordn uses the +exporter output directly as the file key with `aad = mime‖0x00‖filename‖0x00‖sha256(plaintext)`, +where MIP-04 v2 does `HKDF-Expand(exporter, context)` with a different AAD. Separate codec, +shared primitives (ChaCha20-Poly1305, NIP-92 `imeta`, Blossom) that Amethyst already has. + +### 2.5 Settings + +Backup & recovery (passphrase-encrypted export/restore, optional message history), media +(upload server, auto-load policy), notifications (background poll interval), appearance, and +multi-device. + +## 3. Independence is the design constraint + +### 3.1 The rule + +Marmot and cordn are two bindings of RFC 9420 onto two unrelated delivery models. They share no +groups, no messages, no identities and no servers. So: + +- **Marmot is frozen.** No cordn work may edit, refactor or generalise Marmot code. A cordn + requirement is never a reason to touch `marmotGroup/`, `commons/…/marmot/` or + `quartz/…/marmot/`. +- **Neither may import the other.** Not models, not state holders, not screens, not helpers. +- **Either must be deletable.** Removing cordn entirely should not compile-break Marmot, and the + reverse should hold too. + +This is now **enforced, not documented**: + +| Guard | Covers | +| ----- | ------ | +| `quartz/…/BindingIsolationTest` | `mls/` imports neither binding nor Nostr; `cordn/` ⊥ `marmot/` both ways | +| `commons/…/cordn/CordnIndependenceTest` | `commons/…/cordn/` ⊥ `commons/…/marmot/` both ways | + +Both scan shipped source sets only — an interop test that drives both profiles through one engine +is how we *demonstrate* they cannot collide, and a fixture is data, not a dependency. Both also +assert that the scan found real files and real imports, because an architecture test that passes +for the wrong reason is worse than none. + +Writing the first one immediately found a leak: **`mls/group/MlsGroup.kt` imported +`nip01Core.core.toHexKey`** — a Nostr import in an engine whose own README says it "knows nothing +about Marmot, Nostr, or cordn". The README only ever grepped for `marmot`, so the stated invariant +was wider than the check. `toHexKey()` is defined as `Hex.encode(this)`, so the fix was a +one-for-one swap to the binding-neutral `quartz/utils/Hex` with no behaviour change. + +**When the Android screens land, add the third guard** over `chats/cordnGroup/` ⊥ +`chats/marmotGroup/`, in `amethyst/src/test`. It is the one that matters most, because screens are +where "just reuse that composable" is most tempting. + +### 3.2 What cordn may share + +App furniture, and nothing that knows what a group is: + +- The theme, typography and colour scheme. +- Generic components — buttons, text fields, avatars, dialogs, QR encode/scan, image and video + viewers, the media upload pipeline, rich-text rendering, icons. +- Platform services — Blossom, the relay pool, the signer, notifications. + +Not shareable, by the rule above: chatroom models, message view models, feed filters, group list +rows, chat composers, group info screens, system-row renderers, or anything named `Marmot*`. + +### 3.3 The one thing this rules out + +There is no `Note`/`LocalCache` adaptation. Beyond the coupling, it would be the wrong call +anyway: `LocalCache` is the note graph for relay events, cordn envelopes are unsigned +(`spec/02.md` forbids `sig`) with no relay provenance, and one escaping into relay-bound code — +an outbox calculation, a NIP-65 decision, a relay-facing query — is a confidentiality bug rather +than a rendering bug. cordn gets its own store, keyed by envelope id. + +**Cost, stated plainly:** roughly 2,000–3,000 lines of screen code that resembles Marmot's, and +chat fixes that have to be made twice when they apply to both. That is the price of the +constraint, and it is the constraint that was asked for. The compensation is that cordn can move +at its own pace — it is the less settled protocol, with an unlicensed reference coordinator and +several unspecified conventions (§5.1) — without any of that reaching a shipped feature. + +## 4. Plan + +Each stage lists how it is verified. Note throughout: **Tier B is blocked** (interop plan §7 — +the reference coordinator is unlicensed), so nothing below can be checked against a live cordn +deployment. What *can* be checked: the in-memory coordinator (`FakeCoordinator`), the ContextVM +Tier C fixture server, and the ts-mls/`@cordn/core` vectors. Every stage below is verifiable that +way; none of them is verified against cordn.net until §7 of the interop plan is resolved. + +### Stage A — the account-level runtime (blocks everything) — **LANDED** + +No UI. This is the gap between "`CordnGroupManager` has tests" and "an account has cordn groups". + +1. **Wire the transport.** ✅ **LANDED.** `CvmTransport` over Amethyst's relay pool, with the + account signer as the stable identity and a per-session ephemeral signer (`spec/00.md` §8; the + split is already enforced by `CoordinatorMethod`). Encryption pinned `REQUIRED` — + `:contextvm`'s `CvmGiftWrap` defaults that way and nothing here may undo it (§8.6). + `CordnTransportHarness` + `CordnTransportIntegrationTest` drive the whole lifecycle over it. +2. **A per-account, per-coordinator manager registry.** ✅ **LANDED** as + `CordnCoordinatorRegistry` + `CordnSession`, opened through a `CordnCoordinatorScopeFactory` + seam so production passes relay-backed transport and encrypted stores while tests pass the + fixture. Three rules it enforces, each with a test that dies without it: + - **Idempotent per coordinator, under a mutex.** A second `CordnGroupManager` would + deserialise its own copy of every `MlsGroup` from the same store and commit against the same + epoch; MLS has no recovery from a forked ratchet tree. The UI and the sync loop both open a + session at launch, so concurrent opens are ordinary, not a race to wave off. + - **Never merged across coordinators.** A `gid` is unique only within one (§4), so two + coordinators serving `gid = "abc"` are unrelated groups. + - **Editing relays reopens the transport but keeps the stores**, and `forget` closes the wire + without wiping state — removing a coordinator is not purging it (cordn-web draws the same + line in `CoordinatorPurgeDialog`). + + A session also restores its own groups and KeyPackages on open rather than leaving it to the + caller: a session that reports no groups is indistinguishable from a failure, and its first + commit for a group it forgot would start at epoch zero. +3. **Persistence.** ✅ **LANDED** as `FileCordnGroupStore` / `FileCordnKeyPackageStore` in + `commons/jvmAndroid`, behind a `CordnBlobCipher` seam whose Android implementation is + AES-GCM under the Android KeyStore (`KeyStoreCordnBlobCipher`). + `FileBackedCordnScopeFactory` assembles them into a `CordnCoordinatorScope`, leaving only the + transport for the front end to supply (`CordnCoordinatorLinkFactory`). + + The cipher is an interface for one reason: the KeyStore cannot run in a JVM unit test, and + everything below it can fail silently. Marmot's equivalent store is welded to the KeyStore and + has no unit test at all; this one has sixteen, including that **the plaintext never reaches the + disk**. The rules they cover: + - **Scoped per account AND per coordinator.** Two accounts on a device must not read each + other's groups, and two coordinators can both serve `gid = "abc"` as unrelated groups (§4) — + a layout ignoring either would have one overwrite the other's ratchet tree. + - **A `gid` is caller-chosen, so it is encoded, not validated.** Marmot's store demands hex + because a Marmot group id is a hash; a cordn `gid` is whatever its creator picked and may + contain `..` or `/`. Base64url accepts every legal gid, keeps the write inside the store + directory, and still reverses — which is what lets `listGroups` return the real gid. + - **Atomic writes.** A truncated `MlsGroupState` is not a stale group but an unreadable one, + and it cannot be re-derived from anywhere else on the device. + - **Deleting a group takes its cursor.** Otherwise a later re-join of the same gid resumes from + a cursor belonging to a group it is no longer in and silently skips everything before it. + + The Android cipher **serialises** its calls: `KeyStoreEncryption` keeps one `Cipher` in a field + and `init` + `doFinal` is not atomic, while the stores run on `Dispatchers.IO`. Left alone, + two groups saving at once corrupts one of them. (The underlying class is shared with Marmot and + account storage and was not touched — the locking is in the cordn wrapper.) +4. **Key-package lifecycle.** ✅ **LANDED** as `CordnKeyPackages` + `CordnKeyPackageStore`, with + `KeyPackageBundleCodec` in `mls/` for the private half. cordn has no event kind for this + (§4.2), so it is all coordinator calls plus local storage — none of Marmot's rotation + machinery transferred, and none of it was reused. The ordering rules are the load-bearing + part and each has a test: + - **Publish stores the private half first.** A publish that lands on the coordinator and then + fails locally leaves a KeyPackage others can invite us with and we cannot open. Storing + first makes the failure the harmless direction — an unused bundle on disk, cleaned up when + the call fails. + - **Withdraw tells the coordinator first**, for the mirror reason: while it still serves the + package, we still need the key. + - **Top-up counts what the coordinator holds**, not what we published — `kp_take` consumes a + single-use package, so our count never falls. Last-resort packages do not fill pool slots. + - **`kp_ref` is the RFC 9420 KeyPackageRef.** Getting it wrong fails silently in the worst + way: every take misses, every Welcome lands at an address nobody listens on, no group forms. + - **A second device publishes its own last-resort package.** `kp_list` is per account, not per + device, so adopting the other device's reusable key would send every fallback Welcome + somewhere this device cannot open. +5. **A foreground sync loop.** ✅ **LANDED** as `CordnSyncLoop` over a `CordnSyncSource` seam + (which `CordnGroupManager` implements). `catch_up` drains history, then a `subscribe` is held + open and re-opened when it closes; the cursor makes the seam safe, so the two phases cannot + leave a hole. + + Wrapping two calls would be pointless if the happy path were the story. It is not — three + things separate an unattended loop from a call, and each has a test that dies without it: + - **A failure must not end the loop.** A coordinator down for a minute is ordinary; an + exception that escapes stops syncing for the session and looks exactly like a quiet group. + Every attempt is caught, recorded on `CoordinatorHealth`, and retried with a backoff that + **resets on success** — without the reset one bad stretch pins the loop at its cap forever. + - **An account with no groups must not spin.** Both calls return immediately when the manager + holds nothing, so the obvious `while (true)` burns a core on every new account, silently. + The loop parks on `gids` instead. + - **A group joined mid-subscription must not wait.** A subscription is opened for a fixed set + of gids, so a group joined a moment later is not in it; a change to the set cancels and + re-opens rather than leaving the new room empty until the stream times out. + + A closed stream is a re-subscribe, not a failure — counting a normal timeout as an error would + put a healthy loop into permanent backoff. `start()` is idempotent: two loops on one session + would both deliver, and every message would appear twice. + +Nothing in Stage A may touch Marmot. Where a cordn need resembles a Marmot one — an encrypted +state store, a key-package store — cordn gets its own, because §3.1 forbids a shared abstraction +and §3.1's guards will say so. + +*Verified by:* an integration test driving two accounts through the Tier C fixture end to end — +publish, invite, join, send, receive — with the real transport rather than `FakeCoordinator`; +plus store and loop suites over their own seams, because the failures they guard against +(a silent spin, an outage that ends sync, a plaintext write) cannot be provoked on a schedule +through a transport. + +**What Stage A still does not do:** nothing constructs a `FileBackedCordnScopeFactory` yet, because +its `CordnCoordinatorLinkFactory` needs Amethyst's relay pool and account signers. That wiring is +the first thing Stage B does, and it is one function. + +### Stage B — groups exist and are visible — **LANDED** + +6. **A cordn chatroom model + ViewModel** in `commons` (`model/cordnGroups/`, beside + `marmotGroups/`), fed by `CordnGroupManager.Delivery`. Messages keyed by envelope id + (`spec/02.md` §7 — the cursor is a delivery primitive, never a message identity). +7. **Group list**, with `CordnGroupBadge` (built, unused) on every row. +8. **Minimal chat screen**: ordered messages, sender, send a kind-9. No reactions, no media yet. +9. **Group info**: metadata, members, admins, epoch, coordinator, `gid`, share ref, and the + **`CordnExposureCard`** (built, and this is its real home — the link screen was the pre-join + half of the same disclosure). + +Add the **third isolation guard** here, over `chats/cordnGroup/` ⊥ `chats/marmotGroup/` (§3.1). +It belongs with the first screen, not after the fifth. + +*Verified by:* commons tests on the model; render tests on the new composables, the way +`CordnExposureRenderTest` does it; the new guard. + +### Stage C — joining and being joined — **LANDED** + +10. **Welcome inbox surface** — "X invited you to join", accept/decline, wired to + `joinPendingWelcomes`. The skip reasons that method already returns are user-facing text. +11. **Join requests** — `join_request_store` from a share link; the admin side lists pending + requests and completes them (take key package → verify → Add+Commit → `welcome_store`). +12. **QR share + scan**, reusing Amethyst's existing QR components. +13. **Create group**, including choosing a coordinator and the `gid` (their client uses a random + UUID; anything unique works and it must not be derived from the MLS `group_id`, which is + secret). + +*Verified by:* a two-account fixture test covering link → request → accept → first message. + +### Stage D — message features — **LANDED** + +14. Reply (1111), reaction (7), delete (5) — the three that are spec-suggested and match Marmot. +15. **Edit and pin** — only after §5.1 is decided. +16. Drafts, unread state, mentions, pinned ribbon. +17. **Encrypted media**: the cordn codec (§4.5), then image/file send and view over Blossom. +18. Voice notes, if we want parity; Amethyst has audio recording already. + +*Verified by:* codec vectors for media (generated the way `cordn-vector-gen` does), fixture tests +for the rest. + +### Stage E — settings — **LANDED** + +19. Coordinator management screen (add, remove, purge, health, server info). +20. Key-package screen (create, publish, directory, last-resort). +21. Backup/restore — **only if** we decide cordn state belongs in Amethyst's existing backup + story rather than a cordn-specific one. + +## 5. Decisions this plan needs + +### 5.1 Edit and pin kinds — DECIDED: follow cordn-web, and LANDED + +Marmot edits are **1009**, cordn-web edits are **1010**, and pins (**1011**) exist only in +cordn-web. `spec/02.md` §6 blesses none of them — it names chat/reply/reaction and then says there +is "no required set of kind values", so these are app conventions two apps picked differently. + +**Decision: follow cordn-web.** Being the second implementation of an unspecified convention is +how it becomes a specification; a third numbering would leave two clients that silently no-op on +each other's edits. Amethyst carries one kind table per binding, which it needs anyway because +system rows already differ (§5.2). + +Landed 2026-09-19 in `quartz/…/cordn/spec02Envelopes/`: + +- `CordnMessageKinds` — the catalog, with the divergence table in its KDoc. +- `CordnMessageReferences` — parse **and** build in one file, because they are two halves of one + format and the failure of splitting them is a client that emits tags its own parser rejects. + A test asserts exactly that round-trip. +- `CordnAnnotationIndex` — the fold, and the reason any of this is protocol code rather than view + model: **the authorization rules live here.** + +The rules, each with a test that fails if it is loosened: + +| Annotation | Who may | Ordering | +| ---------- | ------- | -------- | +| Reaction | anyone | n/a — a set per emoji | +| Edit | **author only** | newest `created_at`, ties on cursor | +| Deletion | **author only**, and `k` must match the target | n/a | +| Pin | **any member** | newest wins, ties on cursor | + +Two of those are load-bearing and easy to get wrong: + +- **Deletion is resolved before edits**, so a late edit cannot resurrect text its author already + withdrew. The pass order in the fold is not incidental. +- **Ties break on the coordinator's cursor**, which is the only total order two clients both see + (`spec/00.md` §4). Without it, two edits in the same second are a coin flip that two clients + could call differently — and §7 of `spec/02.md` still holds: the cursor breaks ties, it is never + an identity. + +Mutation-checked: dropping the edit author check, the delete author check, the cursor tie-break, +or the delete-before-edit ordering each kills its own test. + +Still worth asking upstream to write 1010, 1011 and the derived system row into a spec. + +### 5.2 System rows are structurally different + +Marmot puts system events **on the wire** (kind 1210, a real envelope). cordn-web **derives** them +client-side from Commits and never transmits them (synthetic kind `-1`). Deriving is strictly +better here — it cannot disagree with the MLS state it describes, and it costs no bytes — and it +is what a cordn peer will do anyway. Take theirs for cordn. Do not change Marmot. + +### 5.3 Multi-device — the fleet stays a non-goal; migration LANDED + +This section twice recorded multi-device as a non-goal, and twice for a reason +that was narrower than stated. The decision below supersedes both. + +**What is still a non-goal: a live fleet.** Two devices of one identity staying +in step is what `multi-device.md` §10 leaves unresolved — committing inside one +delivery round-trip lands them on the same epoch with different states, and §15 +concedes *equal-epoch MLS states have no merge function*. Nothing here changes +that, and nothing should ship that depends on it. + +**What landed: migration.** Moving an account from one Amethyst phone to +another is the same documents, the same seal and the same tip, minus the thing +that makes a fleet hard — a handoff has **one writer**. The old phone publishes +a snapshot and stands down; the new one seeds from it (§9) and starts. The +equal-epoch race is out of reach by construction rather than by mitigation. + +Built: + +| What | Where | +| ---- | ----- | +| §4/§5/§6/§7 documents, seal, tip inventory | `quartz/…/cordn/appMultiDevice/` | +| §6/§11 handoff code (`cordndev1…`) | `CordnHandoffCode` | +| publish / fetch | `commons/…/cordn/CordnMigration` | +| snapshot read + write over the stores | `CordnMigrationStores` | +| the fork guard | `CordnHandoffState`, enforced at `CordnRuntime.session()` | +| Blossom, two platforms | `AndroidCordnBlobStore`, `HttpCordnBlobStore` | +| `amy cordn migrate export\|import` | `cli/…/CordnMigrateCommands` | +| live end-to-end harness | `cli/tests/cordn/migrate.sh` | +| screens | `CordnMigrateScreen`, `CordnHubScreen` | + +Deliberately **not** built, because each exists to keep two *live* devices in +step: §8.5 `prev` chains, §10 sibling-Commit convergence, §10.5 +publish-on-every-Commit, §8 tombstone reconciliation. + +Five additive fields beyond the spec's documents, each because the spec has +nowhere to say it and a migration loses something real otherwise: +`clientStateFormat` (so a ts-mls document is refused with a reason rather than +crashing inside our MLS engine), `amethystRoomState` / `amethystEchoState` / +`amethystJoinedViaRequest` (drafts, read positions and echo bookkeeping — +`CordnBackup` loses all three today), `amethystCoordinatorRelays` (§8.5 of +`spec/00.md` gives a coordinator no address but its pubkey), and the full +key-package list on the meta document (§11.5 carries only the last-resort one, +which is right for a fleet and wrong for a phone that is going away). + +**Cross-client migration remains impossible**, unchanged: `clientState` is +library-private by design (§4.2), so an Amethyst device and a cordn-web device +can never share a leaf. What landed is Amethyst-to-Amethyst, which is what was +asked for. + +**The cost that was weighed and accepted:** the sealed documents leave the +device for a storage server. They are NIP-44 v2 under a DEK reachable only +through a seal to the owner's own npub, so the server sees size and timing and +nothing else — but group state including leaf private keys is on someone else's +disk until the blobs are deleted. The export screen says so before the button. + +Revisit the *fleet* question if upstream specifies the equal-epoch tiebreaker. +A library-neutral `clientState` would separately unlock cross-client migration. +Neither has happened. + +### 5.4 Which backup story — DECIDED: our own format, and LANDED + +cordn-web has its own passphrase-encrypted backup. Amethyst has account backup already. Folding +cordn group state into Amethyst's backup is better for users and worse for portability (their +file will not import). Needs a call; no strong opinion here. + +**Answered while building it, and neither half of the trade survived contact.** + +- *Portability was never available.* The valuable content of any cordn backup is MLS group + state, which is an engine's internal serialization rather than a wire format — the same + reasoning §4.6 of the interop plan uses to rule out *cross-client* multi-device. Theirs is ts-mls's + `ClientState`, ours is `MlsGroupState`, and neither reads the other whatever container wraps + it. A byte-compatible file would buy a restore that cannot restore. Their client also sits + outside the two MIT packages, and the spec prose is unlicensed. +- *There was nothing to fold into.* "Amethyst has account backup already" means **key** backup. + A relay-backed account rebuilds itself from an nsec; a cordn group does not, because the key + alone restores nothing. + +So: `CordnBackup` — our own versioned format, scrypt at NIP-49's cost plus ChaCha20-Poly1305. +Restoring **replaces** a device rather than merging, because an MLS state export is a cloneable +identity and two devices committing from one state fork the ratchet tree — the same equal-epoch +divergence §5.3 records as unresolved in `multi-device.md` §10, reached here by restore instead of +by a race. + +## 6. Out of scope, and why + +- **News feed, donations, supporters** — cordn.net product surface, not protocol. +- **Theme editor / gallery** — Amethyst has its own theming. +- **App update banners, native bridge/shims** — packaging concerns of a web app shipped as a + native wrapper. +- **Profile pages, "why" page** — Amethyst has these. +- **Multi-device** — §5.3. + +## 7. Risks + +1. **Stage A is most of the work and all of the uncertainty.** Every screen is cheap once an + account really holds cordn groups; nothing is possible before that. +2. **No live verification.** Tier B is blocked on licensing, so "works against the fixture" is the + strongest claim any stage below can make. Shipping a chat feature to users on that basis is a + product decision, not a technical one — it should be made deliberately, and probably means + shipping behind a flag until a licensed coordinator exists to test against. +3. **Two group-chat UIs, permanently.** This is the accepted cost of §3, not a risk to mitigate — + but it has a failure mode worth naming: a chat bug fixed in one and forgotten in the other. + The mitigation is not a shared abstraction (§3.1 forbids it); it is that cordn's screens stay + small enough to hold in one head, which means resisting the urge to port Marmot's feature set + wholesale. Build what cordn users need, not what Marmot happens to have. +4. **§8 exposure must ride along.** The badge and the card exist; if the group list and info + screens ship without them the disclosure requirement quietly regresses to what it was before. + +## 8. Open questions + +1. Do we ship cordn to users at all before a licensed coordinator exists to test against (risk 2)? + **Still open, and still the only thing between this plan and users — but the question has + narrowed.** Every stage below it has landed, and the protocol layer is no longer + fixture-only: Tier B ran on 2026-09-22 against the reference coordinator and the full + lifecycle passes end to end (interop plan §7.1). So "untested against a real peer" is no + longer an argument against shipping. + + What has *not* changed is the licensing, and that was always the sharper half of risk 2. A + coordinator we may not depend on is one we cannot put in CI, cannot regression-test against + on every release, and cannot point a user at. Tier B is a diagnostic someone runs by hand, + which is worth a great deal at this stage and nothing at all as a shipping guarantee. + + Worth separating when this is decided: the two bugs Tier B found were both invisible to five + tiers of our own tests, which is evidence about how much fixture-only verification is worth + — in either direction, depending on whether you read it as "the live tier works" or "we do + not know what else is hiding". +2. ~~§5.1 — follow cordn-web's 1010/1011, or wait for a spec?~~ **Answered: follow cordn-web.** + Landed; see §5.1. +3. ~~§5.4 — one backup story or two?~~ **Answered: our own.** Landed; see §5.4. +4. ~~Is there appetite for a shared chat surface?~~ **Answered: no.** Marmot is frozen and the + two must stay independently deletable; §3 is the standing constraint and the guards enforce it. +5. Whose coordinator do we default to, if any? The interop plan's open question 5 applies harder + once there is a UI with a "use default" button: shipping a default is an endorsement. diff --git a/amethyst/plans/README.md b/amethyst/plans/README.md index ca667f7c01..e96cd495cb 100644 --- a/amethyst/plans/README.md +++ b/amethyst/plans/README.md @@ -11,6 +11,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 ## Queued | Plan | Summary | | ---- | ------- | +| [2026-09-19-cordn-ui.md](2026-09-19-cordn-ui.md) | Every user-facing feature cordn-web has, mapped onto Amethyst — the account-level cordn runtime (the real gate), group list/chat/info screens, welcomes, join requests, message features and settings. Marmot is frozen and the two bindings must stay independently deletable, enforced by isolation tests rather than a README; flags the 1009-vs-1010 edit-kind divergence. | | [2026-09-15-qr-reader-overhaul.md](2026-09-15-qr-reader-overhaul.md) | QR reader rebuilt on CameraX + zxing-cpp in-app (replacing the Camera1 zxing-android-embedded activity) — continuous AF, zoom, torch, full-frame decode, explicit failure feedback, and gallery/clipboard import. | | [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. | | [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. | diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 85b79bd3c9..406c21e081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState @@ -207,8 +208,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.image import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -377,8 +378,17 @@ class Account( val cache: LocalCache, val client: INostrClient, val scope: CoroutineScope, + /** + * Where cordn keeps its encrypted group state, or null to run without it. + * + * A directory rather than a built runtime, because `CordnRuntime` needs + * this account's [scope] and that only exists once the account does. + * Nothing about it is shared with Marmot's stores above — cordn has its + * own, by the §3.1 rule in `amethyst/plans/2026-09-19-cordn-ui.md`. + */ + val cordnFilesDir: java.io.File? = null, val mlsGroupStateStore: MlsGroupStateStore? = null, - val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, + val marmotMessageStore: com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, /** * Durable publish obligations. Null means publish-before-apply does not @@ -945,6 +955,24 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + /** + * cordn, for this account, or null when no directory was supplied. + * + * Built here for the same reason [marmotManager] is: it needs [scope]. + * Opening coordinators and starting their sync loops is a separate, + * suspending step ([CordnRuntime.start]) — constructing this touches no + * network and no coordinator learns anything from it. + */ + val cordnRuntime: CordnRuntime? = + cordnFilesDir?.let { + CordnRuntime( + accountSigner = signer, + client = client, + filesDir = it, + scope = scope, + ) + } + val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager( @@ -3885,6 +3913,19 @@ class Account( // the race where a publish would land on a half-built Account. cashuWalletState.start { event -> sendLiterallyEverywhere(event) } + // Reopen the cordn coordinators this account used last time. + // + // Deliberately its own launch rather than a branch of Marmot's block + // below: the two protocols are independent by design (§3.1 of + // amethyst/plans/2026-09-19-cordn-ui.md), and a cordn coordinator that + // is down must not delay Marmot's restore, or the reverse. Failures + // are already contained per coordinator inside start(). + cordnRuntime?.let { runtime -> + scope.launch(Dispatchers.IO) { + runtime.restore() + } + } + // Restore Marmot MLS group state on startup if (marmotManager != null) { // Derived kind:1210 rows go straight into the conversation. Only diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 0a4001414f..68c223da21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -931,7 +931,7 @@ class AccountMarmotActions( /** * Revoke admin privileges from [targetPubKey]. Rejects any change that * would leave the group with zero admins — MIP-03's admin-depletion guard - * in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise + * in [com.vitorpamplona.quartz.mls.group.MlsGroup] would otherwise * throw at commit time. */ suspend fun revokeMarmotGroupAdmin( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 62672c0ce9..6d958f59b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -330,6 +330,10 @@ class AccountCacheState( Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception", throwable) }, ), + // The same per-account directory the Marmot stores use. cordn + // scopes itself further by coordinator underneath it, because a + // gid is unique only within one (spec/00.md §4). + cordnFilesDir = accountDir, mlsGroupStateStore = mlsStore, marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/AndroidCordnBlobStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/AndroidCordnBlobStore.kt new file mode 100644 index 0000000000..39b8dad7a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/AndroidCordnBlobStore.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.cordn + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobStore +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.ByteArrayInputStream + +/** + * Where a migration's sealed documents are stored, on Android. + * + * ## The authorization is signed by a throwaway, and that is the point + * + * `multi-device.md` §12 requires the BUD-01 upload authorization to be signed + * by an ephemeral key and **never** the owner `npub` — the same rule as the + * tip. Signing as the owner would tell the storage server, in the clear and + * under the account's own name, that this person is uploading right now. That + * is precisely the linkage the opaque tip is built to avoid, and it would + * arrive by a side door. + * + * So this does NOT reuse `account.createBlossomUploadAuth` the way + * [CordnMediaService] does for message attachments. Those are already + * attributable — they ride inside a group the coordinator can see traffic for + * — whereas the point of a migration blob is that nothing links it to anyone. + * [signer] is minted per instance and derived from nothing. + */ +class AndroidCordnBlobStore( + private val servers: List, + private val context: Context, +) : CordnBlobStore { + private val signer = NostrSignerInternal(KeyPair()) + + override suspend fun put(blob: ByteArray): List { + val hash = sha256(blob).toHexKey() + + return servers.filter { server -> + runCatching { + BlossomUploader() + .upload( + inputStream = ByteArrayInputStream(blob), + hash = hash, + length = blob.size.toLong(), + baseFileName = hash, + // Opaque on purpose: the server learns a size and a + // hash, and nothing about what kind of thing this is. + contentType = OPAQUE, + alt = null, + sensitiveContent = null, + serverBaseUrl = server, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = { h, size, alt -> BlossomAuthorizationEvent.createUploadAuth(h, size, alt ?: "", signer) }, + context = context, + useMediaEndpoint = false, + ).url != null + }.getOrDefault(false) + } + } + + override suspend fun get( + address: String, + servers: List, + ): ByteArray? = + withContext(Dispatchers.IO) { + // Ordered: §6 has the reader try the tip's servers as listed, most + // reliable first. + servers.firstNotNullOfOrNull { server -> + runCatching { + val url = "${server.trimEnd('/')}/$address" + Amethyst.instance.roleBasedHttpClientBuilder + .okHttpClientForImage(url) + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { if (it.isSuccessful) it.body?.bytes() else null } + }.getOrNull() + } + } + + companion object { + private const val OPAQUE = "application/octet-stream" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt new file mode 100644 index 0000000000..678f62e288 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.cordn + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnEncryptedMedia +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaEncryption +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.ByteArrayInputStream + +/** + * Sending and fetching cordn attachments. + * + * ## What each party sees + * + * | | sees | + * | --- | --- | + * | the blob host | opaque bytes, their size, and who uploaded them | + * | the coordinator | a sealed payload; not the URL, not the type, not the name | + * | the group | everything, because the key rides in the sealed descriptor | + * + * Getting that split right is most of this file. The `imeta` descriptor — + * URL, MIME type, filename, plaintext hash, nonce — rides **inside** the MLS + * envelope, so it is as private as the message it belongs to. + * + * ## Three deliberate choices on the upload + * + * 1. **`application/octet-stream`, always.** The real MIME type goes in the + * encrypted descriptor. Declaring `image/jpeg` to the host would tell it + * what kind of file this is for no benefit to anyone. + * 2. **No `alt` text, no content warning.** Both are plaintext on a Blossom + * upload. An alt string describing a private photo is the photo's caption, + * handed to a server that was supposed to see nothing. + * 3. **`/upload`, never `/media`.** The `/media` endpoint asks the server to + * re-encode. Re-encoding ciphertext destroys it, and an account with + * "optimize uploads" on would otherwise silently break every attachment. + * + * Blossom addresses a blob by the SHA-256 of the bytes it stores — the + * ciphertext. The `imeta` descriptor carries the hash of the **plaintext**. + * Two different hashes on purpose: the host needs one to name the blob, the + * group needs the other to know it got the file that was sent, and neither + * can be derived from the other. + */ +class CordnMediaService( + private val account: Account, +) { + /** + * Encrypts [bytes] under a fresh per-file key and uploads the ciphertext. + * + * @return the `imeta` tag to put on the message, or null when the account + * has no Blossom server configured — there is nowhere to put a file and + * saying so beats a failure deeper in. + */ + suspend fun upload( + group: MlsGroup, + bytes: ByteArray, + mimeType: String, + filename: String, + context: Context, + /** The host to put it on. Defaults to the account's, which is what the + * upload dialog's server spinner starts on. */ + serverBaseUrl: String = account.settings.defaultFileServer.baseUrl, + /** + * Display hints for the `imeta`. All optional and none authenticated: + * they are the spec's "passed through unchanged" fields, so they cost + * nothing to omit and are worth a great deal to include — without + * [dimensions] the bubble has no aspect ratio to reserve and the list + * jumps when the picture lands, and without [waveform] a voice note the + * recorder already measured comes back as bare bars. + */ + dimensions: String? = null, + blurhash: String? = null, + alt: String? = null, + waveform: List? = null, + ): Array? = + withContext(Dispatchers.IO) { + val server = serverBaseUrl.ifBlank { return@withContext null } + + // Derived from the group's epoch exporter, never sent: that is what + // spec/applications/encrypted-media.md §3.1 requires, and what lets + // any cordn client open the blob from group state alone. + val fileKey = CordnMediaEncryption.mediaKey(group) + val sealed = CordnMediaEncryption.encrypt(bytes, fileKey, mimeType, filename) + CordnMediaTag.build( + media = sealed, + url = put(sealed, server, context), + dimensions = dimensions, + blurhash = blurhash, + alt = alt, + waveform = waveform, + ) + } + + /** + * Uploads the ciphertext and returns the URL the host gave it. + * + * Every privacy decision is in [CordnBlobUpload]; this forwards it. Read + * that KDoc before changing an argument here — the wrong constant does not + * fail, it just tells a server something. + */ + private suspend fun put( + sealed: CordnEncryptedMedia, + server: String, + context: Context, + ): String { + val blob = CordnBlobUpload.of(sealed) + + val result = + BlossomUploader().upload( + inputStream = ByteArrayInputStream(blob.bytes), + hash = blob.hash, + length = blob.length, + baseFileName = blob.baseFileName, + contentType = blob.contentType, + alt = blob.alt, + sensitiveContent = blob.sensitiveContent, + serverBaseUrl = server, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }, + context = context, + useMediaEndpoint = blob.useMediaEndpoint, + ) + + return result.url ?: throw IllegalStateException("the blob server returned no URL") + } + + /** + * Fetches [attachment] and opens it with the key it carries. + * + * Throws if the bytes do not authenticate. That is the right outcome and + * not a rare one: a blob host can serve anything it likes for a URL, and + * the AEAD tag plus the plaintext hash are the only reasons to believe + * what came back is what was sent. + */ + suspend fun download( + group: MlsGroup, + attachment: CordnMediaAttachment, + ): ByteArray = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(attachment.url) + .get() + .build() + val response = + Amethyst.instance.roleBasedHttpClientBuilder + .okHttpClientForImage(attachment.url) + .newCall(request) + .execute() + + val body = + response.use { + check(it.isSuccessful) { "the blob server answered ${it.code}" } + it.body.bytes() + } + + CordnMediaEncryption.decrypt( + ciphertext = body, + fileKey = CordnMediaEncryption.mediaKey(group), + nonce = attachment.nonceBytes, + plaintextHash = attachment.hashBytes, + mimeType = attachment.mimeType, + filename = attachment.filename, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt new file mode 100644 index 0000000000..934a3f383a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt @@ -0,0 +1,1132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.cordn + +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.CordnBackup +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLinkFactory +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorRegistry +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager +import com.vitorpamplona.amethyst.commons.cordn.CordnHandoffState +import com.vitorpamplona.amethyst.commons.cordn.CordnLinks +import com.vitorpamplona.amethyst.commons.cordn.CordnMigration +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationSnapshot +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationStores +import com.vitorpamplona.amethyst.commons.cordn.CordnRoomState +import com.vitorpamplona.amethyst.commons.cordn.CordnSession +import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout +import com.vitorpamplona.amethyst.commons.cordn.CordnSyncLoop +import com.vitorpamplona.amethyst.commons.cordn.CordnSyncSource +import com.vitorpamplona.amethyst.commons.cordn.FileBackedCordnScopeFactory +import com.vitorpamplona.amethyst.commons.cordn.FileCordnCoordinatorStore +import com.vitorpamplona.amethyst.commons.cordn.FileCordnGroupStore +import com.vitorpamplona.amethyst.commons.cordn.FileCordnHandoffStore +import com.vitorpamplona.amethyst.commons.cordn.FileCordnKeyPackageStore +import com.vitorpamplona.amethyst.commons.cordn.KeyStoreCordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.OpenedWelcome +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupList +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.cancellation.CancellationException +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * One account's cordn feature, assembled. + * + * This is the wiring Stage A left open on purpose: every piece below it is + * shared KMP code that knows nothing about Amethyst, and this class supplies + * the two things only the running app has — a relay pool and the account's + * signer. + * + * ## The ephemeral signer is created here, once, and never persisted + * + * `spec/00.md` §8 splits the identity a coordinator sees: the account key + * signs what must be attributable (publishing a KeyPackage, posting to a + * group), and a throwaway key signs everything else, so the coordinator cannot + * link a session's reads to an account. Which key signs which call is fixed by + * `CoordinatorMethod` and not a choice made here; what IS decided here is that + * the throwaway key lives as long as this runtime and no longer. Persisting it + * would quietly undo the split — a "session" key reused across launches is + * just a second account key with worse ergonomics. + */ +class CordnRuntime( + private val accountSigner: NostrSigner, + private val client: INostrClient, + private val filesDir: File, + private val scope: CoroutineScope, + private val cipher: CordnBlobCipher = KeyStoreCordnBlobCipher(), + /** + * How a coordinator connection is opened. The default is the real one. + * + * A seam for the same reason [cipher] is one: the production path needs a + * relay, a transport and a live coordinator, so without it nothing in this + * class — the KeyPackage pool rule, purge, restore — could be tested at + * all. The ephemeral signer stays inside the default, because §8's + * identity split is not a thing a caller should be able to widen. + */ + private val links: CordnCoordinatorLinkFactory = CordnLinks.over(accountSigner, client), +) { + /** See the class KDoc: per-runtime, never written to disk. */ + private val registry = + CordnCoordinatorRegistry( + accountPubKey = accountSigner.pubKey, + scopes = FileBackedCordnScopeFactory(filesDir, cipher, links), + ) + + private val coordinatorStore = + FileCordnCoordinatorStore( + CordnStorageLayout.accountDirectoryFor(filesDir, accountSigner.pubKey), + cipher, + ) + + /** + * Whether this device has handed its groups to another one. + * + * Read before the first sync loop starts (see [restore]); checked on every + * path that would advance an epoch, because two devices committing from one + * leaf fork the ratchet tree irrecoverably. + */ + val handoff = + CordnHandoffState( + FileCordnHandoffStore(CordnStorageLayout.accountDirectoryFor(filesDir, accountSigner.pubKey)), + ) + + /** Every cordn room this account is in, for the inbox and the screens. */ + val groups = CordnGroupList(accountSigner.pubKey) + + /** + * A running sync loop, the manager it was built over, and its log watcher. + * + * The manager is the part that matters. [CordnCoordinatorRegistry] hands + * back a **new** session, with a new manager over a freshly opened + * transport, whenever a coordinator's relays are corrected -- the transport + * is bound to them, so it cannot be carried over. [CordnSyncLoop] captures + * its source for the life of the loop, so keeping the old loop across that + * swap left it polling a transport that had just been closed: the + * coordinator retried forever, its rooms never updated again, and the only + * way out was restarting the app. + */ + private class SyncLoopHandle( + val loop: CordnSyncLoop, + val source: CordnSyncSource, + val watcher: Job, + /** + * The one-shot profile fetch. Held so it can be cancelled with the rest: + * it is launched into the runtime scope, and an unheld launch outlives a + * logout or a forget for as long as its relays take to answer. + */ + val prefetch: Job, + ) + + private val loops = mutableMapOf() + private val lock = Mutex() + + val coordinators = registry.coordinators + + /** + * The session for [config], opening and starting it if it is new. + * + * Every epoch-advancing path goes through here — creating a group, joining + * one, accepting a Welcome, sending — so this is where a handed-off device + * is stopped. Reads of state already in memory are left alone: showing a + * user the conversations they had is harmless, and refusing it would make a + * failed migration look like data loss. + */ + suspend fun session(config: CoordinatorConfig): CordnSession { + handoff.requireNotHandedOff() + val session = registry.session(config) + lock.withLock { + // Rebuilt, not only created: an existing loop whose source is not + // this session's manager is one the registry has already orphaned by + // reopening the transport under it. Stopped before the replacement + // starts, because two loops on one coordinator would file every + // delivery twice. + val current = loops[config.pubKey] + if (current == null || current.source !== session.manager) { + current?.let { + it.watcher.cancel() + it.loop.stop() + } + + val loop = + CordnSyncLoop( + source = session.manager, + onDelivery = { file(config.pubKey, it) }, + ) + loop.start(scope) + + // A coordinator is a Nostr identity, and CEP-23/CEP-17 say it + // may publish a kind 0 and a kind 10002 like anybody else. Its + // own relays are where those live, so they are fetched here, + // from the relays this account already talks to for cordn. + // + // All three are ordinary public events about this pubkey, and now + // all three are typed, so the global cache connector files them and + // the cache does the keeping: newest-wins per (kind, pubkey) for the + // replaceable ones, and verification before anything trusts them. + // Nothing is parsed or stored by hand here any more. The fetch + // remains only to aim the request at the coordinator's OWN relays, + // which is where its announcement lives and is the one thing the + // generic data sources cannot know until its relay list is cached. + // + // Not a nicety -- it is what keeps the *outbox discovery* for + // this pubkey off this account's home relays. Rendering a + // coordinator with UserPicture/observeUserNameByHex puts it in + // LocalCache as a User, and UserOutboxFinderSubAssembler then + // asks who it is: given a cached relay list it has an outbox for + // the pubkey and issues no filter at all + // (`if (noOutboxList.isEmpty()) return null`), while without one + // pickRelaysToLoadUsers falls through to this account's index and + // home relays -- telling them the account is interested in a + // pubkey that CEP-6 announcements publicly identify as a + // coordinator. Relay hints alone would not close it either: the + // search broadens anyway below three hints, and a coordinator + // usually lists one or two. + // + // It is not a complete seal, and the KDoc above must not be read + // as one: this only silences the assembler that gates on a + // missing relay list. UserReportsSubAssembler and + // UserCardsSubAssembler ask this account's own relays about the + // pubkey either way, and a coordinator that publishes no kind + // 10002 falls back to the broad path regardless. What this buys + // is the largest of those queries, not silence. + // + // The global CacheClientConnector files whatever comes back, so + // there is nothing to consume here, and failure is silent on + // purpose: a coordinator with no profile is ordinary, and the + // screens already fall back to the key. + val prefetch = + scope.launch { + runCatching { + client.fetchAll( + filters = + config.relays.associateWith { + listOf( + Filter( + kinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + CvmKinds.SERVER_ANNOUNCEMENT, + ), + authors = listOf(config.pubKey), + ), + ) + }, + ) + } + } + // A coordinator that stops answering is otherwise invisible: + // the rooms are there, they are simply never updated again, and + // the whole sync path logged nothing at all. Only the failing + // state is worth a line, and only when it changes. + val watcher = + scope.launch { + loop.state + .filterIsInstance() + .distinctUntilChanged() + .collect { + Log.w(TAG, "coordinator ${config.pubKey.take(8)}\u2026 not syncing (attempt ${it.attempt}, retry in ${it.inMs}ms): ${it.reason}") + } + } + + loops[config.pubKey] = SyncLoopHandle(loop, session.manager, watcher, prefetch) + } + } + // Whatever the store already held, so a relaunch shows its rooms + // before the first message of the session arrives. + session.manager.gids.value + .forEach { + refresh(session, it) + // Read positions come back with the rooms, not when a room is + // opened: an inbox that shows every room as unread until it is + // visited is worse than one with no unread state at all. + val saved = session.manager.roomState(it) + val room = groups.get(config.pubKey, it) + room?.restoreState(saved.draft, saved.lastReadCursor) + // The last message, for the inbox line. One small key per group + // rather than the whole log: the conversation itself loads when + // a room is opened. Without it every cordn room read "No + // messages yet" after a relaunch, however much had been said. + session.manager.storedMessageSummary(it)?.let { summary -> room?.restorePreview(summary.newest) } + } + remember() + maintainKeyPackages(session) + return session + } + + /** + * Keeps the KeyPackage pool healthy on a coordinator this account already + * uses — and does nothing at all on one it does not. + * + * The condition is the whole design. Publishing a KeyPackage is an + * attributable act under the account key (§8.4): it tells the coordinator + * this account exists and is invitable, which is not something to do on + * someone's behalf at login. But once a KeyPackage IS published there, the + * coordinator already knows, and letting the pool drain silently has a + * cost with no matching benefit — `kp_take` consumes a single-use package, + * so the pool falls as people invite us, and an empty pool means the next + * invitation fails for a reason the inviter sees and the invitee never + * does. + * + * So: never the first package, always the ones after it. Publishing the + * first one stays an explicit act on the key-package screen or a join + * request. + * + * Runs detached and swallows failures: this is upkeep, and a coordinator + * that will not take a KeyPackage must not stop its groups from syncing. + */ + private fun maintainKeyPackages(session: CordnSession) { + scope.launch { + try { + if (!session.keyPackages.hasPublished()) return@launch + session.keyPackages.topUp() + session.keyPackages.ensureLastResort() + } catch (e: Exception) { + Log.w(TAG, "could not top up key packages on ${session.coordinatorPubKey.take(8)}\u2026: ${e.message}", e) + } + } + } + + /** + * Creates a group on [config] and returns the `gid` it was given. + * + * The `gid` is a random UUID, which is what cordn's own client uses. It is + * the caller's to choose (`spec/00.md` §4) and the coordinator never + * interprets it — the one hard rule is that it must not be derived from the + * MLS `group_id`, which is secret. + * + * Nothing is sent anywhere. Creating a group is a local MLS operation; the + * coordinator learns the group exists when the first Commit or message is + * posted to it. That is why this succeeds against a coordinator that is + * down, and why a group with no other members has told the coordinator + * nothing at all. + */ + @OptIn(ExperimentalUuidApi::class) + suspend fun createGroup( + config: CoordinatorConfig, + metadata: CordnGroupMetadata, + gid: String = Uuid.random().toString(), + ): String { + val session = session(config) + session.manager.createGroup(gid, metadata) + refresh(session, gid) + return gid + } + + /** + * Which of [roster] this coordinator could deliver a Welcome to. + * + * The question the create-group screen is really asking. An invitation is a + * Welcome left on the coordinator addressed to a KeyPackage the invitee + * published *there* -- so a coordinator that holds no KeyPackage for + * somebody cannot be told to invite them at all, and picking it means + * leaving them out. One `kp_list` answers for the whole roster at once, + * which is why this takes a set rather than a pubkey. + * + * Only answers for a coordinator this account already has a session with. + * Opening one is what commits to a coordinator, and a screen that is still + * only *looking* must not do that on the user's behalf -- so a discovery + * offer reports [CordnCoverage.answered] false rather than being probed. + */ + suspend fun coverage( + coordinatorPubKey: HexKey, + roster: Set, + ): CordnCoverage { + val identities = + identitiesWithKeyPackages(coordinatorPubKey) + ?: return CordnCoverage(coordinatorPubKey, emptySet(), roster, answered = false) + val reachable = roster.filterTo(mutableSetOf()) { it in identities } + return CordnCoverage(coordinatorPubKey, reachable, roster - reachable, answered = true) + } + + /** + * Creates the group, then invites [invitees] into it one at a time. + * + * Not one operation, and the result says so. `createGroup` and each + * `invite` are separate calls to the coordinator, so the group can exist + * with only some of the roster invited -- and nothing that happens to an + * invitation can undo the group. The caller gets a row per invitee instead + * of one thrown exception, because "three of five went out" is the state + * the user has to be shown, not an error to report. + * + * One commit for the whole roster, via [CordnGroupManager.inviteAll]. A + * loop of single invites was not just N epochs and N round trips: an Add is + * applied locally before it is posted, so one failed post left the group + * forked and every later invite in the loop building on the fork. + */ + suspend fun createGroupAndInvite( + config: CoordinatorConfig, + metadata: CordnGroupMetadata, + invitees: List, + ): CordnGroupCreation { + val gid = createGroup(config, metadata) + val session = requireSession(config.pubKey) + + val outcomes = + try { + val batch = session.manager.inviteAll(gid, invitees) + // Order follows the roster, not the batch: this is what the + // screen lists, and a list that reshuffles by outcome is harder + // to read than one that matches what was asked for. + invitees.map { target -> + val undelivered = batch.undelivered[target] + CordnInviteOutcome( + pubKey = target, + failure = batch.refused[target] ?: undelivered, + // Added, but with no Welcome to join by. Kept apart from + // a refusal because the two need opposite answers: this + // one is already a member and needs the Welcome resent, + // where a refusal never reached the group at all. + joinedWithoutWelcome = undelivered != null, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The commit itself failed, so nobody was added. One failure + // for everyone rather than a made-up per-person story. + Log.w(TAG, "could not invite into $gid: ${e.message}", e) + invitees.map { CordnInviteOutcome(it, failure = e) } + } + + refresh(session, gid) + return CordnGroupCreation(gid, outcomes) + } + + fun sessionOrNull(coordinatorPubKey: HexKey): CordnSession? = registry.sessionOrNull(coordinatorPubKey) + + /** + * Renames a coordinator, for this device only. + * + * The user's own word for it, which is the only name here that means + * anything: a coordinator cannot prove one, and both the names it can + * publish (a kind 0 under CEP-23, the CEP-6 announcement's surface) are its + * own claim. Until now a label could only be given while adding a + * coordinator by hand, so one added from discovery -- or restored on a new + * device -- could never be named at all. + * + * Goes through the registry rather than [session]: the loop is already + * running and must not be rebuilt, and renaming is local bookkeeping that + * advances no epoch, so it is allowed on a device that has handed its + * groups away. A label-only change no longer reopens the transport, so this + * costs one write to disk. + */ + suspend fun relabel( + coordinatorPubKey: HexKey, + label: String?, + ) { + val existing = registry.sessionOrNull(coordinatorPubKey) ?: return + registry.session(existing.config.copy(label = label?.trim()?.ifEmpty { null })) + remember() + } + + /** + * Every invitation waiting for this account, opened but not answered. + * + * One round trip per open coordinator, made when someone asks to see their + * invitations and at no other time. There is no background poll on purpose: + * every call to a coordinator is metadata (§8), so a badge that stayed + * up to date would mean telling each coordinator how often this account + * opens the app. + * + * A coordinator that fails to answer is reported rather than logged and + * dropped. "We could not ask" and "there is nothing for you" look the same + * on screen and mean opposite things. + */ + suspend fun invitations(): CordnInvitations { + val pending = mutableListOf() + val skipped = mutableListOf() + val unreachable = mutableListOf() + + registry.coordinators.value.forEach { config -> + val session = registry.sessionOrNull(config.pubKey) ?: return@forEach + try { + val inbox = session.manager.pendingWelcomes(session.keyPackages::bundleFor) + pending += inbox.pending.map { CordnInvitation(config, it) } + skipped += inbox.skipped.map { CordnSkippedInvitation(config, it.keyPackageRef, it.reason) } + } catch (e: Exception) { + Log.w(TAG, "could not read invitations from ${config.pubKey.take(8)}\u2026: ${e.message}", e) + unreachable += CordnCoordinatorFailure(config, e.message ?: "the coordinator did not answer") + } + } + return CordnInvitations(pending, skipped, unreachable) + } + + /** Joins the group [invitation] opens, and shows it in the inbox. */ + suspend fun accept(invitation: CordnInvitation): String { + val session = + registry.sessionOrNull(invitation.coordinator.pubKey) + ?: throw IllegalStateException("no session for ${invitation.coordinator.pubKey}") + val gid = session.manager.accept(invitation.welcome) + refresh(session, gid) + return gid + } + + /** Retires [invitation] without joining. There is no undo; see `decline`. */ + suspend fun decline(invitation: CordnInvitation) { + registry.sessionOrNull(invitation.coordinator.pubKey)?.manager?.decline(invitation.welcome) + } + + /** + * Asks to be added to [gid] on [config]. + * + * Publishes a fresh KeyPackage first, because there is no way to be added + * without one: `spec/00.md` §4.2 gives cordn no KeyPackage event kind, so + * the coordinator is the only place an inviter can find it. This is the + * one moment where publishing is unambiguously what the user asked for — + * they are asking strangers to add them — which is why it happens here + * rather than silently at login (§8.4). + */ + suspend fun requestToJoin( + config: CoordinatorConfig, + gid: String, + ): Long { + val session = session(config) + val published = session.keyPackages.publishNew() + return session.manager.requestToJoin(gid, published.keyPackageRef) + } + + /** + * Everyone asking to join [gid], or an empty list if we hold no session. + * + * Fetched when asked for, never polled — the same §8 reasoning as + * [invitations]. **Any member can answer these**, not only an admin: + * cordn's `admin_pubkeys` is presentation metadata and nothing enforces + * it, so there is no admin check to make here and a UI that implied one + * would be inventing a boundary the protocol does not have. + */ + suspend fun joinRequests( + coordinatorPubKey: HexKey, + gid: String, + ): List = + registry + .sessionOrNull(coordinatorPubKey) + ?.manager + ?.pendingJoinRequests() + ?.filter { it.gid == gid } + .orEmpty() + + /** + * The three admin commits, each followed by the refresh that makes them + * visible. + * + * The manager does the MLS and the posting; only [refresh] pushes the new + * member list, epoch and metadata into the [CordnGroupChatroom] the screens + * render. A caller that reached the manager directly -- which the group info + * screen did -- got a commit that worked and a roster that went on showing + * the group as it was before, until a message happened to arrive or the app + * was relaunched. Every other mutation here already pairs the two; these are + * the ones that were missing it. + */ + suspend fun invite( + coordinatorPubKey: HexKey, + gid: String, + targetPubKey: HexKey, + ) { + val session = requireSession(coordinatorPubKey) + session.manager.invite(gid, targetPubKey) + refresh(session, gid) + } + + suspend fun removeMember( + coordinatorPubKey: HexKey, + gid: String, + targetPubKey: HexKey, + ) { + val session = requireSession(coordinatorPubKey) + session.manager.removeMember(gid, targetPubKey) + refresh(session, gid) + } + + suspend fun updateGroupMetadata( + coordinatorPubKey: HexKey, + gid: String, + metadata: CordnGroupMetadata, + ) { + val session = requireSession(coordinatorPubKey) + session.manager.updateGroupMetadata(gid, metadata) + refresh(session, gid) + } + + private fun requireSession(coordinatorPubKey: HexKey): CordnSession = + registry.sessionOrNull(coordinatorPubKey) + ?: throw IllegalStateException("no session for $coordinatorPubKey") + + /** Adds the account behind [request] to its group. */ + suspend fun acceptJoinRequest( + coordinatorPubKey: HexKey, + request: JoinRequest, + ) { + val session = + registry.sessionOrNull(coordinatorPubKey) + ?: throw IllegalStateException("no session for $coordinatorPubKey") + session.manager.acceptJoinRequest(request) + refresh(session, request.gid) + } + + /** Retires [request] without adding anyone. */ + suspend fun declineJoinRequest( + coordinatorPubKey: HexKey, + request: JoinRequest, + ) { + registry.sessionOrNull(coordinatorPubKey)?.manager?.declineJoinRequest(request) + } + + /** + * Reopens the coordinators this account used last time, and starts syncing. + * + * Call at login. Without it a cordn group is unreachable after a relaunch: + * its MLS state is still on disk, but a `gid` with no coordinator is not a + * group anyone can open, and nothing else on the device knows which + * coordinator serves it. + */ + suspend fun restore() { + handoff.restore() + // A handed-off device does not open sessions at all. Its stores stay on + // disk so the handoff can be undone, but a live sync loop would fetch, + // decrypt and self-echo alongside the device that took over. + if (handoff.handedOff.value) return + start(coordinatorStore.load()) + } + + /** Opens every configured coordinator and starts syncing. */ + suspend fun start(configs: List) { + configs.forEach { + try { + session(it) + } catch (e: Exception) { + // One unreachable coordinator must not stop the others: they + // are independent authorities and a user with two of them has + // two unrelated sets of groups. + Log.w(TAG, "could not open coordinator ${it.pubKey.take(8)}…: ${e.message}", e) + } + } + } + + /** Stops every loop and closes every transport. Call at logout. */ + suspend fun stop() { + lock.withLock { + loops.values.forEach { + it.watcher.cancel() + it.prefetch.cancel() + it.loop.stop() + } + loops.clear() + } + registry.close() + groups.clear() + } + + /** Drops one coordinator, leaving its stored groups on disk. */ + suspend fun forget(coordinatorPubKey: HexKey) { + lock.withLock { + loops.remove(coordinatorPubKey)?.let { + it.watcher.cancel() + it.prefetch.cancel() + it.loop.stop() + } + } + registry.forget(coordinatorPubKey) + remember(forgotten = coordinatorPubKey) + } + + /** + * Forgets [coordinatorPubKey] **and destroys everything stored for it**. + * + * Separate from [forget] because they are different decisions and only one + * of them is reversible. Forgetting closes the session and leaves the MLS + * state on disk, so re-adding the coordinator brings the groups back. + * Purging deletes the ratchet trees, the cursors and the KeyPackage + * private halves — after which those groups cannot be rejoined, only + * re-entered by a fresh invitation, because MLS state cannot be rebuilt + * from anywhere else. + * + * The coordinator is not told. It keeps whatever it already had; purging + * is about this device, not about undoing the exposure, and a UI that + * implied otherwise would be selling a deletion nobody can perform. + */ + suspend fun purge(coordinatorPubKey: HexKey) { + forget(coordinatorPubKey) + groups.forgetCoordinator(coordinatorPubKey) + withContext(Dispatchers.IO) { + CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, coordinatorPubKey).deleteRecursively() + } + } + + /** + * What this account has published on [coordinatorPubKey], and which of + * them this device can still open. + * + * Both halves matter and neither implies the other. The coordinator's + * listing is the truth about what an inviter can take; the local store is + * the truth about whether the resulting Welcome can be opened. A package + * listed there with no private half here belongs to another device of this + * account — or to an install that is gone, in which case anyone using it + * sends a Welcome nobody will ever read. + */ + suspend fun keyPackages(coordinatorPubKey: HexKey): List { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return emptyList() + val held = session.keyPackages.published.value + return session.keyPackages.listPublished().map { + CordnKeyPackageRow( + keyPackageRef = it.keyPackageRef, + lastResort = it.lastResort, + at = it.at, + openableHere = it.keyPackageRef in held, + ) + } + } + + /** + * Which identities [coordinatorPubKey] can deliver a Welcome to, or null. + * + * For annotating people you are *about* to invite. cordn can only add a + * member by spending a KeyPackage they published to this coordinator, and + * `kp_list` is the only non-destructive way to learn who did -- `kp_take` + * by identity consumes one of their single-use packages + * (`spec/00.md` §"Coordinator retrieval behavior"), so it can never be + * used as a probe. One call answers for everybody, which is why this hands + * back the whole set instead of taking a pubkey. + * + * Null is **not** an empty set. It means the question went unanswered, and + * a caller that renders it as "cannot be added" turns a network failure + * into a claim about a person. Distinguish them. + */ + suspend fun identitiesWithKeyPackages(coordinatorPubKey: HexKey): Set? { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return null + return session.keyPackages.identitiesWithKeyPackages() + } + + /** Publishes one KeyPackage. An attributable act under the account key (§8.4). */ + suspend fun publishKeyPackage( + coordinatorPubKey: HexKey, + lastResort: Boolean = false, + ) { + val session = + registry.sessionOrNull(coordinatorPubKey) + ?: throw IllegalStateException("no session for $coordinatorPubKey") + session.keyPackages.publishNew(lastResort) + } + + /** Withdraws [refs], coordinator first. See `CordnKeyPackages.withdraw`. */ + suspend fun withdrawKeyPackages( + coordinatorPubKey: HexKey, + refs: List, + ): List = + registry + .sessionOrNull(coordinatorPubKey) + ?.keyPackages + ?.withdraw(refs) + .orEmpty() + + /** + * Collects everything a replacement device would need. + * + * Reads from the stores rather than from memory, so a coordinator whose + * session failed to open is still exported — a backup that silently + * omitted the groups the app could not reach today would be worst + * precisely when it is needed. + */ + suspend fun exportArchive(passphrase: String): ByteArray { + val configs = coordinatorStore.load() + val groups = mutableListOf() + val keyPackages = mutableListOf() + + withContext(Dispatchers.IO) { + configs.forEach { config -> + val dir = CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, config.pubKey) + val groupStore = FileCordnGroupStore(dir, cipher) + val keyPackageStore = FileCordnKeyPackageStore(dir, cipher) + + groupStore.listGroups().forEach { gid -> + val state = groupStore.loadGroup(gid) ?: return@forEach + groups += + CordnBackup.Archive.Group( + coordinatorPubKey = config.pubKey, + gid = gid, + state = state, + cursor = groupStore.loadCursor(gid), + joinedViaRequest = groupStore.loadJoinedViaRequest(gid), + messages = groupStore.loadMessages(gid), + ) + } + + keyPackageStore.list().forEach { ref -> + val bundle = keyPackageStore.load(ref) ?: return@forEach + keyPackages += CordnBackup.Archive.KeyPackage(config.pubKey, ref, bundle) + } + } + } + + return CordnBackup.seal( + CordnBackup.Archive(accountSigner.pubKey, configs, groups, keyPackages), + passphrase, + ) + } + + /** + * Replaces this device's cordn state with [sealed]'s. + * + * **Replaces, and is not a merge.** An MLS state import is a cloneable + * identity: two devices holding one group's state and both committing fork + * the ratchet tree, and MLS does not recover — every message after the + * fork silently fails to decrypt for somebody. Merging would produce that + * on purpose. Restoring is for a device that has taken over from another, + * which is what §5.3 means by keeping multi-device a non-goal. + * + * An archive from a different account is refused outright: restoring one + * account's groups under another's key gives a device MLS state whose + * credentials name somebody else, and every Commit it made would be + * rejected by the rest of the group. + */ + suspend fun importArchive( + sealed: ByteArray, + passphrase: String, + ) { + val archive = CordnBackup.open(sealed, passphrase) + require(archive.accountPubKey == accountSigner.pubKey) { + "this backup belongs to a different account" + } + + stop() + + withContext(Dispatchers.IO) { + // The old state goes first. Leaving it would merge two devices' + // histories for any gid present in both, which is the one outcome + // this must never produce. + File(filesDir, "cordn/${accountSigner.pubKey}").deleteRecursively() + + archive.groups.forEach { group -> + val store = FileCordnGroupStore(CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, group.coordinatorPubKey), cipher) + store.saveGroup(group.gid, group.state) + group.cursor?.let { store.saveCursor(group.gid, it) } + if (group.joinedViaRequest) store.saveJoinedViaRequest(group.gid) + // Before the cursor is trusted again. The cursor says the + // stream has been read to here, so nothing will re-deliver + // these -- if they are not written back now they are gone. + group.messages.forEach { store.appendMessage(group.gid, it) } + } + + archive.keyPackages.forEach { keyPackage -> + FileCordnKeyPackageStore(CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, keyPackage.coordinatorPubKey), cipher) + .save(keyPackage.keyPackageRef, keyPackage.bundle) + } + } + + coordinatorStore.save(archive.coordinators) + start(archive.coordinators) + } + + /** + * Snapshots every group for a handoff, read off disk. + * + * Reads from the stores rather than memory for the same reason + * [exportArchive] does: a coordinator whose session failed to open today is + * still migrated, because a handoff that silently omitted the groups the + * app could not reach would be wrong precisely when it matters. + */ + suspend fun migrationSnapshot(): CordnMigrationSnapshot = + withContext(Dispatchers.IO) { + CordnMigrationStores.read(filesDir, accountSigner.pubKey, cipher, coordinatorStore.load()) + } + + /** + * Coordinators announcing themselves on [relays], newest first. + * + * Touches no coordinator: it reads the CEP-6 announcements they already + * published, so nothing discovered here learns this account exists. That + * is the whole reason discovery can be offered before the user has + * committed to anything — see [CordnCoordinatorDiscovery]. + */ + suspend fun discover(relays: Set): CordnCoordinatorDiscovery.Result = CordnCoordinatorDiscovery(client).discover(relays) + + /** + * Publishes a handoff and stands this device down. + * + * The order is deliberate. Everything is stored and advertised first, and + * only a migration that got that far marks the device — a failed publish + * that had already locked would leave the account on a phone that refuses + * to send from the only copy of its state. + */ + suspend fun handOff( + migration: CordnMigration, + relays: Set, + ): CordnHandoffCode { + val code = migration.publish(migrationSnapshot(), relays) + stop() + handoff.markHandedOff() + return code + } + + /** Takes this device back after a handoff that did not complete. */ + suspend fun cancelHandOff() { + handoff.resume() + start(coordinatorStore.load()) + } + + /** + * Adopts a snapshot another device published, replacing what is here. + * + * **Replaces, and is not a merge** — the same rule [importArchive] states, + * and for the same reason: two devices holding one group's state and both + * committing fork the ratchet tree, and MLS does not recover. + */ + suspend fun adoptMigration(snapshot: CordnMigrationSnapshot) { + require(snapshot.accountPubKey == accountSigner.pubKey) { + "this migration belongs to a different account" + } + + stop() + + val configs = + withContext(Dispatchers.IO) { + CordnMigrationStores.write(filesDir, accountSigner.pubKey, cipher, snapshot) + } + + // A device that just adopted state is emphatically not handed off, even + // if it had handed off before: it now holds the newest copy. + handoff.resume() + coordinatorStore.save(configs) + start(configs) + } + + /** What [coordinatorPubKey] says about itself, or null if it is not open. */ + suspend fun serverInfo(coordinatorPubKey: HexKey): CoordinatorServerInfo? = registry.sessionOrNull(coordinatorPubKey)?.serverInfo() + + /** Live health for [coordinatorPubKey], as its calls have observed it. */ + fun health(coordinatorPubKey: HexKey): StateFlow? = registry.sessionOrNull(coordinatorPubKey)?.health?.state + + /** + * Writes down which coordinators are open, so the next launch finds them. + * + * Failing to persist must not fail the session it follows: the coordinator + * is already open and working, and losing the record costs a re-entry next + * launch rather than the group. + */ + private suspend fun remember(forgotten: HexKey? = null) { + try { + // Merged with what is already on disk, never replacing it. + // `registry.coordinators` is the coordinators with an OPEN + // session, so a coordinator whose relay is unreachable this launch + // is simply absent from it — and saving that set verbatim would + // erase it, turning "your relay was down" into "your groups are + // gone", silently and permanently. Keyed by pubkey because that is + // the coordinator's identity (§8.5): a reopened one with different + // relays is a corrected address, so the live entry wins. + // + // [forgotten] is the one case the merge cannot infer. A removed + // coordinator and one that simply did not open this launch look + // identical from here — both are missing from `open` — so without + // being told, the merge protects the removed one too and writes it + // straight back. Remove was therefore a no-op that survived until + // the next launch and then undid itself. + val open = registry.coordinators.value + val openKeys = open.mapTo(mutableSetOf()) { it.pubKey } + val kept = + coordinatorStore + .load() + .filterNot { it.pubKey in openKeys || it.pubKey == forgotten } + coordinatorStore.save(kept + open) + } catch (e: Exception) { + Log.w(TAG, "could not persist the coordinator list: ${e.message}", e) + } + } + + private fun file( + coordinatorPubKey: HexKey, + delivery: CordnGroupManager.Delivery, + ) { + when (delivery) { + is CordnGroupManager.Delivery.Message -> + groups.add( + coordinatorPubKey, + delivery.gid, + CordnDeliveredMessage(delivery.received.envelope, delivery.cursor), + ) + + // The rest still get a room. A group whose history we cannot + // read, or that only moved an epoch, is a group we are in — + // showing nothing would read as a bug rather than as the + // forward secrecy it actually is. + is CordnGroupManager.Delivery.Undecryptable, + is CordnGroupManager.Delivery.EpochAdvanced, + is CordnGroupManager.Delivery.Echo, + -> groups.getOrCreate(coordinatorPubKey, delivery.gid) + } + + // After every delivery, not only after an EpochAdvanced: a Commit that + // renames the group or changes its membership arrives as an ordinary + // ingestion, and the room's name and member list are read off MLS + // state rather than stored, so they are only correct if re-read. + sessionOrNull(coordinatorPubKey)?.let { refresh(it, delivery.gid) } + } + + /** Makes sure [gid] has a room, and points it at the current MLS state. */ + private fun refresh( + session: CordnSession, + gid: String, + ) { + val room = groups.getOrCreate(session.coordinatorPubKey, gid) + session.manager.group(gid)?.let { room.refreshFrom(it) } + } + + /** + * Restores a room's draft and read position from disk, once. + * + * Called when a screen opens the room rather than at sync, because it is + * the only moment it matters and because every room's state would + * otherwise be read at login for rooms nobody opens. + */ + suspend fun restoreRoomState( + coordinatorPubKey: HexKey, + gid: String, + ) { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return + val room = groups.get(coordinatorPubKey, gid) ?: return + val saved = session.manager.roomState(gid) + room.restoreState(saved.draft, saved.lastReadCursor) + // The conversation itself. Through addAll, so the annotation fold, the + // ordering and `newest` are all rebuilt by exactly the code that + // handles live delivery — a room restored down a second path would be + // a second set of rules to keep in step. + room.addAll(session.manager.storedMessages(gid)) + } + + /** Persists [gid]'s draft and read position. */ + suspend fun saveRoomState( + coordinatorPubKey: HexKey, + gid: String, + ) { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return + val room = groups.get(coordinatorPubKey, gid) ?: return + session.manager.saveRoomState(gid, CordnRoomState(room.draft.value, room.lastReadCursor.value)) + } + + companion object { + private const val TAG = "CordnRuntime" + } +} + +/** One invitation, and which coordinator it came through. */ +data class CordnInvitation( + val coordinator: CoordinatorConfig, + val welcome: OpenedWelcome, +) + +/** An invitation that could not be opened, and why — the reason is for a person to read. */ +data class CordnSkippedInvitation( + val coordinator: CoordinatorConfig, + val keyPackageRef: String, + val reason: String, +) + +/** A coordinator that did not answer. Not the same as one with nothing to say. */ +data class CordnCoordinatorFailure( + val coordinator: CoordinatorConfig, + val reason: String, +) + +/** What every open coordinator had waiting. */ +data class CordnInvitations( + val pending: List, + val skipped: List, + val unreachable: List, +) { + val isEmpty: Boolean get() = pending.isEmpty() && skipped.isEmpty() && unreachable.isEmpty() +} + +/** One published KeyPackage, as the key-package screen shows it. */ +data class CordnKeyPackageRow( + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + /** Whether this device holds the private half and could open its Welcome. */ + val openableHere: Boolean, +) + +/** + * What one coordinator could do with a roster. + * + * [answered] apart from [unreachable] deliberately: an empty [reachable] on an + * unanswered coordinator means nobody asked it, not that it holds nothing. A + * caller that folds the two together turns a missing session or a failed call + * into a claim about the people in the roster. + */ +data class CordnCoverage( + val coordinatorPubKey: HexKey, + val reachable: Set, + val unreachable: Set, + val answered: Boolean, +) + +/** + * One invitation attempt, in one of three states. + * + * [sent] is done. A failure with [joinedWithoutWelcome] means the commit that + * added them went through and only the Welcome did not, so they are a member + * who cannot join yet -- resending reaches them. A failure without it never + * touched the group, so there is nothing to resend and a share link is the + * only way to them. + */ +data class CordnInviteOutcome( + val pubKey: HexKey, + val failure: Throwable?, + val joinedWithoutWelcome: Boolean = false, +) { + val sent: Boolean get() = failure == null +} + +/** A group that now exists, and how its invitations went. */ +data class CordnGroupCreation( + val gid: String, + val outcomes: List, +) { + val failed: List get() = outcomes.filterNot { it.sent } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt index e7641cb3bf..5d0928cd43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog +import com.vitorpamplona.amethyst.commons.storage.EncryptedAppendLog import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt index 504037d630..4a970b40f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.model.marmot import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 8150139391..5728bab2e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -432,7 +432,7 @@ class UploadOrchestrator { * Deletes a temporary file created during the upload pipeline if its URI * differs from the original (meaning it's an intermediate temp file, not the user's content). */ - private fun deleteTempUri( + internal fun deleteTempUri( tempUri: Uri, originalUri: Uri, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 5dff75f140..892b84128b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -102,6 +102,13 @@ class VoiceMessageRecorder { amplitudeSamplingJob = recorderScope?.launch { while (isActive) { + // Delay FIRST. `maxAmplitude` reports the peak since the + // previous call, and the call that happens immediately + // after start() covers a window in which the encoder has + // captured nothing — it answers 0 and puts a bar of silence + // at the head of every recording that is not in the audio. + delay(SAMPLE_INTERVAL_MS) + val recorderRef = recorder ?: break try { val amplitude = recorderRef.maxAmplitude.toFloat() @@ -113,7 +120,6 @@ class VoiceMessageRecorder { Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e) break } - delay(1000) } } } @@ -131,7 +137,7 @@ class VoiceMessageRecorder { val amplitudesCopy = synchronized(amplitudes) { amplitudes.toList() - } + }.downsampled(MAX_WAVEFORM_POINTS) val duration = (currentTime - startTime).toInt() // Clean up recorder and scope @@ -197,4 +203,43 @@ class VoiceMessageRecorder { amplitudes.clear() } } + + companion object { + /** + * How often the peak is read while recording. + * + * Was one second, which is not a waveform: a five-second note produced + * five numbers, stretched across fifty-odd bars, and the result could + * not track speech at any resolution a person would recognise. Ten a + * second is fine to sample — `maxAmplitude` is a cheap read — and the + * count is bounded on the way out rather than here. + */ + const val SAMPLE_INTERVAL_MS = 100L + + /** + * The most amplitudes a recording reports. + * + * Sampling finely and reducing at the end keeps the shape faithful + * without letting a long recording turn into a long list: these travel + * inside events and `imeta` tags, so the size has to depend on the + * detail wanted rather than on how long somebody spoke. + */ + const val MAX_WAVEFORM_POINTS = 100 + } +} + +/** + * Averages [this] down to at most [max] buckets, keeping the ends in place. + * + * Averaging rather than dropping samples: taking every Nth would let a single + * loud frame stand for a whole bucket and make the bars flicker with the + * sampling phase rather than with the sound. + */ +internal fun List.downsampled(max: Int): List { + if (max <= 0 || size <= max) return this + return List(max) { bucket -> + val from = bucket * size / max + val to = ((bucket + 1) * size / max).coerceAtLeast(from + 1) + subList(from, to.coerceAtMost(size)).average().toFloat() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c99e788323..cb32a317ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -128,6 +128,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen @@ -297,6 +303,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnBackupScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnCoordinatorsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnHubScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnKeyPackagesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnLinkScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnMigrateScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen @@ -662,6 +674,12 @@ fun BuildNavigation( com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) } + composableFromEnd { CordnLinkScreen(accountViewModel, nav) } + composableFromEnd { CordnCoordinatorsScreen(accountViewModel, nav) } + composableFromEnd { CordnKeyPackagesScreen(accountViewModel, nav) } + composableFromEnd { CordnBackupScreen(accountViewModel, nav) } + composableFromEnd { CordnHubScreen(accountViewModel, nav) } + composableFromEnd { CordnMigrateScreen(accountViewModel, nav) } composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEnd { Bolt12OffersScreen(accountViewModel, nav) } @@ -707,6 +725,17 @@ fun BuildNavigation( } composableFromEndArgs { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEndArgs { + CordnGroupChatScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav) + } + composableFromEndArgs { + CordnGroupInfoScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav) + } + composableFromEnd { CordnGroupListScreen(accountViewModel, nav) } + composableFromBottom { CordnCreateGroupScreen(accountViewModel, nav) } + composableFromBottom { CordnCreateMembersScreen(accountViewModel, nav) } + composableFromEnd { CordnInvitationsScreen(accountViewModel, nav) } + composableFromBottom { CreateGroupScreen(accountViewModel, nav) } composableFromBottomArgs { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 0e2bb228fe..b649c9da45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.resources.bottom_bar_category_you import com.vitorpamplona.amethyst.commons.resources.browser import com.vitorpamplona.amethyst.commons.resources.communities import com.vitorpamplona.amethyst.commons.resources.concord_home_title +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title import com.vitorpamplona.amethyst.commons.resources.discover_marketplace import com.vitorpamplona.amethyst.commons.resources.discover_reads import com.vitorpamplona.amethyst.commons.resources.drafts @@ -391,6 +392,16 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Lock, resolveRoute = { Route.MarmotGroupList }, ), + NavBarItem.CORDN_GROUPS to + NavBarItemDef( + id = NavBarItem.CORDN_GROUPS, + labelRes = Res.string.cordn_groups_title, + // Dns, the symbol every other cordn surface uses: a coordinator + // is a server, and that is the one thing that distinguishes + // these from the Marmot rooms directly above. + icon = MaterialSymbols.Dns, + resolveRoute = { Route.CordnGroupList }, + ), NavBarItem.GEOHASH_CHATS to NavBarItemDef( id = NavBarItem.GEOHASH_CHATS, @@ -527,6 +538,7 @@ val BottomBarCategories: List = NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, NavBarItem.MARMOT_GROUPS, + NavBarItem.CORDN_GROUPS, NavBarItem.GEOHASH_CHATS, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 378e698010..5deaaacad1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -153,6 +153,7 @@ private val DrawerFeedsItems: List = NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, NavBarItem.MARMOT_GROUPS, + NavBarItem.CORDN_GROUPS, NavBarItem.GEOHASH_CHATS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 94534ff21a..764d734442 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -254,8 +254,18 @@ class UserSuggestionState( state: TextFieldState, word: String, item: User, + /** + * Always insert the resolved `nostr:` URI, never the `@npub1…` short form. + * + * The short form is only half a mention: it relies on a send-time tagger to + * rewrite it and emit the `p` tag. Surfaces that have one (every nostr-event + * composer) keep it. cordn does not — its content goes into a sealed envelope + * verbatim, and its reader parses `nostr:` URIs out of that content — so an + * `@npub1…` there would ship as literal text and mention nobody. + */ + forceNostrUri: Boolean = false, ) { - val wordToInsert = mentionInsertion(word, item) + val wordToInsert = mentionInsertion(word, item, forceNostrUri) state.edit { val lastWordStart = selection.end - word.length replace(lastWordStart, selection.end, wordToInsert) @@ -280,7 +290,10 @@ class UserSuggestionState( private fun mentionInsertion( word: String, item: User, + forceNostrUri: Boolean = false, ): String { + if (forceNostrUri) return "nostr:${item.toNProfile()} " + val typed = userSearchTermOrNull(word) val wasNip05Mention = typed != null && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index cf881e04eb..502b9da0e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -148,37 +148,15 @@ fun RenderAudioWithWaveform( val callbackUri = remember(note) { note.toNostrUri() } Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { - Row( - Modifier.fillMaxWidth().height(100.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - GetMediaItem( - videoUri = mediaUrl, - title = title, - artworkUri = null, - authorName = note.author?.toBestDisplayName(), - callbackUri = callbackUri, - mimeType = mimeType, - aspectRatio = null, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl), - keepPlaying = false, - waveformData = waveform, - ) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - muted = false, - ) { controller -> - PauseControllerWhenInBackground(controller) - RenderVoicePlayer( - mediaItem = mediaItem, - controllerState = controller, - waveform = waveform, - borderModifier = MaterialTheme.colorScheme.imageModifier, - accountViewModel = accountViewModel, - ) - } - } - } + RenderAudioWaveformPlayer( + mediaUrl = mediaUrl, + title = title, + mimeType = mimeType, + waveform = waveform, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + accountViewModel = accountViewModel, + ) if (noteEvent.hasHashtags()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -188,6 +166,86 @@ fun RenderAudioWithWaveform( } } +/** + * The voice player on its own, with no [Note] behind it. + * + * Separated because a chat that carries audio does not necessarily have a Note + * to hand: cordn messages never enter LocalCache, so the only way they can show + * the same player as every other chat is for the player not to demand one. The + * Note-shaped overload above is this plus the hashtag row, which is the only + * part that genuinely needs the event. + * + * [waveform] may be null. Nothing that arrives as an encrypted blob carries one + * today, and the player then shows its transport controls without the bars — + * still the voice UI, which is the point, rather than a video surface with no + * picture in it. + */ +@Composable +fun RenderAudioWaveformPlayer( + mediaUrl: String, + title: String?, + mimeType: String?, + waveform: WaveformData?, + authorName: String?, + callbackUri: String?, + accountViewModel: AccountViewModel, +) { + Row( + Modifier.fillMaxWidth().height(100.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = mediaUrl, + title = title, + artworkUri = null, + authorName = authorName, + callbackUri = callbackUri, + mimeType = mimeType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl), + keepPlaying = false, + waveformData = waveform, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = waveform, + borderModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + ) + } + } + } +} + +/** + * The bars drawn when a track carries no amplitudes of its own. + * + * A voice note with no waveform used to render as a play button on an empty + * black bar, which reads as a broken video rather than as audio. Every + * encrypted format can hit this: MIP-04 and encrypted-media-v2 define no + * waveform field at all, and a cordn message only carries one if the sender's + * client wrote the hint. + * + * **It has to vary.** `AudioWaveformReadOnly` normalises the amplitudes onto + * the bar height, so a constant list has no range to normalise and every bar + * collapses to the minimum — a flat 0.35f placeholder drew a dotted line, not + * bars. The shape below is a short pattern tiled across the width: enough + * relief to read as audio, regular enough that nobody mistakes it for a + * measurement of their recording. + * + * What it buys beyond looking right is somewhere for the progress fill to run, + * so position stays legible while it plays. + */ +private val PlaceholderPattern = listOf(0.35f, 0.62f, 0.45f, 0.85f, 0.5f, 0.72f, 0.4f, 0.58f) + +private val PlaceholderWaveform = WaveformData(List(48) { PlaceholderPattern[it % PlaceholderPattern.size] }) + @Composable @OptIn(UnstableApi::class) fun RenderVoicePlayer( @@ -224,7 +282,7 @@ fun RenderVoicePlayer( Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) { PlayPauseButton(controllerState) - waveform?.let { Waveform(it, controllerState, Modifier) } + Waveform(waveform ?: PlaceholderWaveform, controllerState, Modifier) } RenderTopButtonsForVoice( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index bab98aebe7..dd46388f4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -184,6 +184,44 @@ class AccountFeedContentStates( } } + // Same for cordn, and more so: a cordn message is an MLS envelope that + // never enters LocalCache at all (§3.3 of + // amethyst/plans/2026-09-19-cordn-ui.md), so nothing about a cordn room + // can ever reach the additive path. Without this the inbox never shows + // a cordn room — not when one is created, not when a Welcome is + // accepted, and not after a relaunch that restored it — and since the + // room list is the only way back into a room, a group became + // unreachable the moment its screen was closed. + // + // `revision` and not `all`: `all` re-emits only when the room *set* + // changes, so a message arriving for a room the inbox already lists + // never reached this collector. The rows therefore kept whatever order + // the build that first saw them gave — and since a cordn row sorts on + // its newest message, that meant the inbox ordered cordn rooms by when + // they were joined and never moved them again. `revision` bumps on the + // set *and* on every message filed into a room. sample() keeps the + // restore burst, when every coordinator's groups arrive at once, from + // rebuilding the feed once per room. + // + // No drop(1), unlike the collectors around it. Those drop the replay + // because the feed's first build already saw their state; cordn's + // restore runs in its own launch from Account's constructor and often + // finishes *after* that build, so the current value is exactly the one + // that matters — a StateFlow replays it on subscribe and dropping it + // waits for a change that, for an account whose rooms are all restored + // rather than newly created, never comes. The cost of keeping it is one + // extra rebuild at login. + account.cordnRuntime?.let { runtime -> + scope.launch(Dispatchers.IO) { + @OptIn(FlowPreview::class) + runtime.groups.revision + .sample(500) + .collect { + dmKnown.invalidateData() + } + } + } + // Same for the NIP-29 joined-group list (kind 10009): joining/leaving changes the list but // doesn't flow through newEventBundles, so force a rebuild — otherwise a just-joined group // (whose messages haven't loaded yet) wouldn't appear on the Messages tab until a later event. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 5275a0cf0e..585669b95c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -160,6 +160,13 @@ private fun PreloadFor( // to warm up, and adding one would duplicate those. NavBarItem.MARMOT_GROUPS -> Unit + // Same as Marmot, for a different reason: a cordn group's messages never + // arrive over a relay REQ at all. They are coordinator calls made by + // CordnRuntime's sync loop, which is already running for every + // coordinator this account holds -- there is no relay subscription a + // list could warm up. + NavBarItem.CORDN_GROUPS -> Unit + NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt new file mode 100644 index 0000000000..0e19b487cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta + +/** + * The cordn room's composer, on the same field every other Amethyst chat uses. + * + * [ThinPaddingTextField] + [ThinSendButton] inside [EditFieldModifier], with the shared + * mention machinery on top: [ShowUserSuggestionList] for `@` autocomplete, + * [MentionPreservingInputTransformation] so an IME cannot rewrite half a bech32, and + * [UrlUserTagOutputTransformation] so a pasted or completed mention reads as a name + * while you type it. Before this the room had a bare `OutlinedTextField` over a + * `String`: cordn could *render* mentions and highlight a message that named you, but + * the only way to enter one was to type `nostr:npub1…` by hand. + * + * Mentions are inserted as resolved `nostr:` URIs (`forceNostrUri`). See the parameter's + * KDoc — cordn has no send-time tagger, so the short `@npub1…` form would ship as text. + * + * Looking users up to offer them is the same safe direction as everywhere else on this + * screen: profiles are public relay data the cache already holds, and reading one puts + * no part of this conversation into it. + */ +@Composable +internal fun CordnComposer( + room: CordnGroupChatroom, + attaching: Boolean, + pendingVoice: RecordingResult?, + accountViewModel: AccountViewModel, + onAttach: (Uri) -> Unit, + onVoiceNote: (RecordingResult) -> Unit, + onRemoveVoice: () -> Unit, + onSend: (String) -> Unit, +) { + // `room.draft` is the persisted String — commons holds the draft and may not depend + // on Compose UI, so it cannot hold a TextFieldState. The field owns text *and* + // selection; these two effects keep them equal. Both writes are guarded on + // inequality, so neither direction can bounce off the other. + val draftState = remember(room.gid) { TextFieldState(room.draft.value) } + + val suggestions = + remember(room.gid, accountViewModel) { + UserSuggestionState( + accountViewModel.account, + accountViewModel.nip05ClientBuilder(), + // Members of this room rank above the rest of the address book: in a + // group, the person you are about to name is almost always in it. + priorityPubkeys = { room.members.value.toSet() }, + ) + } + + // `:shortcode:` completion, as the DM and Concord composers have. Cordn inserts the + // emoji's URL rather than its shortcode: a shortcode only resolves for a reader who + // also got the NIP-30 `emoji` tags, and a cordn message carries none — so everyone + // else would have read a literal ":shrug:". Same reasoning as forcing `nostr:` for + // user mentions, and the URL renders as the image for every reader. + val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) } + + WatchAndLoadMyEmojiList(accountViewModel) + + DisposableEffect(suggestions, emojiSuggestions) { + onDispose { + suggestions.reset() + emojiSuggestions.reset() + } + } + + LaunchedEffect(draftState, room) { + snapshotFlow { draftState.text.toString() }.collect { + if (room.draft.value != it) room.draft.value = it + } + } + + LaunchedEffect(draftState, room) { + room.draft.collect { external -> + // The screen writes the draft from outside on three paths: clearing it on + // send, restoring it when a send fails, and loading a message's text into + // it to edit. Cursor to the end, as editFromDraft does elsewhere. + if (external != draftState.text.toString()) { + if (external.isEmpty()) draftState.clearText() else draftState.setTextAndPlaceCursorAtEnd(external) + // onTextChanged only fires for typing, so a list left open by a + // half-typed "@na" survived the field being cleared on send. + suggestions.reset() + emojiSuggestions.reset() + } + } + } + + // A recorded voice note is something to send even with nothing typed; the text + // beside it rides along as its caption. + val canPost by remember(pendingVoice) { derivedStateOf { draftState.text.isNotBlank() || pendingVoice != null } } + + Column(modifier = EditFieldModifier) { + // The same preview the post screens use: listen back, re-record, or drop it. + // Recording used to send the moment you released the button, which is a hard + // thing to get right first time and impossible to take back afterwards. + pendingVoice?.let { recording -> + val meta = + remember(recording) { + AudioMeta( + // Empty: nothing is uploaded yet, so the preview plays the + // local file instead. + url = "", + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + VoiceMessagePreview( + voiceMetadata = meta, + localFile = recording.file, + onRemove = onRemoveVoice, + onReRecord = onVoiceNote, + isUploading = attaching, + modifier = Modifier.fillMaxWidth(), + ) + } + + ShowEmojiSuggestionList( + emojiSuggestions, + onSelect = { insertEmojiUrl(draftState, emojiSuggestions, it) }, + onFullSize = { insertEmojiUrl(draftState, emojiSuggestions, it) }, + modifier = SuggestionListDefaultHeightChat, + ) + + ShowUserSuggestionList( + suggestions, + onSelect = { user -> + suggestions.replaceCurrentWord(draftState, draftState.currentWord(), user, forceNostrUri = true) + suggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + ) + + ThinPaddingTextField( + state = draftState, + onTextChanged = { + // Only while the caret is a point: during a range selection the "current + // word" is whatever is highlighted, which is not something being typed. + if (draftState.selection.collapsed) { + val lastWord = draftState.currentWord() + when { + lastWord.startsWith("@") -> { + suggestions.processCurrentWord(lastWord) + emojiSuggestions.reset() + } + + lastWord.startsWith(":") -> { + emojiSuggestions.processCurrentWord(lastWord) + suggestions.reset() + } + + else -> { + suggestions.reset() + emojiSuggestions.reset() + } + } + } + }, + // Anything the keyboard or a paste hands over as content — a GIF, a shared + // image — takes the same encrypted-upload path as the file picker. + onContentReceived = { uri, _ -> onAttach(uri) }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(R.string.cordn_composer_hint), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + // Same frame every other chat composer uses for its leading + // icons (`MarmotGalleryLeadingIcon`): 4.dp either side and the + // placeholder tint. Without it these two sat further from the + // edge and further apart than the rest of the app's composers, + // because a bare IconButton keeps its full 48.dp touch target + // and nothing was pulling the row back in. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + ) { + // The app's own gallery picker, so this composer offers the same + // affordance and the same multi-select as the DM and Marmot ones + // rather than a bare file dialog. Each pick is its own encrypted + // upload and its own message, which is what the send path does. + SelectFromGallery( + isUploading = attaching, + enabled = !attaching, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier, + onImageChosen = { picked -> picked.forEach { onAttach(it.uri) } }, + ) + VoiceNoteButton(enabled = !attaching, onRecorded = onVoiceNote) + } + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + onSend(draftState.text.toString()) + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +/** + * Puts [item]'s URL where the half-typed `:shortcode:` was, and closes the list. + * + * [EmojiSuggestionState.autocompleteInto] inserts the shortcode, which is right for a + * composer whose send path attaches the matching `emoji` tags. Cordn's does not, so the + * URL goes in instead — see the state's comment above. + */ +private fun insertEmojiUrl( + field: TextFieldState, + state: EmojiSuggestionState, + item: EmojiPackState.EmojiMedia, +) { + field.replaceCurrentWord(item.link + " ") + state.reset() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt new file mode 100644 index 0000000000..651859f69e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt @@ -0,0 +1,987 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.model.cordn.CordnCoverage +import com.vitorpamplona.amethyst.model.cordn.CordnGroupCreation +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.launch + +/** + * Starting a cordn group: pick the coordinator, name it, create it. + * + * ## The coordinator is the first field, not a setting + * + * `spec/00.md` §4 makes the coordinator the sole authority for a group's + * stream, and a `gid` means nothing outside the one that issued it. So + * "which coordinator" is not a preference that could sensibly default — it is + * half of the group's identity, chosen once and unchangeable afterwards. It is + * asked first, in the open, with the exposure card underneath saying what + * choosing it costs. + * + * ## Creating tells the coordinator nothing + * + * `CordnRuntime.createGroup` is local MLS work. The coordinator hears about + * the group when the first Commit or message is posted, which is why a group + * of one is still entirely private and why this screen works against a + * coordinator that is down. The exposure card is therefore about what will + * become true once someone is invited, not about what just happened. + * + * Inviting is not here. `CordnGroupManager.invite` exists and works, but its + * user-facing half — key-package discovery, share links, join requests — is + * Stage C of `amethyst/plans/2026-09-19-cordn-ui.md`. A member picker that + * could only offer accounts that happen to have published a KeyPackage to this + * exact coordinator would promise more than it can do. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnCreateGroupScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) + } + }, + title = { Text(stringRes(R.string.cordn_create_title)) }, + ) + }, + ) { padding -> + if (runtime == null) { + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val known by runtime.coordinators.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + val me = accountViewModel.account.signer.pubKey + + // The draft outlives this screen: picking people is its own + // destination now, and a Navigation destination is disposed when you + // leave it, so `remember` here would lose a half-typed name on the way + // back from the draft.roster. + val draft = rememberCordnGroupDraft(accountViewModel) + var creation by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + val failureFallback = stringRes(R.string.cordn_create_failed) + var busy by remember { mutableStateOf(false) } + + // Coordinators this account has never used, from their CEP-6 + // announcements. Not added to the account by looking: picking one here + // is what commits to it, and `createGroup` opens the session. + var discovering by remember { mutableStateOf(false) } + val discoverFailed = stringRes(R.string.cordn_coordinators_discover_failed) + + // Offers this account already holds are not offers; they are the choices + // above, and listing them twice would let the same coordinator be picked + // from two places with different relays. + val offers = + remember(draft.discovered, known) { + val knownKeys = known.mapTo(mutableSetOf()) { it.pubKey } + draft.discovered + ?.coordinators + ?.filter { it.pubKey !in knownKeys } + .orEmpty() + } + + val choices = remember(known, offers) { known + offers.map { it.toConfig() } } + + // A re-discovery can drop the coordinator that was picked -- it stopped + // announcing, or it is now in `known` and therefore not an offer. Left + // alone, `draft.selected` would name a coordinator no row shows: no radio + // checked, no exposure card, Create disabled, and nothing saying why. + // Falling back to the manual entry is the one state that explains itself, + // because its fields appear. + LaunchedEffect(choices) { + if (draft.selected != null && choices.none { it.pubKey == draft.selected }) draft.selected = null + } + + val config = remember(draft.selected, draft.pubKeyInput, draft.relaysInput, choices) { resolve(choices, draft.selected, draft.pubKeyInput, draft.relaysInput) } + + val coverage by rememberCordnCoverage(runtime, draft.roster) + + // The best-covering coordinator is offered, not imposed: the moment the + // user picks one themselves it stops moving under them, even if the + // roster later changes and a different one would now reach more people. + LaunchedEffect(coverage, draft.userPicked) { + if (draft.userPicked || coverage.isEmpty()) return@LaunchedEffect + val best = coverage.values.filter { it.answered }.maxByOrNull { it.reachable.size } + if (best != null && best.reachable.isNotEmpty()) draft.selected = best.coordinatorPubKey + } + + val chosenCoverage = draft.selected?.let { coverage[it] } + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.cordn_create_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + StepHeading(1, stringRes(R.string.cordn_create_step_name)) + + OutlinedTextField( + value = draft.name, + onValueChange = { draft.name = it }, + label = { Text(stringRes(R.string.cordn_create_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = draft.description, + onValueChange = { draft.description = it }, + label = { Text(stringRes(R.string.cordn_create_description)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + + StepHeading(2, stringRes(R.string.cordn_create_step_people)) + + RosterSummary( + roster = draft.roster, + coverage = coverage, + onOpen = { nav.nav(Route.CordnCreateGroupMembers) }, + accountViewModel = accountViewModel, + nav = nav, + ) + + StepHeading(3, stringRes(R.string.cordn_create_step_where)) + + // Collapsed to the one coordinator that will be used, because with a + // draft.roster in hand there is usually nothing left to decide. The whole + // list is still one tap away for the times there is. + CoordinatorSummary( + config = config, + coverage = chosenCoverage, + rosterSize = draft.roster.size, + expanded = draft.coordinatorOpen, + onToggle = { draft.coordinatorOpen = !draft.coordinatorOpen }, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (draft.coordinatorOpen) { + known.forEach { coordinator -> + CoordinatorChoice( + label = coordinatorDisplayName(coordinator.pubKey, coordinator.label, accountViewModel), + pubKey = coordinator.pubKey, + selected = draft.selected == coordinator.pubKey, + onSelect = { + draft.selected = coordinator.pubKey + draft.userPicked = true + }, + relays = relayLabel(coordinator.relays), + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Split on staleness rather than listing everything flat. A run + // against a public relay returns a long tail of coordinators that + // last announced months ago, and creating a group on one that has + // gone away fails at the first call -- so the live ones come first + // and the rest sit behind a count. + val cutoff = TimeUtils.now() - TimeUtils.ONE_MONTH + val live = offers.filter { it.announcedAt >= cutoff } + val stale = offers.filter { it.announcedAt < cutoff } + val names = offers.map { resolvedName(it, accountViewModel) } + + // Bounded rather than lazy. The obvious fix for a long list is a + // LazyColumn, and it is the wrong one here: this screen is a form, + // and a lazy list disposes what scrolls off it -- which for the + // text fields below would throw away focus and IME state mid-typing. + // Capping the rows keeps composition bounded without putting a form + // inside a recycler. + val shownLive = if (draft.showAllLive) live else live.take(LIVE_PREVIEW) + + shownLive.forEach { offer -> + CoordinatorChoice( + // Its own word for itself, and only that: nothing here has + // verified the name a coordinator announces. + label = disambiguate(resolvedName(offer, accountViewModel), offer.pubKey, names), + pubKey = offer.pubKey, + selected = draft.selected == offer.pubKey, + onSelect = { + draft.selected = offer.pubKey + draft.userPicked = true + }, + detail = announcedLabel(offer.announcedAt), + relays = relayLabel(offer.relays), + about = offer.surface.about?.takeIf { it.isNotBlank() }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (live.size > LIVE_PREVIEW) { + TextButton(onClick = { draft.showAllLive = !draft.showAllLive }) { + Text( + if (draft.showAllLive) { + stringRes(R.string.cordn_coordinators_show_fewer) + } else { + stringRes(R.string.cordn_coordinators_show_all, live.size) + }, + ) + } + } + + if (stale.isNotEmpty()) { + TextButton(onClick = { draft.showStale = !draft.showStale }) { + Text( + if (draft.showStale) { + stringRes(R.string.cordn_coordinators_hide_older) + } else { + stringRes(R.string.cordn_coordinators_show_older, stale.size) + }, + ) + } + } + + if (draft.showStale && stale.isNotEmpty()) { + Text( + text = stringRes(R.string.cordn_coordinators_stale_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + stale.forEach { offer -> + CoordinatorChoice( + label = disambiguate(resolvedName(offer, accountViewModel), offer.pubKey, names), + pubKey = offer.pubKey, + selected = draft.selected == offer.pubKey, + onSelect = { + draft.selected = offer.pubKey + draft.userPicked = true + }, + detail = announcedLabel(offer.announcedAt), + relays = relayLabel(offer.relays), + about = offer.surface.about?.takeIf { it.isNotBlank() }, + dimmed = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + CoordinatorChoice( + label = stringRes(R.string.cordn_create_coordinator_new), + pubKey = null, + selected = draft.selected == null, + onSelect = { + draft.selected = null + draft.userPicked = true + }, + accountViewModel = accountViewModel, + nav = nav, + ) + + // Asks relays, never a coordinator: an announcement is an ordinary + // event, so looking costs nothing with any of them and tells none + // of them anything. + OutlinedButton( + onClick = { + discovering = true + error = null + scope.launch { + try { + draft.discovered = runtime.discover(accountViewModel.account.outboxRelays.flow.value) + } catch (e: Exception) { + error = e.message ?: discoverFailed + } finally { + discovering = false + } + } + }, + enabled = !discovering && !busy, + ) { + Text(stringRes(R.string.cordn_create_coordinator_discover)) + } + + draft.discovered?.takeIf { offers.isEmpty() }?.let { result -> + Text( + text = + if (result.unreachable.isEmpty()) { + stringRes(R.string.cordn_coordinators_discover_none) + } else { + // "Nobody is announcing" and "we were not told" are + // different answers and only one of them is final. + // Reporting the first for the second sends someone + // off to paste a pubkey by hand over a timeout. + stringRes(R.string.cordn_coordinators_discover_unheard, result.unreachable.size) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (draft.selected == null) { + OutlinedTextField( + value = draft.pubKeyInput, + onValueChange = { + draft.pubKeyInput = it + // Typing one in is choosing it. Without this the + // best-coverage effect would replace a hand-entered + // coordinator the moment the roster produced a number. + draft.userPicked = true + error = null + }, + label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = draft.relaysInput, + onValueChange = { + draft.relaysInput = it + draft.userPicked = true + error = null + }, + label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) }, + // A coordinator has no address beyond its pubkey (§8.5), so + // the relays are how it is reached and more than one is + // ordinary. One per line rather than comma-separated, + // because a relay URL can contain a comma and a newline + // cannot be mistyped into one. + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + StepHeading(4, stringRes(R.string.cordn_create_step_admins)) + + // spec/01.md §5.3: leaving the admin list empty is not "set it up + // later", it is choosing draft.egalitarian permanently -- so it is offered + // as a decision here rather than described as one. + // + // With a draft.roster in hand the choice is the real one the web client + // makes: which of the people being added can add and remove others. + // Choosing anybody at all must include yourself, or the group is + // born unadministrable, so "you" is never one of the toggles. + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f)) { + Text(stringRes(R.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium) + Text( + text = + if (draft.egalitarian) { + stringRes(R.string.cordn_create_egalitarian_note) + } else if (draft.coAdmins.isEmpty()) { + stringRes(R.string.cordn_create_admin_only_me_on) + } else { + pluralStringRes(LocalContext.current, R.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = draft.egalitarian, onCheckedChange = { draft.egalitarian = it }) + } + + config?.let { + CordnExposureCard( + GroupExposure( + coordinator = it.pubKey, + // This group, plus whatever this coordinator already + // serves for this account: the disclosure is about what + // one coordinator can correlate, so a second group on + // the same one widens it. + linkedGroupCount = + 1 + + runtime.groups.all.value + .count { room -> room.coordinatorPubKey == it.pubKey }, + joinedFromShareLink = false, + publishedKeyPackage = false, + encryptionPinned = true, + ), + ) + } + + error?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + } + + Button( + onClick = { + val target = config ?: return@Button + busy = true + error = null + scope.launch { + try { + // Only the people this coordinator can reach are + // attempted. An invitation is a Welcome left against + // a KeyPackage the invitee published *here*, so + // asking for one it does not hold is a call that can + // only fail -- and the draft.roster keeps them either way, + // for the link the outcome offers. + // takeIf(answered): an unanswered coordinator reports an + // EMPTY reachable set, so reading it directly would turn + // "we could not ask" into "invite nobody" and create the + // group silently empty. Unknown means attempt everyone + // and let each call say what went wrong. + val reachable = chosenCoverage?.takeIf { it.answered }?.reachable + val invitees = draft.roster.filter { reachable == null || it in reachable } + val result = + runtime.createGroupAndInvite( + config = target, + metadata = + CordnGroupMetadata( + name = draft.name.trim(), + description = draft.description.trim(), + adminPubkeys = draft.adminPubKeys(me), + ), + invitees = invitees, + ) + // Straight through when there is nothing to report: + // a dialog that only ever says "all five went out" + // is one nobody reads the sixth time. + if (result.failed.isEmpty() && invitees.size == draft.roster.size) { + draft.clear() + nav.nav(Route.CordnGroupChat(target.pubKey, result.gid)) + } else { + creation = result + } + } catch (e: Exception) { + error = e.message ?: failureFallback + } finally { + busy = false + } + } + }, + enabled = !busy && config != null && draft.name.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (draft.roster.isEmpty()) { + stringRes(R.string.cordn_create_action) + } else { + pluralStringRes(LocalContext.current, R.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size) + }, + ) + } + } + + creation?.let { done -> + CordnCreationOutcome( + creation = done, + roster = draft.roster, + coordinatorPubKey = config?.pubKey, + accountViewModel = accountViewModel, + nav = nav, + // The draft is spent either way: the group exists, so a + // dismissal must not leave this roster behind to seed the next + // new group with the last one's people. + onDismiss = { + creation = null + draft.clear() + }, + ) + } + } +} + +@Composable +private fun CoordinatorChoice( + label: String, + pubKey: HexKey?, + selected: Boolean, + onSelect: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, + detail: String? = null, + /** Where it answers. A coordinator has no address beyond its pubkey (§8.5). */ + relays: String? = null, + /** Its own sentence about itself, if it published one. */ + about: String? = null, + /** Dimmed for a coordinator that stopped announcing long ago. */ + dimmed: Boolean = false, +) { + val fade = if (dimmed) 0.6f else 1f + Row( + modifier = Modifier.fillMaxWidth().selectable(selected = selected, onClick = onSelect).padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + // Null on the "use a different one" row, which names no coordinator + // yet, so there is no identity to show for it. + if (pubKey != null) { + // Spacing goes outside, not in `pictureModifier`. That one is the + // INNER modifier: it styles the image within a box already fixed at + // `size`, which is why the sibling screen uses it for a border. An + // end padding there took 8.dp off the drawable's width and none off + // its height, so every coordinator's avatar drew as a 16x24 oval. + UserPicture( + userHex = pubKey, + size = 24.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(Modifier.width(8.dp)) + } + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = fade), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + relays?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + about?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + detail?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade), + ) + } + } + } +} + +/** How many live coordinators a discovery run shows before it asks. */ +private const val LIVE_PREVIEW = 8 + +/** Its announced name, or null when it published none. */ +private fun DiscoveredCoordinator.announcedName(): String? = surface.name?.takeIf { it.isNotBlank() } + +/** + * What to call an offer: its announced name, else its profile, else a short + * npub -- never a hex prefix. The announcement still wins, because in a + * discovery list the CEP-6 surface is the thing being offered. + */ +@Composable +private fun resolvedName( + offer: DiscoveredCoordinator, + accountViewModel: AccountViewModel, +): String = coordinatorDisplayName(offer.pubKey, offer.announcedName(), accountViewModel) + +/** + * How long ago a coordinator last announced, in words that stay words. + * + * `timeAgoNoDot` answers with a span for something recent and an absolute date + * for anything past a month, and the caller used one template for both — so + * every stale coordinator on the discovery list read "Last announced Aug 7 + * ago". The date branch gets a template with no "ago" in it. + */ +@Composable +private fun announcedLabel(announcedAt: Long): String = + if (TimeUtils.now() - announcedAt > TimeUtils.ONE_MONTH) { + stringRes(R.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim()) + } else { + stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim()) + } + +/** The hosts it answers on, the first two and a count of the rest. */ +@Composable +private fun relayLabel(relays: List): String? { + if (relays.isEmpty()) return null + val hosts = relays.map { it.url.substringAfter("://").trim('/') }.distinct() + val shown = hosts.take(2).joinToString(", ") + return if (hosts.size <= 2) { + shown + } else { + stringRes(R.string.cordn_coordinators_relays_more, shown, hosts.size - 2) + } +} + +/** + * Names are the coordinator's own word for itself and collide constantly — the + * reference server ships as "My coordinator", so a discovery run returns a + * dozen rows with that name and nothing to tell them apart. A pubkey prefix is + * added only to the ones that actually clash, so the common case stays clean. + */ +internal fun disambiguate( + name: String, + pubKey: HexKey, + allNames: List, +): String = if (allNames.count { it == name } > 1) "$name \u00b7 ${pubKey.take(8)}" else name + +/** + * The coordinator this screen would create against, or null while the form + * cannot name one. + * + * Null rather than a thrown error: an unfinished form is the normal state of a + * form, and the button is disabled off this rather than the user being told + * they are wrong while they type. `CoordinatorConfig` itself rejects a bad + * pubkey or an empty relay list in its `init`, so the checks here are only + * about not constructing one yet. + */ +private fun resolve( + known: List, + selected: String?, + pubKeyInput: String, + relaysInput: String, +): CoordinatorConfig? { + if (selected != null) return known.firstOrNull { it.pubKey == selected } + + // npub as well as hex: a coordinator pubkey is shared the same ways any + // other Nostr pubkey is, and refusing the bech32 form would just move the + // conversion to the user. + val pubKey = decodePublicKeyAsHexOrNull(pubKeyInput.trim()) ?: return null + val relays = relaysInput.lines().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + if (relays.isEmpty()) return null + + return CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL) +} + +/** A numbered step label, so the form reads as an order rather than a pile. */ +@Composable +private fun StepHeading( + step: Int, + title: String, +) { + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = step.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text(text = title, style = MaterialTheme.typography.titleSmall) + } +} + +/** + * Who is in the group, as a row that opens the screen for choosing them. + * + * A count and a few faces rather than the roster itself: the roster is where + * every later step's answer comes from, so it earns its own destination, and + * repeating it here would put two editors of the same list on screen at once. + */ +@Composable +private fun RosterSummary( + roster: List, + coverage: Map, + onOpen: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onOpen).padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (roster.isEmpty()) { + Text( + text = stringRes(R.string.cordn_create_people_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + roster.take(FACES).forEach { + UserPicture(userHex = it, size = 28.dp, accountViewModel = accountViewModel, nav = nav) + } + } + Column(Modifier.weight(1f)) { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, roster.size + 1, roster.size + 1), + style = MaterialTheme.typography.bodyMedium, + ) + // Counted over coordinators that answered, so an unreachable one + // never turns into "nobody can be reached". + val unreachable = roster.count { member -> coverage.values.none { it.answered && member in it.reachable } } + if (unreachable > 0 && coverage.values.any { it.answered }) { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_create_unreachable_count, unreachable, unreachable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** How many faces the summary row shows before it just counts. */ +private const val FACES = 5 + +/** + * The coordinator that will be used, and why, in one row. + * + * Collapsed by default because with a roster entered there is usually nothing + * left to decide -- one coordinator reaches everybody and the rest do not. The + * full list is one tap away for when that is not true. + */ +@Composable +private fun CoordinatorSummary( + config: CoordinatorConfig?, + coverage: CordnCoverage?, + rosterSize: Int, + expanded: Boolean, + onToggle: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (config == null) { + Text( + text = stringRes(R.string.cordn_create_no_coordinator), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } else { + CoordinatorIdentityRow( + pubKey = config.pubKey, + label = config.label, + accountViewModel = accountViewModel, + nav = nav, + size = 32.dp, + modifier = Modifier.weight(1f), + ) { name -> + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyMedium) + if (rosterSize > 0) { + // Says which of the three it is: reaches everybody, + // leaves somebody out, or was never asked. The third is + // not the second. + val reached = coverage?.reachable?.size + Text( + text = + when { + coverage == null || !coverage.answered -> stringRes(R.string.cordn_create_coverage_unknown) + reached == rosterSize -> stringRes(R.string.cordn_create_coverage_all, rosterSize) + else -> stringRes(R.string.cordn_create_coverage_partial, reached ?: 0, rosterSize) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (coverage != null && coverage.answered && reached != rosterSize) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + + TextButton(onClick = onToggle) { + Text( + if (expanded) { + stringRes(R.string.cordn_create_coordinator_hide) + } else { + stringRes(R.string.cordn_create_coordinator_change) + }, + ) + } + } +} + +/** + * What happened after Create, when it was not simply "everything". + * + * Shown instead of a thrown error because the group exists either way: a + * failed invitation is one row, not a failed creation. Every unfinished person + * gets the action that would actually work for them -- a retry where the + * coordinator refused, a link where it holds no KeyPackage at all and no retry + * ever could. + */ +@Composable +private fun CordnCreationOutcome( + creation: CordnGroupCreation, + roster: List, + coordinatorPubKey: HexKey?, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + val open: () -> Unit = { + onDismiss() + coordinatorPubKey?.let { nav.nav(Route.CordnGroupChat(it, creation.gid)) } + Unit + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.cordn_create_outcome_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringRes(R.string.cordn_create_outcome_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val attempted = creation.outcomes.associateBy { it.pubKey } + roster.forEach { member -> + val outcome = attempted[member] + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = member, size = 26.dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + Text( + text = observeUserNameByHex(member, accountViewModel), + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = + when { + outcome == null -> stringRes(R.string.cordn_create_outcome_skipped) + outcome.sent -> stringRes(R.string.cordn_create_outcome_waiting) + outcome.joinedWithoutWelcome -> stringRes(R.string.cordn_create_outcome_no_welcome) + else -> outcome.failure?.message ?: stringRes(R.string.cordn_create_outcome_failed) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (outcome?.sent == true) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + } + // Nobody was told anything, so the only thing that can + // reach somebody with no KeyPackage here is a link they + // open themselves -- which needs the group to exist, + // and now it does. + if (outcome == null) { + TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_link)) } + } + } + } + } + }, + confirmButton = { TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_open)) } }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt new file mode 100644 index 0000000000..e80829bf90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.viewmodels.CordnGroupDraft +import com.vitorpamplona.amethyst.model.cordn.CordnCoverage +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Who the new group is for. + * + * ## Why this is a screen and not a field on the create form + * + * Choosing people is the step every later one depends on. cordn can only add a + * member by spending a KeyPackage they published **to the coordinator being + * used**, so until the roster exists "which coordinator?" has no answer worth + * giving -- and once it exists the answer is arithmetic. Putting it inline made + * the create form a scroll again, with a search field, a results list and a + * roster competing with the group's own name. + * + * ## Reachability is counted across coordinators, not for the chosen one + * + * A person reachable nowhere cannot be helped by changing the choice on the + * previous screen, and that is a different fact from "not reachable on the one + * currently selected". Counted only over coordinators that actually answered: + * an unreachable server contributes nothing rather than a zero, because a + * network failure must never be rendered as a claim about a person. + */ +@Composable +fun CordnCreateMembersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val draft = rememberCordnGroupDraft(accountViewModel) + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.cordn_create_step_people), nav) }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val me = accountViewModel.account.signer.pubKey + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + var search by remember { mutableStateOf("") } + + val coverage by rememberCordnCoverage(runtime, draft.roster) + + // The badge on a SEARCH RESULT cannot come from coverage: coverage is + // the roster intersected with what a coordinator holds, so somebody not + // yet added is never in it. This is the unfiltered set, which is what + // the question "could this person be added at all" needs. + val reachable by rememberReachableIdentities(runtime) + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.cordn_create_people_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = search, + onValueChange = { + search = it + if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset() + }, + label = { Text(stringRes(R.string.cordn_create_add_member)) }, + placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Three characters, as everywhere else this list appears: fewer + // matches most of the address book and is never what someone meant. + if (search.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user: User -> + search = "" + userSuggestions.reset() + if (user.pubkeyHex != me) draft.add(user.pubkeyHex) + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + onEmpty = { + Text( + text = stringRes(R.string.cordn_info_add_member_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + }, + trailingContent = { user -> + // Marks only what the coordinator told us. An unmarked row + // covers both "has not published here" and "we could not + // ask", so it claims nothing either way. + Row(verticalAlignment = Alignment.CenterVertically) { + if (user.pubkeyHex in reachable) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = stringRes(R.string.cordn_info_has_key_package), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { + search = "" + userSuggestions.reset() + if (user.pubkeyHex != me) draft.add(user.pubkeyHex) + }) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.cordn_create_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) + } + + HorizontalDivider() + + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1), + style = MaterialTheme.typography.titleSmall, + ) + + // You are always in it and always able to administer it, so there is + // nothing to toggle or remove on this row -- it is here so the count + // above is not one short of what the group will look like. + CreatorRow(me, draft.egalitarian, accountViewModel, nav) + + draft.roster.forEach { member -> + MemberRow( + member = member, + isAdmin = member in draft.coAdmins, + egalitarian = draft.egalitarian, + coverage = coverage, + onToggleAdmin = { draft.toggleAdmin(member) }, + onRemove = { draft.remove(member) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (draft.roster.isEmpty()) { + Text( + text = stringRes(R.string.cordn_create_people_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Button( + onClick = { nav.popBack() }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Text(stringRes(R.string.cordn_create_people_done)) + } + } + } +} + +/** The creator's own row: in the group, administering it, not editable. */ +@Composable +private fun CreatorRow( + me: HexKey, + egalitarian: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UserPicture(userHex = me, size = 32.dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + Text(observeUserNameByHex(me, accountViewModel), style = MaterialTheme.typography.bodyMedium) + Text( + text = stringRes(R.string.cordn_create_you), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!egalitarian) { + Text( + text = stringRes(R.string.cordn_create_admin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +/** One person in the roster, with what a coordinator could do about them. */ +@Composable +private fun MemberRow( + member: HexKey, + isAdmin: Boolean, + egalitarian: Boolean, + coverage: Map, + onToggleAdmin: () -> Unit, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val answered = coverage.values.count { it.answered } + val reaching = coverage.values.count { it.answered && member in it.reachable } + + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UserPicture(userHex = member, size = 32.dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + Text(observeUserNameByHex(member, accountViewModel), style = MaterialTheme.typography.bodyMedium) + Text( + text = + when { + answered == 0 -> stringRes(R.string.cordn_create_reach_unknown) + reaching > 0 -> pluralStringRes(LocalContext.current, R.plurals.cordn_create_reach_count, reaching, reaching) + else -> stringRes(R.string.cordn_create_reach_none) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (answered > 0 && reaching == 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Hidden in egalitarian mode: there the admin list is empty by + // definition, so a toggle would offer a choice nothing can record. + if (!egalitarian) { + TextButton(onClick = onToggleAdmin) { + Text( + text = stringRes(R.string.cordn_create_admin), + style = MaterialTheme.typography.labelMedium, + color = if (isAdmin) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.PersonRemove, + contentDescription = stringRes(R.string.cordn_info_remove_member), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The one draft both create screens fill in. + * + * Scoped to the Activity, the way the chess lobby and board share theirs: a + * Navigation destination is disposed when you leave it, so a draft kept in + * `remember` would lose the group's name on the way to picking people. Keyed + * per account so switching accounts starts a new draft rather than inheriting + * a roster built under another key. + */ +@Composable +fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft { + val activity = LocalActivity.current as FragmentActivity + return viewModel( + viewModelStoreOwner = activity, + key = "CordnGroupDraft-${accountViewModel.account.signer.pubKey}", + ) +} + +/** + * What each coordinator this account uses could do with [roster]. + * + * Asked of coordinators there is already a session with and no others: opening + * one is what commits to a coordinator, so a screen that is only looking must + * not open one on the user's behalf. Cheap to call from more than one screen -- + * `CordnKeyPackages` reuses its `kp_list` snapshot for a minute, so the second + * screen's copy of this question usually costs nothing. + */ +@Composable +fun rememberCordnCoverage( + runtime: CordnRuntime, + roster: List, +): State> { + val known by runtime.coordinators.collectAsStateWithLifecycle() + return produceState>(emptyMap(), runtime, known, roster) { + value = + if (roster.isEmpty()) { + emptyMap() + } else { + val target = roster.toSet() + known.associate { it.pubKey to runtime.coverage(it.pubKey, target) } + } + } +} + +/** + * Every identity any coordinator this account uses holds a KeyPackage for. + * + * The union, unfiltered, for answering "could this person be added anywhere" + * about somebody who is not in the roster yet -- which + * [rememberCordnCoverage] cannot answer, because it intersects with the roster + * by construction. Empty also covers "nothing answered", so callers must only + * ever use membership in it to mark a yes, never absence to mark a no. + */ +@Composable +fun rememberReachableIdentities(runtime: CordnRuntime): State> { + val known by runtime.coordinators.collectAsStateWithLifecycle() + return produceState>(emptySet(), runtime, known) { + val all = mutableSetOf() + known.forEach { runtime.identitiesWithKeyPackages(it.pubKey)?.let(all::addAll) } + value = all + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt new file mode 100644 index 0000000000..7e0e2d7a13 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -0,0 +1,1315 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.model.cordn.CordnMediaService +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.AutoScrollToNewest +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaCipher +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaEncryption +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * One cordn room. + * + * Every kind `spec/02.md` defines can now be both sent and read here: text, + * thread replies, reactions, edits, deletions and pins, plus encrypted + * attachments and voice notes. The rule that got us here is worth keeping — + * a composer must never be able to send a kind this room cannot render, or + * the sender's own messages appear as blanks to them. + * + * It renders the room's own envelopes rather than `Note`s, so nothing on this + * screen goes through `LocalCache`. That is the point — a cordn message is not + * a Nostr event that happens to be encrypted, it is an MLS payload that never + * touched a relay, and giving it a Note would make it searchable and + * notifiable alongside things that were actually published. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnGroupChatScreen( + coordinatorPubKey: HexKey, + gid: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val room = remember(coordinatorPubKey, gid) { runtime?.groups?.get(coordinatorPubKey, gid) } + + if (room == null) { + // No room means no session for this coordinator, which is a real state + // (it was forgotten, or never opened) and not an error to throw at the + // user as a blank screen. + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) { + Text(stringRes(R.string.cordn_group_unavailable), style = MaterialTheme.typography.bodyLarge) + } + return + } + + CordnGroupChat(room, accountViewModel, nav) +} + +@Composable +private fun CordnGroupChat( + room: CordnGroupChatroom, + accountViewModel: AccountViewModel, + nav: INav, +) { + val messages by room.messages.collectAsStateWithLifecycle() + val annotations by room.annotations.collectAsStateWithLifecycle() + val name by room.name.collectAsStateWithLifecycle() + val members by room.members.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val me = accountViewModel.account.signer.pubKey + + val draft by room.draft.collectAsStateWithLifecycle() + var replyingTo by remember { mutableStateOf(null) } + var editing by remember { mutableStateOf(null) } + var attaching by remember { mutableStateOf(false) } + // The same upload state every other chat's dialog is built on — caption, media + // quality, metadata stripping and the server to put it on. + val uploadState = + remember(room.gid) { + ChatFileUploadState( + accountViewModel.account.settings.defaultFileServer, + accountViewModel.account.settings.stripLocationOnUpload, + ) + } + var pendingVoice by remember { mutableStateOf(null) } + + DisposableEffect(room.gid) { + onDispose { + // The recorder writes plaintext audio to cacheDir and sendVoiceNote deletes + // it after sending. A note recorded and then abandoned — the screen closed, + // the room switched — never reached that, so it stayed on disk: an + // unencrypted copy of a message that was never even sent. + pendingVoice?.file?.delete() + } + } + var attachError by remember { mutableStateOf(null) } + + // Why sending says anything at all when it fails: `manager()` is null-safe + // all the way down, so a room whose coordinator has no open session + // swallowed every send, reaction, edit, delete and pin without a word — + // and the composer had already cleared the draft, so the text went with it. + // From the outside that is indistinguishable from a message that was sent + // and simply never arrived. + var sendError by remember { mutableStateOf(null) } + val context = LocalContext.current + val uploadFailed = stringRes(R.string.cordn_media_upload_failed) + val sendFailed = stringRes(R.string.cordn_send_failed) + val noSession = stringRes(R.string.cordn_send_no_session) + + fun manager() = + accountViewModel.account.cordnRuntime + ?.sessionOrNull(room.coordinatorPubKey) + ?.manager + + // One place for every outbound action, so none of them can go quiet again. + // Returns false when it failed, which is what lets the composer put the + // draft back rather than eat it. + suspend fun trySend(block: suspend (CordnGroupManager) -> CordnDeliveredMessage?): Boolean { + val manager = manager() + if (manager == null) { + sendError = noSession + return false + } + return try { + // Shown the moment the coordinator takes it, rather than when the + // echo comes back — which, for your own traffic, it never does as + // a message: the sync loop recognises it by cursor and reports it + // as Delivery.Echo, whose branch adds nothing to the room. So a + // sent message used to leave no trace in the room that sent it. + // + // add() is keyed on the envelope id and idempotent, so a later + // re-sync that does hand the message back cannot double it. + block(manager)?.let { room.add(it) } + sendError = null + true + } catch (e: Exception) { + Log.w("CordnGroupChat", "send failed in ${room.gid}: ${e.message}", e) + sendError = e.message ?: sendFailed + false + } + } + + val runtime = accountViewModel.account.cordnRuntime + + // Opening a room is what marks it read, and the read position and draft + // are written when leaving it. Saving on every keystroke would rewrite an + // encrypted file per character; saving on dispose loses nothing a process + // death would not have lost anyway. + DisposableEffect(room) { + onDispose { + room.markRead() + runtime?.let { + CoroutineScope(Dispatchers.IO).launch { + it.saveRoomState(room.coordinatorPubKey, room.gid) + } + } + } + } + + // Where the divider goes, taken once per visit. markRead() below moves the live + // cursor to the newest message, so reading it per frame would erase the line at + // exactly the moment it starts being useful. Restored state first, because the + // cursor this reads is the one that was persisted. + var unreadFrom by remember(room.gid) { mutableStateOf(null) } + + LaunchedEffect(room) { + runtime?.restoreRoomState(room.coordinatorPubKey, room.gid) + unreadFrom = room.lastReadCursor.value.takeIf { room.unreadCount.value > 0 } + room.markRead() + } + + // Tapping a reply's quote jumps to the message it answers and flashes it; the + // bubble clears this itself once the flash is done. + var highlighted by remember(room.gid) { mutableStateOf(null) } + + // The same scaffold every other chat screen uses. A bare Scaffold gave this room + // neither of the two things it provides: the bars' scroll behaviour, and the IME + // inset — without which the composer sat *under* the soft keyboard. + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + CordnChatTopBar( + title = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8)), + members = members, + accountViewModel = accountViewModel, + onBack = { nav.popBack() }, + onInfo = { nav.nav(Route.CordnGroupInfo(room.coordinatorPubKey, room.gid)) }, + ) + }, + accountViewModel = accountViewModel, + allowBarHide = false, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + // One clock for every divider in the room, so they cannot disagree. + val today = rememberToday() + + // Reversed once, into a val: `asReversed()` is a view, and indexing it + // per item to find the neighbour is how a list like this quietly becomes + // quadratic. Hoisted out of the LazyColumn because the jump below needs to + // find a message's row by id. + val rows = remember(messages) { messages.asReversed() } + + // The oldest message this visit had not seen. Own traffic is excluded for + // the same reason unreadCount excludes it: a message of yours coming back + // as an echo is not news, and a divider above it would say it was. + val firstUnreadId = + remember(rows, unreadFrom, me) { + unreadFrom?.let { readUpTo -> + rows.lastOrNull { it.cursor > readUpTo && it.envelope.pubKey != me }?.envelope?.id + } + } + + val jumpTo: (HexKey) -> Unit = { id -> + val index = rows.indexOfFirst { it.envelope.id == id } + // Not found means the quoted message is older than what is loaded. + // Flashing nothing is better than scrolling somewhere arbitrary. + if (index >= 0) { + highlighted = id + scope.launch { listState.animateScrollToItem(index) } + } + } + + // Pinned messages sit above the conversation rather than inside it. + // A pin is a claim about a message's importance, not a message, and + // leaving it only in place means the thing someone pinned scrolls + // away exactly like everything else. + PinnedRibbon( + annotations = annotations, + scope = scope, + accountViewModel = accountViewModel, + nav = nav, + onJumpTo = jumpTo, + ) { message -> + trySend { + it.post( + gid = room.gid, + pinTo = message.target(), + pinOp = CordnMessageReferences.PinOp.REMOVE, + ) + } + } + + // The same rule every other chat follows: your own message always + // pulls the view onto it, someone else's only while you are already + // at the bottom, and history you scrolled up to read is left alone. + val newest = rows.firstOrNull()?.envelope + AutoScrollToNewest(listState, newest?.id, mine = newest?.pubKey == me) + + // A room with nothing in it said nothing at all, where every other chat + // crossfades a real empty state. There is no error or loading branch to + // match: the session opens behind `restoreRoomState` and a room that cannot + // reach its coordinator says so through the send banner below, on the + // action that actually needed it. + if (rows.isEmpty()) { + EmptyState( + title = stringRes(R.string.cordn_chat_empty_title), + description = stringRes(R.string.cordn_chat_empty_description), + modifier = Modifier.weight(1f), + ) + } + + LazyColumn( + state = listState, + // Anchored at the bottom like every other chat: a room opens on + // its newest message. `messages` is oldest-first, so the rows + // are reversed to match. + reverseLayout = true, + contentPadding = FeedPadding, + // No weight while the empty state holds the space, or the two would + // split the screen and the placeholder would sit in half of it. + modifier = + if (rows.isEmpty()) { + Modifier.fillMaxWidth() + } else { + Modifier.weight(1f).fillMaxWidth() + }.padding(horizontal = 12.dp), + ) { + itemsIndexed(rows, key = { _, it -> it.envelope.id }) { index, message -> + // `rows` runs newest-first and the list is reverse-laid-out, + // so the next index is the older message and renders above. + val older = rows.getOrNull(index + 1) + val newer = rows.getOrNull(index - 1) + + // Send/arrival motion, as in the shared feed: a new row fades + // in and the ones above slide to make room, so a sent message + // enters instead of appearing. + val itemModifier = + if (accountViewModel.settings.isPerformanceMode()) { + Modifier + } else { + Modifier.animateItem() + } + + // One Column, so the item is a single placeable. `reverseLayout` + // mirrors each of an item's placeables about the main axis, which + // inverts the order of siblings emitted side by side — but it does + // not reach inside a layout, so this Column still reads top to + // bottom. Both headers are therefore composed BEFORE the bubble + // they introduce: the date above the whole day, then the unread + // line immediately against the message. + Column(modifier = itemModifier) { + // Starts a new day when it differs from the older row, which + // under reverseLayout is the one rendered above. + if (!message.sameDayAs(older)) { + DaySeparator(message.envelope.createdAt, today) + } + + if (message.envelope.id == firstUnreadId) { + UnreadDivider() + } + + CordnMessageRow( + message = message, + room = room, + annotations = annotations, + me = me, + // Grouped when the same person keeps talking inside the + // shared chat window: one avatar and name per burst + // instead of per line, and the bubbles of a burst square + // off against each other — which is most of what makes a + // wall of messages readable. + groupPosition = + remember(newer?.envelope?.id, message.envelope.id, older?.envelope?.id) { + cordnGroupPositionFor(newer, message, older) + }, + // The edit if there is one, and nothing at all if the + // message was withdrawn: rendering the original text of + // a deleted message would defeat the deletion. + text = if (annotations.isDeleted(message.envelope.id)) null else annotations.contentOf(message.envelope.id), + isEdited = annotations.isEdited(message.envelope.id), + shouldHighlight = highlighted == message.envelope.id, + accountViewModel = accountViewModel, + nav = nav, + onHighlightFinished = { highlighted = null }, + onScrollToMessage = jumpTo, + onReply = { + replyingTo = message + editing = null + }, + onEdit = { + editing = message + replyingTo = null + room.draft.value = annotations.contentOf(message.envelope.id).orEmpty() + }, + onDelete = { + // Through trySend like the rest: a deletion is an + // annotation, and an annotation of your own comes back + // as an Echo too, so deleting your own message used to + // look like nothing had happened until someone else's + // traffic refreshed the fold. + scope.launch { trySend { it.post(room.gid, deleteTo = message.target()) } } + }, + onTogglePin = { + val pinned = annotations.isPinned(message.envelope.id) + scope.launch { + trySend { + it.post( + gid = room.gid, + pinTo = message.target(), + pinOp = if (pinned) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD, + ) + } + } + }, + onReact = { emoji -> + scope.launch { trySend { it.post(room.gid, emoji, reactionTo = message.target()) } } + }, + ) + } + } + } + + (attachError ?: sendError)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + + // The app's upload dialog, as Marmot, Concord, the DMs, minichat and Nests + // all use it. cordn keeps its own uploader behind it — encrypted blobs to + // Blossom, not a nostr media post — which is exactly how Marmot uses it too. + if (uploadState.multiOrchestrator != null) { + ChatFileUploadDialog( + state = uploadState, + title = { Text(name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8))) }, + upload = { + scope.launch { + attaching = true + attachError = null + try { + sendAttachment(context, accountViewModel, room, uploadState) + // Only on success: a failed upload leaves the dialog up + // with what you picked still in it, so retrying is one + // tap rather than the picker again. + uploadState.reset() + } catch (e: Exception) { + // A failed attachment left no trace anywhere; the + // banner tells the person, this tells whoever has + // to work out why. + Log.w("CordnGroupChat", "attachment failed in ${room.gid}: ${e.message}", e) + attachError = e.message ?: uploadFailed + } finally { + attaching = false + } + } + }, + onCancel = uploadState::reset, + accountViewModel = accountViewModel, + nav = nav, + // cordn tells its blob host a fixed set of constants on purpose and + // its imeta tag has no field for a warning, so the switch would be + // a control with nowhere to put the answer. + showContentWarning = false, + ) + } + + val replyPreview = replyingTo + if (replyPreview != null) { + // The same quote block the bubbles use, so what you are answering looks + // the same while you write it as it does once it is sent. + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + CordnQuotedMessage( + parent = replyPreview, + annotations = annotations, + me = me, + accountViewModel = accountViewModel, + nav = nav, + onClick = { jumpTo(replyPreview.envelope.id) }, + ) + } + IconButton(onClick = { replyingTo = null }) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.cancel)) + } + } + } + if (editing != null) { + ComposerBanner( + label = stringRes(R.string.cordn_action_editing), + onCancel = { + editing = null + room.draft.value = "" + }, + ) + } + + CordnComposer( + room = room, + accountViewModel = accountViewModel, + // Picking a file no longer sends it. A cordn attachment is encrypted, + // uploaded and announced to the room in one irreversible action, so it + // gets the same confirm-first treatment as anything else that cannot be + // taken back. + onAttach = { uri -> + uploadState.load(persistentListOf(SelectedMedia(uri, context.contentResolver.getType(uri)))) + }, + // Recording no longer sends. It goes to the preview above the field, + // where it can be played back, re-recorded or dropped first. + pendingVoice = pendingVoice, + onVoiceNote = { recording -> + // Re-recording replaces what was there; the old file is a temp + // file this screen owns, so it goes now rather than being leaked. + pendingVoice?.file?.delete() + pendingVoice = recording + }, + onRemoveVoice = { + pendingVoice?.file?.delete() + pendingVoice = null + }, + attaching = attaching, + // The field hands its own text over rather than the screen reading + // `room.draft`: the two are kept in step by a snapshot collector, which + // settles a frame later, and a send must use what is on screen now. + onSend = { typed -> + val text = typed.trim() + + val voice = pendingVoice + if (voice != null) { + pendingVoice = null + room.draft.value = "" + scope.launch { + attaching = true + attachError = null + try { + sendVoiceNote(context, accountViewModel, room, voice, text) + } catch (e: Exception) { + Log.w("CordnGroupChat", "voice note failed in ${room.gid}: ${e.message}", e) + attachError = e.message ?: uploadFailed + } finally { + attaching = false + } + } + return@CordnComposer + } + + if (text.isEmpty()) return@CordnComposer + + val reply = replyingTo + val edit = editing + room.draft.value = "" + replyingTo = null + editing = null + + scope.launch { + val sent = + trySend { + it.post( + gid = room.gid, + content = text, + replyTo = reply?.target(), + editTo = edit?.target(), + ) + } + if (!sent) { + // Hand the message back rather than lose it, and put + // the reply or edit it belonged to back with it, so + // trying again means pressing send and nothing else. + room.draft.value = text + replyingTo = reply + editing = edit + } + } + }, + ) + } + } +} + +/** The target fields `CordnMessageReferences` needs, straight off a delivery. */ +private fun CordnDeliveredMessage.target() = + CordnMessageReferences.Target( + id = envelope.id, + pubKey = envelope.pubKey, + kind = envelope.kind, + // The target's own tags, so replying to a reply keeps the original + // thread root instead of starting a new thread at the reply. + tags = envelope.tags, + ) + +/** + * The pinned messages, one line tall however many there are. + * + * Stacking them was the obvious shape and the wrong one: the ribbon sits above a + * `weight(1f)` conversation, so an unweighted Column of one row per pin is + * measured at its full height first and takes that space off the conversation -- + * eight pins and there is little chat left, with no way to scroll or collapse + * the strip. cordn-web's shape fixes the height by construction instead: one pin + * shown, a position counter, and arrows to step through the rest. + * + * Stepping wraps, and the index is clamped on read rather than corrected in an + * effect -- the list shrinks under it whenever anybody unpins, and a clamp that + * happens at read time cannot lag behind the data the way a correction does. + */ +@Composable +private fun PinnedRibbon( + annotations: CordnAnnotationIndex, + scope: CoroutineScope, + accountViewModel: AccountViewModel, + nav: INav, + onJumpTo: (HexKey) -> Unit, + // Hoisted rather than handed a manager: unpinning has to go through the + // caller's trySend so it reports a failure and lands in the room, and a + // ribbon that posted for itself could do neither. + onUnpin: suspend (CordnDeliveredMessage) -> Unit, +) { + val pinned = annotations.pinnedIds().mapNotNull { annotations.byId[it] } + if (pinned.isEmpty()) return + + var index by remember { mutableIntStateOf(0) } + var showingAll by remember { mutableStateOf(false) } + + val at = index.coerceIn(0, pinned.lastIndex) + val current = pinned[at] + + Column(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PinnedLine( + message = current, + annotations = annotations, + accountViewModel = accountViewModel, + modifier = + Modifier + .weight(1f) + .clickable { onJumpTo(current.envelope.id) } + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + + // Only when there is somewhere to step to. + if (pinned.size > 1) { + IconButton(onClick = { index = (at - 1 + pinned.size) % pinned.size }, modifier = Modifier.size(28.dp)) { + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowLeft, + contentDescription = stringRes(R.string.cordn_pinned_previous), + modifier = Modifier.size(18.dp), + ) + } + Text( + text = "${at + 1}/${pinned.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton(onClick = { index = (at + 1) % pinned.size }, modifier = Modifier.size(28.dp)) { + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = stringRes(R.string.cordn_pinned_next), + modifier = Modifier.size(18.dp), + ) + } + } + + IconButton(onClick = { showingAll = true }, modifier = Modifier.size(28.dp)) { + Icon( + symbol = MaterialSymbols.AutoMirrored.List, + contentDescription = stringRes(R.string.cordn_pinned_show_all), + modifier = Modifier.size(18.dp), + ) + } + } + + HorizontalDivider() + } + + if (showingAll) { + AllPinnedDialog( + pinned = pinned, + annotations = annotations, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showingAll = false }, + onJumpTo = { + showingAll = false + onJumpTo(it) + }, + onUnpin = { scope.launch { onUnpin(it) } }, + ) + } +} + +/** + * Who said it and what it said, on one line. + * + * The author is half of what makes a pin worth reading -- a line of text with no + * name on it says nothing about why it was kept. + */ +@Composable +private fun PinnedLine( + message: CordnDeliveredMessage, + annotations: CordnAnnotationIndex, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = observeUserNameByHex(message.envelope.pubKey, accountViewModel), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 120.dp), + ) + Text( + // A deleted message that is still pinned shows as deleted, not as + // its old text: a pin must not outlive the withdrawal of what it + // points at. + text = + if (annotations.isDeleted(message.envelope.id)) { + stringRes(R.string.cordn_message_deleted) + } else { + annotations.contentOf(message.envelope.id).orEmpty() + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +/** + * Every pinned message, for when the strip's one line is not enough. + * + * The only place `pinnedBy` is shown. The fold has always carried it and + * nothing ever displayed it, yet in a group where any member may pin (spec/01.md + * section 5.1) who did the pinning is often the point. + */ +@Composable +private fun AllPinnedDialog( + pinned: List, + annotations: CordnAnnotationIndex, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, + onJumpTo: (HexKey) -> Unit, + onUnpin: (CordnDeliveredMessage) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(MaterialSymbols.PushPin, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + text = stringRes(R.string.cordn_pinned_title), + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + text = { + Column { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_pinned_count, pinned.size, pinned.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Bounded and scrollable: this list is as long as the group made + // it, and a dialog that grows past the screen cannot be dismissed. + LazyColumn(Modifier.heightIn(max = 360.dp).padding(top = 8.dp)) { + items(pinned, key = { it.envelope.id }) { message -> + Column( + Modifier + .fillMaxWidth() + .clickable { onJumpTo(message.envelope.id) } + .padding(vertical = 8.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + UserPicture( + userHex = message.envelope.pubKey, + size = 24.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Text( + text = observeUserNameByHex(message.envelope.pubKey, accountViewModel), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + TextButton(onClick = { onUnpin(message) }) { + Text(stringRes(R.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall) + } + } + + annotations.pins[message.envelope.id]?.pinnedBy?.let { pinnedBy -> + Text( + text = + stringRes( + R.string.cordn_pinned_by, + observeUserNameByHex(pinnedBy, accountViewModel), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = + if (annotations.isDeleted(message.envelope.id)) { + stringRes(R.string.cordn_message_deleted) + } else { + annotations.contentOf(message.envelope.id).orEmpty() + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + HorizontalDivider() + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +@Composable +private fun ComposerBanner( + label: String, + onCancel: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = onCancel) { Text(stringRes(Res.string.cancel), style = MaterialTheme.typography.labelSmall) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnChatTopBar( + title: String, + members: List, + accountViewModel: AccountViewModel, + onBack: () -> Unit, + onInfo: () -> Unit, +) { + TopAppBar( + title = { + // The whole title is the way in to group info, as it is in every other + // group chat — the faces say who is in the room before you open it. + Row( + modifier = Modifier.clickable(onClick = onInfo), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (members.isNotEmpty()) { + NonClickableUserPictures( + userHexList = members, + size = 36.dp, + accountViewModel = accountViewModel, + ) + } + Column { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (members.isNotEmpty()) { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(Res.string.back), + ) + } + }, + actions = { + // GroupAdd rather than Info, as in Marmot: the same screen, and the thing + // people come to it for is adding someone. + IconButton(onClick = onInfo) { + Icon(MaterialSymbols.GroupAdd, contentDescription = stringRes(R.string.cordn_group_info)) + } + }, + ) +} + +/** A reason an attachment did not go, in words meant for the person who tried. */ +private class CordnAttachmentException( + message: String, +) : Exception(message) + +/** + * Encrypts what the upload dialog is holding and sends it as an attachment. + * + * cordn does not go through [com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator] + * like the nostr surfaces do: its `imeta` tag carries the hash of the *plaintext* and a + * per-file key, which the orchestrator neither produces nor surfaces. So the dialog's + * two byte-level choices are applied here by hand, against the same helpers the + * orchestrator uses, and the encryption and upload stay in [CordnMediaService]. + */ +private suspend fun sendAttachment( + context: Context, + accountViewModel: AccountViewModel, + room: CordnGroupChatroom, + state: ChatFileUploadState, +) { + // Every step here used to `?: return`, which reads as "nothing to do" and + // behaves as "the attach button does nothing at all": the picker closed, + // the spinner ended, and no message and no error appeared. Each one is now + // a reason a person can act on. + val session = + accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + val group = + session.manager.group(room.gid) + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + + // The gallery inside the dialog can delete what was picked, which leaves an empty + // orchestrator rather than a null one. canPost() now refuses that, but indexing it + // blindly here crashed, so it is checked where the index happens too. + val orchestrator = state.multiOrchestrator + if (orchestrator == null || orchestrator.size() == 0) return + + val item = orchestrator.get(0) + val uri = item.media.uri + val declaredMime = item.media.mimeType ?: context.contentResolver.getType(uri) ?: CordnBlobUpload.OPAQUE + + // Marks the dialog busy: its Send button reads canPost(), which is false while a + // tracker says an upload is running. Without this the button stayed live for the + // whole upload and a second tap encrypted, uploaded and posted the file twice. + state.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) + + try { + // The media-quality slider. + // + // Off the main thread, like the strip and the read below it. + // `sendAttachment` is called from the composition's scope, so it + // inherits Main, and `MediaCompressor.compress` opens with + // `checkNotInMainThread()` — so every image attachment threw + // `OnMainThreadException` before it ever reached the uploader. The + // voice path never hit it because it posts its recording already + // encoded and skips compression entirely. + val compressed = + withContext(Dispatchers.IO) { + item.orchestrator.compressIfNeeded( + uri = uri, + mimeType = declaredMime, + compressionQuality = MediaCompressor.intToCompressorQuality(state.mediaQualitySlider), + context = context, + ) + } + val mime = compressed.contentType ?: declaredMime + + // The strip-metadata switch. A file type the stripper does not handle comes back + // untouched and says so, which is not a failure — there was nothing to strip. + val finalUri = + if (state.stripMetadata) { + withContext(Dispatchers.IO) { MetadataStripper.strip(compressed.uri, mime, context) }.uri + } else { + compressed.uri + } + + try { + // Name comes from OpenableColumns: `uri.lastPathSegment` is a document id + // on a content:// URI, not a filename. The query is a disk read, so it + // goes off the main thread with the rest — StrictMode flags it otherwise. + val name = withContext(Dispatchers.IO) { resolveDisplayName(context, uri) } + val bytes = + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(finalUri)?.use { it.readBytes() } } + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_unreadable)) + + // Null means the chosen host has no base URL, which is a setting the person can + // change — the one failure here that is entirely actionable. + val tag = + CordnMediaService(accountViewModel.account) + .upload(group, bytes, mime, name, context, state.selectedServer.baseUrl) + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server)) + + // Into the room as well, for the same reason every other send is: an + // attachment of your own echoes back as an Echo and would otherwise be + // invisible to the person who sent it. + // + // The dialog's description is the message's own content, so an attachment with + // something written about it is one message rather than two. + room.add(session.manager.send(room.gid, content = state.caption.trim(), tags = arrayOf(tag))) + } finally { + // Compressing and stripping each write a new file, and both hold the + // attachment in the clear. Leaving them in the cache would keep plaintext + // copies of a message that is end-to-end encrypted everywhere else — the + // same reason the voice path deletes its recording. Both calls no-op on the + // user's own file. + item.orchestrator.deleteTempUri(finalUri, uri) + item.orchestrator.deleteTempUri(compressed.uri, uri) + } + } finally { + // Always, not just on success: a failure leaves the dialog up to retry from, + // and a tracker stuck "uploading" would keep its Send button dead forever. + state.mediaUploadTracker.finishUpload() + } +} + +/** + * The file's real name. + * + * `uri.lastPathSegment` is a document id on a `content://` URI, not a name — it is what + * every cordn attachment has been named until now. + */ +private fun resolveDisplayName( + context: Context, + uri: Uri, +): String = + runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null + } + }.getOrNull() + ?: uri.lastPathSegment?.substringAfterLast('/') + ?: "file" + +/** + * One attachment, drawn by the pipeline every other chat's media goes through. + * + * Registering a [CordnMediaCipher] against the blob URL lets + * `EncryptedBlobInterceptor` decrypt the download in flight, so the bytes reach + * [ZoomableContentView] already in the clear and cordn gets the app's real + * image, video and voice-note rendering — zoom, the pager, the content-warning + * gate, thumbhash backdrops — instead of its own. + * + * It used to fetch and decrypt by hand and then draw a bare `Image`, an + * OutlinedButton reading "Open ", and its own audio player, on the + * argument that a blob fetch tells the host that a particular person opened a + * particular message and so should wait for a tap. The concern is real; a + * cordn-only tap gate was the wrong place to answer it. Auto-loading media is + * an app-wide setting that the shared renderer already honours, and every other + * encrypted chat — Marmot included — routes through it, so the one chat that + * opted out was also the one whose media did not look like the app. + */ +@Composable +internal fun CordnAttachment( + attachment: CordnMediaAttachment, + room: CordnGroupChatroom, + accountViewModel: AccountViewModel, +) { + // Derived, not carried: spec/applications/encrypted-media.md §3.1. Null + // means this device cannot open the group at all, which is the one case + // there is nothing to draw for. + val mediaKey = + remember(room.gid, attachment.url) { + accountViewModel.account.cordnRuntime + ?.sessionOrNull(room.coordinatorPubKey) + ?.manager + ?.group(room.gid) + ?.let { CordnMediaEncryption.mediaKey(it) } + } + + if (mediaKey == null) { + Text( + text = stringRes(R.string.cordn_media_download_failed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + return + } + + val cipher = remember(attachment, mediaKey) { CordnMediaCipher(mediaKey, attachment) } + Amethyst.instance.keyCache.add(attachment.url, cipher, attachment.mimeType) + + // A voice note is audio, and audio has no picture: sent down the video + // branch below it plays on a blank video surface. The Note-free player is + // the same one the other chats reach, which cordn cannot get to through a + // Note because its messages never enter LocalCache. + if (attachment.isAudio) { + // Present when the sender wrote the hint; the player falls back to a + // placeholder when nobody did, so the bars are never simply missing. + val bars = remember(attachment) { attachment.waveform?.let { WaveformData(it) } } + Box(Modifier.padding(top = 6.dp)) { + RenderAudioWaveformPlayer( + mediaUrl = attachment.url, + title = attachment.filename, + mimeType = attachment.mimeType, + waveform = bars, + authorName = null, + callbackUri = null, + accountViewModel = accountViewModel, + ) + } + return + } + + val content = + remember(attachment, mediaKey) { + val dim = attachment.dimensions?.let { DimensionTag.parse(it) } + if (attachment.isImage) { + EncryptedMediaUrlImage( + url = attachment.url, + description = attachment.filename, + hash = attachment.plaintextHash, + blurhash = attachment.blurhash, + dim = dim, + mimeType = attachment.mimeType, + encryptionAlgo = CordnMediaTag.VERSION_V1, + encryptionKey = mediaKey, + encryptionNonce = attachment.nonceBytes, + ) + } else { + EncryptedMediaUrlVideo( + url = attachment.url, + description = attachment.filename, + hash = attachment.plaintextHash, + blurhash = attachment.blurhash, + dim = dim, + mimeType = attachment.mimeType, + encryptionAlgo = CordnMediaTag.VERSION_V1, + encryptionKey = mediaKey, + encryptionNonce = attachment.nonceBytes, + ) + } + } + + Box(Modifier.padding(top = 6.dp)) { + ZoomableContentView( + content = content, + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } +} + +/** + * Hold to record, release to send. + * + * The permission is requested on the first press rather than when the room + * opens: opening a chat is not consent to use the microphone, and a dialog + * that appears before anyone reached for it trains people to dismiss it. + */ +@Composable +internal fun VoiceNoteButton( + enabled: Boolean, + onRecorded: (RecordingResult) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val recorder = remember { VoiceMessageRecorder() } + var recording by remember { mutableStateOf(false) } + var granted by remember { mutableStateOf(hasMicPermission(context)) } + + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it } + + IconButton( + enabled = enabled, + onClick = { + if (!granted) { + permission.launch(Manifest.permission.RECORD_AUDIO) + return@IconButton + } + if (recording) { + recording = false + // Null when the press was too short to be a message. Dropping + // it silently is right: an accidental tap should not send a + // zero-second voice note to a group. + recorder.stop()?.let(onRecorded) + } else { + recording = true + recorder.start(context, scope) + } + }, + ) { + Icon( + symbol = if (recording) MaterialSymbols.Stop else MaterialSymbols.Mic, + contentDescription = stringRes(if (recording) R.string.cordn_voice_stop else R.string.cordn_voice_record), + // Matches the attach icon beside it; red only while recording, + // which is the one state worth pulling the eye. + tint = if (recording) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.placeholderText, + ) + } +} + +private fun hasMicPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + +/** + * Sends a recording through exactly the same encrypted path as any other file. + * + * A voice note is a file: same codec, same Blossom upload, same `imeta` + * descriptor inside the envelope. The only cordn-specific part is deleting + * the cache file afterwards — the recorder writes plaintext audio to + * `cacheDir`, and leaving it there would keep an unencrypted copy of a + * message that was end-to-end encrypted everywhere else. + */ +private suspend fun sendVoiceNote( + context: Context, + accountViewModel: AccountViewModel, + room: CordnGroupChatroom, + recording: RecordingResult, + caption: String, +) { + // Reported rather than returned, for the reason sendAttachment is: a voice + // note that goes nowhere and says nothing is the same bug twice. + val session = + accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + val group = + session.manager.group(room.gid) + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + + try { + val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() } + val tag = + CordnMediaService(accountViewModel.account) + .upload( + group = group, + bytes = bytes, + mimeType = recording.mimeType, + filename = recording.file.name, + context = context, + // The recorder measured these while it was recording; the + // composer's own preview already draws them. Carrying them + // is what makes the bubble match the preview. + waveform = recording.amplitudes, + ) + ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server)) + // Into the room as well, for the same reason every other send is: an + // attachment of your own echoes back as an Echo and would otherwise be + // invisible to the person who sent it. + room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag))) + } finally { + withContext(Dispatchers.IO) { recording.file.delete() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt new file mode 100644 index 0000000000..25c90c354e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt @@ -0,0 +1,1034 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.content.Context +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupException +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.copy_npub_to_clipboard +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SectionCollapse +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SectionExpand +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch + +/** + * What this room is, and what the coordinator can see of it. + * + * The **exposure card's real home**. The link screen shows the same disclosure + * before joining, when it is a decision; this shows it after, when it is a + * fact worth being able to check. §8 is only meaningful if it is available at + * both moments — a privacy property nobody can look up again is a claim, not a + * property. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnGroupInfoScreen( + coordinatorPubKey: HexKey, + gid: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val room = remember(coordinatorPubKey, gid) { runtime?.groups?.get(coordinatorPubKey, gid) } + + if (room == null) { + Scaffold(topBar = { InfoTopBar(nav) }) { padding -> + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + } + return + } + + CordnGroupInfo(room, coordinatorPubKey, accountViewModel, nav) +} + +/** + * The bar both states share, with a slot for the one action only a loaded + * group can offer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InfoTopBar( + nav: INav, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) + } + }, + title = { Text(stringRes(R.string.cordn_group_info)) }, + actions = actions, + ) +} + +@Composable +private fun CordnGroupInfo( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val name by room.name.collectAsStateWithLifecycle() + val description by room.description.collectAsStateWithLifecycle() + val members by room.members.collectAsStateWithLifecycle() + val admins by room.adminPubkeys.collectAsStateWithLifecycle() + val epoch by room.epoch.collectAsStateWithLifecycle() + + val me = accountViewModel.account.signer.pubKey + // The same rule the policy enforces, asked here so the screen offers only + // what would actually go through: empty is egalitarian and everyone + // administers, otherwise it is the named set. + val canAdminister = admins.isEmpty() || me in admins + + val scope = rememberCoroutineScope() + val runtime = accountViewModel.account.cordnRuntime + val manager = runtime?.sessionOrNull(coordinatorPubKey)?.manager + + // The user's own name for it, when they gave it one. Read from the stored + // config, never from the coordinator: serverInfo() is a live MCP request over + // kind 25910, and a screen that fired one on open would tell the coordinator + // every time somebody glanced at a group (spec/00.md §8). The announced name + // and the profile are read from the cache by CoordinatorIdentityRow itself. + val coordinatorLabel = + runtime + ?.coordinators + ?.collectAsStateWithLifecycle() + ?.value + ?.firstOrNull { it.pubKey == coordinatorPubKey } + ?.label + var adminError by remember(room.gid) { mutableStateOf(null) } + var busy by remember(room.gid) { mutableStateOf(false) } + var renaming by remember(room.gid) { mutableStateOf(false) } + var removing by remember(room.gid) { mutableStateOf(null) } + var memberSearch by remember(room.gid) { mutableStateOf("") } + + // The app's own people-finder, not a second one: the same state object the + // composers and the sibling group screen drive, so a name, an npub and a + // NIP-05 address all resolve here exactly as they do everywhere else. + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + val failed = stringRes(R.string.cordn_admin_action_failed) + val noSession = stringRes(R.string.cordn_send_no_session) + + // Computed, never asserted: hardcoding these made the card look like a + // disclosure while reporting the same four values for every group. + val exposure by + produceState(null, runtime, coordinatorPubKey, room.gid) { + value = + runtime + ?.sessionOrNull(coordinatorPubKey) + ?.runCatching { exposure(room.gid) } + ?.getOrNull() + } + var showExposure by remember { mutableStateOf(false) } + + /** + * Runs an admin commit, reporting rather than swallowing what it costs. + * + * A room whose coordinator has no open session cannot commit anything, and + * saying so beats a button that does nothing. + * + * Hands over the **runtime**, not the manager. The manager commits and posts + * but does not touch the [CordnGroupChatroom] these rows are drawn from, so + * reaching it directly left every one of these actions invisible until + * something else refreshed the room: a member removed here stayed in the + * roster, and a rename stayed the old name. The runtime's own methods pair + * each commit with that refresh. + */ + val context = LocalContext.current + + fun runAdmin( + who: String? = null, + block: suspend (CordnRuntime) -> Unit, + ) { + val target = runtime + if (target == null || manager == null) { + adminError = noSession + return + } + busy = true + adminError = null + scope.launch { + try { + block(target) + } catch (e: Exception) { + // Not e.message. Those are written for a log and name pubkeys + // and gids in full, so the screen that had just shown a face + // answered with 64 hex characters. + Log.w("CordnGroupInfo", "admin action failed in ${room.gid}: ${e.message}", e) + adminError = adminFailureText(context, e, who, failed) + } finally { + busy = false + } + } + } + + Scaffold( + topBar = { + InfoTopBar(nav) { + // Where the sibling group-info screen keeps it. It was the only + // action on the page, so it was also the only reason the body + // held a button between the roster and the share block. + if (canAdminister) { + IconButton(onClick = { renaming = true }, enabled = !busy) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(R.string.cordn_info_edit_details), + ) + } + } + } + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + text = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8)), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + description?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + // A subtitle, not a labelled row above a list that is itself the count. + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + // First, because it is the fact that shapes everything below it: one + // server carries every message in this group, in order, and which one + // that is matters more than any of the group's own settings. It used + // to sit below Members, Share and Requests, where a reader met the + // people before the machine that sees all of them. + // + // A coordinator is a Nostr identity, not an opaque service address: + // ContextVM addresses it with ordinary p-tags, so it has a kind 0 like + // anyone else and the app can already resolve it to a name, an avatar + // and a profile to tap through to. + Text(stringRes(R.string.cordn_info_coordinator), style = MaterialTheme.typography.titleMedium) + + Row( + Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CoordinatorIdentityRow( + pubKey = coordinatorPubKey, + label = coordinatorLabel, + accountViewModel = accountViewModel, + nav = nav, + modifier = Modifier.weight(1f), + ) { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + + // What it can see is a paragraph, and one nobody reads twice. As a + // card it took a screenful on every visit; behind an (i) it is one + // tap away the once it is wanted. + IconButton(onClick = { showExposure = true }, enabled = exposure != null) { + Icon( + symbol = MaterialSymbols.Info, + contentDescription = stringRes(R.string.cordn_exposure_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + Text(stringRes(R.string.cordn_info_members), style = MaterialTheme.typography.titleMedium) + + // Said once, and only when it is true. An empty admin set is not "none + // configured" -- spec/01.md makes it permanently egalitarian, so the + // roster's missing badges are a decision rather than a group waiting to + // be set up. With admins present the badges below say who they are, and + // a count of them adds nothing. + if (admins.isEmpty()) { + Text( + text = stringRes(R.string.cordn_info_egalitarian), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp, bottom = 4.dp), + ) + } + + members.forEach { member -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = member, size = 28.dp, accountViewModel = accountViewModel, nav = nav) + Text( + text = observeUserNameByHex(member, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (member in admins) { + Text( + text = stringRes(R.string.cordn_info_admin_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + // Never against yourself: cordn has no self-removal, and the + // manager refuses it, so offering the button would only be a + // way to be told no. + if (canAdminister && member != me) { + IconButton(onClick = { removing = member }, enabled = !busy) { + Icon( + symbol = MaterialSymbols.PersonRemove, + contentDescription = stringRes(R.string.cordn_info_remove_member), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + // Admins only: CordnGroupPolicy refuses an `add` from anyone else once + // a group names admins, in both directions, so offering this to + // everybody would build commits the rest of the group drops on + // receipt. + if (canAdminister) { + // Asked only once the field is in use. `kp_list` is unpaginated, + // so opening this screen must not download the coordinator's + // whole table on behalf of somebody who never types. + val searching = memberSearch.length > 2 + val reachable by + produceState?>(null, runtime, coordinatorPubKey, searching) { + if (searching) value = runtime?.identitiesWithKeyPackages(coordinatorPubKey) + } + CordnAddMember( + userSuggestions = userSuggestions, + search = memberSearch, + reachable = reachable, + onSearchChange = { + memberSearch = it + adminError = null + if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset() + }, + busy = busy, + accountViewModel = accountViewModel, + onInvite = { user -> + memberSearch = "" + userSuggestions.reset() + runAdmin(user.toBestDisplayName()) { it.invite(coordinatorPubKey, room.gid, user.pubkeyHex) } + }, + ) + } + + adminError?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), + ) + } + + if (renaming) { + EditGroupDetailsDialog( + name = name.orEmpty(), + description = description.orEmpty(), + admins = admins, + onDismiss = { renaming = false }, + onSave = { newName, newDescription -> + renaming = false + runAdmin { + // The whole metadata travels together, admin list + // included, because the extension is replaced whole. + it.updateGroupMetadata( + coordinatorPubKey, + room.gid, + CordnGroupMetadata(name = newName, description = newDescription, adminPubkeys = admins), + ) + } + }, + ) + } + + removing?.let { target -> + val removingName = observeUserNameByHex(target, accountViewModel) + + // A plain confirm rather than the shared quick-action dialog: that + // one offers "don't ask again", which for an irreversible removal + // would be a setting nobody should be nudged into. + AlertDialog( + onDismissRequest = { removing = null }, + title = { Text(stringRes(R.string.cordn_info_remove_confirm_title)) }, + text = { + Text(stringRes(R.string.cordn_info_remove_confirm_body, removingName)) + }, + confirmButton = { + TextButton(onClick = { + removing = null + runAdmin(removingName) { it.removeMember(coordinatorPubKey, room.gid, target) } + }) { + Text( + text = stringRes(R.string.cordn_info_remove_member), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { removing = null }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + ShareGroup(room, coordinatorPubKey, accountViewModel) + + // Only where they could be accepted. The manager already asks the + // coordinator only for groups this account administers, so a non-admin + // would otherwise see an empty list that reads as "nobody has asked" + // rather than "these are not yours to answer". + if (canAdminister) { + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + JoinRequests(room, coordinatorPubKey, accountViewModel, nav) + } + + if (showExposure) { + exposure?.let { + AlertDialog( + onDismissRequest = { showExposure = false }, + text = { CordnExposureCard(it) }, + confirmButton = { + TextButton(onClick = { showExposure = false }) { + Text(stringRes(R.string.cordn_exposure_close)) + } + }, + ) + } + } + + // The exact values, for filing a bug or telling two devices apart. + // The key stays here as well as above because the two answer + // different questions: the row above says who the coordinator is, + // this says which bytes to compare against a share ref, a device + // document or a log -- all of which speak hex. + TechnicalDetails(coordinatorPubKey, room.gid, epoch) + } + } +} + +/** + * Finding someone to add, the way the rest of the app finds people. + * + * [ShowUserSuggestionList] over [UserSuggestionState] -- the same pair the + * composers and the sibling group-info screen use -- rather than a field + * wanting 64 hex characters. A name, an npub and a NIP-05 address all work, + * because that state already resolves all three. + * + * ## The search finding somebody is not a promise + * + * cordn can only add a member by spending a KeyPackage they published **to + * this coordinator** (`spec/00.md`). [reachable] is who the coordinator says + * did, so the common disappointment is visible before the tap rather than + * after it, and `CordnGroupManager.invite` still says which of the two went + * wrong when one does -- "the coordinator holds no KeyPackage for ..." is a + * different problem from a name that matched nobody. + * + * ## Why the badge marks the yes and says nothing about the no + * + * [reachable] has three states, not two: a set, an empty set, and null for a + * lookup that never came back (`CordnRuntime.identitiesWithKeyPackages`). Only + * membership in it is something we were told; absence covers both "has not + * published here" and "we could not ask". Marking the yes therefore asserts + * exactly what we verified, and an unmarked row asserts nothing -- where a + * "cannot be added" marker would turn an unreachable coordinator into a claim + * about a person. Unmarked rows stay fully tappable for the same reason: the + * listing can be up to a minute stale, and the coordinator gets the last word. + */ +@Composable +private fun CordnAddMember( + userSuggestions: UserSuggestionState, + search: String, + reachable: Set?, + onSearchChange: (String) -> Unit, + busy: Boolean, + accountViewModel: AccountViewModel, + onInvite: (User) -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + OutlinedTextField( + value = search, + onValueChange = onSearchChange, + label = { Text(stringRes(R.string.cordn_info_add_member)) }, + placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) }, + singleLine = true, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringRes(R.string.cordn_info_add_member_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + + // Three characters, as everywhere else this list appears: fewer matches + // most of the address book and is never what someone meant. + if (!busy && search.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = onInvite, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + onEmpty = { + Text( + text = stringRes(R.string.cordn_info_add_member_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + }, + trailingContent = { user -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (reachable?.contains(user.pubkeyHex) == true) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = stringRes(R.string.cordn_info_has_key_package), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { onInvite(user) }) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.cordn_info_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) + } + } +} + +/** + * What to tell somebody when an admin action failed. + * + * `CordnGroupException` carries a [CordnGroupException.Reason] precisely so this + * can be a `when` rather than a search for substrings in English. [who] is the + * display name already on screen; the exception only knows the pubkey, and a + * person who just tapped a face should not be answered with 64 hex characters. + * + * Anything with no better wording falls back to the message, which is still + * better than silence — and the caller has logged the throwable in full either + * way. + */ +private fun adminFailureText( + context: Context, + e: Throwable, + who: String?, + fallback: String, +): String { + val name = who ?: stringRes(context, R.string.cordn_admin_someone) + return when (e) { + is CordnGroupException -> + when (e.reason) { + CordnGroupException.Reason.NO_KEY_PACKAGE -> stringRes(context, R.string.cordn_admin_no_key_package, name) + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER -> stringRes(context, R.string.cordn_admin_wrong_key_package, name) + CordnGroupException.Reason.NO_WELCOME -> stringRes(context, R.string.cordn_admin_no_welcome, name) + CordnGroupException.Reason.NOT_A_MEMBER -> stringRes(context, R.string.cordn_admin_not_a_member, name) + CordnGroupException.Reason.OTHER -> e.message ?: fallback + } + + // The UI hides Remove against yourself, so reaching this means the + // roster and the button disagreed rather than that anyone tried. + is IllegalArgumentException -> + if (e.message?.contains("self-removal") == true) { + stringRes(context, R.string.cordn_admin_no_self_remove) + } else { + e.message ?: fallback + } + + // A coordinator that does not answer is the single commonest failure + // here and says nothing useful in its own words. + is TimeoutCancellationException -> stringRes(context, R.string.cordn_admin_coordinator_silent) + + else -> e.message ?: fallback + } +} + +/** + * The three values that only matter when something is wrong. + * + * Collapsed by default and selectable when open: the reason to look at a + * 64-character coordinator key is to copy it somewhere else, never to read it. + */ +@Composable +private fun TechnicalDetails( + coordinatorPubKey: HexKey, + gid: String, + epoch: Long, +) { + var expanded by remember { mutableStateOf(false) } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + Row( + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.cordn_info_technical), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { + Column { + // Through the User, in the encodings the rest of the app uses for a + // person's key. A profile screen shows a short npub with a button that + // copies the full one, then does the same for the nprofile + // (DrawAdditionalInfo); nothing user-facing anywhere shows raw hex, + // and there was no reason for a coordinator to be the exception. The + // nprofile earns its place here more than on a profile, because it + // carries the relay hints that are how a coordinator is reached at all. + val coordinator = remember(coordinatorPubKey) { LocalCache.getOrCreateUser(coordinatorPubKey) } + + CopyableKeyRow( + label = stringRes(R.string.cordn_info_coordinator_key), + shown = coordinator.pubkeyDisplayHex(), + copied = coordinator.pubkeyNpub(), + copyDescription = stringRes(Res.string.copy_npub_to_clipboard), + ) + CopyableKeyRow( + label = stringRes(R.string.cordn_info_coordinator_nprofile), + shown = coordinator.toNProfile().toShortDisplay(6), + copied = coordinator.toNProfile(), + copyDescription = stringRes(R.string.cordn_info_copy_nprofile), + ) + + SelectionContainer { + Column { + InfoRow(stringRes(R.string.cordn_info_gid), gid) + InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString()) + } + } + } + } +} + +/** + * A key, short enough to read, with a button that copies the whole thing. + * + * The shape a profile uses for the same job: nobody reads a bech32 string off a + * screen, they copy it, so the visible half is there to confirm which key it is + * and the button is there to do the actual work. + */ +@Composable +internal fun CopyableKeyRow( + label: String, + shown: String, + copied: String, + copyDescription: String, +) { + val clipboard = LocalClipboardManager.current + + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = shown, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + IconButton( + onClick = { clipboard.setText(AnnotatedString(copied)) }, + modifier = Modifier.size(24.dp).padding(start = 4.dp), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = copyDescription, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, +) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +/** + * Who is asking to get in, and the two buttons that answer them. + * + * ## Admins only, because the commit would be refused anyway + * + * `CordnGroupPolicy.authorizeCommit` rejects an `add` from a non-admin once a + * group carries `admin_pubkeys`, in both directions — so a non-admin pressing + * accept would build a commit the other members drop on receipt. The manager + * matches it: `pendingJoinRequests` only asks the coordinator about groups + * this account administers. An empty admin set is egalitarian, which makes + * every member an admin and shows this to everyone. + * + * ## Fetched on a tap, never polled + * + * Same reason as the invitations screen: every call to a coordinator is + * metadata (`spec/00.md` §8), so a live count of pending requests would be a + * steady heartbeat telling the coordinator this group is open on someone's + * screen. + */ +@Composable +private fun JoinRequests( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime ?: return + val scope = rememberCoroutineScope() + val failed = stringRes(R.string.cordn_requests_failed) + + var requests by remember { mutableStateOf?>(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + suspend fun reload() { + busy = true + error = null + try { + requests = runtime.joinRequests(coordinatorPubKey, room.gid) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + + Text(stringRes(R.string.cordn_requests_title), style = MaterialTheme.typography.titleMedium) + Text( + text = stringRes(R.string.cordn_requests_admin_only), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton(onClick = { scope.launch { reload() } }, enabled = !busy) { + Text(stringRes(R.string.cordn_requests_check)) + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + val loaded = requests + if (loaded != null && !busy) { + if (loaded.isEmpty()) { + Text( + text = stringRes(R.string.cordn_requests_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + loaded.forEach { request -> + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = request.pubKey, size = 28.dp, accountViewModel = accountViewModel, nav = nav) + Text( + // Admitting someone to an encrypted group off sixteen hex + // characters is not a decision anyone can actually make. + text = observeUserNameByHex(request.pubKey, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Button(onClick = { + scope.launch { + try { + runtime.acceptJoinRequest(coordinatorPubKey, request) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }) { + Text(stringRes(R.string.cordn_requests_accept)) + } + TextButton(onClick = { + scope.launch { + try { + runtime.declineJoinRequest(coordinatorPubKey, request) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }) { + Text(stringRes(R.string.cordn_requests_decline)) + } + } + } + } +} + +/** + * The group's `cordn1…` ref, as text and as a QR. + * + * The ref is built from the live MLS group (`CordnGroupManager.shareRef`), so + * it carries the `gid` and the coordinator that actually serves it rather than + * whatever this screen was navigated with. It is public by design — `spec/02.md` + * gives it no secret — and holding one makes nobody a member: the most it does + * is let someone ask, which is what the line under it says. + * + * A QR because a ref is a long bech32 string nobody wants to read aloud, and + * because the person you are handing a group to is usually in the room. + */ +@Composable +private fun ShareGroup( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, +) { + val runtime = accountViewModel.account.cordnRuntime ?: return + val ref = + remember(room.gid, coordinatorPubKey) { + runtime + .sessionOrNull(coordinatorPubKey) + ?.manager + ?.runCatching { shareRef(room.gid).encode() } + ?.getOrNull() + } ?: return + + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + + Text(stringRes(R.string.cordn_share_title), style = MaterialTheme.typography.titleMedium) + Text( + text = stringRes(R.string.cordn_share_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // The bech32 itself is not shown. It is three lines of characters nobody + // reads, and both things a person actually does with it are already here: + // point a camera at the QR, or press Copy link. + QrCodeDrawer(ref, Modifier.padding(top = 8.dp).size(220.dp)) + + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString(ref)) + copied = true + }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(stringRes(if (copied) R.string.cordn_share_copied else R.string.cordn_share_copy)) + } +} + +/** + * Renames a group, or rewrites its description. + * + * [admins] rides through untouched. A GroupContextExtensions proposal replaces + * the whole `cordn_group_metadata` extension, so a save that dropped the admin + * list would quietly turn an administered group egalitarian — which nobody can + * undo from inside an egalitarian group's own rules. + */ +@Composable +private fun EditGroupDetailsDialog( + name: String, + description: String, + admins: List, + onDismiss: () -> Unit, + onSave: (String, String) -> Unit, +) { + var draftName by remember { mutableStateOf(name) } + var draftDescription by remember { mutableStateOf(description) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.cordn_info_edit_details)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draftName, + onValueChange = { draftName = it }, + label = { Text(stringRes(R.string.cordn_create_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = draftDescription, + onValueChange = { draftDescription = it }, + label = { Text(stringRes(R.string.cordn_create_description)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + if (admins.isNotEmpty()) { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_info_admins_kept, admins.size, admins.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onSave(draftName.trim(), draftDescription.trim()) }, + enabled = draftName.isNotBlank(), + ) { + Text(stringRes(R.string.cordn_info_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt new file mode 100644 index 0000000000..e7a5defb33 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.ui.CoordinatorHealthRow +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Every cordn group this account is in, grouped by the coordinator serving it. + * + * ## Why it groups rather than lists + * + * A flat list here would be the Messages inbox with everything else removed, + * which is not worth a screen. What the inbox structurally cannot show is the + * thing that decides whether a cordn group works at all: a cordn message never + * arrives over a relay, it arrives because one coordinator answered a call, so + * when a group goes quiet the question is always "is that server up" and never + * "which relay". Sectioning by coordinator puts the answer above the groups it + * explains. + * + * ## The health is free + * + * `CordnRuntime.health` is a local StateFlow of what the calls the sync loop + * was making anyway observed -- it never polls, because a poll is a call and + * every call to a coordinator is metadata (`spec/00.md` §8). So this screen + * costs no network at all, and "unknown" means nothing has been asked of that + * coordinator yet, which is shown differently from "down". + * + * Key-package health is deliberately NOT here even though an empty pool means + * nobody can invite you: the only truthful source is `kp_list`, which is + * unpaginated, and fetching it per coordinator to decorate a list would be the + * cost this branch has otherwise been careful to avoid. It stays one tap away + * on the coordinators screen. + */ +@Composable +fun CordnGroupListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(Res.string.cordn_groups_title), nav) { + IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = stringRes(R.string.cordn_groups_manage), + modifier = Modifier.size(22.dp), + ) + } + IconButton(onClick = { nav.nav(Route.CordnCreateGroup) }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.cordn_groups_start), + modifier = Modifier.size(24.dp), + ) + } + } + }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + // `revision` rather than `all`: a room's newest message changes INSIDE + // the room object, which `all` cannot see, so ordering off `all` alone + // would freeze after each group's first message. See + // CordnGroupList.revision. + val revision by runtime.groups.revision.collectAsStateWithLifecycle() + + // Coordinators ordered by their liveliest group, groups ordered within + // them the same way: the section that just received something rises, + // which is the order somebody opening this screen is looking for. + val sections = + remember(revision) { + runtime.groups.all.value + .groupBy { it.coordinatorPubKey } + .map { (coordinator, rooms) -> coordinator to rooms.sortedByDescending { it.newestAt() } } + .sortedByDescending { (_, rooms) -> rooms.firstOrNull()?.newestAt() ?: 0L } + } + + if (sections.isEmpty()) { + EmptyState( + title = stringRes(R.string.cordn_groups_none), + description = stringRes(R.string.cordn_groups_none_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + state = rememberLazyListState(), + ) { + sections.forEach { (coordinator, rooms) -> + item(key = "head-$coordinator") { + CoordinatorSection( + coordinatorPubKey = coordinator, + runtime = runtime, + groupCount = rooms.size, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + itemsIndexed(rooms, key = { _, room -> coordinator + room.gid }) { index, room -> + CoordinatorGroupRow( + room = room, + coordinatorPubKey = coordinator, + runtime = runtime, + isLast = index == rooms.lastIndex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } +} + +/** When this room last heard anything, for ordering. */ +private fun CordnGroupChatroom.newestAt(): Long = newest.value?.envelope?.createdAt ?: 0L + +/** + * One coordinator, above the groups it serves. + * + * Tinted by its own health rather than decorated uniformly: a coordinator that + * has stopped answering is the reason every group under it looks idle, and + * saying so here is the whole argument for this screen existing. + */ +@Composable +private fun CoordinatorSection( + coordinatorPubKey: HexKey, + runtime: CordnRuntime, + groupCount: Int, + accountViewModel: AccountViewModel, + nav: INav, +) { + val health = healthOf(runtime, coordinatorPubKey) + + Column( + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CoordinatorIdentityRow( + pubKey = coordinatorPubKey, + label = runtime.sessionOrNull(coordinatorPubKey)?.config?.label, + accountViewModel = accountViewModel, + nav = nav, + size = 26.dp, + ) { name -> + Column(Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + color = + if (health.isDown) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_groups_count, groupCount, groupCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + CoordinatorHealthRow(health, Modifier.padding(start = 34.dp)) + + // Said once per coordinator rather than once per group: the groups are + // not each broken, the one server they share is. + if (health.isDown) { + Row( + Modifier.fillMaxWidth().padding(start = 34.dp, top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = stringRes(R.string.cordn_groups_down_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** + * One group row, dimmed while its coordinator is down. + * + * Dimmed and still tappable: the history already on this device reads fine + * offline, and blocking the tap would hide what the user came for. The dimming + * is there so a row that cannot be receiving does not look like one that is. + */ +@Composable +private fun CoordinatorGroupRow( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + runtime: CordnRuntime, + isLast: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val health = healthOf(runtime, coordinatorPubKey) + + Column(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().alpha(if (health.isDown) DIMMED else 1f)) { + // The inbox's own row, not a second rendering of it: it already + // reads the live name, preview, annotations and unread count off + // the room, and two of those drifting apart would be a bug nobody + // would look for in a list screen. + CordnGroupRoomCompose(room, accountViewModel, nav) + } + // Between rows only. A divider after the last one leaves a rule hanging + // under the section, where the next section's own spacing already + // separates them. + if (!isLast) HorizontalDivider(thickness = DividerThickness) + } +} + +/** + * This coordinator's health, or a blank state when there is no session. + * + * Blank rather than absent so the caller can render unconditionally: a + * coordinator with no open session has told us nothing, which is exactly what + * [CoordinatorHealth.State.isUnknown] means. + * + * The null is resolved into a flow BEFORE collecting, never by skipping the + * collect. `collectAsStateWithLifecycle` behind a `?.` is a conditional + * @Composable call, and this is a branch that flips at runtime -- a session + * opening mid-screen would change the shape of the slot table under Compose. + * Keyed on the coordinator list so it re-resolves when a session does open. + */ +@Composable +private fun healthOf( + runtime: CordnRuntime, + coordinatorPubKey: HexKey, +): CoordinatorHealth.State { + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + val flow = + remember(runtime, coordinatorPubKey, coordinators) { + runtime.health(coordinatorPubKey) ?: MutableStateFlow(CoordinatorHealth.State()) + } + return flow.collectAsStateWithLifecycle().value +} + +/** How much a group under a coordinator that stopped answering fades. */ +private const val DIMMED = 0.55f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt new file mode 100644 index 0000000000..147527e9ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.model.cordn.CordnInvitation +import com.vitorpamplona.amethyst.model.cordn.CordnInvitations +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** + * Invitations to cordn groups, waiting to be answered. + * + * ## Why this is a screen you open, not a badge that appears + * + * Finding out whether anyone has invited you means asking each coordinator, + * and every call to a coordinator is metadata (`spec/00.md` §8). A live badge + * would tell each of them how often this account opens the app, forever, to + * save a tap. So the fetch happens when someone comes here, and the screen + * says what it is showing and when it looked. + * + * ## What an invitation can honestly say + * + * The group's name, who is already in it, and which coordinator it came + * through — all read out of the Welcome. Not "X invited you": a Welcome + * carries the ratchet tree, not the identity of whoever signed the Commit + * that produced it, so naming an inviter would be a guess presented as a + * fact. + * + * Declining is permanent, and the screen says so rather than discovering it + * afterwards. A retired Welcome is gone from the coordinator and the + * KeyPackage it was addressed to has been spent; getting back in means being + * invited again. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnInvitationsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val scope = rememberCoroutineScope() + + var invitations by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val loadFailed = stringRes(R.string.cordn_invitations_load_failed) + + suspend fun reload() { + busy = true + error = null + try { + invitations = runtime?.invitations() + } catch (e: Exception) { + error = e.message ?: loadFailed + } finally { + busy = false + } + } + + LaunchedEffect(runtime) { reload() } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) + } + }, + title = { Text(stringRes(R.string.cordn_invitations_title)) }, + actions = { + IconButton(onClick = { scope.launch { reload() } }, enabled = !busy) { + Icon(MaterialSymbols.Refresh, contentDescription = stringRes(R.string.cordn_invitations_refresh)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (runtime == null) { + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + ) + return@Column + } + + Text( + text = stringRes(R.string.cordn_invitations_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (busy) CircularProgressIndicator() + + error?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + } + + val loaded = invitations + if (loaded != null && !busy) { + loaded.pending.forEach { invitation -> + InvitationCard( + invitation = invitation, + accountViewModel = accountViewModel, + nav = nav, + onAccept = { + scope.launch { + try { + val gid = runtime.accept(invitation) + nav.nav(Route.CordnGroupChat(invitation.coordinator.pubKey, gid)) + } catch (e: Exception) { + error = e.message ?: loadFailed + reload() + } + } + }, + onDecline = { + scope.launch { + try { + runtime.decline(invitation) + } catch (e: Exception) { + error = e.message ?: loadFailed + } + reload() + } + }, + ) + } + + // A coordinator that did not answer is its own row. Folding it + // into "nothing waiting" would tell someone they have no + // invitations when the truth is that nobody asked. + loaded.unreachable.forEach { + NoticeCard( + title = + stringRes( + R.string.cordn_invitations_unreachable, + coordinatorDisplayName(it.coordinator.pubKey, it.coordinator.label, accountViewModel), + ), + detail = it.reason, + isError = true, + ) + } + + // Left on the coordinator for another device of this account. + // Shown rather than hidden, because otherwise the invitation a + // friend swears they sent simply does not exist here. + loaded.skipped.forEach { + NoticeCard( + title = stringRes(R.string.cordn_invitations_skipped), + detail = it.reason, + isError = false, + ) + } + + if (loaded.isEmpty) { + Text( + text = + if (runtime.coordinators.value.isEmpty()) { + stringRes(R.string.cordn_invitations_no_coordinators) + } else { + stringRes(R.string.cordn_invitations_none) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** Faces on the invitation card before it folds into "+N". */ +private const val MEMBER_FACES = 5 + +/** How many of them are also named in full underneath. */ +private const val MEMBER_NAMES = 3 + +@Composable +private fun InvitationCard( + invitation: CordnInvitation, + accountViewModel: AccountViewModel, + nav: INav, + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + val welcome = invitation.welcome + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = welcome.metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, welcome.gid.take(8)), + style = MaterialTheme.typography.titleMedium, + ) + welcome.metadata?.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + + // "Already in it", never "invited you" — see the screen KDoc. + Text( + text = pluralStringResource(R.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Who, not just how many. This is the moment someone decides + // whether to join an encrypted group, and the roster is already in + // the Welcome — printing only its size withheld the one fact the + // decision actually turns on. + if (welcome.members.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + welcome.members.take(MEMBER_FACES).forEach { member -> + UserPicture( + userHex = member, + size = 28.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (welcome.members.size > MEMBER_FACES) { + Text( + text = stringRes(R.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + // Resolved through `map`, which is inline and so keeps the + // composable context; joinToString's transform is not. + val names = welcome.members.take(MEMBER_NAMES).map { observeUserNameByHex(it, accountViewModel) } + Text( + // Named in full for the first few, so a decision does not + // rest on recognising an avatar. + text = names.joinToString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Accepting is a decision about the coordinator as much as about + // the group -- it is the party that will hold the membership and + // serve the messages -- so it is named and pictured rather than + // abbreviated to a key. + CoordinatorIdentityRow( + pubKey = invitation.coordinator.pubKey, + label = invitation.coordinator.label, + accountViewModel = accountViewModel, + nav = nav, + size = 20.dp, + ) { name -> + Text( + text = stringRes(R.string.cordn_invitations_via, name), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = onAccept) { Text(stringRes(R.string.cordn_invitations_accept)) } + TextButton(onClick = onDecline) { Text(stringRes(R.string.cordn_invitations_decline)) } + } + Text( + text = stringRes(R.string.cordn_invitations_decline_warning), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun NoticeCard( + title: String, + detail: String, + isError: Boolean, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (isError) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column(Modifier.padding(12.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(detail, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt new file mode 100644 index 0000000000..189fca42a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -0,0 +1,1034 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor +import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout +import com.vitorpamplona.amethyst.commons.cordn.CordnMentions +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.copied_to_clipboard +import com.vitorpamplona.amethyst.commons.resources.copy_text +import com.vitorpamplona.amethyst.commons.resources.quick_action_share +import com.vitorpamplona.amethyst.commons.resources.today +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox +import com.vitorpamplona.amethyst.commons.ui.components.util.setText +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP +import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp +import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.commons.ui.theme.isLight +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ActionTile +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatChipFlowRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.MoreActionsToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChipView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.SectionDivider +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.TileRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.authorNameColorFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.CHAT_GROUP_WINDOW_SECONDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.abs + +/** + * A cordn message, drawn as an Amethyst chat bubble. + * + * Everything structural here is the app's shared chat furniture — [ChatBubbleLayout] + * for the bubble, its grouping shapes and its whole gesture vocabulary (long-press for + * the action sheet, double-tap to react, swipe toward the centre to reply), + * [ReactionChipView] for the engagement strip, [ActionTile] for the sheet. A cordn room + * used to draw its own flat rows, which meant the one chat in the app where a tap + * opened a menu and nothing could be swiped. + * + * What cordn cannot share is the *content* of those slots. The shared fillings + * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatReactionChips], + * `ChatMessageActionSheet`, `ChatMessageFooter`, `DrawAuthorInfo`) all take a `Note`, + * and a `Note` comes from `LocalCache` — which nothing in a cordn room may enter, see + * the screen's KDoc. So the slots are filled from the envelope and the annotation fold + * instead, and the layout above them is the same one every other chat uses. + */ +@Composable +internal fun CordnMessageRow( + message: CordnDeliveredMessage, + room: CordnGroupChatroom, + annotations: CordnAnnotationIndex, + me: HexKey, + groupPosition: ChatGroupPosition, + text: String?, + isEdited: Boolean, + shouldHighlight: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + onHighlightFinished: () -> Unit, + onScrollToMessage: (HexKey) -> Unit, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onTogglePin: () -> Unit, + onReact: (String) -> Unit, +) { + val isMine = message.envelope.pubKey == me + val isPinned = annotations.isPinned(message.envelope.id) + val reactions = annotations.reactions[message.envelope.id].orEmpty() + + // A deleted message keeps its place in the conversation but stops accepting + // annotations: replying to, editing or reacting to a withdrawal is meaningless, + // and the manager would refuse most of it anyway. + val isLive = text != null + + // A mention is the one reason to pick a message out of a wall of them. Read from + // the content rather than from a `p` tag: a tag is a claim the sender makes about + // who they addressed, while the text is what everyone in the room actually sees. + val mentionsMe = remember(text, me) { text != null && me in CordnMentions.mentioned(text) } + val jumboCount = remember(text) { if (text != null) jumboEmojiCount(text) else 0 } + + val mentionTint = MaterialTheme.colorScheme.primary.copy(alpha = MENTION_TINT_ALPHA) + + Box(if (mentionsMe) Modifier.fillMaxWidth().background(mentionTint) else Modifier.fillMaxWidth()) { + ChatBubbleLayout( + isLoggedInUser = isMine, + isDraft = false, + innerQuote = false, + // Your own bubbles are right-aligned and tinted, so naming yourself over + // every burst of them is noise. Everyone else is named once per burst. + drawAuthorInfo = groupPosition.isFirstOfGroup && !isMine, + groupPosition = groupPosition, + transparentBubble = jumboCount > 0, + shouldHighlight = shouldHighlight, + onHighlightFinished = onHighlightFinished, + // A plain tap is a no-op, exactly as in every other Amethyst chat. + onClick = { false }, + // The account's own first choice, as `reactToOrDelete` uses in every other + // chat — a hardcoded thumb ignored the palette the user configured, and + // disagreed with cordn's own action sheet, which already reads it. + onDoubleTap = + if (isLive) { + { onReact(accountViewModel.reactionChoices().firstOrNull() ?: DEFAULT_REACTION) } + } else { + null + }, + onSwipeReply = + if (isLive) { + onReply + } else { + null + }, + onAuthorClick = { nav.nav(Route.Profile(message.envelope.pubKey)) }, + actionMenu = { onDismiss -> + CordnMessageActionSheet( + body = text.orEmpty(), + isMine = isMine, + isPinned = isPinned, + isLive = isLive, + accountViewModel = accountViewModel, + onDismiss = onDismiss, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete, + onTogglePin = onTogglePin, + onReact = onReact, + ) + }, + reactionsRow = + if (reactions.isEmpty()) { + null + } else { + { CordnReactionChips(reactions, me, accountViewModel, nav, onReact) } + }, + // Mirrors chatFooterHasMeta: the footer earns its row on the last message + // of a burst (for the time) or on any message carrying a marker of its own. + footerRow = + if (groupPosition.isLastOfGroup || isEdited || isPinned) { + { + CordnMessageFooter( + message = message, + isMine = isMine, + coordinatorPubKey = room.coordinatorPubKey, + accountViewModel = accountViewModel, + isEdited = isEdited, + isPinned = isPinned, + showTime = groupPosition.isLastOfGroup, + ) + } + } else { + null + }, + drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) }, + ) { bgColor -> + CordnBubbleContents( + message = message, + room = room, + annotations = annotations, + me = me, + text = text, + jumboCount = jumboCount, + bubbleColor = bgColor, + accountViewModel = accountViewModel, + nav = nav, + onScrollToMessage = onScrollToMessage, + ) + } + } +} + +/** What a double-tap sends, and the fallback when the account lists no reaction choices. */ +private const val DEFAULT_REACTION = "👍" + +/** How strongly a message that mentions you tints its row. */ +private const val MENTION_TINT_ALPHA = 0.10f + +@Composable +private fun CordnBubbleContents( + message: CordnDeliveredMessage, + room: CordnGroupChatroom, + annotations: CordnAnnotationIndex, + me: HexKey, + text: String?, + jumboCount: Int, + bubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, + onScrollToMessage: (HexKey) -> Unit, +) { + val thread = remember(message.envelope.id) { CordnMessageReferences.thread(message.envelope.tags) } + val parent = thread?.let { annotations.byId[it.parentId] } + if (parent != null) { + CordnQuotedMessage( + parent = parent, + annotations = annotations, + me = me, + accountViewModel = accountViewModel, + nav = nav, + parentBackgroundColor = bubbleColor, + onClick = { onScrollToMessage(parent.envelope.id) }, + ) + } + + when { + text == null -> + Text( + text = stringRes(R.string.cordn_message_deleted), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Emoji-only messages render bare and large, over a transparent bubble. + jumboCount > 0 -> Text(text = text.trim(), fontSize = jumboEmojiFontSize(jumboCount)) + + // The app's own message renderer, which is what every other chat's bubble + // reaches through RenderRegularTextNote. Cordn hand-rolled plain Text plus a + // row of mention spans, so a link, an image URL, a hashtag, a `nostr:` entity + // and — since the composer started inserting them — a custom emoji's URL all + // arrived as raw text. Nothing here needed a Note: the viewer takes a String, + // the bubble's colour and an id. + // + // `tags` is EmptyTagList deliberately. A cordn envelope's tags are cordn's own + // (§4 media, references), not NIP-92/NIP-30, so handing them over would ask the + // viewer to read them as something they are not. + // + // canPreview = true is what an unblocked note gets in a DM, which is equally + // end-to-end encrypted. It does not force previews: whether one is fetched is + // still `automaticallyShowUrlPreview`, the account's own Tor-aware setting. + else -> + TranslatableRichTextViewer( + content = text, + canPreview = true, + quotesLeft = 1, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = bubbleColor, + id = message.envelope.id, + authorPubKey = message.envelope.pubKey, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Only on a live message: a deleted one must not keep offering its attachment, + // and the blob is still on the host either way. + if (text != null) { + CordnMediaTag.parseAll(message.envelope.tags).forEach { attachment -> + CordnAttachment(attachment, room, accountViewModel) + } + } +} + +/** + * Name and face on the first bubble of a burst, in the shared chat author layout — so a + * cordn sender is drawn exactly like a DM sender, colour included. [authorNameColorFor] + * derives a stable hue from the pubkey, which is what makes authors scannable in a + * fast-moving room. + * + * [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a + * sender up for a display name is safe: a profile is public relay data the cache already + * holds, and reading one puts no part of this conversation into it. + */ +@Composable +private fun CordnAuthorLine( + pubKey: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val name = observeUserNameByHex(pubKey, accountViewModel) + val isLightTheme = MaterialTheme.colorScheme.isLight + val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) } + + UserDisplayNameLayout( + picture = { + UserPicture( + userHex = pubKey, + size = Size20dp, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + name = { + Text( + text = name, + color = nameColor, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) +} + +/** + * The engagement strip riding the bubble's bottom border. + * + * A reaction is a set of pubkeys per emoji in the fold, so the count is the set size — a + * member who reacted twice with the same emoji counts once, which is what the index + * already guarantees and what a naive message count would get wrong on a re-sync. + */ +@Composable +private fun CordnReactionChips( + reactions: Map>, + me: HexKey, + accountViewModel: AccountViewModel, + nav: INav, + onReact: (String) -> Unit, +) { + val chips = + remember(reactions, me) { + reactions + .map { (emoji, who) -> ReactionChip(emoji, who.size, me in who) } + .sortedByDescending { it.count } + } + + var showWho by remember { mutableStateOf(false) } + + if (showWho) { + CordnReactionDetailSheet( + reactions = reactions, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showWho = false }, + ) + } + + ChatChipFlowRow { + chips.forEach { chip -> + ReactionChipView( + chip = chip, + onClick = { onReact(chip.type) }, + // Long press opens who reacted, as the DM strip does. + onLongClick = { showWho = true }, + ) + } + } +} + +/** + * Who reacted, and with what. + * + * The fold keys reactions by emoji to a *set* of senders, so this is the whole truth + * the room holds about them — there is no separate receipt to open, and no count that + * could disagree with the list under it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnReactionDetailSheet( + reactions: Map>, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + // Most-reacted first, matching the order of the chips that opened this. + val groups = remember(reactions) { reactions.entries.sortedByDescending { it.value.size } } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + Text( + text = stringRes(R.string.cordn_reactions_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + + groups.forEach { (emoji, who) -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = emoji, style = MaterialTheme.typography.titleMedium) + Text( + text = who.size.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + who.forEach { pubKey -> + Row( + Modifier.fillMaxWidth().padding(start = 36.dp, end = 20.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture( + userHex = pubKey, + size = Size20dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Text( + text = observeUserNameByHex(pubKey, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** + * The message a reply is answering, drawn above it as a nested bubble. + * + * The same [ChatBubbleLayout] in `innerQuote` mode that every other chat uses for a + * quote, so what a reply answers reads identically in a cordn room and a DM: the + * quoted author's own bubble shape and tint, their name in their colour, and a tap + * that takes you to the message. Cordn drew a bespoke accent-bar card here, which was + * the one place in the app where a quote did not look like a quote. + * + * [parentBackgroundColor] is the enclosing bubble's fill. The layout composites the + * nested bubble over it, which is what keeps a quote legible inside both a sent and a + * received bubble instead of being tinted against the screen background. + */ +@Composable +internal fun CordnQuotedMessage( + parent: CordnDeliveredMessage, + annotations: CordnAnnotationIndex, + me: HexKey, + accountViewModel: AccountViewModel, + nav: INav, + parentBackgroundColor: MutableState? = null, + onClick: (() -> Unit)? = null, +) { + val isMine = parent.envelope.pubKey == me + + val body = + if (annotations.isDeleted(parent.envelope.id)) { + stringRes(R.string.cordn_message_deleted) + } else { + annotations.contentOf(parent.envelope.id).orEmpty() + } + + ChatBubbleLayout( + isLoggedInUser = isMine, + isDraft = false, + innerQuote = true, + // Same rule as the bubbles: your own quoted message needs no name on it. + drawAuthorInfo = !isMine, + parentBackgroundColor = parentBackgroundColor, + // Returning true marks the tap as handled, which is how the shared layout + // distinguishes a quote (goes somewhere) from a bubble (a tap does nothing). + onClick = { + onClick?.invoke() + onClick != null + }, + onAuthorClick = { nav.nav(Route.Profile(parent.envelope.pubKey)) }, + // The shared layout does not gate long-press on innerQuote, so a quote that + // offered no menu would swallow the gesture. Only what makes sense on a + // preview: where it came from, and its text. + actionMenu = { onDismiss -> + CordnQuoteActionSheet( + body = body, + onDismiss = onDismiss, + onGoToMessage = onClick, + ) + }, + drawAuthorLine = { CordnAuthorLine(parent.envelope.pubKey, accountViewModel, nav) }, + ) { _ -> + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Long-press on a quote: go to what it quotes, or take its text. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnQuoteActionSheet( + body: String, + onDismiss: () -> Unit, + onGoToMessage: (() -> Unit)?, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(bottom = 24.dp)) { + TileRow { + if (onGoToMessage != null) { + ActionTile(MaterialSymbols.ArrowUpward, stringRes(R.string.cordn_action_go_to_message)) { + onGoToMessage() + onDismiss() + } + } + CopyTextTile(body, onDismiss) + } + } + } +} + +/** + * The line where the messages you have already read end. + * + * Drawn from a cursor snapshotted when the room opened: `markRead()` runs on open, so a + * divider read from the live cursor would vanish the moment it became useful. + */ +@Composable +internal fun UnreadDivider() { + ChatDivisor(stringRes(R.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary) +} + +/** + * The bubble's bottom-corner footer: the markers this message carries, then the time on + * the last bubble of a burst. Until this existed a cordn room showed no per-message + * time at all — only the day separator — so nothing said when anything was said. + */ +@Composable +private fun CordnMessageFooter( + message: CordnDeliveredMessage, + isMine: Boolean, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + isEdited: Boolean, + isPinned: Boolean, + showTime: Boolean, +) { + var showDetails by remember(message.envelope.id) { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isPinned) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.cordn_action_pin), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(StdHorzSpacer) + } + + if (isEdited) { + Text( + text = stringRes(R.string.cordn_message_edited), + fontSize = Font12SP, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + Spacer(StdHorzSpacer) + } + + if (showTime) { + ClickableBox(onClick = { showDetails = true }) { + Row(verticalAlignment = Alignment.CenterVertically) { + ToggleableTimeAgoText( + timestamp = message.envelope.createdAt, + style = TimeAgoStyle.Short, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, + // The tap belongs to the details sheet, as it does in the shared + // footer. Left toggleable, the same tap flipped relative/absolute + // here and opened delivery detail everywhere else; the absolute + // time is in the sheet instead. + toggleable = false, + ) + + // Anything of yours that is in this list was accepted by the + // coordinator: `post` returns the cursor it was filed under, and + // nothing enters the room until it does. So this says what the + // shared ticks say, for the one hop cordn has. + if (isMine) { + Spacer(StdHorzSpacer) + Icon( + symbol = MaterialSymbols.Done, + contentDescription = stringRes(R.string.cordn_delivery_accepted), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + } + } + } + } + } + + if (showDetails) { + CordnMessageDetailsSheet( + message = message, + isMine = isMine, + coordinatorPubKey = coordinatorPubKey, + accountViewModel = accountViewModel, + onDismiss = { showDetails = false }, + ) + } +} + +/** + * Where a message came from and when, behind a tap on its timestamp. + * + * The shared footer puts relay acceptance here. Cordn has one hop instead of a relay + * set — the coordinator that filed it — and the cursor it was filed under, which is the + * only ordering the room has and the thing to quote when a message looks out of place. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnMessageDetailsSheet( + message: CordnDeliveredMessage, + isMine: Boolean, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val sentAt = + remember(message.envelope.createdAt) { + ZonedDateTime + .ofInstant(Instant.ofEpochSecond(message.envelope.createdAt), ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringRes(R.string.cordn_message_details_title), + style = MaterialTheme.typography.titleSmall, + ) + DetailLine(stringRes(R.string.cordn_message_details_sent_at), sentAt) + DetailLine(stringRes(R.string.cordn_message_details_cursor), message.cursor.toString()) + DetailLine( + stringRes(R.string.cordn_message_details_coordinator), + coordinatorDisplayName(coordinatorPubKey, label = null, accountViewModel = accountViewModel), + ) + if (isMine) { + Text( + text = stringRes(R.string.cordn_delivery_accepted), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.allGoodColor, + ) + } + } + } +} + +@Composable +private fun DetailLine( + label: String, + value: String, +) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** Copies [text] and says so, the way every other copy affordance in the app does. */ +@Composable +private fun CopyTextTile( + text: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val copied = stringRes(Res.string.copied_to_clipboard) + + ActionTile(MaterialSymbols.ContentCopy, stringRes(Res.string.copy_text)) { + scope.launch { + clipboard.setText(text) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + } + onDismiss() + } +} + +/** + * The long-press surface, built from the same [ActionTile]s as the DM sheet. + * + * It opens with the account's own reaction palette, because the strip below a bubble now + * appears only once a message *has* a reaction — without the palette, double-tap would + * be the only way to leave the first one, and an undiscoverable gesture is not an + * affordance. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnMessageActionSheet( + body: String, + isMine: Boolean, + isPinned: Boolean, + isLive: Boolean, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onTogglePin: () -> Unit, + onReact: (String) -> Unit, +) { + var showAllActions by remember { mutableStateOf(false) } + var confirmingDelete by remember { mutableStateOf(false) } + + // Same gate the shared sheet uses, and the same setting, so someone who has + // already said "don't ask again" is not asked again here either. + val performDelete = { + onDelete() + onDismiss() + } + + if (confirmingDelete) { + QuickActionAlertDialog( + title = stringRes(R.string.cordn_delete_confirm_title), + textContent = stringRes(R.string.cordn_delete_confirm_body), + buttonIcon = MaterialSymbols.Delete, + buttonText = stringRes(R.string.cordn_action_delete), + onClickDoOnce = performDelete, + onClickDontShowAgain = { + accountViewModel.account.settings.setHideDeleteRequestDialog() + performDelete() + }, + onDismiss = { confirmingDelete = false }, + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isLive) { + val choices by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle() + val palette = choices.ifEmpty { listOf(DEFAULT_REACTION) } + + // Never drawn as "mine": cordn has no un-react, so this row adds a + // reaction rather than toggling one, and highlighting a choice you + // already used would promise a second tap that takes it back. + ChatChipFlowRow { + palette.forEach { emoji -> + ReactionChipView( + chip = ReactionChip(emoji, 1, false), + onClick = { + onReact(emoji) + onDismiss() + }, + onLongClick = {}, + ) + } + } + + SectionDivider() + } + + TileRow { + if (isLive) { + ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.cordn_action_reply)) { + onReply() + onDismiss() + } + } + + // Pinning is any member's (spec/01.md §5.1), so it is offered on every + // message rather than only on your own. + ActionTile( + MaterialSymbols.PushPin, + stringRes(if (isPinned) R.string.cordn_action_unpin else R.string.cordn_action_pin), + ) { + onTogglePin() + onDismiss() + } + + // Edit and delete are author-only, and the manager refuses them for + // anyone else. Hiding them here is the same rule, stated where it stops + // being a surprise. + if (isMine && isLive) { + ActionTile(MaterialSymbols.Edit, stringRes(R.string.cordn_action_edit)) { + onEdit() + onDismiss() + } + ActionTile(MaterialSymbols.Delete, stringRes(R.string.cordn_action_delete), isDestructive = true) { + // A withdrawal cannot be taken back and the coordinator keeps + // the ciphertext either way, so it is worth one question — + // unless the account has already opted out of being asked. + if (accountViewModel.account.settings.hideDeleteRequestDialog) { + performDelete() + } else { + confirmingDelete = true + } + } + } + } + + // Stage two, exactly as the shared sheet splits it: the actions native to + // this chat sit up front, and the generic what-you-can-do-with-any-message + // inventory lives behind the toggle so the sheet opens compact. + SectionDivider() + MoreActionsToggle(expanded = showAllActions, onToggle = { showAllActions = !showAllActions }) + + if (showAllActions) { + SectionDivider() + TileRow { + CopyTextTile(body, onDismiss) + ShareTextTile(body, onDismiss) + } + } + } + } +} + +/** + * Hands the message text to the system share sheet. + * + * The shared sheet shares a note by its nostr address, which a cordn message does not + * have — it lives on a coordinator, not a relay, and there is no URI that would resolve + * for anyone else. So this shares the text itself, which is what the reader can + * actually pass on. + */ +@Composable +private fun ShareTextTile( + text: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + ActionTile(MaterialSymbols.Share, stringRes(Res.string.quick_action_share)) { + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(send, null)) + onDismiss() + } +} + +/** + * Where [message] sits inside a run of consecutive bubbles by the same sender, in the + * shared [ChatGroupPosition] vocabulary — so a cordn burst gets the same squared-off + * corners and tightened spacing as a DM burst, not just a hidden author line. + * + * The feed is reverse-laid-out: [newer] is the message rendered below, [older] above. + */ +internal fun cordnGroupPositionFor( + newer: CordnDeliveredMessage?, + message: CordnDeliveredMessage, + older: CordnDeliveredMessage?, +): ChatGroupPosition { + val connectedAbove = older != null && groupsWith(message, older) + val connectedBelow = newer != null && groupsWith(newer, message) + + return when { + connectedAbove && connectedBelow -> ChatGroupPosition.MIDDLE + connectedAbove -> ChatGroupPosition.BOTTOM + connectedBelow -> ChatGroupPosition.TOP + else -> ChatGroupPosition.SINGLE + } +} + +/** + * Whether [newer] continues the run [older] started — same sender, close in time, same + * day. Time as well as sender, because a reply hours later to your own last message is a + * new thought, and joining it to the run reads as though the conversation never paused. + * + * The window is the shared [CHAT_GROUP_WINDOW_SECONDS], so a cordn burst and a DM burst + * break in the same place. + */ +private fun groupsWith( + newer: CordnDeliveredMessage, + older: CordnDeliveredMessage, +): Boolean { + if (newer.envelope.pubKey != older.envelope.pubKey) return false + if (abs(newer.envelope.createdAt - older.envelope.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false + // A day separator between the two breaks the run, exactly as a date divisor does in + // the DM feed. + return newer.sameDayAs(older) +} + +/** Whether both fall on the same local calendar day. A null [older] is a new day. */ +internal fun CordnDeliveredMessage.sameDayAs(older: CordnDeliveredMessage?): Boolean { + if (older == null) return false + return localDayOf(envelope.createdAt) == localDayOf(older.envelope.createdAt) +} + +private fun localDayOf(epochSeconds: Long): LocalDate = Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()).toLocalDate() + +/** + * The day a run of messages belongs to. + * + * Without one, a conversation is an undivided column and "yesterday evening" and "this + * morning" sit flush against each other. + */ +@Composable +internal fun DaySeparator( + createdAt: Long, + today: LocalDate, +) { + val day = remember(createdAt) { localDayOf(createdAt) } + + val label = + when (day) { + today -> stringRes(Res.string.today) + today.minusDays(1) -> stringRes(R.string.cordn_chat_yesterday) + // Year included only when it is not this one: printing 2026 on every + // divider all year is noise. + else -> + day.format( + DateTimeFormatter.ofPattern( + if (day.year == today.year) "d MMM" else "d MMM yyyy", + ), + ) + } + + ChatDivisor(label) +} + +/** Upper bound on how long a stale "Today" can survive a clock correction. */ +private const val TODAY_POLL_MS = 60_000L + +/** + * Today, as a value that stops being today when it stops being today. + * + * [DaySeparator] used to hold `remember { LocalDate.now() }` of its own. That is a + * snapshot of the wall clock with nothing to invalidate it, and each separator keeps a + * separate one, so they can disagree: a separator composed before midnight goes on + * saying "Today" while the one for the new day says it too. Seen on the tablet — one + * room, two "Today" dividers. + * + * Polling rather than a single sleep to the next midnight, because a device clock does + * not only advance: it is corrected, and the tablet this was found on jumped nine hours + * in one step. Re-assigning an equal [LocalDate] is not a change, so a quiet minute + * costs no recomposition. + */ +@Composable +internal fun rememberToday(): LocalDate { + val zone = remember { ZoneId.systemDefault() } + return produceState(LocalDate.now(zone), zone) { + while (true) { + val now = ZonedDateTime.now(zone) + val untilMidnight = Duration.between(now, now.toLocalDate().plusDays(1).atStartOfDay(zone)).toMillis() + delay(untilMidnight.coerceIn(1_000L, TODAY_POLL_MS)) + value = LocalDate.now(zone) + } + }.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/AutoScrollToNewest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/AutoScrollToNewest.kt new file mode 100644 index 0000000000..d2d06805f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/AutoScrollToNewest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +/** + * Keeps a reverse-laid-out chat sitting on its newest message. + * + * [newest] is whatever identifies the bottom-most row — a note, a message id, + * anything whose equality changes when a message arrives. In a `reverseLayout` + * list the newest row is index 0, and a list anchors itself to the row that was + * already first visible, so without this a sent message lands just below the + * viewport and the sender never sees it. + * + * Someone else's message only pulls the view down when the reader is already at + * the bottom. A reader who scrolled up into history is deliberately left there: + * yanking them away because somebody else typed is worse than a missed arrival, + * which the unread divider covers anyway. Index 1 counts as "at the bottom" + * because a row that just arrived has already pushed the previous newest up one. + * + * [mine] lifts that guard for the reader's own message, and is the whole reason + * this takes a flag. Sending is an explicit act with an obvious expectation — + * every chat app puts you on what you just sent — so someone who scrolls up, + * types and sends must land on it rather than be left reading history with the + * message they just wrote somewhere off-screen below. + */ +@Composable +internal fun AutoScrollToNewest( + listState: LazyListState, + newest: Any?, + mine: Boolean = false, +) { + LaunchedEffect(newest) { + if (mine || listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index ba50867242..d58a1422a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -206,11 +206,8 @@ fun ChatFeedLoaded( // reorders no longer re-fire paging. The per-gap markers below are pure UI. sentinels?.invoke(items.list, listState) - LaunchedEffect(items.list.firstOrNull()) { - if (listState.firstVisibleItemIndex <= 1) { - listState.animateScrollToItem(0) - } - } + val newest = items.list.firstOrNull() + AutoScrollToNewest(listState, newest, mine = accountViewModel.isLoggedUser(newest?.author?.pubkeyHex)) val scope = rememberCoroutineScope() val highlightedNoteId = remember { mutableStateOf(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 59a5c2f773..72b483ee4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -425,7 +425,7 @@ private fun RelayGroupPinTile( * zap, or a reply) and reveals everything else on demand. */ @Composable -private fun MoreActionsToggle( +internal fun MoreActionsToggle( expanded: Boolean, onToggle: () -> Unit, ) { @@ -487,7 +487,7 @@ private fun ChatOnlyRow( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun TileRow(content: @Composable () -> Unit) { +internal fun TileRow(content: @Composable () -> Unit) { FlowRow( modifier = Modifier @@ -501,7 +501,7 @@ private fun TileRow(content: @Composable () -> Unit) { } @Composable -private fun ActionTile( +internal fun ActionTile( symbol: MaterialSymbol, label: String, isDestructive: Boolean = false, @@ -540,7 +540,7 @@ private fun ActionTile( private const val SECTION_DIVIDER_ALPHA = 0.5f @Composable -private fun SectionDivider() { +internal fun SectionDivider() { HorizontalDivider( thickness = DividerThickness, color = MaterialTheme.colorScheme.placeholderText.copy(alpha = SECTION_DIVIDER_ALPHA), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index c605fae8e3..cee05e166e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -83,7 +83,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Immutable -private data class ReactionChip( +internal data class ReactionChip( val type: String, val count: Int, val includesMe: Boolean, @@ -212,7 +212,25 @@ fun ChatReactionChips( } } +/** + * The strip of engagement chips that rides a chat bubble's bottom border. Shared so + * every chat surface lays its chips out identically — [ChatReactionChips] fills it from + * a [Note]'s reactions and zaps, cordn fills it from its own annotation fold. + */ @OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ChatChipFlowRow(content: @Composable () -> Unit) { + FlowRow( + // Inset from the bubble's edge so overlapping chips ride the border without + // poking past the bubble's rounded corners. + modifier = Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + content() + } +} + @Composable private fun RenderChatReactionChips( chips: ImmutableList, @@ -225,13 +243,7 @@ private fun RenderChatReactionChips( ) { if (chips.isEmpty() && zapAmount.isBlank() && minichatCount <= 0 && !isZapping) return - FlowRow( - // Inset from the bubble's edge so overlapping chips ride the border without - // poking past the bubble's rounded corners. - modifier = Modifier.padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + ChatChipFlowRow { if (zapAmount.isNotBlank()) { ZapChip(zapAmount, onClick = onOpenDetails) } else if (isZapping) { @@ -290,7 +302,7 @@ private fun MinichatChip( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ReactionChipView( +internal fun ReactionChipView( chip: ReactionChip, onClick: () -> Unit, onLongClick: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt index 9e6934ca77..84d66d72b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.util.codePointAtKmp import com.vitorpamplona.amethyst.commons.util.codePointCharCount @@ -137,3 +139,19 @@ private fun isEmojiBase(cp: Int): Boolean = cp in 0x3297..0x3299 || // circled ideographs cp == 0x3030 || // wavy dash cp == 0x303D // part alternation mark + +// Jumbo sizes step down as the emoji count grows so up to three still fit a line. +private val JumboEmojiSingle = 50.sp +private val JumboEmojiPair = 40.sp +private val JumboEmojiTriple = 32.sp + +/** + * How large to draw an emoji-only message of [jumboCount] emoji. Shared by every chat + * surface that renders one, so a jumbo message is the same size wherever it appears. + */ +fun jumboEmojiFontSize(jumboCount: Int): TextUnit = + when (jumboCount) { + 1 -> JumboEmojiSingle + 2 -> JumboEmojiPair + else -> JumboEmojiTriple + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt index 2f7c439a38..d942a54dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt @@ -88,7 +88,7 @@ fun chatBubbleShapeFor( } /** Messages more than this far apart never group, even from the same author. */ -private const val GROUP_WINDOW_SECONDS = 10 * 60L +internal const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L /** * Event kinds that don't render as regular bubbles (zaps, raids, clips) or that @@ -120,7 +120,7 @@ private fun groupsWith( val olderAuthor = older.author?.pubkeyHex ?: return false if (newerAuthor != olderAuthor) return false - if (abs(newerEvent.createdAt - olderEvent.createdAt) > GROUP_WINDOW_SECONDS) return false + if (abs(newerEvent.createdAt - olderEvent.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false // A subject header renders as a divisor above the newer message. if (newerEvent.subject() != null) return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index c7b2241d50..bfc0d136ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -39,8 +39,10 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip31Alts.alt @@ -64,6 +66,25 @@ fun RenderEncryptedFile( if (algo == AESGCM.NAME && key != null && nonce != null) { Amethyst.instance.keyCache.add(noteEvent.content, AESGCM(key, nonce), mimeType) + // Audio is neither an image nor a video, and the image-or-video split + // below would hand it to the video player: a picture-less surface where + // the voice UI belongs. Checked before that split rather than inside it, + // since the whole ZoomableContentView pipeline is about visual media. + if (RichTextParser.isAudioContent(mimeType, noteEvent.content)) { + val waveform = remember(noteEvent) { noteEvent.waveform()?.let { WaveformData(it) } } + + RenderAudioWithWaveform( + mediaUrl = noteEvent.content, + title = noteEvent.alt(), + mimeType = mimeType, + waveform = waveform, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + return + } + val content by remember(noteEvent) { val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt index 688ee380ca..a084b2d178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2 @@ -130,6 +131,24 @@ fun RenderEncryptedMediaV2( Amethyst.instance.keyCache.add(url, cipher, reference.mediaType) val description = event.alt() + + // Audio would otherwise fall to the video branch below and play on a + // picture-less video surface. MIP-04 carries no waveform, so the player + // shows its transport without bars — still the voice UI rather than a + // video one. + if (RichTextParser.isAudioContent(reference.mediaType, url)) { + RenderAudioWithWaveform( + mediaUrl = url, + title = description, + mimeType = reference.mediaType, + waveform = null, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + return + } + val dim = reference.dim?.let { DimensionTag.parse(it) } val content by remember(reference) { mutableStateOf( @@ -236,6 +255,21 @@ private fun RenderMip04Content( Amethyst.instance.keyCache.add(meta.url, cipher, meta.mimeType) val description = note.event?.alt() + + // Same reason as the reference path above. + if (RichTextParser.isAudioContent(meta.mimeType, meta.url)) { + RenderAudioWithWaveform( + mediaUrl = meta.url, + title = description, + mimeType = meta.mimeType, + waveform = null, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + return + } + val isImage = meta.mimeType.startsWith("image/") || RichTextParser.isImageUrl(meta.url) val dim = meta.dimensions?.let { DimensionTag.parse(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index 7252ebb663..eec5965811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists @@ -37,14 +36,12 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta import com.vitorpamplona.amethyst.ui.note.types.appendMissingImetaUrls +import com.vitorpamplona.amethyst.ui.note.types.getAudioMetaWithWaveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount - -// Jumbo sizes step down as the emoji count grows so up to three still fit a line. -private val JumboEmojiSingle = 50.sp -private val JumboEmojiPair = 40.sp -private val JumboEmojiTriple = 32.sp +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize @Composable fun RenderRegularTextNote( @@ -63,17 +60,23 @@ fun RenderRegularTextNote( ) { val jumboCount = remember(eventContent) { jumboEmojiCount(eventContent) } - if (jumboCount > 0) { + // A voice message is an audio URL whose imeta carries the waveform. + // The main feed's note renderer short-circuits those to the waveform + // player; without the same check here the generic media pipeline + // takes them and a recorded message arrives as a player surface + // instead of the waveform it was previewed as. Matched against the + // DECRYPTED content rather than `isAudioOnlyContent()`, because in a + // chat the note's own `content` may still be the sealed payload. + val audioMeta = remember(note.event) { note.event?.getAudioMetaWithWaveform() } + + if (audioMeta != null && eventContent.trim() == audioMeta.url) { + RenderAudioFromIMeta(note, accountViewModel, nav) + } else if (jumboCount > 0) { // Emoji-only messages render as jumbo emoji (the bubble behind // them is transparent — see NormalChatNote). Text( text = eventContent.trim(), - fontSize = - when (jumboCount) { - 1 -> JumboEmojiSingle - 2 -> JumboEmojiPair - else -> JumboEmojiTriple - }, + fontSize = jumboEmojiFontSize(jumboCount), ) } else { val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 2bd7b241c0..8b2ba48747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.concord.ui.ConcordCommunityPill +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom @@ -78,6 +80,14 @@ import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix import com.vitorpamplona.amethyst.commons.resources.concord_home_title import com.vitorpamplona.amethyst.commons.resources.concord_server_label +import com.vitorpamplona.amethyst.commons.resources.cordn_group_no_messages_yet +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_group_via_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_deleted +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_file +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_photo +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_video +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_voice_note import com.vitorpamplona.amethyst.commons.resources.could_not_decrypt_the_message import com.vitorpamplona.amethyst.commons.resources.ephemeral_relay_chat import com.vitorpamplona.amethyst.commons.resources.geohash_chat @@ -142,9 +152,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -155,6 +168,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.groupId import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import org.jetbrains.compose.resources.StringResource @@ -168,12 +182,21 @@ fun ChatroomHeaderCompose( // joined Marmot/NIP-29 group with no messages yet (an event-less placeholder carrying its channel // as a gatherer). Render these directly instead of waiting for an event that never arrives, which // would blank the row. + // + // A cordn room has no event *ever*, not just before its first message: its + // messages are MLS envelopes that never become Notes (§3.3 of + // amethyst/plans/2026-09-19-cordn-ui.md), so the row is only ever a carrier + // for the room in `inGatherers`. Leaving it out of this list sent every + // cordn room down the branch that waits for an event and drew `BlankNote()` + // instead — the room was in the feed, correctly, and simply had no pixels. val rendersWithoutEvent = baseNote is RelayGroupServerRoomNote || baseNote is ConcordServerRoomNote || ( baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true + baseNote.inGatherers?.any { + it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel || it is CordnGroupChatroom + } == true ) if (baseNote.event != null || rendersWithoutEvent) { @@ -228,6 +251,12 @@ private fun ChatroomEntry( return } + val cordnGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? CordnGroupChatroom } + if (cordnGroup != null) { + CordnGroupRoomCompose(cordnGroup, accountViewModel, nav) + return + } + val relayGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } if (relayGroup != null) { RelayGroupRoomCompose(lastMessage, relayGroup, accountViewModel, nav) @@ -527,6 +556,110 @@ private fun MarmotGroupRoomCompose( ) } +/** + * What to call a message whose whole content is an attachment. + * + * The first attachment decides, which is what the eye does too: a row is one + * line and a message with a photo and a file in it is, to a reader skimming the + * inbox, a message with a photo in it. + */ +private fun attachmentLabelFor(tags: TagArray): StringResource { + val first = CordnMediaTag.parseAll(tags).firstOrNull() ?: return Res.string.cordn_preview_file + return when { + first.isAudio -> Res.string.cordn_preview_voice_note + first.isImage -> Res.string.cordn_preview_photo + first.mimeType.startsWith("video/") -> Res.string.cordn_preview_video + else -> Res.string.cordn_preview_file + } +} + +/** + * One cordn room in the Messages list. + * + * Takes the room rather than the Note, unlike every other row here. The Note is + * only a carrier: cordn messages are MLS envelopes that never reach LocalCache, + * so there is no `lastMessage.event` to read a preview from and the live data + * is on the room. Reading the Note would render an empty row forever. + */ +@Composable +internal fun CordnGroupRoomCompose( + chatroom: CordnGroupChatroom, + accountViewModel: AccountViewModel, + nav: INav, +) { + val name by chatroom.name.collectAsStateWithLifecycle() + val newest by chatroom.newest.collectAsStateWithLifecycle() + val annotations by chatroom.annotations.collectAsStateWithLifecycle() + val unread by chatroom.unreadCount.collectAsStateWithLifecycle() + + val groupName = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, chatroom.gid.take(8)) + + // Who carries this conversation. "cordn" was the same word on every cordn + // row and told a reader nothing they could act on; the coordinator is the + // one server that sees every message in the group, so naming it is the + // thing worth the space. + val coordinators by ( + accountViewModel.account.cordnRuntime?.coordinators + ?: remember { MutableStateFlow(emptyList()) } + ).collectAsStateWithLifecycle() + val coordinatorName = + coordinatorDisplayName( + pubKey = chatroom.coordinatorPubKey, + label = coordinators.firstOrNull { it.pubKey == chatroom.coordinatorPubKey }?.label, + accountViewModel = accountViewModel, + ) + + val lastContent = + newest?.let { message -> + val authorName by observeUserName(LocalCache.getOrCreateUser(message.envelope.pubKey), accountViewModel) + // The edited text when there is one: showing the original in the + // inbox while the room shows the edit is the kind of mismatch that + // reads as a sync bug. + val text = annotations.contentOf(message.envelope.id) ?: message.envelope.content + // A voice note or a photo carries no text, so the row used to read + // "Someone: " and trail off — an attachment looked like an empty + // message. Name what was sent instead, the way the bubble does. + val preview = + text.takeIf { it.isNotBlank() } + ?: if (annotations.isDeleted(message.envelope.id)) { + stringRes(Res.string.cordn_preview_deleted) + } else { + stringRes(attachmentLabelFor(message.envelope.tags)) + } + "$authorName: ${preview.take(200)}" + } ?: stringRes(Res.string.cordn_group_no_messages_yet) + + ChannelName( + channelIdHex = chatroom.gid, + channelPicture = null, + channelTitle = { modifier -> + ChannelTitleWithLabelInfo( + channelName = groupName, + labelIcon = MaterialSymbols.Dns, + labelText = coordinatorName, + modifier = modifier, + // The pill no longer says "cordn", so the kind of room has to + // reach a screen reader some other way. + labelContentDescription = stringRes(Res.string.cordn_group_via_coordinator, coordinatorName), + ) + }, + channelLastTime = newest?.envelope?.createdAt, + channelLastContent = lastContent, + // Counted against the read position the room persists, on the + // coordinator's cursor rather than the sender's clock -- see + // CordnGroupChatroom.unreadCount for why a clock cannot be trusted + // with this. Annotations and this account's own echoes do not count. + hasNewMessages = unread > 0, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.CordnGroupChat(chatroom.coordinatorPubKey, chatroom.gid)) }, + ) +} + @Composable private fun RelayGroupRoomCompose( lastMessage: Note, @@ -944,6 +1077,23 @@ private fun ChannelTitleWithLabelInfo( label: StringResource, modifier: Modifier, labelContentDescription: String? = null, +) = ChannelTitleWithLabelInfo(channelName, labelIcon, stringRes(id = label), modifier, labelContentDescription) + +/** + * As above, for a pill whose text is a name rather than a fixed word. + * + * A cordn room's pill carries its coordinator, which is a value and not a + * string resource: the coordinator is the one server that carries every message + * in that group, so which one it is tells a reader more than being told twice + * that this is a cordn chat. + */ +@Composable +private fun ChannelTitleWithLabelInfo( + channelName: String, + labelIcon: MaterialSymbol, + labelText: String, + modifier: Modifier, + labelContentDescription: String? = null, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( @@ -957,7 +1107,7 @@ private fun ChannelTitleWithLabelInfo( Spacer(Modifier.width(6.dp)) HeaderPill( symbol = labelIcon, - text = stringRes(id = label), + text = labelText, modifier = Modifier.widthIn(max = ChatLabelMaxWidth), contentDescription = labelContentDescription, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 950fd43ec0..ad3d06dcbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -142,6 +142,26 @@ class ChatroomListKnownFeedFilter( } } + // cordn groups. Every one this account holds is a room it is IN — a + // cordn group is joined by accepting a Welcome, so there is no + // "known vs new" split to make here the way there is for a DM from a + // stranger. (The pending side is the Welcome inbox, Stage C.) + // + // One row per room, carried by the room's own Note: cordn messages are + // MLS envelopes and never enter LocalCache, so the row reads its name + // and preview from the room through Note.inGatherers. + val cordnGroups = + if (!isEnabled(ChatFeedType.CORDN)) { + emptyList() + } else { + account.cordnRuntime + ?.groups + ?.all + ?.value + ?.map { it.inboxRow() } + .orEmpty() + } + // NIP-29 relay groups the user joined (kind 10009). In INLINE view mode each group is its // own row tagged with its host relay; in GROUPED mode all the groups on one relay collapse // to a single relay row positioned by that relay's newest message. Both interleave with the @@ -210,7 +230,12 @@ class ChatroomListKnownFeedFilter( } } - return sort((privateMessages + publicChannels + ephemeralChats + geohashChannels + marmotGroups + relayGroups + concordChannels).toSet()) + return sort( + ( + privateMessages + publicChannels + ephemeralChats + geohashChannels + + marmotGroups + cordnGroups + relayGroups + concordChannels + ).toSet(), + ) } override fun updateListWith( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 140818f591..637f91bb53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -99,6 +99,14 @@ fun ChatFileUploadDialog( accountViewModel: AccountViewModel, nav: INav, isNip17: Boolean = false, + /** + * Whether the sensitive-content switch is offered. + * + * False for a surface with nowhere to put the answer: cordn's blob host is told a + * fixed set of constants on purpose (see CordnBlobUpload), and its `imeta` tag has + * no field for a warning, so the switch would be a control that does nothing. + */ + showContentWarning: Boolean = true, ) { val scrollState = rememberScrollState() @@ -150,7 +158,7 @@ fun ChatFileUploadDialog( ) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { - ImageVideoPostChat(state, accountViewModel, isNip17) + ImageVideoPostChat(state, accountViewModel, isNip17, showContentWarning) } } } @@ -163,6 +171,7 @@ private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, isNip17: Boolean = false, + showContentWarning: Boolean = true, ) { val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() @@ -201,13 +210,15 @@ private fun ImageVideoPostChat( ), ) - SettingSwitchItem( - title = Res.string.add_sensitive_content_label, - description = Res.string.add_sensitive_content_description, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - checked = fileUploadState.contentWarning, - onCheckedChange = fileUploadState::updateContentWarning, - ) + if (showContentWarning) { + SettingSwitchItem( + title = Res.string.add_sensitive_content_label, + description = Res.string.add_sensitive_content_description, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + checked = fileUploadState.contentWarning, + onCheckedChange = fileUploadState::updateContentWarning, + ) + } if (isNip17) { SettingSwitchItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 5f7ca6d6a3..ac0c7a21c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -81,7 +81,12 @@ class ChatFileUploadState( multiOrchestrator?.remove(selected) } - fun canPost(): Boolean = !mediaUploadTracker.isUploading && multiOrchestrator != null + /** + * Deleting the last picked item leaves an *empty* orchestrator, not a null one, so + * a non-null check alone kept Send live with nothing to send. Callers that loop + * over the items then post nothing; one that indexes item 0 crashes. + */ + fun canPost(): Boolean = !mediaUploadTracker.isUploading && (multiOrchestrator?.size() ?: 0) > 0 fun hasPickedMedia() = multiOrchestrator != null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt index e21ab9fb9b..14cf5b3aeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.napplet.ui.PolicyCard import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chat_type_concord_desc import com.vitorpamplona.amethyst.commons.resources.chat_type_concord_title +import com.vitorpamplona.amethyst.commons.resources.chat_type_cordn_desc +import com.vitorpamplona.amethyst.commons.resources.chat_type_cordn_title import com.vitorpamplona.amethyst.commons.resources.chat_type_ephemeral_desc import com.vitorpamplona.amethyst.commons.resources.chat_type_ephemeral_title import com.vitorpamplona.amethyst.commons.resources.chat_type_geohash_desc @@ -124,6 +126,7 @@ private val CHAT_FEED_TYPES = ChatFeedTypeUi(ChatFeedType.NIP29, Res.string.chat_type_nip29_title, Res.string.chat_type_nip29_desc, Color(0xFF9E77ED)), ChatFeedTypeUi(ChatFeedType.MARMOT, Res.string.chat_type_marmot_title, Res.string.chat_type_marmot_desc, Color(0xFF5B6AD0)), ChatFeedTypeUi(ChatFeedType.CONCORD, Res.string.chat_type_concord_title, Res.string.chat_type_concord_desc, Color(0xFFEC4899)), + ChatFeedTypeUi(ChatFeedType.CORDN, Res.string.chat_type_cordn_title, Res.string.chat_type_cordn_desc, Color(0xFF14B8A6)), ChatFeedTypeUi(ChatFeedType.GEOHASH, Res.string.chat_type_geohash_title, Res.string.chat_type_geohash_desc, Color(0xFFEF4444)), ChatFeedTypeUi(ChatFeedType.EPHEMERAL, Res.string.chat_type_ephemeral_title, Res.string.chat_type_ephemeral_desc, Color(0xFF06B6D4)), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 99fdcd79ed..a180a43368 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -44,6 +44,8 @@ import com.vitorpamplona.amethyst.commons.resources.call_settings import com.vitorpamplona.amethyst.commons.resources.call_settings_search_keywords import com.vitorpamplona.amethyst.commons.resources.compose_search_keywords import com.vitorpamplona.amethyst.commons.resources.compose_settings +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_search_keywords +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_title import com.vitorpamplona.amethyst.commons.resources.danger_zone import com.vitorpamplona.amethyst.commons.resources.drawer_search_keywords import com.vitorpamplona.amethyst.commons.resources.drawer_settings @@ -162,6 +164,14 @@ fun buildSettingsCatalog( symEntry(Res.string.napplet_permissions_title, MaterialSymbols.Apps, Res.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), symEntry(Res.string.relay_auth_settings_title, MaterialSymbols.Lock, Res.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(Res.string.call_settings, MaterialSymbols.Phone, Res.string.call_settings_search_keywords, Route.CallSettings), + // One entry, not five. cordn's screens are coordinators, + // key packages, link inspection, backup and migration — + // each a page a user visits rarely and only because they + // are already thinking about cordn. Five flat rows made it + // the largest feature in this list by count and the least + // used by far; the hub keeps them all reachable and + // searchable under one name. + symEntry(Res.string.cordn_hub_title, MaterialSymbols.Dns, Res.string.cordn_hub_search_keywords, Route.CordnHub), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/BusyLabel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/BusyLabel.kt new file mode 100644 index 0000000000..c916830d4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/BusyLabel.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A button label that shows work in progress. + * + * Every cordn action screen tracked a `busy` flag and spent it only on + * `enabled = !busy`, so the whole signal that anything was happening was a + * button going grey. Backup and restore derive a key with scrypt — slow by + * design — and migration publishes to relays, so "grey for a few seconds" + * was indistinguishable from "did not register the tap". + */ +@Composable +fun BusyLabel( + busy: Boolean, + label: String, +) { + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current, + ) + Spacer(Modifier.width(8.dp)) + } + Text(label) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt new file mode 100644 index 0000000000..2de55c63fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * What to call a coordinator, and the face to put next to it. + * + * A coordinator is a Nostr identity -- ContextVM addresses it with ordinary + * p-tags, and CEP-23 says outright that a server MAY publish NIP-01 profile + * metadata (`CvmKinds.PROFILE_METADATA` is kind 0 for exactly this reason). So + * the app can name one the same way it names anybody else, and showing 64 hex + * characters instead was throwing that away. + * + * ## The order, and why + * + * 1. `CoordinatorConfig.label` -- documented as "what the user calls it. Never + * a claim -- a coordinator cannot prove a name." The only name here that + * means anything, so it wins. + * 2. The CEP-6 announcement's name, when the cache holds one. Before the + * profile because a coordinator added from discovery was *picked* by this + * name, and having the settings screen rename it afterwards would be its own + * small confusion. + * 3. The kind 0's display name (CEP-23). + * 4. The key's first characters. + * + * Both 2 and 3 are the coordinator's own word for itself, and two servers may + * publish the same one, which is why neither outranks a label. + * + * The profile is observed whatever the outcome, so the avatar still loads for a + * coordinator that is named by 1 or 2. + */ +@Composable +fun coordinatorDisplayName( + pubKey: HexKey, + label: String?, + accountViewModel: AccountViewModel, +): String { + // Both unconditional: each one registers the subscription that fetches what + // it reads, so neither may come and go with whether an earlier source in the + // chain happens to have an answer. + val fromProfile = observeUserNameByHex(pubKey, accountViewModel) + val announced = observeAnnouncedServerName(pubKey, accountViewModel) + + return label?.takeIf { it.isNotBlank() } + ?: announced + ?: fromProfile +} + +/** + * The name from the coordinator's CEP-6 announcement, as the cache holds it. + * + * A [CvmServerAnnouncementEvent] is a replaceable event like any other now, so + * reading it here gets the newest one per coordinator, already verified, and + * fetched by the same event-finder data source the rest of the app uses -- + * rather than a hand-rolled fetch, a hand-rolled newest-wins and a second copy + * of the name kept beside the cache. + */ +@Composable +fun observeAnnouncedServerName( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String? { + val note = + remember(pubKey) { + LocalCache.getOrCreateAddressableNote(Address(CvmServerAnnouncementEvent.KIND, pubKey, "")) + } + val announcement by observeNoteEvent(note, accountViewModel) + + return announcement?.serverName() +} + +/** + * A coordinator rendered as the user it is: avatar, name, and whatever acts on + * it. + * + * The avatar navigates to the profile, because [UserPicture] already does and + * a coordinator's profile is as worth reading as anyone's -- more, given it is + * the party whose metadata exposure the group info screen is about. + * + * [name] is a `RowScope` slot so a caller can weight it, which both of them + * need; before it was, they weighted it anyway and compiled only because + * `ColumnScope.weight` happens to produce the same element. [trailing] is its + * own slot for the same reason: an action smuggled through a slot called `name` + * is a lie about where it lands. + */ +@Composable +fun CoordinatorIdentityRow( + pubKey: HexKey, + label: String?, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, + size: Dp = 28.dp, + trailing: @Composable RowScope.() -> Unit = {}, + name: @Composable RowScope.(String) -> Unit, +) { + Row( + modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = pubKey, size = size, accountViewModel = accountViewModel, nav = nav) + name(coordinatorDisplayName(pubKey, label, accountViewModel)) + trailing() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt new file mode 100644 index 0000000000..3560b60be6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_export +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_passphrase +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_restore +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Exporting and restoring cordn state. + * + * ## Why cordn has this when the rest of Amethyst does not + * + * Amethyst's "backup" is a key backup: your nsec, from which a relay-backed + * account rebuilds itself. A cordn group does not work that way. It is MLS + * state on **one device** plus an ordered stream on a coordinator that cannot + * read a byte of it, so the key alone restores nothing. Lose the device and + * the group is gone — not "until someone re-invites you", because a fresh + * invitation begins at a new epoch and everything before it stays unreadable + * forever. + * + * ## Restoring replaces a device, and the screen says so before the tap + * + * An MLS state export is a cloneable identity. Two devices holding one + * group's state and both committing fork the ratchet tree, and MLS does not + * recover: after the fork, messages silently fail to decrypt for somebody, + * with nothing on screen to explain it. So restoring wipes this device's + * cordn state and takes the file's — it is not a merge and it is not sync, + * and presenting it as either would be selling multi-device support that + * §5.3 deliberately does not exist. + * + * ## The file is as sensitive as the conversations + * + * It carries ratchet trees and KeyPackage private halves. The passphrase is + * the only thing protecting it once it leaves the app, which is why there is + * no "export without a passphrase" and why the warning names what is inside. + */ +@Composable +fun CordnBackupScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var passphrase by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var pendingRestore by remember { mutableStateOf(null) } + + val exported = stringRes(R.string.cordn_backup_exported) + val restored = stringRes(R.string.cordn_backup_restored) + val failed = stringRes(R.string.cordn_backup_failed) + + val saver = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + val target = uri ?: return@rememberLauncherForActivityResult + val runtimeNow = runtime ?: return@rememberLauncherForActivityResult + scope.launch { + busy = true + error = null + status = null + try { + val bytes = runtimeNow.exportArchive(passphrase) + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(target)?.use { it.write(bytes) } + } + status = exported + passphrase = "" + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + } + + // OpenDocument, not GetContent. `GetContent("*/*")` sends ACTION_GET_CONTENT, + // which on this Android version is intercepted by the system photo picker's + // shim (`PhotopickerGetContentActivity`) before it hands off to DocumentsUI — + // and a file chosen through that handoff came back as a cancelled result, so + // Restore opened a picker, took a tap, and quietly did nothing. An archive is + // not media; ACTION_OPEN_DOCUMENT is the right intent for it and reaches SAF + // directly. + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + pendingRestore = uri + } + + val snackbarHostState = remember { SnackbarHostState() } + + // Outcomes go to a snackbar rather than two Text lines at the bottom of a + // scrolling form. They were easy to scroll past, they never left once + // shown, and an export that had just failed sat under a button that looked + // ready to try again. + LaunchedEffect(status) { + status?.let { + snackbarHostState.showSnackbar(it) + status = null + } + } + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar(it) + error = null + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_backup_title), nav) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (runtime == null) { + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(R.string.cordn_backup_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // The passphrase is its own section because it governs both of the + // two below: the same word exports and restores, and a field + // floating above two unrelated-looking cards did not say so. + SettingsSection(Res.string.cordn_backup_section_passphrase) { + SettingsFormBlock { + OutlinedTextField( + value = passphrase, + onValueChange = { + passphrase = it + error = null + }, + label = { Text(stringRes(R.string.cordn_backup_passphrase)) }, + visualTransformation = PasswordVisualTransformation(), + // The dots are only half of it. Without the password + // keyboard type the IME treats this as ordinary prose: + // it offers the passphrase in the suggestion strip, in + // the clear, and learns it into the personalised + // dictionary, where it outlives the app. Every other + // secret field in Amethyst already sets this; this one + // was the only one that did not. + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + SettingsSection(Res.string.cordn_backup_section_export) { + SettingsFormBlock { + Text(stringRes(R.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(R.string.cordn_backup_contents_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Button( + onClick = { saver.launch("cordn-backup.bin") }, + // No passphrase, no export. The file carries ratchet + // trees and private key material; there is no version + // of it that is safe to write unprotected. + enabled = !busy && passphrase.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + // Key derivation here is scrypt, which is slow on + // purpose, so a greyed-out button was the only sign + // anything was happening for several seconds. + BusyLabel(busy, stringRes(R.string.cordn_backup_export)) + } + } + } + + SettingsSection(Res.string.cordn_backup_section_restore) { + SettingsFormBlock { + Text( + text = stringRes(R.string.cordn_backup_restore_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Button( + onClick = { picker.launch(arrayOf("*/*")) }, + enabled = !busy && passphrase.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + BusyLabel(busy, stringRes(R.string.cordn_backup_restore)) + } + } + } + } + } + + pendingRestore?.let { uri -> + AlertDialog( + onDismissRequest = { pendingRestore = null }, + title = { Text(stringRes(R.string.cordn_backup_restore_confirm_title)) }, + text = { Text(stringRes(R.string.cordn_backup_restore_confirm_body)) }, + confirmButton = { + TextButton(onClick = { + pendingRestore = null + val runtimeNow = runtime ?: return@TextButton + scope.launch { + busy = true + error = null + status = null + try { + val bytes = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } + if (bytes == null) { + error = failed + } else { + runtimeNow.importArchive(bytes, passphrase) + status = restored + passphrase = "" + } + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }) { + Text(stringRes(R.string.cordn_backup_restore), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingRestore = null }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt new file mode 100644 index 0000000000..01b0a3c93f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt @@ -0,0 +1,710 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_discover +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_manual +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_yours +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_title +import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CopyableKeyRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.launch + +/** + * The coordinators this account talks to. + * + * ## Why a coordinator is a first-class thing and not a relay setting + * + * Relays are interchangeable and redundant; losing one costs nothing. A + * coordinator is the sole authority for the groups it serves (`spec/00.md` + * §4) — losing it loses the ordering, and a second one does not mirror the + * first. So this screen lists named things a user chose, with what each one + * costs and two very different ways to stop using one. + * + * ## Remove and purge are different decisions + * + * Removing closes the session and leaves the MLS state on disk, so re-adding + * brings the groups back. Purging deletes the ratchet trees, the cursors and + * the KeyPackage private halves, after which those groups can only be + * re-entered by a fresh invitation — MLS state cannot be rebuilt from + * anywhere else. The confirmation says that, because "delete" normally means + * the reversible one. + * + * Neither tells the coordinator anything. It keeps what it already had; this + * screen is about the device, and implying otherwise would be selling a + * deletion nobody can perform. + */ +@Composable +fun CordnCoordinatorsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_coordinators_title), nav) }, + ) { padding -> + if (runtime == null) { + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(R.string.cordn_coordinators_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Three sections rather than one column split by rules: what you + // already use, what is on offer, and the manual escape hatch. The + // dividers said "something else follows" without saying what. + SettingsSection(Res.string.cordn_coordinators_section_yours) { + SettingsFormBlock { + coordinators.forEachIndexed { index, config -> + CoordinatorCard(config, runtime, accountViewModel, nav) + // Between coordinators, never after the last: the + // section's own card edge already ends the list. + if (index != coordinators.lastIndex) HorizontalDivider(thickness = DividerThickness) + } + + if (coordinators.isEmpty()) { + Text( + text = stringRes(R.string.cordn_coordinators_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + SettingsSection(Res.string.cordn_coordinators_section_discover) { + SettingsFormBlock { + DiscoverCoordinators(runtime, accountViewModel, nav, coordinators.map { it.pubKey }.toSet()) + } + } + + // Last, and it reads as the fallback it is now that discovery is + // above it: pasting a 64-character key is what you do when nobody + // announced the one you were told to use. + SettingsSection(Res.string.cordn_coordinators_section_manual) { + SettingsFormBlock { + AddCoordinator(runtime) + } + } + } + } +} + +@Composable +private fun CoordinatorCard( + config: CoordinatorConfig, + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var info by remember(config.pubKey) { mutableStateOf(null) } + var infoChecked by remember(config.pubKey) { mutableStateOf(false) } + var confirmingPurge by remember(config.pubKey) { mutableStateOf(false) } + var renaming by remember(config.pubKey) { mutableStateOf(false) } + var menuOpen by remember(config.pubKey) { mutableStateOf(false) } + + val health = runtime.health(config.pubKey)?.collectAsStateWithLifecycle()?.value + + // No Card here: SettingsSection already IS the card, and a second one + // inside it rendered as a square-cornered slab of a different grey inside + // the section's rounded one -- every other cordn settings section puts its + // content straight into the form block. + Column(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + CoordinatorIdentityRow( + pubKey = config.pubKey, + label = config.label, + accountViewModel = accountViewModel, + nav = nav, + size = 36.dp, + trailing = { + // The label was only ever settable while adding a coordinator + // by hand, so one that arrived from discovery, or came back + // with a restore, could not be named at all. + IconButton(onClick = { renaming = true }) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(R.string.cordn_coordinators_rename), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) { name -> + // Name and state together at the top, because "is this one working" + // is the question this screen is opened with. Everything verifiable + // sits below, where it is read once rather than scanned. + Column(Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + health?.let { HealthLine(it) } + } + } + + if (renaming) { + RenameCoordinatorDialog( + current = config.label, + onDismiss = { renaming = false }, + onSave = { + renaming = false + scope.launch { runtime.relabel(config.pubKey, it) } + }, + ) + } + + // An npub, not the 64 hex characters that used to sit here. This is the + // screen where you check that the coordinator you were told to use is + // the one you added -- and what you were told is an npub, which a hex + // string cannot be compared against at all. The full value is one tap + // away on the clipboard, which is how a comparison is actually done. + val coordinator = remember(config.pubKey) { LocalCache.getOrCreateUser(config.pubKey) } + CopyableKeyRow( + label = stringRes(R.string.cordn_coordinators_key), + shown = coordinator.pubkeyDisplayHex(), + copied = coordinator.pubkeyNpub(), + copyDescription = stringRes(R.string.cordn_coordinators_copy_key), + ) + + // Every relay, not the first two and a count: this is the management + // screen, and an address the coordinator answers on is the thing you + // came here to check or to rule out. + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringRes(R.string.cordn_coordinators_relays_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + config.relays.forEach { relay -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + symbol = MaterialSymbols.Link, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = relay.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Only ever what a call of ours already observed -- nothing here + // polls. The handshake below is a call the user asked for. + if (infoChecked) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = + info?.let { + stringRes( + R.string.cordn_coordinators_server, + listOfNotNull(it.name, it.version, it.protocolVersion).joinToString(" \u00b7 "), + ) + } ?: stringRes(R.string.cordn_coordinators_server_silent), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + // Said next to it, every time, because a name on a settings + // screen reads as verified and this one is not: §8.5 makes + // the pubkey the identity and nothing else. + text = stringRes(R.string.cordn_coordinators_server_claim), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { + scope.launch { + info = runCatching { runtime.serverInfo(config.pubKey) }.getOrNull() + infoChecked = true + } + }) { + Text(stringRes(R.string.cordn_coordinators_identify)) + } + + Spacer(Modifier.weight(1f)) + + // Behind a menu rather than beside Identify. Purge deletes this + // coordinator's groups and keys off the device, and it sat one + // tap away from a harmless handshake button, at the same size. + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.cordn_coordinators_more), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.cordn_coordinators_remove)) }, + onClick = { + menuOpen = false + scope.launch { runtime.forget(config.pubKey) } + }, + ) + DropdownMenuItem( + text = { + Text( + text = stringRes(R.string.cordn_coordinators_purge), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuOpen = false + confirmingPurge = true + }, + ) + } + } + } + } + + if (confirmingPurge) { + AlertDialog( + onDismissRequest = { confirmingPurge = false }, + title = { Text(stringRes(R.string.cordn_coordinators_purge_title)) }, + text = { Text(stringRes(R.string.cordn_coordinators_purge_body)) }, + confirmButton = { + TextButton(onClick = { + confirmingPurge = false + scope.launch { runtime.purge(config.pubKey) } + }) { + Text(stringRes(R.string.cordn_coordinators_purge), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmingPurge = false }) { + Text(stringRes(Res.string.cancel)) + } + }, + ) + } +} + +/** + * What this coordinator's own traffic has shown, and nothing more. + * + * `CoordinatorHealth` records what the calls the app was making anyway + * observed; it never polls, because a poll is a call and every call is + * metadata (§8). So "unknown" here means nothing has been asked of it yet, + * which is genuinely different from "down" and is shown differently. + */ +@Composable +private fun HealthLine(state: CoordinatorHealth.State) { + val text = + when { + state.isUnknown -> stringRes(R.string.cordn_coordinators_health_unknown) + state.isDown -> stringRes(R.string.cordn_coordinators_health_down, state.consecutiveFailures) + state.consecutiveFailures > 0 -> stringRes(R.string.cordn_coordinators_health_retrying) + else -> stringRes(R.string.cordn_coordinators_health_ok) + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = if (state.isDown) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** + * Coordinators that announced themselves, offered instead of a hex field. + * + * Adding one used to mean pasting a 64-character public key and a list of + * relay URLs, which is not something anyone can do without being told the + * answer out of band. Coordinators publish CEP-6 announcements; reading them + * is a relay query that touches no coordinator, so nothing here tells anyone + * that this account exists. + * + * Deliberately not automatic on entry. The query is cheap but it is still the + * user's relays being asked a question on their behalf, and a screen that + * reaches out the moment it opens is the kind of thing this feature is + * supposed to be careful about. + */ +@Composable +private fun DiscoverCoordinators( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, + known: Set, +) { + val scope = rememberCoroutineScope() + var result by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val failed = stringRes(R.string.cordn_coordinators_discover_failed) + + Text( + text = stringRes(R.string.cordn_coordinators_discover_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { + busy = true + error = null + scope.launch { + try { + result = runtime.discover(accountViewModel.account.outboxRelays.flow.value) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }, + enabled = !busy, + ) { + if (busy) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringRes(R.string.cordn_coordinators_discover_action)) + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + // The app's own crossfade, so performance mode still turns it off. + CrossfadeIfEnabled( + targetState = result, + label = "cordn-discovery", + ) { found -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (found == null) return@Column + + // Already-added ones are dropped rather than shown disabled: this + // list is "what you could add", and a row that does nothing is + // just something else to read. + val offers = found.coordinators.filter { it.pubKey !in known } + + if (offers.isEmpty()) { + Text( + text = + if (found.unreachable.isEmpty()) { + stringRes(R.string.cordn_coordinators_discover_none) + } else { + // "Nobody is announcing" and "we were not told" are + // different answers and only one of them is final. + stringRes(R.string.cordn_coordinators_discover_unheard, found.unreachable.size) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + offers.forEach { offer -> DiscoveredCard(offer, runtime, accountViewModel, nav) } + } + } +} + +/** + * Names a coordinator, or clears the name it was given. + * + * Empty saves as no label rather than an empty one, so the display falls back + * to the profile and then the key instead of rendering a blank line. + */ +@Composable +private fun RenameCoordinatorDialog( + current: String?, + onDismiss: () -> Unit, + onSave: (String?) -> Unit, +) { + var input by remember { mutableStateOf(current.orEmpty()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.cordn_coordinators_rename)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + label = { Text(stringRes(R.string.cordn_coordinators_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text( + // The same caution the add form gives, for the same reason. + text = stringRes(R.string.cordn_coordinators_label_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(onClick = { onSave(input.trim().ifEmpty { null }) }) { + Text(stringRes(R.string.cordn_info_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +@Composable +private fun DiscoveredCard( + offer: DiscoveredCoordinator, + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + // The announcement's name, not the profile's: in a discovery list + // the CEP-6 surface is the thing being offered, and both are the + // server's own word for itself anyway. The avatar is worth having + // regardless -- it is the only part of this card that is hard to + // impersonate at a glance. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = offer.pubKey, size = 24.dp, accountViewModel = accountViewModel, nav = nav) + Text( + // Its own word for itself, and said so: a coordinator cannot + // prove a name, which is why this never becomes the label. + // Passed as the label so the announcement still wins, with + // the profile catching an offer that announced no name. + text = coordinatorDisplayName(offer.pubKey, offer.surface.name, accountViewModel), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + } + offer.surface.about?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + text = offer.relays.joinToString { it.url }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + // Staleness matters more here than anywhere: the last live + // survey found most announcements were months-dead demos. + text = stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(offer.announcedAt).trim()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + busy = true + scope.launch { + try { + runtime.session(offer.toConfig()) + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.padding(top = 4.dp), + ) { + Text(stringRes(R.string.cordn_coordinators_discover_add)) + } + } + } +} + +@Composable +private fun AddCoordinator(runtime: CordnRuntime) { + val scope = rememberCoroutineScope() + var pubKeyInput by remember { mutableStateOf("") } + var relaysInput by remember { mutableStateOf("") } + var labelInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val failed = stringRes(R.string.cordn_coordinators_add_failed) + + val config = + remember(pubKeyInput, relaysInput, labelInput) { + val pubKey = decodePublicKeyAsHexOrNull(pubKeyInput.trim()) + val relays = relaysInput.lines().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + if (pubKey == null || relays.isEmpty()) { + null + } else { + CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL, labelInput.trim().ifEmpty { null }) + } + } + + OutlinedTextField( + value = pubKeyInput, + onValueChange = { + pubKeyInput = it + error = null + }, + label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = relaysInput, + onValueChange = { + relaysInput = it + error = null + }, + label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = labelInput, + onValueChange = { labelInput = it }, + label = { Text(stringRes(R.string.cordn_coordinators_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + // A label is ours, never theirs. A coordinator cannot prove a name and + // this one is not asked for one. + text = stringRes(R.string.cordn_coordinators_label_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + val target = config ?: return@Button + busy = true + error = null + scope.launch { + try { + runtime.session(target) + pubKeyInput = "" + relaysInput = "" + labelInput = "" + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }, + enabled = !busy && config != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.cordn_coordinators_add_action)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnHubScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnHubScreen.kt new file mode 100644 index 0000000000..776bde38f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnHubScreen.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_backup +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_backup_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_coordinators +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_coordinators_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_keypackages +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_keypackages_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_link +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_link_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_migrate +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_migrate_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_section_device +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_section_service +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_title +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsControlRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import org.jetbrains.compose.resources.StringResource + +/** + * One door into cordn, instead of five rows in the account settings list. + * + * cordn's pages — coordinators, key packages, link inspection, backup, + * migration — are each visited rarely and only by someone already thinking + * about cordn. As flat entries they made it the biggest feature in that list + * by count and among the least used, pushing everything else down. Grouping + * them costs one tap and keeps every page searchable under a name a user + * would actually look for. + * + * Built from [SettingsSection] and [SettingsItem] like every other settings + * page. The first version hand-rolled its own cards, which made the one screen + * reached FROM the settings list the one screen that did not look like it. + */ +@Composable +fun CordnHubScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_hub_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(Res.string.cordn_hub_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + + // Split by what the page is about rather than listed flat: the + // first three are about a coordinator, the last two about this + // device's own state. + SettingsSection(Res.string.cordn_hub_section_service) { + HubEntry(MaterialSymbols.Dns, Res.string.cordn_hub_coordinators, Res.string.cordn_hub_coordinators_desc) { + nav.nav(Route.CordnCoordinators) + } + SettingsDivider() + HubEntry(MaterialSymbols.Key, Res.string.cordn_hub_keypackages, Res.string.cordn_hub_keypackages_desc) { + nav.nav(Route.CordnKeyPackages) + } + SettingsDivider() + HubEntry(MaterialSymbols.Link, Res.string.cordn_hub_link, Res.string.cordn_hub_link_desc) { + nav.nav(Route.CordnLink) + } + } + + SettingsSection(Res.string.cordn_hub_section_device) { + HubEntry(MaterialSymbols.Save, Res.string.cordn_hub_backup, Res.string.cordn_hub_backup_desc) { + nav.nav(Route.CordnBackup) + } + SettingsDivider() + HubEntry(MaterialSymbols.SwapHoriz, Res.string.cordn_hub_migrate, Res.string.cordn_hub_migrate_desc) { + nav.nav(Route.CordnMigrate) + } + } + } + } +} + +/** + * A navigation row that keeps its description. + * + * [SettingsItem] is the plain navigation row and has no room for one. Every + * page behind this hub is obscure enough that its title alone does not say + * what it does, so the row with a description — and a chevron supplied as the + * trailing slot, since [SettingsControlRow] is built for inline controls — + * is the honest fit. + */ +@Composable +private fun HubEntry( + icon: MaterialSymbol, + title: StringResource, + description: StringResource, + onClick: () -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = onClick, + ) { + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Preview +@Composable +fun CordnHubScreenPreview() { + ThemeComparisonColumn { + CordnHubScreen(mockAccountViewModel(), EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt new file mode 100644 index 0000000000..5f33e13b24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_section +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.model.cordn.CordnKeyPackageRow +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** + * The KeyPackages this account has published, per coordinator. + * + * ## Why this screen has to exist at all + * + * `spec/00.md` §4.2 gives cordn **no KeyPackage event kind**. There is no + * relay to fall back on: the coordinator is the only place an inviter can + * find one, so an account with none published simply cannot be added to a + * group, and nothing anywhere would say why. Marmot's equivalent is invisible + * because its packages live on relays and the app republishes them for you; + * here the publish is a call to one named party, which makes it a decision + * rather than a housekeeping detail. + * + * ## Publishing is attributable, and that is the whole cost + * + * §8.4: a KeyPackage is published under the account key, with a signed payload + * the coordinator stores and serves. It tells that coordinator this account + * exists and is invitable — permanently, and whether or not anyone ever + * invites anyone. That is stated above the button, not discovered after it. + * + * ## Two different truths, shown separately + * + * The coordinator's listing says what an inviter can take. The local store + * says whether the Welcome that results can be opened. A package in the first + * without the second belongs to another device of this account — or to an + * install that is gone, in which case anyone who uses it sends a Welcome + * nobody will ever read. Collapsing the two would hide exactly that case. + */ +@Composable +fun CordnKeyPackagesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_keypackages_title), nav) }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(R.string.cordn_keypackages_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // One section holding every coordinator rather than one each: + // SettingsSection titles come from a StringResource and a + // coordinator's label is runtime text, so a section per + // coordinator is not something this primitive can say. The + // dividers stay, inside, where they now separate peers within a + // box instead of floating in open page. + SettingsSection(Res.string.cordn_keypackages_section) { + SettingsFormBlock { + if (coordinators.isEmpty()) { + Text( + text = stringRes(R.string.cordn_coordinators_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + coordinators.forEachIndexed { index, config -> + // Between, not after: a rule under the last one drew a + // line to nothing. + if (index > 0) HorizontalDivider() + CoordinatorKeyPackages(config, runtime, accountViewModel, nav) + } + } + } + } + } +} + +@Composable +private fun CoordinatorKeyPackages( + config: CoordinatorConfig, + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var rows by remember(config.pubKey) { mutableStateOf?>(null) } + var busy by remember(config.pubKey) { mutableStateOf(false) } + var error by remember(config.pubKey) { mutableStateOf(null) } + val failed = stringRes(R.string.cordn_keypackages_failed) + + suspend fun reload() { + busy = true + error = null + try { + rows = runtime.keyPackages(config.pubKey) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + + LaunchedEffect(config.pubKey) { reload() } + + CoordinatorIdentityRow( + pubKey = config.pubKey, + label = config.label, + accountViewModel = accountViewModel, + nav = nav, + size = 24.dp, + ) { name -> + Text(text = name, style = MaterialTheme.typography.titleSmall) + } + + if (busy) CircularProgressIndicator() + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + val loaded = rows + if (loaded != null && !busy) { + val single = loaded.count { !it.lastResort } + val hasLastResort = loaded.any { it.lastResort } + + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_summary, single, single), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + if (hasLastResort) { + stringRes(R.string.cordn_keypackages_last_resort_yes) + } else { + stringRes(R.string.cordn_keypackages_last_resort_no) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + loaded.filterNot { it.openableHere }.takeIf { it.isNotEmpty() }?.let { orphans -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_orphans, orphans.size, orphans.size), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.cordn_keypackages_orphans_body), + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = { + scope.launch { + try { + runtime.withdrawKeyPackages(config.pubKey, orphans.map { it.keyPackageRef }) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }) { + Text(stringRes(R.string.cordn_keypackages_withdraw_orphans)) + } + } + } + } + + Text( + // Above the buttons, because it is the part that cannot be taken + // back: §8.4 makes publishing an attributable, permanent record. + text = stringRes(R.string.cordn_keypackages_disclosure), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { + scope.launch { + try { + runtime.publishKeyPackage(config.pubKey) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }, + enabled = !busy, + ) { + Text(stringRes(R.string.cordn_keypackages_publish)) + } + OutlinedButton( + onClick = { + scope.launch { + try { + runtime.publishKeyPackage(config.pubKey, lastResort = true) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }, + enabled = !busy && !hasLastResort, + ) { + Text(stringRes(R.string.cordn_keypackages_publish_last_resort)) + } + if (loaded.isNotEmpty()) { + TextButton( + onClick = { + scope.launch { + try { + runtime.withdrawKeyPackages(config.pubKey, loaded.map { it.keyPackageRef }) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }, + enabled = !busy, + ) { + Text(stringRes(R.string.cordn_keypackages_withdraw_all), color = MaterialTheme.colorScheme.error) + } + } + } + + Text( + // The rule CordnRuntime.maintainKeyPackages implements, said where + // someone can see that it applies to them. + text = stringRes(R.string.cordn_keypackages_topup_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt new file mode 100644 index 0000000000..86aab6c17c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CordnLinkInspection +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_link_clear +import com.vitorpamplona.amethyst.commons.resources.cordn_link_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_link_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_link_field +import com.vitorpamplona.amethyst.commons.resources.cordn_link_group_id +import com.vitorpamplona.amethyst.commons.resources.cordn_link_inspect +import com.vitorpamplona.amethyst.commons.resources.cordn_link_invalid +import com.vitorpamplona.amethyst.commons.resources.cordn_link_no_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_link_not_joinable +import com.vitorpamplona.amethyst.commons.resources.cordn_link_paste +import com.vitorpamplona.amethyst.commons.resources.cordn_link_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_link_section_input +import com.vitorpamplona.amethyst.commons.resources.cordn_link_title +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** + * "Someone sent me a `cordn1…` link" — what it points at, and what following it + * would cost. + * + * This screen exists because of §8 of `quartz/plans/2026-09-17-cordn-interop.md`: + * a cordn group and a Marmot group look the same and are not the same, and the + * moment the difference can still change a decision is **before** joining. A + * link is where that moment happens, so the disclosure lives here rather than + * in a settings sub-page nobody opens. + * + * It deliberately does not join anything. Joining needs a live coordinator, and + * Tier B of the interop plan is blocked (§7) — so the honest scope is "read the + * link, tell the truth about it" rather than a join button whose other half has + * never been run. + * + * All parsing is [CordnLinkInspection] in `commons`, which has its own tests; + * everything here is drawing. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnLinkScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var input by remember { mutableStateOf("") } + var inspection by remember { mutableStateOf(null) } + var requestState by remember { mutableStateOf(null) } + var requesting by remember { mutableStateOf(false) } + val runtime = accountViewModel.account.cordnRuntime + val scope = rememberCoroutineScope() + val requestFailed = stringRes(R.string.cordn_link_request_failed) + val asked = stringRes(R.string.cordn_link_request_sent) + var scanning by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_link_title), nav) }, + ) { insets -> + Column( + modifier = + Modifier + .padding(insets) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringResource(Res.string.cordn_link_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // The field, its buttons and the scanner are one thing to do; the + // verdict below is another. Boxing the input says where the screen + // asks something of you and where it answers. + SettingsSection(Res.string.cordn_link_section_input) { + SettingsFormBlock { + OutlinedTextField( + value = input, + onValueChange = { + input = it + // Clearing on edit rather than re-parsing per keystroke: a + // half-typed ref is always invalid, and showing that while + // someone is still pasting is noise, not feedback. + inspection = null + requestState = null + }, + label = { Text(stringResource(Res.string.cordn_link_field)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { inspection = CordnLinkInspection.of(input) }, + enabled = input.isNotBlank(), + ) { + Text(stringResource(Res.string.cordn_link_inspect)) + } + OutlinedButton( + onClick = { + clipboard.getText()?.text?.let { + input = it + inspection = CordnLinkInspection.of(it) + } + }, + ) { + Text(stringResource(Res.string.cordn_link_paste)) + } + OutlinedButton(onClick = { scanning = true }) { + Text(stringRes(R.string.cordn_link_scan)) + } + if (input.isNotEmpty()) { + OutlinedButton( + onClick = { + input = "" + inspection = null + }, + ) { + Text(stringResource(Res.string.cordn_link_clear)) + } + } + } + } + } + + if (scanning) { + // A cordn ref is a long bech32 string nobody types twice. + // Reusing the app's scanner rather than writing one: the + // ref goes through exactly the same parse as a pasted link, + // so a scanned link cannot take a shortcut a typed one + // cannot. + SimpleQrCodeScanner { + scanning = false + if (!it.isNullOrEmpty()) { + input = it + inspection = CordnLinkInspection.of(it) + requestState = null + } + } + } + + when (val result = inspection) { + null -> Unit + + is CordnLinkInspection.Invalid -> + Text( + text = stringResource(Res.string.cordn_link_invalid, result.reason), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + + is CordnLinkInspection.Valid -> { + LabelledValue(stringResource(Res.string.cordn_link_group_id), result.ref.gid) + + result.coordinator?.let { coordinator -> + // The screen exists to answer "should I trust this + // link", and the coordinator is the party being + // trusted -- so it is shown as whoever it is, with a + // face, rather than as 64 characters nobody reads. + LabelledCoordinator( + label = stringResource(Res.string.cordn_link_coordinator), + pubKey = coordinator.pubKey, + coordinatorLabel = coordinator.label, + accountViewModel = accountViewModel, + nav = nav, + ) + LabelledValue( + stringResource(Res.string.cordn_link_relays), + coordinator.relays.joinToString("\n") { it.url }, + ) + } + + result.exposure?.let { CordnExposureCard(it) } + + if (!result.isFollowable) { + Text( + text = stringResource(Res.string.cordn_link_no_coordinator), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + // Still true, and still worth saying: reading a link + // is not joining, and the button below is the only + // thing on this screen that tells anyone anything. + text = stringResource(Res.string.cordn_link_not_joinable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val coordinator = result.coordinator + if (coordinator != null && runtime != null) { + Button( + onClick = { + requesting = true + requestState = null + scope.launch { + requestState = + try { + runtime.requestToJoin(coordinator, result.ref.gid) + asked + } catch (e: Exception) { + e.message ?: requestFailed + } + requesting = false + } + }, + enabled = !requesting, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.cordn_link_request)) + } + + Text( + // Said before the tap, because it is the part that + // cannot be undone: asking to join publishes a + // KeyPackage under this account's own key and tells + // the coordinator this account wants into this + // group, whether or not anyone ever answers (§8.4). + text = stringRes(R.string.cordn_link_request_disclosure), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + requestState?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = + if (it == asked) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + } + } + } + } + } + } +} + +/** + * A [LabelledValue] whose value is a coordinator, rendered as its profile. + * + * The pubkey is still reachable -- tapping the avatar opens the profile, which + * carries the npub -- so nothing is hidden by not printing it here. + */ +@Composable +private fun LabelledCoordinator( + label: String, + pubKey: HexKey, + coordinatorLabel: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CoordinatorIdentityRow( + pubKey = pubKey, + label = coordinatorLabel, + accountViewModel = accountViewModel, + nav = nav, + modifier = Modifier.padding(top = 2.dp), + ) { name -> + Text(text = name, style = MaterialTheme.typography.bodyMedium) + } + } +} + +/** + * A field the user may need to compare against something they were sent, so it + * is selectable and never truncated. + */ +@Composable +private fun LabelledValue( + label: String, + value: String, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectionContainer { + Text(text = value, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt new file mode 100644 index 0000000000..db9b3d4d4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.cordn.CordnMigration +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_code_note +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_done +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_export +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exporting +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exposure_body +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exposure_title +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_handed_off +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_handed_off_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_import +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_importing +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_no_groups +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_no_server +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_paste +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_receive +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_replaces_warning +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan_this +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_receive +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_send +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_sign_in_first +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.model.cordn.AndroidCordnBlobStore +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import kotlinx.coroutines.launch + +/** + * Moving this account's cordn groups to a phone the user is switching to. + * + * ## Why this is a handoff and not sync + * + * `spec/applications/multi-device.md` §10 leaves one case unresolved: two + * devices of one identity committing inside a single delivery round-trip reach + * the same epoch with different states, and §15 concedes that equal-epoch MLS + * states have no merge function. This screen is buildable because a migration + * has one writer — which is only true if this device stops afterwards, which + * is what the handed-off state below is. + * + * ## What the user is told, and where + * + * Two things are said at the moment they are decided rather than in a help + * page. Before exporting: the encrypted documents leave the device for a + * storage server. Before importing: this replaces whatever cordn groups are + * already here, because MLS state cannot be merged. Both are irreversible in + * the ways that matter, and neither is discoverable afterwards. + */ +@Composable +fun CordnMigrateScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_migrate_title), nav) }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(R.string.cordn_group_unavailable), + description = stringRes(R.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val handedOff by runtime.handoff.handedOff.collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (handedOff) { + HandedOff(runtime) + return@Column + } + + Text( + text = stringRes(Res.string.cordn_migrate_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Two boxes, because they are two different devices' jobs and + // only one of them is yours today. A rule between them said they + // were separate without saying which was which — and doing the + // wrong one here hands your account to another phone. + SettingsSection(Res.string.cordn_migrate_section_send) { + SettingsFormBlock { + SendSide(runtime, accountViewModel) + } + } + + SettingsSection(Res.string.cordn_migrate_section_receive) { + SettingsFormBlock { + ReceiveSide(runtime, accountViewModel) + } + } + } + } +} + +/** + * The state that makes the rest of this safe. + * + * Deliberately not phrased as an error. Nothing is broken and nothing was + * deleted — the device stood down on purpose, and the groups are still on + * disk so taking it back is possible. + */ +@Composable +private fun HandedOff(runtime: CordnRuntime) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.cordn_migrate_handed_off), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(Res.string.cordn_migrate_handed_off_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + text = stringRes(Res.string.cordn_migrate_cancel_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { + busy = true + scope.launch { + try { + runtime.cancelHandOff() + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(Res.string.cordn_migrate_cancel)) + } +} + +@Composable +private fun SendSide( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var code by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val noServer = stringRes(Res.string.cordn_migrate_no_server) + val noGroups = stringRes(Res.string.cordn_migrate_no_groups) + + Text( + text = stringRes(Res.string.cordn_migrate_sign_in_first), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Said before the button, because this is the moment the decision is made + // and the upload cannot be taken back afterwards. + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringRes(Res.string.cordn_migrate_exposure_title), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(Res.string.cordn_migrate_exposure_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + code?.let { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringRes(Res.string.cordn_migrate_scan_this), style = MaterialTheme.typography.titleSmall) + QrCodeDrawer(it, Modifier.size(260.dp)) + SelectionContainer { Text(it, style = MaterialTheme.typography.labelSmall) } + Text( + text = stringRes(Res.string.cordn_migrate_code_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + busy = true + error = null + scope.launch { + try { + val servers = listOfNotNull(accountViewModel.account.settings.defaultFileServer.baseUrl) + if (servers.isEmpty()) { + error = noServer + return@launch + } + if (runtime.migrationSnapshot().groups.isEmpty()) { + error = noGroups + return@launch + } + + val migration = + CordnMigration( + accountViewModel.account.client, + accountViewModel.account.signer, + AndroidCordnBlobStore(servers, context), + ) + code = runtime.handOff(migration, accountViewModel.account.outboxRelays.flow.value).encode() + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + }, + enabled = !busy && code == null, + modifier = Modifier.fillMaxWidth(), + ) { + // The label already says "Exporting"; the spinner says it is still + // going, which a static word cannot. + BusyLabel(busy, stringRes(if (busy) Res.string.cordn_migrate_exporting else Res.string.cordn_migrate_export)) + } +} + +@Composable +private fun ReceiveSide( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var typed by remember { mutableStateOf("") } + var scanning by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var done by remember { mutableStateOf(null) } + val badCode = stringRes(Res.string.cordn_migrate_scan) + + Text(stringRes(Res.string.cordn_migrate_receive), style = MaterialTheme.typography.titleSmall) + + // Said before the button, for the same reason as the exposure card: an + // import replaces, and MLS state cannot be merged back afterwards. + Text( + text = stringRes(Res.string.cordn_migrate_replaces_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (scanning) { + SimpleQrCodeScanner { + scanning = false + if (it != null) typed = it + } + } + + OutlinedButton(onClick = { scanning = true }, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(Res.string.cordn_migrate_scan)) + } + + OutlinedTextField( + value = typed, + onValueChange = { + typed = it + error = null + }, + label = { Text(stringRes(Res.string.cordn_migrate_paste)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + done?.let { Text(stringRes(Res.string.cordn_migrate_done, it), style = MaterialTheme.typography.bodyMedium) } + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + val parsed = CordnHandoffCode.decodeOrNull(typed.trim()) + if (parsed == null) { + error = badCode + return@Button + } + busy = true + error = null + scope.launch { + try { + val migration = + CordnMigration( + accountViewModel.account.client, + accountViewModel.account.signer, + AndroidCordnBlobStore(emptyList(), context), + ) + val snapshot = migration.fetch(parsed) + runtime.adoptMigration(snapshot) + done = snapshot.groups.size + typed = "" + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + }, + enabled = !busy && typed.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + BusyLabel(busy, stringRes(if (busy) Res.string.cordn_migrate_importing else Res.string.cordn_migrate_import)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/SettingsFormBlock.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/SettingsFormBlock.kt new file mode 100644 index 0000000000..ca8825fec0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/SettingsFormBlock.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Form content inside a `SettingsSection`. + * + * The section's card deliberately has no padding of its own: it is built for + * rows like `SettingsItem`, which carry theirs so a divider can run edge to + * edge between them. cordn's pages are forms rather than row lists — text + * fields, explainers and buttons — and those need the inset the rows would + * otherwise have supplied. + * + * One helper rather than a `Modifier.padding(16.dp)` repeated down five + * screens, so the pages stay identical to each other as they change. + */ +@Composable +fun SettingsFormBlock(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1219814557..2da4657d8d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -305,6 +305,250 @@ + Coordinators + Nothing is arriving from these groups. Amethyst keeps retrying. + No cordn groups yet + A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes. + Start one + This group is not available. Its coordinator may have been removed. + Group info + Message deleted + edited + Go to message + Withdraw message + Everyone in the group will see this message as withdrawn, and Amethyst will stop showing its text. The coordinator still holds the encrypted copy it was sent as, and anyone who already read it has already read it. + Accepted by the coordinator + Message details + Sent + Cursor + Coordinator + Message + Reply + Edit + Delete + Pin + Previous pinned message + Next pinned message + Show all pinned messages + Pinned messages + Pinned by %1$s + + %1$d pinned message in this group + %1$d pinned messages in this group + + Unpin + Editing your message + Could not send that file. + That did not send. + This group\'s coordinator is not open, so nothing can be sent yet. + Could not open that file. + No media server is set for this account, so there is nowhere to put the file. Pick one in Settings. + That file could not be read. + Record a voice note + Stop and send + Play voice note + Your nsec cannot bring cordn groups back. A cordn group lives as encryption state on this device and an ordered stream on a coordinator that cannot read it — lose the device without a backup and the group is gone, including everything already said in it. + Passphrase + What is in the file + Your group encryption keys, your key packages, and every message in those groups. Anyone who opens it can read the conversations. The passphrase is the only thing protecting it once it leaves Amethyst. + Export + Backup saved. + Restore + Restoring replaces the cordn groups on this device with the ones in the file. It is for a phone that has taken over from another — not a way to use the same groups on two devices at once. + Restore + Replace this device\'s cordn groups? + Every cordn group now on this device will be removed and replaced by the ones in the file.\n\nDo not restore onto a second phone while the first is still in use. Both would hold the same group keys, and once both have sent a message the group breaks for everyone — silently, and with no way back. + Restored. + That did not work. Check the passphrase and the file. + Send + Coordinator + Coordinator key + Coordinator link + Copy the coordinator\'s nprofile + Group id + Epoch + Members + This group has no admins: everyone can add, remove and rename, permanently. + Technical details + New Cordn group + A cordn group is ordered by one coordinator you choose. Creating the group happens on this device — the coordinator only learns the group exists once you invite someone or send a message. + Nobody yet — tap to add people + Nobody added yet. A group can be created empty and invitations sent later, but naming people now is what shows which coordinator can reach them. + Done + You, always an admin + Name it + Who is in it + Where it lives + Who can add and remove + Add someone + Naming people now is what tells us which coordinator can reach them. Each one gets a Welcome left on that coordinator, which they pick up next time they open their app. + Not checked yet + No key on any coordinator you use + Everyone can add and remove + Admin + No coordinator chosen + Change + Done + Not checked — picking it will ask + Reaches all %1$d of your people + Reaches %1$d of %2$d — the rest would be left out + The group exists + Each invitation is a Welcome left on the coordinator, so they can fail one at a time. Nobody has been notified: they see it next time they open their app. + Added to the group, but the invitation did not reach them — try again from the group screen + Waiting for them + The coordinator refused the invitation + No key here, so no Welcome could be left + Send a link + Open the chat + Coordinator + Find coordinators + Another coordinator + Coordinator public key (hex or npub) + Relays it answers on, one per line + Group name + Description (optional) + Everyone in the group can invite, remove members and change its details. Any member can name admins later, which ends that. + Only I manage this group + That change could not be made. + Remove + Remove from group? + %1$s stops receiving messages from here. They keep what they have already read, and this cannot be undone — they would have to be invited again. + Edit details + Save + Only you can invite, remove members or change the group\'s details. You can hand that to someone else later. + Create group + The group could not be created. + Cordn invitations + Checked when you open this screen, and at no other time — asking a coordinator whether anyone invited you tells it you are here, so Amethyst does not do it in the background. + Check again + Join + Decline + Declining is permanent. Getting back in means being invited again. + Yesterday + New messages + No messages yet + Anything sent here is encrypted for this group. The coordinator relays it without being able to read it. + Reactions + Add a coordinator in Settings to start using cordn groups. + Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked. + Look for coordinators + Add + Could not read announcements + Nobody is announcing on your relays. + Nothing found, and %1$d of your relays did not answer. + Announced %1$s ago + Last announced %1$s + %1$s + %1$s +%2$d more + Show all %1$d + Show fewer + Show %1$d that stopped announcing + Hide the ones that stopped announcing + These have not announced in over a month. Creating a group on one that has gone away will fail. + Add someone + Name, npub or name@domain + Can be added on this coordinator + They can only be added if they published a key package to this coordinator. + What this coordinator can see + Got it + That person + %1$s has not published a key package to this coordinator, so they cannot be added yet. Ask them to open cordn on this coordinator first. + This coordinator served a key package that belongs to someone else, so %1$s was not added. Nothing was changed. + %1$s could not be given a way into the group, so the add was abandoned. + %1$s is not in this group. + You cannot remove yourself from a cordn group. + The coordinator did not answer, so nothing was changed. Try again in a moment. + Nobody found by that name. + Admin + +%1$d more + Through %1$s + No invitations waiting. + You have no coordinator open. cordn invitations arrive through a coordinator, so there is nowhere to look yet. + %1$s did not answer + An invitation was left for another device + Could not read invitations. + Ask to join this group + This publishes a key package under your own account key and tells the coordinator you want into this group — whether or not anyone answers. + Asked. A member of the group has to add you; the invitation will show up under cordn invitations. + Could not ask to join. + Requests to join + Check for requests + Nobody is waiting to join. + Add + Dismiss + Only an admin can accept. In a group with no admins, that is everyone. + Could not read the requests. + Scan + Share this group + Anyone with this link can ask to join. An admin still has to let them in. + Copy link + Copied + A coordinator orders one group\'s messages and is the only thing that can. Losing it loses the ordering, and a second one does not mirror the first — so these are named things you choose, not interchangeable relays. + No coordinators yet. + Add + Could not open that coordinator. + A name for it (yours, optional) + Answers on + Coordinator key + Copy the coordinator\'s npub + More actions + Rename + This name is yours and stays on this device. A coordinator cannot prove a name, so it is never asked for one. + Ask who it is + Says it is: %1$s + Answered, but named nothing. + A coordinator\'s name and version are strings it chose. Its public key is the only thing that identifies it. + Answering. + A call failed; retrying. + Not answering — %1$d calls in a row have failed. Groups on it are not syncing. + Nothing asked of it yet. + Remove + Purge + Purge this coordinator? + Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had. + Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why. + + %1$d person cannot be reached by any coordinator you use + %1$d people cannot be reached by any coordinator you use + + + %1$d group + %1$d groups + + + Reachable on %1$d coordinator + Reachable on %1$d coordinators + + + %1$d person can add and remove + %1$d people can add and remove + + + Create and invite %1$d person + Create and invite %1$d people + + + %1$d member + %1$d members + + + %1$d single-use package available. + %1$d single-use packages available. + + A last-resort package is published, so an invitation can still be made once the single-use ones run out. + No last-resort package. Once the single-use ones run out, invitations fail. + + %1$d package this device cannot open + %1$d packages this device cannot open + + The coordinator will hand these to anyone inviting you, but the private half is not on this device — it belongs to another install. If that install is gone, an invitation using one of these produces a welcome nobody can ever open. + Withdraw those + Publishing signs a record under your own account key. That coordinator then knows this account exists and is invitable — permanently, and whether or not anyone invites you. + Publish one + Publish last-resort + Withdraw all + Once you have published here, Amethyst quietly replaces packages as they are used up. It never publishes the first one for you. + Could not read the key packages. Forum channel Threaded posts instead of a chat timeline. This cannot be changed later. @@ -568,8 +812,6 @@ - Amy Debug - Amy Benchmark @@ -582,7 +824,6 @@ - Health Connect and Amethyst @@ -650,6 +891,18 @@ Voice Post Voice Reply Report + + + Amy Debug + Amy Benchmark + Health Connect and Amethyst + + + + %1$d person is already in this group + %1$d people are already in this group + Private Message Chat message Nest — Live @@ -996,4 +1249,8 @@ Birthday CLINK offer Bot flag + + The group\'s %1$d admin stays as it is. + The group\'s %1$d admins stay as they are. + diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt new file mode 100644 index 0000000000..339fa80822 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.disambiguate +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Telling two coordinators apart on the discovery list. + * + * An announced name is the coordinator's own word for itself and collides + * constantly: the reference server ships as "My coordinator", so a run against + * a public relay comes back with a dozen rows carrying that name and nothing + * else to separate them. + */ +class CordnCoordinatorNameTest { + @Test + fun `a name nobody else uses is left alone`() { + val names = listOf("cordn-net", "My coordinator") + + assertEquals("cordn-net", disambiguate("cordn-net", "aabbccdd11223344", names)) + } + + @Test + fun `a colliding name gains its key`() { + val names = listOf("My coordinator", "My coordinator", "cordn-net") + + assertEquals("My coordinator · aabbccdd", disambiguate("My coordinator", "aabbccdd11223344", names)) + } + + @Test + fun `two that collide get different suffixes`() { + val names = listOf("My coordinator", "My coordinator") + + val first = disambiguate("My coordinator", "aaaaaaaa11112222", names) + val second = disambiguate("My coordinator", "bbbbbbbb33334444", names) + + assertEquals("My coordinator · aaaaaaaa", first) + assertEquals("My coordinator · bbbbbbbb", second) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/VoiceWaveformDownsampleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/VoiceWaveformDownsampleTest.kt new file mode 100644 index 0000000000..bafa34d841 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/VoiceWaveformDownsampleTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.ui.actions.uploads.downsampled +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The bars a recording reports. + * + * Sampling used to run once a second, so a five-second note produced five + * numbers and the drawn waveform could not track speech at all. It now samples + * ten times a second and reduces here, which is only worth doing if the + * reduction keeps the shape and bounds the size. + */ +class VoiceWaveformDownsampleTest { + @Test + fun `a short recording is left exactly as it is`() { + val short = listOf(1f, 2f, 3f) + + assertEquals(short, short.downsampled(100)) + } + + @Test + fun `a long recording is bounded`() { + // Ten minutes at ten samples a second. These travel inside events and + // imeta tags, so the size has to depend on the detail wanted rather + // than on how long somebody spoke. + val long = List(6000) { it.toFloat() } + + assertEquals(100, long.downsampled(100).size) + } + + @Test + fun `reducing averages rather than dropping samples`() { + // Taking every Nth would let one loud frame stand for a whole bucket + // and make the bars flicker with the sampling phase. The first bucket + // here averages 0..9, whose mean is 4.5. + val ramp = List(1000) { it.toFloat() } + + val reduced = ramp.downsampled(100) + + assertEquals(4.5f, reduced.first(), 0.001f) + assertEquals(994.5f, reduced.last(), 0.001f) + } + + @Test + fun `a loud burst survives the reduction`() { + // Averaging must not flatten real signal away: a quiet recording with + // one loud moment should still show that moment. + val quietWithBurst = MutableList(1000) { 100f }.also { for (i in 500..509) it[i] = 30000f } + + val reduced = quietWithBurst.downsampled(100) + + assertTrue("the burst was averaged away: ${reduced.max()}", reduced.max() > 10000f) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt new file mode 100644 index 0000000000..8a6066509a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cordn + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.cordnGroupPositionFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.sameDayAs +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId + +/** + * Where a cordn bubble sits in a run of its sender's messages. + * + * The feed is reverse-laid-out, so the newer/older argument order is easy to invert and + * impossible to notice from the code alone — an inverted version still produces + * plausible-looking bubbles, just with the tail on the wrong end of every burst. These + * assert the ends of a run by name. + */ +class CordnMessageGroupingTest { + private val alice: HexKey = "aa".repeat(32) + private val bob: HexKey = "bb".repeat(32) + + /** Local noon today, so a ±10 minute window never crosses midnight by accident. */ + private val noon = + LocalDate + .now(ZoneId.systemDefault()) + .atStartOfDay(ZoneId.systemDefault()) + .plusHours(12) + .toEpochSecond() + + private var cursor = 0L + + private fun msg( + author: HexKey, + at: Long, + ) = CordnDeliveredMessage( + envelope = CordnEnvelope.build(author, at, 9, emptyArray(), "hi"), + cursor = ++cursor, + ) + + @Test + fun `a lone message is SINGLE`() { + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, msg(alice, noon), null)) + } + + @Test + fun `a run of three from one sender is TOP MIDDLE BOTTOM oldest first`() { + val oldest = msg(alice, noon) + val middle = msg(alice, noon + 60) + val newest = msg(alice, noon + 120) + + // The oldest of a run renders at the visual top of the burst and carries the + // author line; the newest carries the bubble tail. + assertEquals(ChatGroupPosition.TOP, cordnGroupPositionFor(middle, oldest, null)) + assertEquals(ChatGroupPosition.MIDDLE, cordnGroupPositionFor(newest, middle, oldest)) + assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, newest, middle)) + } + + @Test + fun `a different sender between them breaks the run`() { + val mine = msg(alice, noon) + val theirs = msg(bob, noon + 60) + val mineAgain = msg(alice, noon + 120) + + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(theirs, mine, null)) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(mineAgain, theirs, mine)) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, mineAgain, theirs)) + } + + @Test + fun `the shared ten-minute window is what decides, not a cordn-local one`() { + val first = msg(alice, noon) + + // Inside the window the run holds... + val nine = msg(alice, noon + 9 * 60) + assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, nine, first)) + + // ...and just past it, it breaks. A cordn-local 5-minute window (what this + // screen used before adopting the shared one) would already have broken at 9. + val eleven = msg(alice, noon + 11 * 60) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, eleven, first)) + } + + @Test + fun `a run does not cross local midnight even inside the time window`() { + val midnight = + LocalDate + .now(ZoneId.systemDefault()) + .atStartOfDay(ZoneId.systemDefault()) + .toEpochSecond() + + val lastNight = msg(alice, midnight - 120) + val thisMorning = msg(alice, midnight + 120) + + // Four minutes apart, same sender — but a day separator lands between them, so + // joining them into one burst would draw the separator inside a sealed bubble run. + assertFalse(thisMorning.sameDayAs(lastNight)) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, thisMorning, lastNight)) + } + + @Test + fun `sameDayAs treats a missing neighbour as a new day`() { + // Drives the day separator above the very first message in the list. + assertFalse(msg(alice, noon).sameDayAs(null)) + assertTrue(msg(alice, noon).sameDayAs(msg(bob, noon + 3600))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnRuntimeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnRuntimeTest.kt new file mode 100644 index 0000000000..8adf6ce8db --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnRuntimeTest.kt @@ -0,0 +1,743 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cordn + +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLink +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLinkFactory +import com.vitorpamplona.amethyst.commons.cordn.CordnHandedOffException +import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The rules `CordnRuntime` holds, made executable. + * + * It is the assembly point for everything cordn: the KeyPackage pool policy, + * purge, restore, the invitation fan-out. Every one of those was a sentence in + * a commit message and none of them was a test, which is the combination that + * makes a rule quietly stop being true. + * + * Two seams make this possible without a device or a relay. `cipher` was + * always one, because the Android KeyStore cannot run in a JVM unit test. + * `links` is the second: production opens a real ContextVM transport per + * coordinator, so without it nothing in this class could be reached at all. + * Both default to the real thing. + */ +class CordnRuntimeTest { + /** Reversible and unmistakably not the input — the same trick the store tests use. */ + private class XorCipher : CordnBlobCipher { + override fun encrypt(bytes: ByteArray) = ByteArray(bytes.size) { (bytes[it].toInt() xor 0x5A).toByte() } + + override fun decrypt(bytes: ByteArray) = encrypt(bytes) + } + + private val signer = NostrSignerInternal(KeyPair()) + private val account: HexKey get() = signer.pubKey + + private val coordinatorA = StubCoordinator() + private val coordinatorB = StubCoordinator() + private val keyA = "a".repeat(64) + private val keyB = "b".repeat(64) + + private val root = + File.createTempFile("cordn-runtime", "").also { + it.delete() + it.mkdirs() + } + + private val scopes = mutableListOf() + + /** + * Anything a runtime coroutine threw and nobody caught. + * + * A bare `CoroutineScope(Dispatchers.Default + Job())` has no handler, so + * an exception escaping a `launch` goes to the THREAD's uncaught handler — + * out of this test entirely and into the JVM the whole module shares. The + * next test to call `runTest` then fails with + * `UncaughtExceptionsBeforeTest`, blaming a test that did nothing wrong. + * That is a miserable failure to chase: it is a race between a coroutine + * outliving its test and the next one starting, so it moves between + * classes and build variants and vanishes when run in isolation. + * + * Collecting them here keeps them inside this suite, and [cleanup] fails + * this test rather than a stranger's. + */ + private val leaked = java.util.concurrent.CopyOnWriteArrayList() + + private fun configFor(pubKey: HexKey) = + CoordinatorConfig( + pubKey = pubKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + ) + + /** + * Every connection opened, newest last. + * + * One per `connect`, so a coordinator whose relays were corrected appears + * twice -- which is the only way to tell whether the sync loop followed the + * reopen or stayed on the connection that was replaced. The stub behind + * them is shared per pubkey, so key packages and published state survive a + * reopen exactly as the real stores do. + */ + private val connections = java.util.concurrent.CopyOnWriteArrayList() + + /** Hands each coordinator pubkey its own stub, with no transport in between. */ + private val links = + CordnCoordinatorLinkFactory { _, config -> + val counting = + CountingCoordinator(if (config.pubKey == keyA) coordinatorA else coordinatorB) + .also { connections += it } + + object : CordnCoordinatorLink { + override val coordinator = counting + + override suspend fun close() = Unit + } + } + + /** + * A scope for the runtime, on real threads. + * + * Real rather than a `TestScope` dispatcher, and that is forced rather + * than chosen: `maintainKeyPackages` reads the KeyPackage store, which + * hops to `Dispatchers.IO`. Virtual time cannot reach across that, so no + * amount of `advanceUntilIdle()` makes the upkeep deterministic — an + * earlier version of this suite looked green while the work had simply not + * run yet, which is the failure mode a test is supposed to prevent. + * + * Its own `Job`, so nothing waits on `CordnSyncLoop`, whose design is + * never to return; cancelled in [cleanup]. + */ + private fun runtimeScope() = + CoroutineScope( + Dispatchers.Default + Job() + + CoroutineExceptionHandler { _, throwable -> leaked += throwable }, + ).also { scopes += it } + + private fun runtime( + scope: CoroutineScope, + client: INostrClient = EmptyNostrClient(), + ) = CordnRuntime( + accountSigner = signer, + client = client, + filesDir = root, + scope = scope, + cipher = XorCipher(), + links = links, + ) + + /** + * Waits for [condition], or fails by name. + * + * A bounded poll rather than a barrier, for the reason above: the work is + * on another dispatcher. The budget is orders of magnitude more than it + * needs, so a timeout here is a real failure and not a slow machine. + */ + private suspend fun awaitUntil( + what: String, + condition: () -> Boolean, + ) { + withTimeoutOrNull(AWAIT_BUDGET_MS) { + while (!condition()) delay(POLL_MS) + } ?: error("timed out waiting for $what") + } + + /** + * Gives the runtime's detached upkeep room to run, then returns. + * + * Only used before asserting something did NOT happen. Weaker than a + * barrier by nature — the honest way to assert a negative about another + * dispatcher is to give it far more time than it needs and look again. + */ + private suspend fun settle() = delay(SETTLE_MS) + + @After + fun cleanup() { + scopes.forEach { it.cancel() } + root.deleteRecursively() + + // Cancellation is not a leak; anything else is, and it would otherwise + // have surfaced as an unrelated test failing somewhere else entirely. + val real = leaked.filterNot { it is CancellationException } + check(real.isEmpty()) { "a runtime coroutine leaked: ${real.joinToString { it.toString() }}" } + } + + /** + * Records every REQ, so a test can say which relays were asked what. + * + * [EmptyNostrClient] answers nothing, which is all this needs: the + * assertion is about the filter that goes out, not the events that come + * back. + */ + private class RecordingClient( + delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + val reqs = java.util.concurrent.CopyOnWriteArrayList>>() + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + reqs += filters + } + } + + @Test + fun `opening a coordinator asks its own relays who it is`() = + runBlocking { + // The screens render a coordinator as the Nostr user it is, which + // puts it in LocalCache and sets the user-metadata machinery looking + // for its kind 0. Left alone, that lookup has no outbox to go on and + // falls through to THIS ACCOUNT's index and home relays -- telling + // them the account is interested in a pubkey that CEP-6 + // announcements publicly identify as a coordinator. Fetching the + // kind 0 and the kind 10002 here, from the coordinator's own relays, + // is what stops that: with a relay list cached the outbox finder + // issues no discovery filter at all. + val client = RecordingClient() + val config = configFor(keyA) + + runtime(runtimeScope(), client).session(config) + + awaitUntil("the coordinator's profile to be requested") { client.reqs.isNotEmpty() } + + val profileReqs = + client.reqs.filter { req -> + req.values.any { filters -> + filters.any { it.authors == listOf(keyA) } + } + } + + assertTrue("no request carried the coordinator as its author", profileReqs.isNotEmpty()) + + profileReqs.forEach { req -> + assertEquals( + "asked the wrong relays -- this must not reach the account's own relays", + config.relays.toSet(), + req.keys, + ) + req.values.flatten().forEach { + assertEquals( + "expected the profile, the relay list and the announcement (CEP-23, CEP-17, CEP-6)", + listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND, CvmKinds.SERVER_ANNOUNCEMENT), + it.kinds, + ) + } + } + } + + @Test + fun `correcting a coordinator's relays moves the sync loop onto the new connection`() = + runBlocking { + // The registry cannot carry a transport across a relay change, so it + // opens a new one and builds a new manager over it. CordnSyncLoop + // captures its source for life, so a loop kept across that swap goes + // on polling the connection that was just closed -- the coordinator + // then retries forever and its rooms never update again, with a + // restart the only way out. + val runtime = runtime(runtimeScope()) + // A group first: the loop has nothing to subscribe to until the + // coordinator holds one, so a bare session would never subscribe and + // the assertion below would pass for the wrong reason. + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "A"), gid = "moved-gid") + + awaitUntil("the first connection to be subscribed") { + connections.firstOrNull()?.subscriptions?.let { it > 0 } == true + } + + runtime.session( + configFor(keyA).copy( + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://moved.example.com")!!), + ), + ) + + assertEquals("the relay change did not open a second connection", 2, connections.size) + + awaitUntil("the loop to follow the reopen onto the new connection") { + connections[1].subscriptions > 0 + } + } + + @Test + fun `renaming a coordinator keeps the connection it already had`() = + runBlocking { + // The registry reopens the transport for a config that differs, + // because the transport is bound to the relays -- and that builds a + // NEW manager while CordnSyncLoop goes on holding the old one, whose + // scope has just been closed. A label is not part of the address, so + // renaming must not take that path. + val runtime = runtime(runtimeScope()) + val config = configFor(keyA) + runtime.session(config) + + val before = runtime.sessionOrNull(keyA)?.manager + assertNotNull("no session to rename", before) + + runtime.relabel(keyA, " My coordinator ") + + assertSame( + "renaming reopened the transport, which orphans the running sync loop", + before, + runtime.sessionOrNull(keyA)?.manager, + ) + assertEquals( + "the label is not trimmed to what the user meant", + "My coordinator", + runtime.coordinators.value + .first { it.pubKey == keyA } + .label, + ) + } + + @Test + fun `clearing a coordinator's name stores no name rather than an empty one`() = + runBlocking { + // An empty label would render as a blank line where a name goes, and + // the display chain could never fall through to the profile or the + // key behind it. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + runtime.relabel(keyA, "named") + + runtime.relabel(keyA, " ") + + assertNull( + runtime.coordinators.value + .first { it.pubKey == keyA } + .label, + ) + } + + @Test + fun `opening a coordinator with nothing published does not publish a key package`() = + runBlocking { + // §8.4: publishing tells a coordinator this account exists and is + // invitable, permanently. Doing that on someone's behalf at login + // is the decision this rule exists to refuse. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + settle() + + assertFalse("published without being asked", coordinatorA.published.isNotEmpty()) + } + + @Test + fun `a forgotten coordinator stays forgotten across a relaunch`() = + runBlocking { + // `remember()` merges the open sessions over what is on disk rather + // than replacing it, because a coordinator whose relay was down + // this launch is missing from the open set and saving that set + // verbatim would erase it. A forgotten coordinator is missing for + // the other reason and looks identical, so the merge protected it + // too and wrote it straight back: Remove worked until the next + // launch and then undid itself. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + runtime.session(configFor(keyB)) + settle() + + runtime.forget(keyA) + settle() + + // A second runtime over the same files is the relaunch: it reads + // the coordinator list back off disk, which is where the removal + // either stuck or did not. + val reopened = runtime(runtimeScope()) + reopened.restore() + settle() + + val keys = reopened.coordinators.value.map { it.pubKey } + assertFalse("the forgotten coordinator came back", keys.contains(keyA)) + assertTrue("the other coordinator was lost with it", keys.contains(keyB)) + } + + @Test + fun `opening a coordinator that already has one tops the pool back up`() = + runBlocking { + // The other half of the rule. Once a package is published there the + // coordinator already knows, and a drained pool fails the next + // invitation for a reason the inviter sees and the invitee never + // does. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + runtime.publishKeyPackage(keyA) + val afterFirst = coordinatorA.published.size + + // A second runtime over the same files: a relaunch, with one + // package already published and its private half on disk. + runtime(runtimeScope()).session(configFor(keyA)) + + awaitUntil("the pool to be topped up after a relaunch") { coordinatorA.published.size > afterFirst } + } + + @Test + fun `purging one coordinator leaves the other's groups on disk`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "A"), gid = "shared-gid") + runtime.createGroup(configFor(keyB), CordnGroupMetadata(name = "B"), gid = "shared-gid") + + runtime.purge(keyA) + + // A gid is unique only within one coordinator, so both hold + // "shared-gid" as unrelated groups. Deleting a level too high takes + // both, and the only visible symptom is a room that vanished. + assertFalse(CordnStorageLayout.directoryFor(root, account, keyA).exists()) + assertTrue(CordnStorageLayout.directoryFor(root, account, keyB).exists()) + assertEquals( + listOf(keyB), + runtime.groups.all.value + .map { it.coordinatorPubKey }, + ) + } + + @Test + fun `an archive from another account is refused`() = + runBlocking { + // Restoring one account's groups under another's key gives a device + // MLS state whose credentials name somebody else; every Commit it + // made would be rejected by the rest of the group. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + val archive = runtime.exportArchive("pw") + + val stranger = + CordnRuntime( + accountSigner = NostrSignerInternal(KeyPair()), + client = EmptyNostrClient(), + filesDir = root, + scope = runtimeScope(), + cipher = XorCipher(), + links = links, + ) + + val failure = runCatching { stranger.importArchive(archive, "pw") }.exceptionOrNull() + assertTrue("expected a refusal, got $failure", failure is IllegalArgumentException) + } + + @Test + fun `a snapshot carries the groups, their cursors and their key packages`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + + val snapshot = runtime.migrationSnapshot() + + assertEquals(listOf("mine"), snapshot.groups.map { it.gid }) + assertEquals(keyA, snapshot.groups[0].coordinatorPubKey) + assertTrue(snapshot.groups[0].coordinatorRelays.isNotEmpty()) + assertTrue(snapshot.groups[0].clientStateBase64.isNotEmpty()) + } + + @Test + fun `adopting a snapshot replaces this device's groups rather than merging them`() = + runBlocking { + // Same rule as restoring a backup, and for the same reason: a merge + // is what produces two devices holding one group's state. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "In the snapshot"), gid = "kept") + val snapshot = runtime.migrationSnapshot() + + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Added after"), gid = "dropped") + + runtime.adoptMigration(snapshot) + + assertEquals( + setOf("kept"), + runtime.groups.all.value + .map { it.gid } + .toSet(), + ) + } + + @Test + fun `a snapshot from another account is refused`() = + runBlocking { + val runtime = runtime(runtimeScope()) + val snapshot = runtime.migrationSnapshot().copy(accountPubKey = "ff".repeat(32)) + + val failure = runCatching { runtime.adoptMigration(snapshot) }.exceptionOrNull() + + assertTrue("expected a refusal, got $failure", failure is IllegalArgumentException) + } + + @Test + fun `a handed-off device refuses to open a session`() = + runBlocking { + // The fork guard. Without it both phones hold one leaf and both + // commit, and MLS does not recover from that. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + runtime.handoff.markHandedOff() + + val failure = runCatching { runtime.session(configFor(keyA)) }.exceptionOrNull() + + assertTrue("expected a refusal, got $failure", failure is CordnHandedOffException) + } + + @Test + fun `cancelling a handoff lets the device work again`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + runtime.handoff.markHandedOff() + + runtime.cancelHandOff() + + assertNotNull(runtime.session(configFor(keyA))) + } + + @Test + fun `adopting a snapshot clears a handoff, because this device now holds the newest copy`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + val snapshot = runtime.migrationSnapshot() + runtime.handoff.markHandedOff() + + runtime.adoptMigration(snapshot) + + assertFalse(runtime.handoff.handedOff.value) + } + + @Test + fun `restoring replaces this device's groups rather than merging them`() = + runBlocking { + // The rule the confirmation dialog states. A merge is what produces + // two devices holding one group's state, and MLS does not recover + // from the fork that follows. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "In the backup"), gid = "kept") + val archive = runtime.exportArchive("pw") + + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Added after"), gid = "dropped") + assertEquals( + setOf("kept", "dropped"), + runtime.groups.all.value + .map { it.gid } + .toSet(), + ) + + runtime.importArchive(archive, "pw") + + assertEquals( + setOf("kept"), + runtime.groups.all.value + .map { it.gid } + .toSet(), + ) + } + + @Test + fun `a coordinator that will not answer is reported, not counted as empty`() = + runBlocking { + // "We could not ask" and "there is nothing for you" look identical + // on screen and mean opposite things. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + coordinatorA.failEverything = true + + val invitations = runtime.invitations() + + assertTrue(invitations.pending.isEmpty()) + assertEquals(listOf(keyA), invitations.unreachable.map { it.coordinator.pubKey }) + assertFalse("an unreachable coordinator is not an empty inbox", invitations.isEmpty) + } + + /** + * One opened connection, counting what was asked of it. + * + * Only the subscribe matters: it is what `CordnSyncLoop` does forever, so a + * connection that is never subscribed is one no loop is pointing at. + */ + private class CountingCoordinator( + private val delegate: ICoordinator, + ) : ICoordinator by delegate { + @Volatile + var subscriptions = 0 + private set + + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + subscriptions++ + delegate.subscribeMessages(cursors, timeoutMs, onMessage) + } + } + + /** + * Just enough coordinator to let the runtime run. + * + * Not a fidelity fixture — `CordnFixtureCoordinator` is that, and it sits + * behind a transport because it answers `CvmRequest`s. What these tests + * need is the `ICoordinator` surface the runtime talks to, with a way to + * make it stop answering. + */ + private class StubCoordinator : ICoordinator { + val published = mutableMapOf() + var failEverything = false + + private var clock = 1L + + private fun guard() { + if (failEverything) throw IllegalStateException("coordinator is down") + } + + override suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage { + guard() + published[keyPackageRef] = keyPackageBase64 + return PublishedKeyPackage(keyPackageRef, false, clock++) + } + + override suspend fun removeKeyPackages(keyPackageRefs: List): List { + guard() + return keyPackageRefs.filter { published.remove(it) != null } + } + + override suspend fun listKeyPackages(): List { + guard() + return published.keys.map { AvailableKeyPackage(OWNER, it, false, clock) } + } + + override suspend fun takeKeyPackage(id: String): TakenKeyPackage? { + guard() + return null + } + + override suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long?, + ): Long { + guard() + return clock++ + } + + override suspend fun takeWelcomes(consumed: List): List { + guard() + return emptyList() + } + + override suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long { + guard() + return clock++ + } + + override suspend fun takeJoinRequests( + gids: List, + consumed: List, + ): List { + guard() + return emptyList() + } + + override suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage { + guard() + return PostedMessage(gid, clock++, clock) + } + + override suspend fun fetchMessages(cursors: Map): List { + guard() + return emptyList() + } + + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + guard() + // Suspends for the whole window, like a real subscription: the + // coordinator holds the call open and returns when it closes. + // Returning immediately turns `CordnSyncLoop` into a spin, which + // on a virtual-time dispatcher is an unbounded one. + delay(timeoutMs) + } + + private companion object { + /** Whose packages these are. The runtime filters `kp_list` by account. */ + const val OWNER = "owner" + } + } + + private companion object { + const val AWAIT_BUDGET_MS = 10_000L + const val POLL_MS = 10L + + /** Long enough that upkeep which was going to happen already has. */ + const val SETTLE_MS = 1_000L + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt new file mode 100644 index 0000000000..e5a28cf819 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cordn + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The screen half of the Marmot/cordn boundary. + * + * `CordnIndependenceTest` in `commons` guards the models and the protocol + * code; this guards the Android screens, which is where the coupling would + * actually be convenient. A cordn chat screen that imported a Marmot composable + * would work — and would then make Marmot's UI, which is frozen, unable to + * change without breaking cordn. + * + * What cordn may share is what any feature shares: the design system, the + * theme, navigation, and generic components. The line is drawn at + * `marmotGroup`-named code, because that is where a real coupling would land. + * + * Third of the three guards §3.1 of `amethyst/plans/2026-09-19-cordn-ui.md` + * asks for, added with the first screen rather than after the fifth. + */ +class CordnScreenIndependenceTest { + private val screensRoot: File by lazy { + // Resolved, not assumed: a wrong root makes an architecture test pass + // for the wrong reason, which is worse than not having one. + generateSequence(File(".").absoluteFile) { it.parentFile } + .map { File(it, "amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats") } + .firstOrNull { it.isDirectory } + ?: error("cannot locate the chats screen package from ${File(".").absolutePath}") + } + + private fun kotlinFilesIn(pkg: String): List = + File(screensRoot, pkg) + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .toList() + + private fun importsMatching( + pkg: String, + forbidden: Regex, + ): List = + kotlinFilesIn(pkg).flatMap { file -> + file.readLines().withIndex().mapNotNull { (i, line) -> + val trimmed = line.trimStart() + if (trimmed.startsWith("import ") && forbidden.containsMatchIn(trimmed)) { + "${file.name}:${i + 1} $trimmed" + } else { + null + } + } + } + + /** + * Plain substring, NOT `\bmarmot\b`. + * + * The word-boundary form cannot match `…marmotGroups.MarmotGroupChatroom` + * — a word character follows `marmot` both times, so `\b` fails and the + * guard passes exactly the import it exists to stop. Only `import` lines + * are scanned, so a substring is both safe and the correct test. + */ + private val marmotImport = Regex("marmot", RegexOption.IGNORE_CASE) + + private val cordnImport = Regex("cordn", RegexOption.IGNORE_CASE) + + @Test + fun `the matcher catches the import it exists to catch`() { + // A guard that can never fire is indistinguishable from a codebase + // that never violates the rule. This tells them apart. + assertTrue( + "the Marmot matcher would not flag a real Marmot import — the guard cannot fire", + marmotImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom"), + ) + assertTrue( + "the cordn matcher cannot fire either", + cordnImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom"), + ) + } + + @Test + fun `the cordn screens do not reach into Marmot's`() { + val offences = importsMatching("cordnGroup", marmotImport) + assertTrue( + "cordn screens must not import Marmot — Marmot's UI is frozen and cordn's is not\n " + + offences.joinToString("\n "), + offences.isEmpty(), + ) + } + + @Test + fun `Marmot's screens do not reach into cordn's`() { + val offences = importsMatching("marmotGroup", cordnImport) + assertTrue( + "Marmot screens must not import cordn — cordn moves and Marmot is frozen\n " + + offences.joinToString("\n "), + offences.isEmpty(), + ) + } + + @Test + fun `the scan sees real files and real imports`() { + // Without this the two tests above pass just as happily when the + // scanner is pointed at nothing at all. + listOf("cordnGroup", "marmotGroup").forEach { + assertTrue( + "found ${kotlinFilesIn(it).size} files under $it — the scan is broken, not the code", + kotlinFilesIn(it).size >= 2, + ) + } + assertTrue( + "the scanner found no amethyst imports in cordnGroup, so it would not find a marmot one either", + importsMatching("cordnGroup", Regex("amethyst", RegexOption.IGNORE_CASE)).isNotEmpty(), + ) + } +} diff --git a/cli/README.md b/cli/README.md index d031074438..359e2c5787 100644 --- a/cli/README.md +++ b/cli/README.md @@ -680,6 +680,65 @@ also carried on-relay as an encrypted kind:13302. | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | +### cordn (MLS over an MCP coordinator) + +MLS group chat where delivery is one **coordinator** — an MCP server reached +over ContextVM — instead of relays. State lives under +`~/.amy//cordn/`, encrypted with a key at +`~/.amy//cordn/blob.key`; the coordinator list is encrypted beside it. + +Two things shape every verb: + +- **A `gid` is unique only within one coordinator** (`spec/00.md` §4), so + `--coordinator` is part of a group's address. Every live verb takes + `[--coordinator PK] [--relay URL[,URL…]]`, and you can leave them off only + while exactly one coordinator is remembered. A coordinator has no address + besides its pubkey (§8.5), which is why its relays have to be remembered + rather than looked up. +- **Delivery is pulled, not pushed.** A CLI run is a process and cannot hold a + subscription, so `amy cordn fetch` drains what the cursor has not seen and + exits. Nothing arrives while amy is not running; the coordinator holds the + ordered stream until asked. + +| Command | What it does | +|---|---| +| `amy cordn coordinator add --coordinator PK --relay URL[,URL…] [--label L]` | Remember a coordinator. Local only — nothing is announced to it. | +| `amy cordn coordinator list` | Coordinators this account knows, with their relays. | +| `amy cordn coordinator info` | The MCP `initialize` handshake. Every field is a claim the coordinator signed with the key that was already answering (§8.5) — the pubkey is the identity, the name is what an operator typed. | +| `amy cordn coordinator forget --coordinator PK` | Drop it from the list. Local: the group state on disk is kept, and so is anything the coordinator holds. | +| `amy cordn keypackage publish [--last-resort] [--count N]` | Publish KeyPackages. **Attributable** (§8.4): a signed, re-servable record that this npub uses cordn here. | +| `amy cordn keypackage list` | Ours on the coordinator, and whether this device holds the private half that could open the Welcome one produces. | +| `amy cordn keypackage withdraw --kp-ref REF[,REF…] \| --all` | Remove them. Does not undo the exposure — that already happened. | +| `amy cordn group create --name N [--about A] [--gid GID] [--admin PK[,PK…]] [--icon I] [--image URL]` | Create a group. The `gid` is ours to choose and the coordinator never interprets it (§4); random unless given. No `--admin` means egalitarian **permanently** (`spec/01.md` §5.3). | +| `amy cordn group list` | Groups on this coordinator, with epoch and member count. | +| `amy cordn group info [--gid GID]` | Metadata, members, the shareable `cordn1…` ref, and what this coordinator learns about the group. | +| `amy cordn invite --pubkey PK [--gid GID] [--kp-ref REF]` | Take their KeyPackage, verify the publication payload binds it to that npub, commit, and leave a Welcome. | +| `amy cordn request --gid GID \| --ref cordn1…` | Ask to join. Publishes a KeyPackage first if none exists. Attributable (§8.1), and asking is not joining. | +| `amy cordn requests list` | Who is asking to join a group we hold. | +| `amy cordn requests accept --pubkey PK \| --all` / `decline` | Answer them. Any member may — `admin_pubkeys` is presentation metadata with nothing enforcing it (§5.3). | +| `amy cordn welcomes` | Open every pending invitation **without joining**: a Welcome is opaque until processed, so the gid, name and members can only be shown after opening it. | +| `amy cordn join --gid GID \| --all` / `decline` | Accept or refuse one. | +| `amy cordn send --text "…" [--gid GID]` | A kind-9 chat message. | +| `amy cordn send [--reply-to ID \| --react-to ID \| --edit ID \| --delete ID \| --pin ID \| --unpin ID] --to-author PK [--to-kind N]` | Annotate a message. `--to-author` is required because an annotation's tags name the target's author and kind, not just its id, and amy keeps no message store. | +| `amy cordn fetch` | Drain the stream and print it: messages, epoch changes, echoes, and anything undecryptable (reported, not hidden — it is a gap in a conversation). | +| `amy cordn ref encode --gid GID [--coordinator PK] [--relay URL[,URL…]]` | Build a `cordn1…` group reference. | +| `amy cordn ref decode REF` | Read one back. | +| `amy cordn exposure --coordinator PK [--groups N] [--published]` | What a coordinator would learn, before joining anything (§8). | + +> A group ref is a **locator, not an invitation**: holding one lets you ask to +> join, it does not make you a member, and nothing obliges anyone to answer. + +Two live harnesses, neither wired into any build — read +[`tests/cordn/stack.sh`](tests/cordn/stack.sh) first, it boots an unlicensed +reference coordinator: + +- [`tests/cordn/tier-b.sh`](tests/cordn/tier-b.sh) — amy against amy through + the reference coordinator. Proves the transport and the coordinator client. +- [`tests/cordn/interop-client.sh`](tests/cordn/interop-client.sh) — amy and + the reference client (`@cordn/cli`, MIT) in one group. Proves the MLS layer + against a second implementation, in both directions, including a + public-framed Commit of ours that their engine has to apply. + ### Geochat (Bitchat geohash channels) Bitchat-interoperable public location chat: ephemeral kind:20000 events diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 2cd961752f..efc4c20f40 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -239,6 +239,26 @@ class DataDir( /** Inbound events this account has terminally decided about. */ val ingestDedupFile = File(marmotDir, "ingested.ids") + /** + * cordn state. `CordnStorageLayout` lays out `//` + * underneath, the same shape the Android app writes, so a directory can be + * read by either without a migration. + */ + val cordnDir = File(root, "cordn") + + /** + * The key `KeyedCordnBlobCipher` encrypts cordn blobs with. + * + * A random 32 bytes at 0600, generated on first use. Be clear about what + * that buys: the blobs are genuinely encrypted, and the key sits beside + * them, so this protects a copied directory or a stale backup and not a + * process running as this user — which is already the threat model of + * every other file under `~/.amy/`. Android's equivalent key lives in the + * KeyStore and does protect against a reader of the device's storage; a + * CLI has nowhere comparable to put one. + */ + val cordnBlobKeyFile = File(cordnDir, "blob.key") + /** * SQLite event-store DB file, a sibling of [eventsDir] under * `/shared/`. Used when the store backend is SQLite (the @@ -272,6 +292,7 @@ class DataDir( SecureFileIO.tighten(identityFile) SecureFileIO.tighten(stateFile) SecureFileIO.tighten(marmotDir) + SecureFileIO.tighten(cordnDir) SecureFileIO.tighten(keyPackageBundleFile) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index be88e3861b..af23b2f575 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -377,6 +377,13 @@ class Context( */ val cashu: CashuContext by lazy { CashuContext(this) } + /** + * cordn wiring (coordinator registry, MLS groups, key packages) — see + * [CordnContext]. Lazy for the same reason: a run that never says `cordn` + * opens no ContextVM transport and writes no blob key. + */ + val cordn: CordnContext by lazy { CordnContext(this) } + /** See [CashuContext.ops]. */ fun cashuOps(): CashuWalletOps = cashu.ops() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CordnContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CordnContext.kt new file mode 100644 index 0000000000..5e299d73ad --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CordnContext.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorRegistry +import com.vitorpamplona.amethyst.commons.cordn.CordnLinks +import com.vitorpamplona.amethyst.commons.cordn.CordnSession +import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout +import com.vitorpamplona.amethyst.commons.cordn.FileBackedCordnScopeFactory +import com.vitorpamplona.amethyst.commons.cordn.FileCordnCoordinatorStore +import com.vitorpamplona.amethyst.commons.cordn.KeyedCordnBlobCipher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.io.File + +/** + * cordn wiring for the CLI, split out of [Context] the way [CashuContext] is + * and built lazily, so a run that never says `cordn` opens no transport and + * generates no blob key. + * + * Everything below this class is shared: the registry, the manager, the + * stores, and `CordnLinks` are the same objects the Android app runs. What is + * here is the three things a CLI has to answer differently. + * + * **Where the blob key comes from.** Android hides it in the KeyStore; a CLI + * has nowhere comparable, so it is a 0600 file beside the blobs. See + * [DataDir.cordnBlobKeyFile] for what that does and does not protect. + * + * **There is no sync loop.** Each invocation is a process: it opens a + * session, does one thing, and exits. `CordnSyncLoop` is for a front end that + * stays running, and a CLI that started one would hang. So a `fetch` verb + * drains explicitly and the cursor on disk is what carries continuity between + * runs — which is exactly the primitive `spec/02.md` §7 says a cursor is. + * + * **A coordinator has to be named.** The app has a screen listing them; here + * `--coordinator` selects one, and is optional only while exactly one is + * remembered. Guessing would be worse than asking: a `gid` means nothing + * outside the coordinator that issued it (`spec/00.md` §4), so the wrong + * coordinator is not a slower answer, it is an answer about a different + * group. + */ +class CordnContext( + private val ctx: Context, +) { + private val accountPubKey: HexKey get() = ctx.identity.pubKeyHex + + private val cipher: CordnBlobCipher by lazy { KeyedCordnBlobCipher(blobKey()) } + + private val registry: CordnCoordinatorRegistry by lazy { + CordnCoordinatorRegistry( + accountPubKey = accountPubKey, + scopes = + FileBackedCordnScopeFactory( + root = ctx.dataDir.root, + cipher = cipher, + links = CordnLinks.over(ctx.signer, ctx.client), + ), + ) + } + + private val coordinatorStore by lazy { + FileCordnCoordinatorStore( + CordnStorageLayout.accountDirectoryFor(ctx.dataDir.root, accountPubKey), + cipher, + ) + } + + /** Every coordinator this account has been told about, in the order added. */ + suspend fun coordinators(): List = coordinatorStore.load() + + /** + * Adds [config], replacing any entry for the same pubkey. + * + * Keyed by pubkey because that IS the coordinator's identity (§8.5) — a + * second entry for the same key with different relays is a corrected + * address, not a second coordinator. + */ + suspend fun remember(config: CoordinatorConfig) { + val kept = coordinators().filterNot { it.pubKey == config.pubKey } + coordinatorStore.save(kept + config) + } + + suspend fun forget(coordinatorPubKey: HexKey): Boolean { + val before = coordinators() + val after = before.filterNot { it.pubKey == coordinatorPubKey } + if (after.size == before.size) return false + coordinatorStore.save(after) + registry.forget(coordinatorPubKey) + return true + } + + /** The live session for [config], restored from disk. */ + suspend fun session(config: CoordinatorConfig): CordnSession = + registry.session(config).also { + it.manager.restore() + it.keyPackages.restore() + } + + /** Where the cordn tree lives, for the migration verbs. */ + val migrationRoot: File get() = ctx.dataDir.root + + /** The at-rest cipher, so a migration can read and write the same blobs. */ + val blobCipher: CordnBlobCipher get() = cipher + + /** Replaces the remembered coordinator list, after adopting a migration. */ + suspend fun replaceCoordinators(configs: List) = coordinatorStore.save(configs) + + /** + * Picks the coordinator a command should act on. + * + * `--coordinator` wins. With no flag and exactly one remembered, that one + * is used; with none or several, the caller is asked rather than guessed + * at. `--relay` is accepted alongside a pubkey so a first call can name a + * coordinator that is not remembered yet — that is how `coordinator add` + * and a one-shot `--coordinator … --relay …` are the same code path. + */ + suspend fun resolve(args: Args): CoordinatorConfig { + val requested = args.flag("coordinator") + val relays = relayFlag(args) + val known = coordinators() + + if (requested == null) { + if (relays.isNotEmpty()) throw CordnArgException("--relay needs --coordinator to belong to") + return when (known.size) { + 0 -> throw CordnArgException("no coordinator known yet: amy cordn coordinator add --coordinator PK --relay URL") + 1 -> known.single() + else -> + throw CordnArgException( + "several coordinators known, name one with --coordinator: " + + known.joinToString(", ") { it.pubKey.take(8) }, + ) + } + } + + val remembered = known.firstOrNull { it.pubKey == requested } + if (relays.isEmpty()) { + return remembered + ?: throw CordnArgException("coordinator $requested is not known here; add --relay to reach it") + } + // A coordinator named with relays is reachable whether or not it was + // remembered, and the relays given now are the current ones. + return (remembered ?: CoordinatorConfig(pubKey = requested, relays = relays)).copy(relays = relays) + } + + /** `--relay URL[,URL…]`; the flag map collapses repeats, so they arrive comma-separated. */ + fun relayFlag(args: Args): List = + args + .flag("relay") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + .map { url -> + RelayUrlNormalizer.normalizeOrNull(url) ?: throw CordnArgException("not a relay URL: $url") + } + + suspend fun close() = registry.close() + + /** + * Reads the blob key, generating one on first use. + * + * Not derived from the account key on purpose. A derivation would mean a + * bunker-backed identity — which has no local private key at all — could + * not use cordn, and it would tie every blob to a key the user may rotate. + */ + private fun blobKey(): ByteArray { + val file = ctx.dataDir.cordnBlobKeyFile + if (file.exists()) { + val bytes = file.readBytes() + require(bytes.size == KeyedCordnBlobCipher.KEY_LENGTH) { + "$file is ${bytes.size} bytes, expected ${KeyedCordnBlobCipher.KEY_LENGTH}" + } + return bytes + } + return KeyedCordnBlobCipher.newKey().also { SecureFileIO.writeBytesAtomic(file, it) } + } +} + +/** A bad-args failure a command turns into `Output.error("bad_args", …)`. */ +class CordnArgException( + message: String, +) : IllegalArgumentException(message) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 6d7207d0f9..89f9929c45 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.commands.Bolt12Commands import com.vitorpamplona.amethyst.cli.commands.BunkerCommand import com.vitorpamplona.amethyst.cli.commands.BuzzCommands import com.vitorpamplona.amethyst.cli.commands.ConcordCommands +import com.vitorpamplona.amethyst.cli.commands.CordnCommands import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.CyberspaceCommands @@ -337,6 +338,7 @@ private suspend fun dispatch(argv: Array): Int { FofCommand.dispatch(dataDir, tail) } "concord" -> ConcordCommands.dispatch(dataDir, tail) + "cordn" -> CordnCommands.dispatch(dataDir, tail) else -> { Output.error("bad_args", "unknown subcommand: $head") printVerbList() @@ -359,7 +361,7 @@ private fun printVerbList() { | primitives: decode encode verify key filter nip kind pow namecoin | events: event publish fetch subscribe count sync encrypt decrypt gift | social: notes profile follow unfollow search zap dm outbox - | groups: marmot relaygroup concord geochat + | groups: marmot relaygroup concord cordn geochat | relays: relay admin serve store | trust: graperank fof | media/sites: blossom nsite napplet podcast podcast20 git @@ -885,6 +887,27 @@ private fun printUsage() { | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | + | cordn coordinator add --coordinator PK --relay URL[,URL] + | remember one (it has no other address) + | cordn coordinator list|info|forget what we know; MCP initialize; drop it + | cordn keypackage publish [--last-resort] [--count N] + | attributable: names this npub (§8.4) + | cordn keypackage list|withdraw ours on the coordinator + | cordn group create --name N [--gid GID] [--admin PK[,PK]] + | cordn group list|info [--gid GID] metadata, members, exposure + | cordn invite --pubkey PK [--gid GID] take their KeyPackage, commit, leave a Welcome + | cordn request --gid GID | --ref cordn1… ask to join (§8.1) + | cordn requests list|accept|decline answer askers (any member may, §5.3) + | cordn welcomes open invitations without joining + | cordn join|decline --gid GID | --all accept or refuse one + | cordn send --text "…" [--gid GID] a kind-9 chat message + | cordn fetch drain the stream and print it + | cordn ref encode --gid GID [--coordinator PK] [--relay URL[,URL]] + | build a cordn1… group reference + | cordn ref decode REF read one back + | cordn exposure --coordinator PK [--groups N] [--published] + | what that coordinator would learn (spec/00.md §8) + | |Local event store (shared, under `/shared/`): | Backend selected by AMY_STORE: sqlite (default; `shared/events.db`) | or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCommands.kt new file mode 100644 index 0000000000..b3492b9156 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCommands.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.ExposureNote +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * `amy cordn …` — the cordn (MLS-over-an-MCP-coordinator) surface. + * + * Two halves. The offline half needs no coordinator: the `cordn1…` group-ref + * codec, and the §8 metadata-exposure model. A ref is the one cordn artifact a + * human copies by hand, and `exposure` is how a script or a person checks what + * a coordinator would learn *before* joining anything. + * + * The rest drives a live coordinator — `coordinator`, `keypackage`, `group`, + * `invite`, `request`, `requests`, `welcomes`, `join`, `decline`, `send`, + * `fetch` — and lives in [CordnCoordinatorCommands] and + * [CordnGroupCommands]. They exist so the binding can be run end to end + * against a real counterparty from a shell script, which is the only kind of + * interop test that proves anything. + * + * Two shapes to know before reading the verbs: + * + * - **A `gid` is unique only within one coordinator** (`spec/00.md` §4), so + * `--coordinator` is part of a group's address and not a convenience. + * Omitting it works only while exactly one coordinator is remembered. + * - **Delivery is pulled, not pushed.** A CLI invocation is a process and + * cannot hold a subscription, so `fetch` drains what the cursor has not seen + * and exits. The cursor on disk is the whole continuity mechanism between + * runs. + */ +object CordnCommands { + val USAGE: String = + """ + |cordn (MLS group chat over an MCP coordinator): + | offline — no coordinator needed: + | cordn ref encode --gid GID [--coordinator PK] build a cordn1… group reference + | [--relay URL[,URL…]] + | cordn ref decode REF read one back + | cordn exposure --coordinator PK what that coordinator would learn + | [--groups N] [--published] (spec/00.md §8) + | + | live — every verb below takes [--coordinator PK] [--relay URL[,URL…]], + | optional while exactly one coordinator is remembered: + | cordn coordinator add --coordinator PK --relay URL[,URL…] [--label L] + | cordn coordinator list what this account knows + | cordn coordinator info MCP initialize; every field a claim + | cordn coordinator forget --coordinator PK local only; state is kept + | cordn keypackage publish [--last-resort] attributable (§8.4) + | [--count N] + | cordn keypackage list ours on the coordinator + | cordn keypackage withdraw --kp-ref REF[,REF…] | --all + | cordn group create --name N [--about A] gid is ours to choose (§4) + | [--gid GID] [--admin PK[,PK…]] + | [--icon I] [--image URL] + | cordn group list groups on this coordinator + | cordn group info [--gid GID] metadata, members, exposure + | cordn invite --pubkey PK [--gid GID] [--kp-ref REF] + | cordn request --gid GID | --ref cordn1… ask to join (§8.1) + | cordn requests list who is asking + | cordn requests accept --pubkey PK | --all any member may answer (§5.3) + | cordn requests decline --pubkey PK | --all + | cordn welcomes open invitations, joining none + | cordn join --gid GID | --all accept one + | cordn decline --gid GID | --all refuse and retire it + | cordn send --text "…" [--gid GID] a kind-9 chat message + | [--reply-to ID] [--react-to ID] + | cordn fetch drain the stream and print it + | cordn watch [--timeout MS] hold a live subscription + | + |A group ref is a locator, not an invitation: holding one lets you ASK to + |join, it does not make you a member. Relays say where to reach the + |coordinator and are meaningless without --coordinator. + """.trimMargin() + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn", + tail, + "cordn ", + help = USAGE, + routes = + mapOf( + "ref" to { rest -> ref(rest) }, + "exposure" to { rest -> exposure(rest) }, + "coordinator" to { rest -> CordnCoordinatorCommands.coordinator(dataDir, rest) }, + "keypackage" to { rest -> CordnCoordinatorCommands.keyPackage(dataDir, rest) }, + "migrate" to { rest -> CordnMigrateCommands.migrate(dataDir, rest) }, + "group" to { rest -> CordnGroupCommands.group(dataDir, rest) }, + "invite" to { rest -> CordnGroupCommands.invite(dataDir, rest) }, + "request" to { rest -> CordnGroupCommands.request(dataDir, rest) }, + "requests" to { rest -> CordnGroupCommands.requests(dataDir, rest) }, + "welcomes" to { rest -> CordnGroupCommands.welcomes(dataDir, rest) }, + "join" to { rest -> CordnGroupCommands.join(dataDir, rest) }, + "decline" to { rest -> CordnGroupCommands.decline(dataDir, rest) }, + "send" to { rest -> CordnGroupCommands.send(dataDir, rest) }, + "fetch" to { rest -> CordnGroupCommands.fetch(dataDir, rest) }, + "watch" to { rest -> CordnGroupCommands.watch(dataDir, rest) }, + ), + ) + + private suspend fun ref(tail: Array): Int = + route( + "cordn ref", + tail, + "cordn ref ", + help = USAGE, + routes = + mapOf( + "encode" to { rest -> encode(rest) }, + "decode" to { rest -> decode(rest) }, + ), + ) + + private fun encode(tail: Array): Int { + val args = Args(tail) + val gid = args.flag("gid") ?: return Output.error("bad_args", "cordn ref encode needs --gid") + val coordinator = args.flag("coordinator") + // The flag map collapses repeats, so several relays arrive comma-separated. + val relays = + args + .flag("relay") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + args.rejectUnknown() + + val normalized = + relays.map { url -> + RelayUrlNormalizer.normalizeOrNull(url) + ?: return Output.error("bad_args", "not a relay URL: $url") + } + + val ref = + try { + CordnGroupRef(gid, coordinator, normalized.map { it.url }) + } catch (e: IllegalArgumentException) { + return Output.error("bad_args", e.message ?: "invalid group reference") + } + + Output.emit( + mapOf( + "ref" to ref.encode(), + "gid" to ref.gid, + "coordinator" to ref.coordinatorPubKey, + "relays" to ref.relays, + ), + ) + return 0 + } + + private fun decode(tail: Array): Int { + val args = Args(tail) + val encoded = args.positional.firstOrNull() ?: return Output.error("bad_args", "cordn ref decode needs a cordn1… reference") + args.rejectUnknown() + + val ref = + try { + CordnGroupRef.decode(encoded) + } catch (e: IllegalArgumentException) { + return Output.error("bad_ref", e.message ?: "not a cordn group reference") + } + + Output.emit( + mapOf( + "gid" to ref.gid, + "coordinator" to ref.coordinatorPubKey, + "relays" to ref.relays, + // A ref carrying no coordinator is legal (spec §2) and means the + // recipient must already know who serves this group. + "reachable" to (ref.coordinatorPubKey != null && ref.relays.isNotEmpty()), + ), + ) + return 0 + } + + private fun exposure(tail: Array): Int { + val args = Args(tail) + val coordinator = args.flag("coordinator") ?: return Output.error("bad_args", "cordn exposure needs --coordinator") + val groups = args.flag("groups")?.toIntOrNull() ?: 1 + val published = "published" in args.booleans + val fromLink = "from-link" in args.booleans + args.rejectUnknown("published", "from-link") + + if (groups < 1) return Output.error("bad_args", "--groups must be at least 1") + + val exposure = + GroupExposure( + coordinator = coordinator, + linkedGroupCount = groups, + joinedFromShareLink = fromLink, + publishedKeyPackage = published, + // Our transport pins CEP-4 encryption to REQUIRED and fails + // closed (§8.6), so this is a property of the client, not a + // setting a coordinator can talk us out of. + encryptionPinned = true, + ) + + Output.emit( + mapOf( + "coordinator" to coordinator, + "content" to exposure.content.name, + "membership" to exposure.membership.name, + "messaging" to exposure.messaging.name, + "linked_groups" to exposure.linkedGroupCount, + "differs_from_marmot" to exposure.differsFromMarmot(), + "notes" to exposure.notes().map { it.name to describe(it) }.toMap(), + ), + ) + return 0 + } + + /** One line per note, for the human-readable half of the dual output. */ + private fun describe(note: ExposureNote): String = + when (note) { + ExposureNote.MEMBERSHIP_IS_IDENTIFIED -> + "admission names real npubs on both ends, so the coordinator sees who is in this group" + ExposureNote.GROUPS_LINKED_BY_SESSION -> + "one throwaway key posts and fetches for every group you have here, linking them to each other" + ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY -> + "one operator holds the complete ordered history of every group it serves" + ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD -> + "your published KeyPackage is a signed, re-servable record that this account uses cordn" + ExposureNote.MESSAGE_SIZES_UNPADDED -> + "sealed payloads are not padded, so message sizes are visible" + ExposureNote.ENCRYPTION_NOT_PINNED -> + "BUG: this client is not pinning transport encryption; report it" + } + + /** The coordinator a ref points at, for callers wiring one up. */ + fun coordinatorOf(ref: CordnGroupRef): CoordinatorConfig? = CoordinatorConfig.from(ref) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCoordinatorCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCoordinatorCommands.kt new file mode 100644 index 0000000000..0bc3b78383 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCoordinatorCommands.kt @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig + +/** + * `amy cordn coordinator …` and `amy cordn keypackage …`. + * + * ## Why a coordinator has to be remembered at all + * + * A coordinator has no address beyond its pubkey (`spec/00.md` §8.5), so + * "which relays reach it" is knowledge the client holds and nothing on the + * network can supply. Losing it does not degrade to a slow lookup — it + * degrades to a coordinator you cannot reach and groups you cannot open. + * That is why `coordinator add` exists as its own verb and why the list is + * encrypted at rest alongside the group state. + * + * ## Why publishing a KeyPackage is a separate, explicit verb + * + * It is the one attributable thing a client does before anyone has invited it + * anywhere (§8.4): a signed, re-servable record that this npub uses cordn on + * this coordinator. Doing it implicitly — at `coordinator add`, say — would + * make being told about a coordinator indistinguishable from announcing + * yourself to it. + */ +internal object CordnCoordinatorCommands { + suspend fun coordinator( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn coordinator", + tail, + "cordn coordinator ", + help = CordnCommands.USAGE, + routes = + mapOf( + "add" to { rest -> add(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "info" to { rest -> info(dataDir, rest) }, + "forget" to { rest -> forget(dataDir, rest) }, + ), + ) + + private suspend fun add( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val pubKey = args.flag("coordinator") ?: return Output.error("bad_args", "cordn coordinator add needs --coordinator") + val label = args.flag("label") + args.rejectUnknown("relay") + + return Context.open(dataDir).use { ctx -> + try { + val relays = ctx.cordn.relayFlag(args) + if (relays.isEmpty()) return@use Output.error("bad_args", "cordn coordinator add needs --relay URL[,URL…]") + + val config = CoordinatorConfig(pubKey = pubKey, relays = relays, label = label) + ctx.cordn.remember(config) + + Output.emit( + mapOf( + "coordinator" to config.pubKey, + "relays" to config.relays.map { it.url }, + "label" to config.label, + "known" to ctx.cordn.coordinators().size, + // Said here because this is where a person decides. The + // list is local knowledge; nothing has been told to the + // coordinator by adding it. + "announced" to false, + ), + ) + 0 + } catch (e: IllegalArgumentException) { + Output.error("bad_args", e.message ?: "bad coordinator") + } + } + } + + private suspend fun list( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown() + + return Context.open(dataDir).use { ctx -> + Output.emit( + mapOf( + "coordinators" to + ctx.cordn.coordinators().map { + mapOf( + "coordinator" to it.pubKey, + "relays" to it.relays.map { relay -> relay.url }, + "label" to it.label, + "origin" to it.origin.name, + ) + }, + ), + ) + 0 + } + } + + /** + * `cordn coordinator info` — the MCP `initialize` handshake. + * + * Every field it returns is a claim signed with nothing but the key that + * was already signing the response (§8.5). Reported with the pubkey beside + * it so the two are never confused: the pubkey is the identity, the name is + * what the operator typed. + */ + private suspend fun info( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val info = scope.session.serverInfo() + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "reachable" to (info != null), + "name" to info?.name, + "version" to info?.version, + "protocol_version" to info?.protocolVersion, + "capabilities" to info?.capabilities?.keys?.sorted(), + // The one field worth branching on, and the reason the rest + // are not: a coordinator answering a protocol this client + // does not implement is one whose later answers may not + // mean what they appear to. + "claims_are_unverified" to true, + ), + ) + if (info == null) 1 else 0 + } + } + + private suspend fun forget( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val pubKey = args.flag("coordinator") ?: return Output.error("bad_args", "cordn coordinator forget needs --coordinator") + args.rejectUnknown() + + return Context.open(dataDir).use { ctx -> + val forgotten = ctx.cordn.forget(pubKey) + Output.emit( + mapOf( + "coordinator" to pubKey, + "forgotten" to forgotten, + // Forgetting is local. The coordinator still holds every + // message of every group it served, and still holds any + // KeyPackage published to it — `keypackage withdraw` is + // the verb for that, and it is a different act. + "state_on_disk_kept" to true, + ), + ) + if (forgotten) 0 else 1 + } + } + + suspend fun keyPackage( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn keypackage", + tail, + "cordn keypackage ", + help = CordnCommands.USAGE, + routes = + mapOf( + "publish" to { rest -> publish(dataDir, rest) }, + "list" to { rest -> keyPackageList(dataDir, rest) }, + "withdraw" to { rest -> withdraw(dataDir, rest) }, + ), + ) + + private suspend fun publish( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val lastResort = "last-resort" in args.booleans + val count = args.intFlag("count", 1) + args.rejectUnknown("coordinator", "relay", "last-resort") + + if (count < 1) return Output.error("bad_args", "--count must be at least 1") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val published = (1..count).map { scope.keyPackages.publishNew(lastResort = lastResort) } + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "published" to + published.map { + mapOf("kp_ref" to it.keyPackageRef, "last_resort" to it.lastResort, "at" to it.at) + }, + // Worth printing every time, not once in a doc: this is the + // call that tells the coordinator this npub exists here. + "attributable" to true, + ), + ) + 0 + } + } + + private suspend fun keyPackageList( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val onCoordinator = scope.keyPackages.listPublished() + val held = scope.keyPackages.published.value + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "on_coordinator" to + onCoordinator.map { + mapOf( + "kp_ref" to it.keyPackageRef, + "last_resort" to it.lastResort, + "at" to it.at, + // A package the coordinator will serve but we + // have no private half for cannot open the + // Welcome it produces. It belongs to another + // device of this account, or to a wiped one. + "openable_here" to (it.keyPackageRef in held), + ) + }, + "held_locally" to held.size, + ), + ) + 0 + } + } + + private suspend fun withdraw( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val refs = + args + .flag("kp-ref") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + args.rejectUnknown("coordinator", "relay", "all") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val target = + refs + ?: if ("all" in args.booleans) { + scope.keyPackages.published.value + .toList() + } else { + return@withSession Output.error("bad_args", "cordn keypackage withdraw needs --kp-ref REF[,REF…] or --all") + } + + val removed = scope.keyPackages.withdraw(target) + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "requested" to target, + "removed" to removed, + // Withdrawing removes the package, not the record that it + // was once published: §8.4's exposure is a past event and + // no call undoes it. + "exposure_is_not_undone" to true, + ), + ) + 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt new file mode 100644 index 0000000000..7c44488126 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt @@ -0,0 +1,694 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * `amy cordn group …`, `invite`, `request`, `welcomes`, `join`, `send`, `fetch` + * — the group lifecycle over a live coordinator. + * + * ## What this is for + * + * A second implementation is the only thing that tests a protocol, and a + * non-interactive client is the only way to drive one from a script. These + * verbs exist so the cordn binding can be run end to end against the + * reference coordinator without a phone, and so an interop scenario can be a + * shell script rather than a description of one. + * + * ## A fetch is explicit here, and that is a real difference + * + * The Android app runs `CordnSyncLoop`, which catches up and then holds a + * live subscription. A CLI invocation is a process: it cannot hold anything. + * So `fetch` drains what the cursor has not seen and exits, and the cursor on + * disk is the entire continuity mechanism between runs. `spec/02.md` §7 calls + * a cursor a delivery primitive rather than message identity — here it is + * also the only state that makes two separate invocations one conversation. + * + * A consequence worth stating: nothing arrives while amy is not running. A + * message posted between two `fetch` calls is not lost — the coordinator holds + * the ordered stream — but it is not seen until asked for. That is the correct + * model for a script and the wrong one for a chat app, which is why both exist. + */ +internal object CordnGroupCommands { + suspend fun group( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn group", + tail, + "cordn group ", + help = CordnCommands.USAGE, + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "info" to { rest -> info(dataDir, rest) }, + ), + ) + + /** + * `cordn group create --name NAME` + * + * The `gid` is ours to choose and the coordinator never interprets it + * (`spec/00.md` §4). A random 128-bit hex value by default; `--gid` is + * accepted because an interop script needs to name the group it is about + * to assert things about. It must not be derived from the MLS `group_id`, + * which is secret — here the relationship runs the other way, which is + * what lets a joiner read the delivery id out of the Welcome alone. + */ + private suspend fun create( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val name = args.flag("name") ?: return Output.error("bad_args", "cordn group create needs --name") + val about = args.flag("about") ?: "" + val icon = args.flag("icon") ?: "" + val imageUrl = args.flag("image") ?: "" + val gid = args.flag("gid") ?: RandomInstance.bytes(16).joinToString("") { "%02x".format(it) } + val admins = + args + .flag("admin") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val metadata = + try { + CordnGroupMetadata(name = name, description = about, adminPubkeys = admins, icon = icon, imageUrl = imageUrl) + } catch (e: IllegalArgumentException) { + return@withSession Output.error("bad_args", e.message ?: "bad group metadata") + } + + val group = scope.manager.createGroup(gid, metadata) + val ref = scope.manager.shareRef(gid) + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "gid" to gid, + "epoch" to group.epoch, + "ref" to ref.encode(), + "name" to name, + // Empty is not "no admins yet" — spec/01.md §5.3 makes it + // egalitarian, permanently, because there is no way to add + // an admin later to a field that has no enforcement behind + // it anyway. + "egalitarian" to admins.isEmpty(), + // Nothing has been posted: creating a group is local until + // the first message or the first invite. + "posted" to false, + ), + ) + 0 + } + } + + private suspend fun list( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "groups" to + scope.manager.gids.value.sorted().map { gid -> + val group = scope.manager.group(gid) + mapOf( + "gid" to gid, + "epoch" to group?.epoch, + "name" to group?.let { CordnGroupMetadata.fromExtensions(it.extensions)?.name }, + "members" to group?.let { CordnCredential.memberIdentities(it).size }, + ) + }, + ), + ) + 0 + } + } + + private suspend fun info( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay", "gid") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val gid = CordnRun.gid(args, scope) + val group = scope.manager.group(gid) ?: return@withSession Output.error("not_found", "no group $gid here") + val metadata = CordnGroupMetadata.fromExtensions(group.extensions) + val exposure = scope.session.exposure(gid) + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "gid" to gid, + "epoch" to group.epoch, + "name" to metadata?.name, + "about" to metadata?.description, + "members" to CordnCredential.memberIdentities(group).toList(), + "admins" to metadata?.adminPubkeys.orEmpty(), + "egalitarian" to metadata?.adminPubkeys.orEmpty().isEmpty(), + "ref" to scope.manager.shareRef(gid).encode(), + "exposure" to + mapOf( + "content" to exposure.content.name, + "membership" to exposure.membership.name, + "messaging" to exposure.messaging.name, + "notes" to exposure.notes().map { it.name }, + ), + ), + ) + 0 + } + } + + /** + * `cordn invite --gid GID --pubkey PK` + * + * Takes their KeyPackage from the coordinator, verifies the publication + * payload binds it to that npub, commits, and leaves a Welcome. The + * verification is not belt-and-braces: §9/§10 require the client to check + * even though the coordinator does, because a coordinator that skipped it + * could hand any account's name over any account's key material and we + * would add the wrong person. + */ + suspend fun invite( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val target = args.flag("pubkey") ?: return Output.error("bad_args", "cordn invite needs --pubkey") + val keyPackageRef = args.flag("kp-ref") + args.rejectUnknown("coordinator", "relay", "gid") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val gid = CordnRun.gid(args, scope) + val result = scope.manager.invite(gid, target, keyPackageRef) + Output.emit( + mapOf( + "gid" to result.gid, + "invited" to result.invited, + // Which of their one-time packages this spent. Their + // client needs it to find the Welcome; nobody else can + // use it again. + "kp_ref" to result.keyPackageRef, + "commit_cursor" to result.commitCursor, + "welcome_at" to result.welcomeAt, + "epoch" to scope.manager.group(gid)?.epoch, + ), + ) + 0 + } + } + + /** + * `cordn request --gid GID` + * + * Asks to join. Needs a published KeyPackage, because the request names + * the exact one the inviter should use — a request with nothing to add is + * a request nobody can accept. The asking itself is attributable (§8.1): + * the coordinator learns this npub wants into this group whether or not + * anyone ever answers, and that is unavoidable rather than an oversight. + */ + suspend fun request( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val gid = args.flag("gid") + val ref = args.flag("ref") + val keyPackageRef = args.flag("kp-ref") + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val target = + gid + ?: ref?.let { CordnGroupRef.decode(it).gid } + ?: return@withSession Output.error("bad_args", "cordn request needs --gid or --ref cordn1…") + + val using = + keyPackageRef + ?: scope.keyPackages.published.value + .firstOrNull() + ?: scope.keyPackages.publishNew().keyPackageRef + + val at = scope.manager.requestToJoin(target, using) + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "gid" to target, + "kp_ref" to using, + "at" to at, + // Holding a ref lets you ask. It does not make you a member, + // and nothing obliges anyone to answer. + "member" to false, + "attributable" to true, + ), + ) + 0 + } + } + + /** + * `cordn requests` — everyone asking to join a group we hold. + * + * Any member can answer these, not only an admin: `spec/01.md` §5.3 makes + * `admin_pubkeys` presentation metadata and nothing enforces it, so a + * check here would be inventing a boundary cordn does not have. + */ + suspend fun requests( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn requests", + tail, + "cordn requests ", + help = CordnCommands.USAGE, + routes = + mapOf( + "list" to { rest -> requestList(dataDir, rest) }, + "accept" to { rest -> answerRequest(dataDir, rest, accept = true) }, + "decline" to { rest -> answerRequest(dataDir, rest, accept = false) }, + ), + ) + + private suspend fun requestList( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "requests" to scope.manager.pendingJoinRequests().map { it.toMap() }, + ), + ) + 0 + } + } + + private suspend fun answerRequest( + dataDir: DataDir, + tail: Array, + accept: Boolean, + ): Int { + val args = Args(tail) + val who = args.flag("pubkey") + args.rejectUnknown("coordinator", "relay", "gid", "all") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val pending = scope.manager.pendingJoinRequests() + val gidFilter = args.flag("gid") + val chosen = + pending.filter { (who == null || it.pubKey == who) && (gidFilter == null || it.gid == gidFilter) } + + if (chosen.isEmpty()) { + Output.emit(mapOf("answered" to emptyList(), "pending" to pending.size)) + return@withSession 1 + } + if (who == null && "all" !in args.booleans && chosen.size > 1) { + return@withSession Output.error( + "bad_args", + "${chosen.size} requests pending; name one with --pubkey or pass --all", + ) + } + + // Taken, so the loop cannot hand the same request to two calls. + val answered = + chosen.map { req -> + if (accept) { + val result = scope.manager.acceptJoinRequest(req) + mapOf("pubkey" to req.pubKey, "gid" to req.gid, "welcome_at" to result.welcomeAt) + } else { + scope.manager.declineJoinRequest(req) + mapOf("pubkey" to req.pubKey, "gid" to req.gid, "declined" to true) + } + } + + Output.emit(mapOf("accepted" to accept, "answered" to answered)) + 0 + } + } + + /** + * `cordn welcomes` — open every pending Welcome without joining anything. + * + * Opening and accepting are separate acts on purpose. A Welcome is opaque + * until processed: the `gid`, the group's name and who is already in it all + * live inside it, so a person cannot be asked about an invitation that has + * not been opened. Printing them without joining is that split, in a shell. + */ + suspend fun welcomes( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val inbox = scope.manager.pendingWelcomes(scope.keyPackages::bundleFor) + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "pending" to + inbox.pending.map { + mapOf( + "gid" to it.gid, + "kp_ref" to it.keyPackageRef, + "at" to it.at, + "epoch" to it.epoch, + "name" to it.metadata?.name, + "members" to it.members.toList(), + ) + }, + // Not errors. A Welcome for a KeyPackage this device never + // held belongs to another device of the same account, and + // draining it here would destroy it. + "skipped" to inbox.skipped.map { mapOf("kp_ref" to it.keyPackageRef, "reason" to it.reason) }, + ), + ) + 0 + } + } + + /** `cordn join [--gid GID | --all]` — accept a pending Welcome. */ + suspend fun join( + dataDir: DataDir, + tail: Array, + ): Int = acceptOrDecline(dataDir, tail, accept = true) + + /** `cordn decline [--gid GID | --all]` — refuse one, and retire it. */ + suspend fun decline( + dataDir: DataDir, + tail: Array, + ): Int = acceptOrDecline(dataDir, tail, accept = false) + + private suspend fun acceptOrDecline( + dataDir: DataDir, + tail: Array, + accept: Boolean, + ): Int { + val args = Args(tail) + val gid = args.flag("gid") + val all = "all" in args.booleans + args.rejectUnknown("coordinator", "relay", "all") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val inbox = scope.manager.pendingWelcomes(scope.keyPackages::bundleFor) + val chosen = inbox.pending.filter { gid == null || it.gid == gid } + + if (chosen.isEmpty()) { + Output.emit(mapOf("joined" to emptyList(), "skipped" to inbox.skipped.size)) + return@withSession 1 + } + if (gid == null && !all && chosen.size > 1) { + return@withSession Output.error( + "bad_args", + "${chosen.size} invitations pending; name one with --gid or pass --all", + ) + } + + val done = + chosen.map { + if (accept) { + scope.manager.accept(it) + } else { + scope.manager.decline(it) + it.gid + } + } + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + (if (accept) "joined" else "declined") to done, + "epochs" to done.associateWith { scope.manager.group(it)?.epoch }, + "skipped" to inbox.skipped.map { mapOf("kp_ref" to it.keyPackageRef, "reason" to it.reason) }, + ), + ) + 0 + } + } + + /** + * `cordn send --text "…"` — a chat message, or an annotation of one. + * + * `--reply-to` / `--react-to` / `--edit` / `--delete` / `--pin` / `--unpin` + * take a message id and need `--to-author` beside it, and that is not an + * awkward flag — it is the shape of the data. An annotation's tags name the + * target's author and kind as well as its id, and the CLI keeps no message + * store: cursors and MLS state persist between runs, decrypted messages + * deliberately do not. So the fields a reference needs have to come from + * the caller, who has them from the `fetch` that printed the message. + * + * One limitation worth naming rather than discovering. Threading reads the + * *target's* tags to find the thread root, and those are not passed here, + * so replying to a reply roots the new message at the message it answers + * instead of at the original root. Correct for a one-level reply, which is + * what a script tends to send; a client with a message store does better. + */ + suspend fun send( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val text = args.flag("text") ?: "" + val author = args.flag("to-author") + val targetKind = args.intFlag("to-kind", CordnGroupManager.CHAT_KIND) + val replyTo = args.flag("reply-to") + val reactionTo = args.flag("react-to") + val editTo = args.flag("edit") + val deleteTo = args.flag("delete") + val pinTo = args.flag("pin") + val unpinTo = args.flag("unpin") + args.rejectUnknown("coordinator", "relay", "gid") + + val referenced = listOfNotNull(replyTo, reactionTo, editTo, deleteTo, pinTo, unpinTo) + if (referenced.size > 1) { + return Output.error("bad_args", "cordn send takes at most one of --reply-to/--react-to/--edit/--delete/--pin/--unpin") + } + val targetId = referenced.firstOrNull() + if (targetId != null && author == null) { + return Output.error("bad_args", "a reference needs --to-author PK: its tags name the target's author, not only its id") + } + if (targetId == null && text.isEmpty()) { + return Output.error("bad_args", "cordn send needs --text, or a reference to annotate") + } + + val target = targetId?.let { CordnMessageReferences.Target(id = it, pubKey = author!!, kind = targetKind) } + + return CordnRun.withSession(dataDir, args) { _, scope -> + val gid = CordnRun.gid(args, scope) + val envelope = + try { + scope.manager.post( + gid = gid, + content = text, + replyTo = target.takeIf { replyTo != null }, + reactionTo = target.takeIf { reactionTo != null }, + editTo = target.takeIf { editTo != null }, + deleteTo = target.takeIf { deleteTo != null }, + pinTo = target.takeIf { pinTo != null || unpinTo != null }, + pinOp = if (unpinTo != null) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD, + ) + } catch (e: IllegalArgumentException) { + // The manager refuses an edit or delete of somebody else's + // message. That is a rule, not a failure to handle here. + return@withSession Output.error("refused", e.message ?: "refused") + } + + Output.emit( + mapOf( + "gid" to gid, + // The envelope id is the message's identity (spec/02.md §7) + // — the cursor a fetch reports is not, so a script that + // needs to refer to this message later must use this. + "id" to envelope.envelope.id, + "kind" to envelope.envelope.kind, + "created_at" to envelope.envelope.createdAt, + // The coordinator's cursor for this post, so a script can + // see where in the stream its own message landed. + "cursor" to envelope.cursor, + "epoch" to scope.manager.group(gid)?.epoch, + ), + ) + 0 + } + } + + /** + * `cordn fetch` — drain everything the cursor has not seen, and print it. + * + * Every outcome is reported, including the ones that are not messages. An + * `undecryptable` payload especially: the cursor moves past it, because the + * alternative is a stream that never advances, and a client that printed + * nothing would be hiding a gap in a conversation rather than reporting one. + */ + suspend fun fetch( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val messages = mutableListOf>() + val epochs = mutableListOf>() + val echoes = mutableListOf>() + val undecryptable = mutableListOf>() + + val drained = scope.manager.catchUp { it.collectInto(messages, epochs, echoes, undecryptable) } + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "drained" to drained, + "messages" to messages, + "epoch_changes" to epochs, + "echoes" to echoes, + "undecryptable" to undecryptable, + // A fetch is the response most likely to outgrow one relay + // event, so this is where CEP-22 shows up if it shows up at + // all. Zero is the normal answer and not a warning. + "oversized_transfers" to scope.session.oversizedTransfers, + ), + ) + 0 + } + } + + /** + * `cordn watch` - holds a live subscription and prints what arrives. + * + * The sibling of `fetch`, and deliberately not a superset of it: this + * calls `msg_sub_many` and nothing else, so what it prints arrived over + * an open CEP-41 stream rather than a poll. That distinction is the + * point - it is the only way to exercise the one coordinator tool a + * request/response client never reaches. + * + * `--timeout` is a budget, not a failure: the call returns when the + * coordinator closes the stream or the budget runs out, whichever comes + * first, and either way what was delivered has been ingested and saved. + */ + suspend fun watch( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val timeoutMs = args.longFlag("timeout", DEFAULT_WATCH_MS) + args.rejectUnknown("coordinator", "relay", "timeout") + + if (timeoutMs < 1) return Output.error("bad_args", "--timeout must be positive") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val messages = mutableListOf>() + val epochs = mutableListOf>() + val echoes = mutableListOf>() + val undecryptable = mutableListOf>() + + scope.manager.subscribe(timeoutMs) { delivery -> delivery.collectInto(messages, epochs, echoes, undecryptable) } + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "watched_ms" to timeoutMs, + "messages" to messages, + "epoch_changes" to epochs, + "echoes" to echoes, + "undecryptable" to undecryptable, + // Everything above came over the subscription, so this is + // a fact about the run rather than a label. + "via" to "msg_sub_many", + ), + ) + 0 + } + } + + /** Files one delivery under the list its kind belongs in. */ + private fun CordnGroupManager.Delivery.collectInto( + messages: MutableList>, + epochs: MutableList>, + echoes: MutableList>, + undecryptable: MutableList>, + ) { + when (this) { + is CordnGroupManager.Delivery.Message -> + messages += + mapOf( + "gid" to gid, + "cursor" to cursor, + "id" to received.envelope.id, + // What MLS authenticated, not what the envelope + // claims: the envelope is unsigned (spec/02.md), so + // its pubKey field is a claim and this is the fact. + "sender" to received.sender, + "epoch" to received.epoch, + "kind" to received.envelope.kind, + "created_at" to received.envelope.createdAt, + "content" to received.envelope.content, + ) + + is CordnGroupManager.Delivery.EpochAdvanced -> + epochs += mapOf("gid" to gid, "cursor" to cursor, "epoch" to epoch) + + is CordnGroupManager.Delivery.Echo -> echoes += mapOf("gid" to gid, "cursor" to cursor) + + is CordnGroupManager.Delivery.Undecryptable -> + undecryptable += mapOf("gid" to gid, "cursor" to cursor, "reason" to reason) + } + } + + /** Long enough for a round trip, short enough to be a foreground command. */ + private const val DEFAULT_WATCH_MS = 15_000L + + private fun JoinRequest.toMap() = + mapOf( + "gid" to gid, + "pubkey" to pubKey, + "kp_ref" to keyPackageRef, + "at" to at, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnMigrateCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnMigrateCommands.kt new file mode 100644 index 0000000000..47458d9aae --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnMigrateCommands.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.HttpCordnBlobStore +import com.vitorpamplona.amethyst.commons.cordn.CordnMigration +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationException +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationStores +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode + +/** + * `amy cordn migrate …` — the device handoff, drivable without a phone. + * + * Here because the whole point of the CLI is that a protocol flow can be + * exercised end to end before any UI exists. A migration is two devices and a + * scan; two `amy` homes and a string is the same thing with the camera taken + * out. + * + * ## Why `export` does not stand the CLI down + * + * On a phone, exporting is a handoff: the device stops, because two devices + * committing from one MLS leaf fork the ratchet tree irrecoverably. `amy` has + * no sync loop to stop — each invocation is a process — so there is no + * equivalent state to set, and `--and-stop` would be a flag that did nothing. + * The check that matters lives on the runtime that actually keeps loops + * running. Exporting from an `amy` home and then continuing to send from it is + * the same mistake, and this verb says so rather than pretending to prevent it. + */ +internal object CordnMigrateCommands { + suspend fun migrate( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn migrate", + tail, + "cordn migrate ", + help = CordnCommands.USAGE, + routes = + mapOf( + "export" to { rest -> export(dataDir, rest) }, + "import" to { rest -> import(dataDir, rest) }, + ), + ) + + private suspend fun export( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val servers = + args + .flag("server") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + args.rejectUnknown("relay", "server") + + if (servers.isEmpty()) { + return Output.error("bad_args", "cordn migrate export needs --server URL[,URL…] to store the documents on") + } + + return Context.open(dataDir).use { ctx -> + val relays = ctx.cordn.relayFlag(args) + if (relays.isEmpty()) { + return@use Output.error("bad_args", "cordn migrate export needs --relay URL[,URL…] to publish the tip on") + } + + try { + val snapshot = + CordnMigrationStores.read( + ctx.cordn.migrationRoot, + ctx.identity.pubKeyHex, + ctx.cordn.blobCipher, + ctx.cordn.coordinators(), + ) + + val code = + CordnMigration(ctx.client, ctx.signer, HttpCordnBlobStore(servers)) + .publish(snapshot, relays.toSet()) + + Output.emit( + mapOf( + "code" to code.encode(), + "groups" to snapshot.groups.size, + "key_packages" to snapshot.keyPackages.size, + "relays" to relays.map { it.url }, + "servers" to servers, + // Said here because this is where a person decides. The + // sealed state is on someone else's disk now; a reader + // learns size and timing but not content. + "uploaded" to true, + // amy keeps no sync loop, so nothing was stood down — + // see the object KDoc. + "this_device_stopped" to false, + ), + ) + 0 + } catch (e: CordnMigrationException) { + Output.error("migration_failed", e.message ?: "the handoff could not be published") + } catch (e: IllegalArgumentException) { + Output.error("bad_args", e.message ?: "bad migration") + } + } + } + + private suspend fun import( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val raw = args.flag("code") ?: args.positionalOrNull(0) + args.rejectUnknown("code") + + if (raw == null) return Output.error("bad_args", "cordn migrate import needs a cordndev1… code") + + val code = + CordnHandoffCode.decodeOrNull(raw) + ?: return Output.error("bad_args", "that is not a cordndev1… handoff code") + + return Context.open(dataDir).use { ctx -> + try { + val snapshot = + CordnMigration(ctx.client, ctx.signer, HttpCordnBlobStore(emptyList())) + .fetch(code) + + // Replaces, never merges: two devices holding one group's state + // and both committing fork the ratchet tree, and MLS does not + // recover from that. + val configs = + CordnMigrationStores.write( + ctx.cordn.migrationRoot, + ctx.identity.pubKeyHex, + ctx.cordn.blobCipher, + snapshot, + ) + ctx.cordn.replaceCoordinators(configs) + + Output.emit( + mapOf( + "groups" to snapshot.groups.size, + "key_packages" to snapshot.keyPackages.size, + "coordinators" to configs.map { it.pubKey }, + "replaced_local_state" to true, + ), + ) + 0 + } catch (e: CordnMigrationException) { + Output.error("migration_failed", e.message ?: "the handoff could not be read") + } catch (e: IllegalArgumentException) { + Output.error("bad_args", e.message ?: "bad migration") + } + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnRun.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnRun.kt new file mode 100644 index 0000000000..a364373f7f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnRun.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.CordnArgException +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CordnSession + +/** + * The plumbing every `amy cordn` verb that touches a coordinator shares. + * + * Two things live here rather than in each command. The first is opening and + * closing a session around a block, because a CLI run that forgets to close + * leaves a relay subscription behind. The second is turning the three ways a + * cordn call fails into the CLI's three error codes, which matters more than + * it looks: `bad_args` means the caller can fix it, `coordinator` means the + * other end did something, and anything else is ours. A script that cannot + * tell those apart has to treat every failure as fatal. + */ +internal object CordnRun { + /** + * Opens a session on the coordinator [args] names, runs [block], closes. + * + * The session is restored from disk first, so a `gid` from a previous run + * is already known and a cursor already positioned. That is the whole + * continuity story for a process-per-command client: `spec/02.md` §7 calls + * a cursor a delivery primitive, and here it is also the only thing + * carried between invocations. + */ + suspend fun withSession( + dataDir: DataDir, + args: Args, + block: suspend (Context, CordnSessionScope) -> Int, + ): Int = + Context.open(dataDir).use { ctx -> + ctx.prepare() + try { + val config = ctx.cordn.resolve(args) + val session = ctx.cordn.session(config) + try { + block(ctx, CordnSessionScope(config, session)) + } finally { + ctx.cordn.close() + } + } catch (e: CordnArgException) { + Output.error("bad_args", e.message ?: "bad coordinator selection") + } + } + + /** Config plus session, so a command does not have to carry both. */ + class CordnSessionScope( + val config: CoordinatorConfig, + val session: CordnSession, + ) { + val manager get() = session.manager + val keyPackages get() = session.keyPackages + } + + /** + * The `gid` a command was given, or the only one we are in. + * + * Same rule as `--coordinator`: convenient when there is one, refuses to + * guess when there are several. A `gid` is unique only within a + * coordinator (`spec/00.md` §4), so a wrong guess is not a near miss. + */ + fun gid( + args: Args, + scope: CordnSessionScope, + ): String { + val requested = args.flag("gid") + if (requested != null) return requested + val known = scope.manager.gids.value + return when (known.size) { + 0 -> throw CordnArgException("not in any group on this coordinator yet") + 1 -> known.single() + else -> throw CordnArgException("several groups here, name one with --gid: ${known.sorted().joinToString(", ")}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt index 245373936d..2e945bb1a3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextSt import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.InMemoryAgentTextStreamSequenceStore import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.RecordOutcome -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt index 3457784ec7..5290c1f1e6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.cli.stores import com.vitorpamplona.amethyst.cli.SecureFileIO import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/HttpCordnBlobStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/HttpCordnBlobStore.kt new file mode 100644 index 0000000000..0c89ff3890 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/HttpCordnBlobStore.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.stores + +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Base64 +import java.util.concurrent.TimeUnit + +/** + * A Blossom store for `amy`, over plain OkHttp. + * + * Exists because the app's `BlossomUploader` needs an Android `Context` for a + * MIME lookup that a migration blob does not need — every document is opaque + * bytes. Keeping a JVM implementation means the whole handoff is drivable and + * testable without a phone, which is how the interop scripts exercise it. + * + * ## The ephemeral signer is the point, not a detail + * + * §12 requires the BUD-01 authorization event to be signed by an ephemeral key + * and never the owner. Signing as the owner would publish, in the clear to the + * storage server, that this account is uploading right now — the linkage the + * opaque tip exists to prevent. [signer] is minted per instance and is not + * derived from the account. + */ +class HttpCordnBlobStore( + private val servers: List, + private val http: OkHttpClient = + OkHttpClient + .Builder() + .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(), +) : CordnBlobStore { + private val signer = NostrSignerInternal(KeyPair()) + + override suspend fun put(blob: ByteArray): List = + withContext(Dispatchers.IO) { + val hash = sha256(blob).toHexKey() + servers.filter { server -> runCatching { upload(server, blob, hash) }.getOrDefault(false) } + } + + override suspend fun get( + address: String, + servers: List, + ): ByteArray? = + withContext(Dispatchers.IO) { + // Ordered: §6 says the reader tries the tip's servers in the order + // it listed them, most reliable first. + servers.firstNotNullOfOrNull { server -> + runCatching { + http + .newCall( + Request + .Builder() + .url("${server.trimEnd('/')}/$address") + .get() + .build(), + ).execute() + .use { if (it.isSuccessful) it.body?.bytes() else null } + }.getOrNull() + } + } + + private suspend fun upload( + server: String, + blob: ByteArray, + hash: String, + ): Boolean { + val auth = + BlossomAuthorizationEvent.createUploadAuth( + hash = hash, + size = blob.size.toLong(), + alt = "", + signer = signer, + ) + + val request = + Request + .Builder() + .url("${server.trimEnd('/')}/upload") + .header("Authorization", "Nostr " + Base64.getEncoder().encodeToString(auth.toJson().encodeToByteArray())) + .put(blob.toRequestBody(null)) + .build() + + return http.newCall(request).execute().use { it.isSuccessful } + } + + companion object { + private const val CALL_TIMEOUT_SECONDS = 60L + } +} diff --git a/cli/tests/cordn/interop-client.sh b/cli/tests/cordn/interop-client.sh new file mode 100755 index 0000000000..89ee63ab2a --- /dev/null +++ b/cli/tests/cordn/interop-client.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# +# interop-client.sh — amy and the REFERENCE CLIENT in one group. +# +# The claim Tier B does not test. `tier-b.sh` runs amy against amy through the +# reference *coordinator*: it proves our transport and our coordinator client, +# but both MLS endpoints are ours, so the ratchet tree, the Welcome and the +# Commit are only ever agreeing with themselves. This script puts +# **`@cordn/cli` (ts-mls)** on one end and **amy (quartz)** on the other, which +# is the actual interop claim: two independent RFC 9420 implementations in one +# group, over a live wire. +# +# `@cordn/cli` is **MIT** and comes from npm, so this half carries no licensing +# problem. The coordinator underneath it still does — see stack.sh, and read it +# before running this. +# +# What it covers, and why each direction is its own test: +# +# 1. Their group, our joiner — our engine opens a ts-mls Welcome, reads +# the group metadata extension and the +# roster out of it, and decrypts their +# application messages. +# 2. Our group, their joiner — their engine opens OUR Welcome. This is +# the direction that tests our output, and +# it is the one a fixture can never check, +# because a fixture we wrote accepts what we +# emit by construction. +# 3. Our later Commit — the sharpest one. Our engine emits +# **public-framed** handshake messages +# (`MlsMessage(PublicMessage)`, wireformat +# 2) while cordn's client emits +# private-framed. `CordnGroupManager.invite` +# asserts in its KDoc that their +# `processMessageBase64` admits both — a +# claim read off their source and never +# executed. Here they must process our +# Commit to stay in the group at all: if +# they cannot, their epoch stalls and every +# later message fails to open. +# +# Prereqs: see stack.sh, plus network access to npm for `@cordn/cli`. +# +# Usage: +# ./cli/tests/cordn/interop-client.sh +# KEEP=1 ./cli/tests/cordn/interop-client.sh # leave the stack up +# +# Exit 0 only if every step passed. + +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +PORT="${PORT:-7452}" +CONTAINER="cordn-interop-client" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-interop}" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } +check() { if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (expected '$2', got '$1')"; fi; } + +trap stack_down EXIT + +stack_require +command -v npm >/dev/null 2>&1 || { echo "npm is needed to install @cordn/cli"; exit 2; } + +# A stuck reference client should fail the run rather than hang it, but macOS +# ships no `timeout` (it is GNU coreutils; `gtimeout` if Homebrew installed +# them). Without this the whole harness died at the first `ref` call with +# nothing but "could not read the reference client's pubkey", because ref() +# folds stderr into the output it parses and "command not found" matches no +# field. Running untimed is better than not running. +if command -v timeout >/dev/null 2>&1; then + REF_TIMEOUT="timeout 120" +elif command -v gtimeout >/dev/null 2>&1; then + REF_TIMEOUT="gtimeout 120" +else + REF_TIMEOUT="" + echo "note: no timeout(1) — the reference client runs untimed" +fi + +step "boot geode on $RELAY and the reference coordinator" +stack_up +ok "coordinator $COORD" + +step "install @cordn/cli (MIT) from npm" +mkdir -p "$WORK/ref" +npm install --silent --prefix "$WORK/ref" @cordn/cli >"$WORK/npm.log" 2>&1 || { + echo "npm install failed; see $WORK/npm.log" + exit 1 +} +CORDN="$WORK/ref/node_modules/.bin/cordn" +[ -x "$CORDN" ] || { echo "no cordn binary at $CORDN"; exit 1; } +ok "$("$CORDN" --version 2>/dev/null || echo unknown)" + +# ---- the two clients ------------------------------------------------------ +# amy is process-per-command by design; the reference client is driven the +# same way with --command, so neither side gets to hold state in RAM that the +# other cannot see. Whatever agreement they reach went over the wire. +amy() { HOME="$WORK/amy" "$AMY" --account a --secret-backend ncryptsec "$@" 2>/dev/null; } +amy2() { HOME="$WORK/amy2" "$AMY" --account b --secret-backend ncryptsec "$@" 2>/dev/null; } +ref() { + # shellcheck disable=SC2086 # REF_TIMEOUT is a command prefix, or empty + $REF_TIMEOUT "$CORDN" \ + --private-key-file "$WORK/ref.key" \ + --server-pubkey "$COORD" \ + --relay "$RELAY" \ + --state-file "$WORK/ref-state.json" \ + --command "$1" 2>&1 +} +field() { python3 -c "import json,sys; d=json.load(sys.stdin); v=d$1; print(v if isinstance(v,str) else json.dumps(v))"; } +# Their output comes in two shapes and it is worth having both readers rather +# than one clever one: `group-info` and `available-kps` print `key=value` +# tokens, `status` prints `key: value`. +reffield() { grep -oE "$1=[^ ]+" | head -1 | cut -d= -f2-; } +refcolon() { grep -oE "^$1: .*" | head -1 | cut -d' ' -f2-; } + +step "identities" +mkdir -p "$WORK/amy" "$WORK/amy2" +openssl rand -hex 32 >"$WORK/ref.key" +amy create --json >/dev/null +amy2 create --json >/dev/null +AMY_PK=$(amy whoami --json | field "['hex']") +AMY2_PK=$(amy2 whoami --json | field "['hex']") +REF_PK=$(ref "status" | refcolon "stablePubkey") +[ -n "$REF_PK" ] || { echo "could not read the reference client's pubkey"; exit 1; } +ok "amy $AMY_PK" +ok "amy(2nd) $AMY2_PK" +ok "ts-mls $REF_PK" + +for a in amy amy2; do + $a cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --json >/dev/null +done + +step "both sides publish a KeyPackage" +amy cordn keypackage publish --json >/dev/null +amy2 cordn keypackage publish --json >/dev/null +ref "gen-kp k1" >/dev/null +# Each client has to be able to READ the other's publication off the +# coordinator, which means our KeyPackage has to parse under their zod schema +# and their capability flags have to survive our encoder. +KPS=$(ref "available-kps") +echo "$KPS" | grep -q "$AMY_PK" && ok "ts-mls can read our published KeyPackage" || bad "our KeyPackage is invisible to them" +echo "$KPS" | grep -q "groupMetadataSupport=yes" && ok "and reads our metadata capability" || bad "our capability flags did not survive" + +# --------------------------------------------------------------------------- +step "DIRECTION 1 — their group, our joiner" +# --------------------------------------------------------------------------- +ref "create-group g1 --name TheirGroup" >/dev/null +THEIR_GID=$(ref "group-info g1" | reffield "groupId") +ok "ts-mls created $THEIR_GID" + +ref "add-member g1 $AMY_PK" >/dev/null +PENDING=$(amy cordn welcomes --json) +check "$(echo "$PENDING" | field "['pending'][0]['gid']")" "$THEIR_GID" "our engine opened a ts-mls Welcome" +# Read out of the Welcome itself, so these assert that their GroupContext +# extensions and their credentials decode under our parser. +check "$(echo "$PENDING" | field "['pending'][0]['name']")" "TheirGroup" "and read their metadata extension" +echo "$PENDING" | grep -q "$REF_PK" && ok "and their credential in the roster" || bad "their credential did not decode" + +amy cordn join --all --json >/dev/null +ref "send-to g1 hello from ts-mls" >/dev/null +GOT=$(amy cordn fetch --json) +check "$(echo "$GOT" | field "['messages'][0]['content']")" "hello from ts-mls" "we decrypt their application message" +check "$(echo "$GOT" | field "['messages'][0]['sender']")" "$REF_PK" "and MLS authenticates them as the sender" + +amy cordn send --gid "$THEIR_GID" --text "hello from quartz" --json >/dev/null +ref "sync g1" >/dev/null +ref "messages g1" | grep -q "hello from quartz" && ok "they decrypt ours" || bad "they could not read our message" + +# --------------------------------------------------------------------------- +step "DIRECTION 2 — our group, their joiner" +# --------------------------------------------------------------------------- +# The direction a fixture cannot test: a fixture we wrote accepts what we emit +# by construction, so only a foreign implementation can say our Welcome is +# well formed. +OUR_GID="quartz-side-group" +amy cordn group create --gid "$OUR_GID" --name "OurGroup" --json >/dev/null +ref "gen-kp k2" >/dev/null +INVITE=$(amy cordn invite --gid "$OUR_GID" --pubkey "$REF_PK" --json) +SPENT=$(echo "$INVITE" | field "['kp_ref']") +check "$(echo "$INVITE" | field "['epoch']")" "1" "our commit advanced us to epoch 1" + +ref "fetch-welcomes" >/dev/null +ACCEPTED=$(ref "accept-welcome $SPENT g2") +echo "$ACCEPTED" | grep -q "name=OurGroup" && ok "ts-mls opened OUR Welcome and read our metadata" || bad "ts-mls could not open our Welcome: $ACCEPTED" +check "$(ref "group-info g2" | reffield "groupId")" "$OUR_GID" "and agrees on the gid" + +amy cordn send --gid "$OUR_GID" --text "quartz made this group" --json >/dev/null +ref "sync g2" >/dev/null +ref "messages g2" | grep -q "quartz made this group" && ok "they read ours at epoch 1" || bad "they could not read ours" + +ref "send-to g2 ts-mls replying in a quartz group" >/dev/null +amy cordn fetch --json | grep -q "ts-mls replying in a quartz group" && ok "we read theirs" || bad "we could not read theirs" + +# --------------------------------------------------------------------------- +step "DIRECTION 3 — our LATER commit, which they must process" +# --------------------------------------------------------------------------- +# Until now their epoch came from a Welcome, which carries the group state +# ready-made. This is the first time they have to apply one of our handshake +# messages, and ours are public-framed (wireformat 2) where theirs are +# private-framed. If they cannot parse it their epoch stalls at 1 and the +# message they send afterwards is sealed under a key we do not have. +amy2 cordn keypackage publish --json >/dev/null +COMMIT=$(amy cordn invite --gid "$OUR_GID" --pubkey "$AMY2_PK" --json) +check "$(echo "$COMMIT" | field "['epoch']")" "2" "our second commit advanced us to epoch 2" + +ref "sync g2" >/dev/null +ref "send-to g2 after the quartz commit" >/dev/null +AFTER=$(amy cordn fetch --json) +# The real assertion: a message they sealed at epoch 2 only opens if they +# applied our Commit. A stalled peer would have sealed at epoch 1, and this +# would come back undecryptable instead. +check "$(echo "$AFTER" | field "['messages'][0]['content']")" "after the quartz commit" "ts-mls applied our public-framed Commit" +check "$(echo "$AFTER" | field "['messages'][0]['epoch']")" "2" "and sealed at the new epoch" + +step "the third member joins a group two implementations built" +amy2 cordn join --all --json >/dev/null +THIRD=$(amy2 cordn fetch --json) +echo "$THIRD" | grep -q "after the quartz commit" && ok "reads the ts-mls message it was welcomed into" || bad "third member could not read history at its join epoch" + +step "all three agree" +A_EPOCH=$(amy cordn group info --gid "$OUR_GID" --json | field "['epoch']") +B_EPOCH=$(amy2 cordn group info --gid "$OUR_GID" --json | field "['epoch']") +R_CURSOR=$(ref "group-info g2" | reffield "cursor") +check "$A_EPOCH" "2" "quartz (inviter) at epoch 2" +check "$B_EPOCH" "2" "quartz (invitee) at epoch 2" +[ -n "$R_CURSOR" ] && ok "ts-mls advanced to cursor $R_CURSOR" || bad "ts-mls reported no cursor" + +MEMBERS=$(amy cordn group info --gid "$OUR_GID" --json | field "['members']") +for pk in "$AMY_PK" "$AMY2_PK" "$REF_PK"; do + echo "$MEMBERS" | grep -q "$pk" || bad "roster is missing $pk" +done +ok "roster holds all three credentials" + +echo +if [ "$fail" = "0" ]; then + echo "CLIENT INTEROP PASSED" +else + echo "CLIENT INTEROP FAILED" +fi +exit "$fail" diff --git a/cli/tests/cordn/migrate.sh b/cli/tests/cordn/migrate.sh new file mode 100755 index 0000000000..1a7bec33b5 --- /dev/null +++ b/cli/tests/cordn/migrate.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# migrate.sh — one account, two devices, a handoff between them. +# +# The claim tier-b.sh does not test. There, two accounts talk to each other. +# Here ONE account moves from the phone it is on to a new one, which is the +# case `spec/applications/multi-device.md` calls device addition (§11) and we +# implement as a one-shot handoff rather than continuous sync. +# +# What this proves that the unit tests cannot: +# +# * the sealed documents survive a real HTTP round trip to a blob server, +# * the tip survives a real relay as a real replaceable event, +# * the new device, starting from an empty home and a scanned string alone, +# ends up holding the same groups. +# +# The blob server is a throwaway python stand-in for Blossom: PUT /upload +# stores by sha256, GET / serves it back. Enough to exercise the real +# HttpCordnBlobStore, and deliberately not a Blossom implementation. +# +# Usage: cli/tests/cordn/migrate.sh +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +CONTAINER="cordn-migrate" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-migrate}" + +# Above 1024: binding below it needs root, and this test has no business +# asking for that. 877 was the default and bound for nobody. +BLOB_PORT="${BLOB_PORT:-8877}" +BLOB="http://127.0.0.1:$BLOB_PORT" +BLOB_DIR="$WORK/blobs" +BLOB_PID="" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } + +# Two HOMEs, one identity: the same nsec on an old phone and a new one. That is +# what a migration is, and it is why the new device needs no Welcome — it +# adopts the shared leaf rather than joining as a member (§9). +old() { HOME="$WORK/old" "$AMY" --account me --secret-backend ncryptsec "$@" 2>/dev/null; } +new() { HOME="$WORK/new" "$AMY" --account me --secret-backend ncryptsec "$@" 2>/dev/null; } +field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; } + +blob_up() { + mkdir -p "$BLOB_DIR" + python3 - "$BLOB_PORT" "$BLOB_DIR" >"$WORK/blob.log" 2>&1 & + BLOB_PID=$! + # Wait for the port, and say so here if it never opens. Letting a dead blob + # server through costs three misleading failures later — export reports "no + # server accepted the document", and the group/blob assertions all fall over + # behind it — none of which name the thing that is actually wrong. + for _ in $(seq 20); do + if python3 -c "import socket,sys; s=socket.socket(); s.settimeout(0.3); sys.exit(0 if s.connect_ex(('127.0.0.1',$BLOB_PORT))==0 else 1)"; then + return 0 + fi + kill -0 "$BLOB_PID" 2>/dev/null || break + sleep 0.5 + done + echo "the blob server never came up on $BLOB — see $WORK/blob.log" + tail -3 "$WORK/blob.log" 2>/dev/null + exit 2 +} <<'PYEOF' +import hashlib, os, sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +port, root = int(sys.argv[1]), sys.argv[2] + +class H(BaseHTTPRequestHandler): + def do_PUT(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + digest = hashlib.sha256(body).hexdigest() + open(os.path.join(root, digest), "wb").write(body) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(('{"sha256":"%s","size":%d}' % (digest, len(body))).encode()) + + def do_GET(self): + path = os.path.join(root, self.path.lstrip("/")) + if not os.path.isfile(path): + self.send_response(404); self.end_headers(); return + body = open(path, "rb").read() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): pass + +ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever() +PYEOF + +cleanup() { + [ -n "$BLOB_PID" ] && kill "$BLOB_PID" 2>/dev/null + stack_down +} +trap cleanup EXIT + +stack_require + +step "boot geode on $RELAY, the reference coordinator, and a blob server on $BLOB" +stack_up +blob_up +ok "coordinator $COORD" + +step "one account, on its old phone" +mkdir -p "$WORK/old" "$WORK/new" +old create --json >/dev/null +PK=$(old whoami --json | field "['hex']") +ok "npub $PK" + +# The new phone is the SAME identity. Copy the key across the way a user would +# by signing in again; the migration carries group state, never identity (§4.3). +NSEC=$(old key export --json 2>/dev/null | field "['nsec']" 2>/dev/null) +if [ -z "$NSEC" ]; then + cp -r "$WORK/old/.amy" "$WORK/new/.amy" + ok "new phone signed in (identity copied)" +else + new import --nsec "$NSEC" --json >/dev/null + ok "new phone signed in" +fi + +step "the old phone has a group" +old cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label migrate --json >/dev/null +GROUP=$(old cordn group create --name "Moves with me" --about "handoff" --json) +GID=$(echo "$GROUP" | field "['gid']") +[ -n "$GID" ] && ok "gid $GID" || bad "no group to migrate" + +step "the old phone exports a handoff" +EXPORT=$(old cordn migrate export --relay "$RELAY" --server "$BLOB" --json) +echo " $EXPORT" +CODE=$(echo "$EXPORT" | field "['code']") +if [ -n "$CODE" ] && [ "${CODE:0:9}" = "cordndev1" ]; then ok "code minted"; else bad "no handoff code"; fi +[ "$(echo "$EXPORT" | field "['groups']")" = "1" ] && ok "one group in the snapshot" || bad "wrong group count" +[ "$(echo "$EXPORT" | field "['uploaded']")" = "true" ] && ok "documents stored" || bad "nothing stored" + +step "blobs really landed on the server" +COUNT=$(ls "$BLOB_DIR" 2>/dev/null | wc -l | tr -d ' ') +# One group document plus the meta document. +[ "$COUNT" -ge 2 ] && ok "$COUNT blobs" || bad "expected >=2 blobs, found $COUNT" + +step "the new phone has nothing yet" +BEFORE=$(new cordn group list --json 2>/dev/null | field "['groups']" 2>/dev/null || echo "[]") +[ "$BEFORE" = "[]" ] && ok "empty" || echo " (starting from: $BEFORE)" + +step "the new phone imports, from the code alone" +IMPORT=$(new cordn migrate import --code "$CODE" --json) +echo " $IMPORT" +[ "$(echo "$IMPORT" | field "['groups']")" = "1" ] && ok "one group adopted" || bad "group did not arrive" +[ "$(echo "$IMPORT" | field "['replaced_local_state']")" = "true" ] && ok "replaced, not merged" || bad "did not replace" + +step "the group is really there, with the same gid" +AFTER=$(new cordn group list --json 2>/dev/null) +echo " $AFTER" +echo "$AFTER" | grep -q "$GID" && ok "gid $GID on the new phone" || bad "gid missing after import" + +step "a group ref is not a handoff code" +# Called directly rather than through new(): the --json error contract writes +# to stderr, which the helper sends to /dev/null. The first version of this +# check compared an empty string and passed for the wrong reason. +STRANGER=$(HOME="$WORK/new" "$AMY" --account me --secret-backend ncryptsec \ + cordn migrate import --code "cordn1qqqqqq" --json 2>&1 || true) +echo " $STRANGER" +echo "$STRANGER" | grep -q "bad_args" && ok "refused" || bad "accepted a non-handoff code" + +echo +if [ "$fail" -eq 0 ]; then + echo "── migrate: PASS" +else + echo "── migrate: FAIL" +fi +exit "$fail" diff --git a/cli/tests/cordn/stack.sh b/cli/tests/cordn/stack.sh new file mode 100644 index 0000000000..ca8005b95a --- /dev/null +++ b/cli/tests/cordn/stack.sh @@ -0,0 +1,117 @@ +# shellcheck shell=bash +# +# stack.sh — boot the cordn test stack: a geode relay plus the REFERENCE +# coordinator in Docker. Sourced by tier-b.sh and interop-client.sh. +# +# ───────────────────────────────────────────────────────────────────────────── +# The reference coordinator (`ghcr.io/cordn-msg/cordn`, and the +# `packages/coordinator` / `packages/server` sources it is built from) ships +# with NO LICENSE — default copyright, all rights reserved. See §7 of +# quartz/plans/2026-09-17-cordn-interop.md. +# +# Nothing here is wired into a build: no Gradle task, no CI job, and nothing +# pulls the image for you. You pull it by hand having decided that is +# something you want to do. Do not add these scripts to a build file. +# ───────────────────────────────────────────────────────────────────────────── +# +# The caller sets WORK (a scratch directory) and may set PORT. After +# `stack_up`, these are exported: +# +# RELAY ws://127.0.0.1:$PORT +# COORD the coordinator's pubkey, read from its own startup log +# +# `stack_down` is registered by the caller's EXIT trap; KEEP=1 skips it. + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" +AMY="$ROOT/cli/build/install/amy/bin/amy" +GEODE="$ROOT/geode/build/install/geode/bin/geode" +IMAGE="ghcr.io/cordn-msg/cordn:latest" + +PORT="${PORT:-7447}" +RELAY="ws://127.0.0.1:$PORT" +CONTAINER="${CONTAINER:-cordn-test}" +GEODE_PID="" + +# Prereqs, each with its own message. A stopped daemon and an unpulled image +# both fail `docker image inspect`, and telling someone to pull an image they +# cannot pull sends them the wrong way. +stack_require() { + for f in "$AMY" "$GEODE"; do + [ -x "$f" ] || { + echo "missing $f — run ./gradlew :cli:installDist :geode:installDist" + exit 2 + } + done + docker info >/dev/null 2>&1 || { + echo "the docker daemon is not reachable — start it (e.g. 'sudo dockerd &' or 'systemctl start docker') and retry" + exit 2 + } + docker image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "missing $IMAGE — pull it by hand, and read the licence note at the top of this file first" + exit 2 + } +} + +stack_up() { + "$GEODE" --port "$PORT" >"$WORK/geode.log" 2>&1 & + GEODE_PID=$! + for _ in $(seq 30); do + curl -sS --noproxy '*' -H 'Accept: application/nostr+json' "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break + sleep 1 + done + + # The container has to reach geode, which is on the host's loopback. + # + # On Linux `--network host` puts it there. On Docker Desktop (macOS, + # Windows) the daemon is inside a VM, so `--network host` is the *VM's* + # loopback and 127.0.0.1:$PORT is nothing at all: the coordinator retries + # "Relay connection error" until stack_up gives up on a pubkey that was + # never going to arrive. There the host is reachable by name instead, over + # the default bridge. + # + # A stable key so the coordinator pubkey survives a re-run against the + # same WORK directory. + if [ "$(uname -s)" = "Linux" ]; then + COORD_NET="--network host" + COORD_RELAY="$RELAY" + else + COORD_NET="--add-host=host.docker.internal:host-gateway" + COORD_RELAY="ws://host.docker.internal:$PORT" + fi + + [ -f "$WORK/coordinator.key" ] || openssl rand -hex 32 >"$WORK/coordinator.key" + docker rm -f "$CONTAINER" >/dev/null 2>&1 + # shellcheck disable=SC2086 # COORD_NET is two words on purpose + docker run -d --name "$CONTAINER" $COORD_NET \ + -e CORDN_STORAGE_BACKEND=memory \ + -e CORDN_ANNOUNCED=false \ + -e CORDN_RELAY_URLS="$COORD_RELAY" \ + -e CORDN_SERVER_PRIVATE_KEY="$(cat "$WORK/coordinator.key")" \ + -e CORDN_SERVER_NAME="cordn-test" \ + "$IMAGE" >/dev/null || { echo "could not start $CONTAINER"; exit 1; } + + # Read the pubkey out of its own startup log rather than deriving it: the + # coordinator is the authority on its identity, and a key we derived + # wrongly would fail later as an unreachable coordinator. + COORD="" + for _ in $(seq 60); do + COORD=$(docker logs "$CONTAINER" 2>&1 | grep -oE 'serverPubkey":"[0-9a-f]{64}' | head -1 | cut -d'"' -f3) + [ -n "$COORD" ] && break + sleep 1 + done + [ -n "$COORD" ] || { + echo "coordinator never announced its pubkey" + docker logs "$CONTAINER" | tail -20 + exit 1 + } +} + +stack_down() { + if [ "${KEEP:-0}" != "1" ]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 + [ -n "$GEODE_PID" ] && kill "$GEODE_PID" 2>/dev/null + else + echo + echo "KEEP=1: relay on $RELAY, coordinator $CONTAINER ($COORD), state in $WORK" + fi +} diff --git a/cli/tests/cordn/tier-b.sh b/cli/tests/cordn/tier-b.sh new file mode 100755 index 0000000000..ce85b43bd0 --- /dev/null +++ b/cli/tests/cordn/tier-b.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# tier-b.sh — the cordn binding, end to end against the REFERENCE coordinator. +# +# Tier B of quartz/plans/2026-09-17-cordn-interop.md §6.4: a live +# counterparty, not a fixture. Three amy accounts, one local relay (geode), +# one reference coordinator, and the whole lifecycle — publish a KeyPackage, +# create a group, invite, open the Welcome without joining, join, talk in both +# directions, and check both sides agree on epoch and membership. +# +# Then the parts that lifecycle never reaches, each driven directly, so that +# all eleven coordinator tools of `spec/00.md` are exercised against a real +# coordinator: a third account who ASKS to join rather than being invited +# (join_request_store, join_request_take_many), a withdrawn KeyPackage +# (kp_remove), a backlog big enough to be chunked (CEP-22), and a message +# pushed down an open stream (msg_sub_many). +# +# The reference coordinator it runs against is UNLICENSED — read the header of +# stack.sh, which boots it, before running this. Nothing here is wired into a +# build, and it must not become so. +# +# Sibling: interop-client.sh puts amy and the reference CLIENT in one group, +# which is the test this one does not do — here both MLS endpoints are ours. +# +# Prereqs: see stack.sh. +# +# Usage: +# ./cli/tests/cordn/tier-b.sh # boot everything, run, tear down +# KEEP=1 ./cli/tests/cordn/tier-b.sh # leave the relay + coordinator up +# +# Exit 0 only if every step passed. + +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +PORT="${PORT:-7447}" +CONTAINER="cordn-tier-b" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-tier-b}" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } + +# Every amy run is its own process, which is the point: the cursor on disk is +# the only thing carrying continuity between them. +alice() { HOME="$WORK/alice" "$AMY" --account alice --secret-backend ncryptsec "$@" 2>/dev/null; } +bob() { HOME="$WORK/bob" "$AMY" --account bob --secret-backend ncryptsec "$@" 2>/dev/null; } +carol() { HOME="$WORK/carol" "$AMY" --account carol --secret-backend ncryptsec "$@" 2>/dev/null; } +field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; } + +trap stack_down EXIT + +stack_require + +step "boot geode on $RELAY, and the reference coordinator" +stack_up +ok "coordinator $COORD" + +step "three accounts" +# Carol is here for the door nobody knocks on in the two-party flow: bob is +# invited, so he never sends a join request. +mkdir -p "$WORK/alice" "$WORK/bob" "$WORK/carol" +alice create --json >/dev/null +bob create --json >/dev/null +carol create --json >/dev/null +ALICE_PK=$(alice whoami --json | field "['hex']") +BOB_PK=$(bob whoami --json | field "['hex']") +CAROL_PK=$(carol whoami --json | field "['hex']") +ok "alice $ALICE_PK" +ok "bob $BOB_PK" +ok "carol $CAROL_PK" + +step "all three remember the coordinator" +alice cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null +bob cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null +carol cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null + +step "the MCP handshake" +# The first thing to break if the transport regresses, and the cheapest to +# check. A timeout here means the coordinator never saw the request at all — +# which is exactly how §7.1's finding 1 presented. +INFO=$(alice cordn coordinator info --json) +echo " $INFO" +[ "$(echo "$INFO" | field "['reachable']")" = "true" ] && ok "reachable" || bad "no initialize response" + +step "bob publishes a KeyPackage" +KP=$(bob cordn keypackage publish --json) +echo " $KP" +[ -n "$(echo "$KP" | field "['published'][0]['kp_ref']")" ] && ok "published" || bad "no kp_ref" + +step "alice creates a group" +GROUP=$(alice cordn group create --name "Tier B" --about "live" --json) +echo " $GROUP" +GID=$(echo "$GROUP" | field "['gid']") +[ -n "$GID" ] && ok "gid $GID" || bad "no gid" + +step "alice invites bob" +INVITE=$(alice cordn invite --pubkey "$BOB_PK" --json) +echo " $INVITE" +[ "$(echo "$INVITE" | field "['epoch']")" = "1" ] && ok "epoch 1 after the commit" || bad "epoch did not advance" + +step "bob opens the invitation without joining" +PENDING=$(bob cordn welcomes --json) +echo " $PENDING" +[ "$(echo "$PENDING" | field "['pending'][0]['gid']")" = "$GID" ] && ok "sees $GID" || bad "no pending welcome" +[ "$(echo "$PENDING" | field "['pending'][0]['name']")" = "Tier B" ] && ok "reads the name out of the Welcome" || bad "no metadata in the Welcome" + +step "bob joins" +JOINED=$(bob cordn join --all --json) +echo " $JOINED" +[ "$(echo "$JOINED" | field "['joined']")" = "[\"$GID\"]" ] && ok "joined" || bad "join failed" + +step "alice sends, bob reads" +SENT=$(alice cordn send --text "hello from amy" --json) +MSG_ID=$(echo "$SENT" | field "['id']") +GOT=$(bob cordn fetch --json) +echo " $GOT" +[ "$(echo "$GOT" | field "['messages'][0]['id']")" = "$MSG_ID" ] && ok "same envelope id" || bad "bob did not read it" +[ "$(echo "$GOT" | field "['messages'][0]['sender']")" = "$ALICE_PK" ] && ok "sender is what MLS authenticated" || bad "wrong sender" + +step "bob replies, alice reads" +BACK=$(bob cordn send --text "and hello back" --json) +BACK_ID=$(echo "$BACK" | field "['id']") +GOT2=$(alice cordn fetch --json) +echo " $GOT2" +echo "$GOT2" | grep -q "$BACK_ID" && ok "alice read the reply" || bad "alice did not read the reply" +# §7.1 finding 2: a sender must not report its own traffic as a gap in its own +# conversation. Everything alice posted came back at a cursor she re-reads, +# and recognising it needs bookkeeping that survives the process exit. +[ "$(echo "$GOT2" | field "['undecryptable']")" = "[]" ] && ok "no self-inflicted gaps" || bad "own traffic came back undecryptable: $(echo "$GOT2" | field "['undecryptable']")" + +# ── The three tools the two-party flow never reaches, and CEP-22 ──────────── +# Every other step above exercises a tool as a side effect of the lifecycle. +# These four do not happen on that path, so they are driven directly. + +step "carol asks to join — join_request_store" +REQ=$(carol cordn request --gid "$GID" --json) +echo " $REQ" +[ "$(echo "$REQ" | field "['gid']")" = "$GID" ] && ok "request stored" || bad "join request not stored" +# A ref is an invitation to ask, not membership (spec/01.md §5.3). +[ "$(echo "$REQ" | field "['member']")" = "false" ] && ok "asking is not joining" || bad "request reported membership" + +step "alice reads it — join_request_take_many" +PEND=$(alice cordn requests list --json) +echo " $PEND" +[ "$(echo "$PEND" | field "['requests'][0]['pubkey']")" = "$CAROL_PK" ] && ok "sees carol" || bad "no pending request" +[ "$(echo "$PEND" | field "['requests'][0]['gid']")" = "$GID" ] && ok "for $GID" || bad "wrong gid on the request" +# Listing must NOT consume. A request is retired by answering it, not by +# reading it, and the ack rides the next call — so a reader that lost the +# process between list and accept has to still find it. Two separate amy +# runs is exactly that case. +AGAIN=$(alice cordn requests list --json) +[ "$(echo "$AGAIN" | field "['requests'][0]['pubkey']")" = "$CAROL_PK" ] && ok "still there for a second reader" || bad "reading a join request consumed it" + +step "alice accepts, carol joins" +ACC=$(alice cordn requests accept --pubkey "$CAROL_PK" --json) +echo " $ACC" +[ "$(echo "$ACC" | field "['answered'][0]['pubkey']")" = "$CAROL_PK" ] && ok "accepted" || bad "accept failed" +CJOIN=$(carol cordn join --all --json) +[ "$(echo "$CJOIN" | field "['joined']")" = "[\"$GID\"]" ] && ok "carol joined" || bad "carol did not join" +# Now it is retired — and the ack for it rode a later call, so this also +# proves the retirement survived the process that issued it. +GONE=$(alice cordn requests list --json) +[ "$(echo "$GONE" | field "['requests']")" = "[]" ] && ok "answering retired it" || bad "an answered request is still pending: $(echo "$GONE" | field "['requests']")" + +step "bob withdraws a KeyPackage — kp_remove" +# Two, so there is something left to prove the removal was targeted rather +# than a wipe. +PUB=$(bob cordn keypackage publish --count 2 --json) +KEEP=$(echo "$PUB" | field "['published'][0]['kp_ref']") +DROP=$(echo "$PUB" | field "['published'][1]['kp_ref']") +WD=$(bob cordn keypackage withdraw --kp-ref "$DROP" --json) +echo " $WD" +[ "$(echo "$WD" | field "['removed']")" = "[\"$DROP\"]" ] && ok "coordinator confirms the removal" || bad "kp_remove did not confirm $DROP" +LIST=$(bob cordn keypackage list --json) +echo "$LIST" | grep -q "$DROP" && bad "the withdrawn package is still served" || ok "gone from kp_list" +echo "$LIST" | grep -q "$KEEP" && ok "the other one survived" || bad "kp_remove took more than it was asked for" + +step "a response too big for one event — CEP-22" +# The reference coordinator switches to oversized transfer at a 48000-byte +# published envelope. One message does not reach it; a backlog of them does, +# and a fetch is where a backlog is delivered. Nothing below reads the +# messages differently — a reassembled response is meant to be invisible — +# so the count is the only thing that can tell us the profile ran. +BIG=$(python3 -c "print('x' * 6000)") +for i in $(seq 12); do + alice cordn send --text "chunk-$i $BIG" --json >/dev/null +done +FETCH=$(bob cordn fetch --json) +COUNT=$(echo "$FETCH" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['messages']))") +CHUNKED=$(echo "$FETCH" | field "['oversized_transfers']") +echo " $COUNT messages, oversized_transfers=$CHUNKED" +[ "$COUNT" = "12" ] && ok "all 12 arrived" || bad "expected 12 messages, got $COUNT" +[ "${CHUNKED:-0}" -ge 1 ] && ok "reassembled over CEP-22" || bad "the response was never chunked — CEP-22 went unexercised" +# Carol was added at a later epoch, so she must read them too: an oversized +# response is still one response, not a per-member special case. +CGOT=$(carol cordn fetch --json) +CCOUNT=$(echo "$CGOT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['messages']))") +[ "$CCOUNT" = "12" ] && ok "carol read the same 12" || bad "carol got $CCOUNT of 12" + +step "a message delivered over an open stream — msg_sub_many" +# The eleventh tool, and the only one a request/response client never +# reaches: `cordn watch` calls msg_sub_many and nothing else, so anything +# it prints arrived over an open CEP-41 stream rather than a poll. +# +# Ordering is the whole test. The watcher goes up FIRST and we wait for it +# to be listening; only then does alice send. A message sent beforehand +# would be backlog the subscription replays, which proves a stream opened +# but not that anything was pushed down it. +WATCH_OUT="$WORK/watch.json" +bob cordn watch --timeout 25000 --json >"$WATCH_OUT" 2>/dev/null & +WATCH_PID=$! +sleep 8 +LIVE="live-$$-$(date +%s)" +alice cordn send --text "$LIVE" --json >/dev/null +wait "$WATCH_PID" +WATCHED=$(cat "$WATCH_OUT") +echo " $(echo "$WATCHED" | head -c 400)" +[ "$(echo "$WATCHED" | field "['via']")" = "msg_sub_many" ] && ok "the stream was the source" || bad "cordn watch did not report a subscription" +echo "$WATCHED" | grep -q "$LIVE" && ok "pushed live, not polled for" || bad "the subscription never delivered $LIVE" +[ "$(echo "$WATCHED" | field "['messages'][0]['sender']")" = "$ALICE_PK" ] && ok "MLS authenticated the sender over the stream too" || bad "wrong sender on the streamed message" + +step "the watcher saved what it ingested" +# Decrypting a streamed message advances the ratchet and the cursor. A +# subscription that ended without writing would leave that only in memory, +# and the next process would re-read its own progress as a gap. +AFTER=$(bob cordn fetch --json) +echo "$AFTER" | grep -q "$LIVE" && bad "the streamed message came back on the next fetch" || ok "the cursor survived the watching process" +[ "$(echo "$AFTER" | field "['undecryptable']")" = "[]" ] && ok "no gaps after the stream closed" || bad "gaps after the stream: $(echo "$AFTER" | field "['undecryptable']")" + +step "both sides agree" +A=$(alice cordn group info --json) +B=$(bob cordn group info --json) +[ "$(echo "$A" | field "['epoch']")" = "$(echo "$B" | field "['epoch']")" ] && ok "same epoch" || bad "epochs differ" +[ "$(echo "$A" | field "['members']")" = "$(echo "$B" | field "['members']")" ] && ok "same members" || bad "membership differs" + +echo +if [ "$fail" = "0" ]; then + echo "TIER B PASSED" +else + echo "TIER B FAILED" +fi +exit "$fail" diff --git a/commons/plans/2026-09-24-cordn-message-store.md b/commons/plans/2026-09-24-cordn-message-store.md new file mode 100644 index 0000000000..07f326a7b7 --- /dev/null +++ b/commons/plans/2026-09-24-cordn-message-store.md @@ -0,0 +1,192 @@ +# cordn message store + +*2026-09-24* + +## The problem + +A cordn room's messages exist in exactly one place on the device: a +`LinkedHashMap` inside `CordnGroupChatroom`. `CordnGroupStore` persists the MLS +group state, the `GroupCursor`, echo state, joined-via-request and +`CordnRoomState(draft, lastReadCursor)`. It does not persist a single message. + +That is worse than an uncached history, because of how the cursor works. +`GroupCursor.fetchCursor` advances past every message the stream delivers and is +saved on the way out of `catchUp`/`subscribe`. On the next launch, +`msg_fetch_many(after: fetchCursor)` therefore returns nothing — the client has +already consumed everything. The room opens empty and **never refills**. + +Rewinding the cursor would not rescue it either. A cordn payload is sealed twice: +the MLS application message, then `SealedPayload` under +`MLS-Exporter("cordn", "group-payload", 32)`. Both keys are epoch-derived, so +once the group ratchets forward the ciphertext the coordinator still holds is +unreadable to us. **Ingestion is the only moment the message is in the clear.** + +Three visible symptoms, one cause: + +- rooms are empty after every app restart +- the Messages inbox says "No messages yet" for every cordn room, because + `CordnGroupChatroom._newest` is only ever set by `add`/`addAll` +- leaving and re-opening a room mid-session is fine; killing the app is not + +## What Marmot already does + +`MarmotMessageStore` / `AndroidMarmotMessageStore` is the same subsystem, built: +an `EncryptedAppendLog` at `/mls_groups//messages`, storing the +**decrypted inner event JSON**, with idempotent appends. Its own KDoc states the +principle we need: *"the ratchet moved past the ciphertext long ago, so this +store is the only copy."* + +Two differences shape our design rather than letting us copy it outright: + +1. **Marmot has a second recovery path; cordn has none.** Marmot's kind-445 + events sit on relays and it retains epoch secrets, so a replay can + re-decrypt — which is *why* it needs dedup. cordn has no replay, which makes + the store more load-bearing here, not less. +2. **Marmot messages are `Note`s in `LocalCache`; cordn's must never be.** The + isolation rule means our read path hydrates `CordnGroupChatroom` directly. + +cordn also has **no message expiry**, so there is no prune-before-read step. + +## Design + +### What a stored entry is + +`CordnDeliveredMessage` = `CordnEnvelope` + `cursor`. The envelope already has +`toJson()`/`encode()`/`decode()`. The cursor has to ride along: the room orders +on it and the unread divider compares against it, and it is not derivable from +the envelope. + +Entry format — one JSON object per log entry, versioned: + +```json +{"v":1,"c":,"e":{…envelope…}} +``` + +A new `CordnDeliveredMessageCodec` beside `CordnEnvelope` in quartz owns it, so +the format is tested where the envelope's own round-trip test already lives. + +### Where it is written + +`CordnGroupManager.ingest` produces `Delivery.Message(gid, cursor, …)` but is +not `suspend`. The append belongs in the `suspend` caller — the `onDelivery` +path inside `catchUp`/`subscribe` — so that "the room has it" and "disk has it" +happen together rather than on separate beats. + +**Ordering is the correctness property.** Append the message, *then* advance and +persist the cursor. Crash between the two and the message is on disk while the +cursor still points before it: the next `catchUp` re-delivers it and dedup drops +it. Do it in the other order and the message is gone forever, with no way back. +This is the one invariant a reviewer should check first. + +### Dedup + +Marmot dedups on whole-entry string equality via `EncryptedAppendLog.contains`. +That is not enough here: the same message re-delivered after a crash carries the +same envelope but the entry string is only equal if the cursor matches too, and +`Ingestion` can legitimately hand back a different cursor. Dedup on +`envelope.id`, using an in-memory `Set` per group built at load — cheap, +and the log is already read in full on open. + +### Where it is read + +`CordnRuntime.restoreRoomState` loads draft + read position when a screen opens +a room, deliberately: its KDoc notes that doing it at login would read state for +rooms nobody opens. + +The inbox preview breaks that symmetry — it needs `_newest` for every room +*before* any room is opened. So two reads, not one: + +- **per-group summary** (newest envelope + count), its own small whole-blob key, + read at login. Fixes "No messages yet" without touching the logs. +- **full log**, read on room open, hydrating via `addAll` so the existing + `recompute`/annotation fold runs exactly as it does for live delivery. + +The summary is written on the same beat as the append. + +### `EncryptedAppendLog` placement — needs a decision + +`CordnIndependenceTest` forbids cordn importing anything whose import line +contains "marmot". The log currently lives in +`commons/.../commons/marmot/EncryptedAppendLog.kt`, so cordn cannot use it where +it is. + +It is, however, entirely protocol-neutral: it takes `encrypt`/`decrypt` lambdas +and stores opaque strings. Nothing in it knows what Marmot is. + +- **A — move it** to a neutral package (`commons/.../store/`), update Marmot's + import. One implementation, one file format, one migration path. Touches a + Marmot file (import line only, no behaviour change) and *improves* + independent-deletability: neither feature owns the primitive any more. +- **B — duplicate it** into a cordn package. ~400 lines of subtle framing, + folding and legacy-format migration, copied, free to drift. +- **C — whole-blob rewrites** with cordn's existing `atomicWrite` pattern. O(n) + bytes per send. This is the cost the append log exists to avoid. + +**Recommend A.** The guard's own KDoc draws the line at "marmot-named code" — +the coupling it exists to prevent — and a shared neutral primitive is not that. +Worth Vitor's sign-off before it happens, since it edits a Marmot file. + +### Backup and migration — needs a decision + +`CordnBackup.Archive.Group` carries coordinator, gid, state, cursor and +joined-via-request. No messages. + +- **Device migration** (`CordnMigrationStores`) is "this device becomes that + device". Arriving with no history would be the surprising outcome. **Include.** + *Done* — as `amethystMessages` on the group document, alongside the other + additive `amethyst*` fields, written before the cursor on import for the same + reason the live path writes it that way. Bounded per group by + `MESSAGE_BUDGET_BYTES`, newest first: these blobs go to hosts whose limits we + do not know, and a handoff that fails because one group is chatty is worse + than one that carries a deep but bounded history. +- **Backup** is a recovery artifact whose size the user sees. History could + multiply it by a large factor. **Exclude for now**, and say so in the backup + screen's copy rather than letting someone discover it at restore time. *Still + open.* + +Both are reversible later; the format is versioned. + +### Deletion + +`deleteGroup(gid)` must remove the log and the summary. Leaving a group today +removes the MLS state; leaving plaintext history behind would be a quiet +regression in exactly the property this feature sells. Explicit test. + +## Test plan + +The failure modes here are lifecycle, not logic, and this branch has already +shipped two bugs of that shape. Tests that matter: + +- **crash between append and cursor save** → message survives, re-delivery + dedups on id. The invariant above, tested directly. +- **round-trip** an envelope with tags, an edit, emoji, and empty content. +- **rehydrate equals live** — the annotation fold built from a loaded log + matches the one built by ingesting the same messages. The fold is what the + user sees; equality of the raw list is not enough. +- **`_newest` after load** → the inbox preview shows the last message. +- **`deleteGroup` removes the log** and the summary. +- **cancellation** — a load or append cancelled mid-flight leaves no partial + entry. + +One trap, learned the hard way on this branch: `InMemoryCordnGroupStore`'s +`suspend` methods never reach a suspension point, so cancellation is +*unobservable* in any test using it. The fake for the message store must +actually suspend, or the cancellation test above proves nothing. + +## Non-goals + +No expiry (cordn has none), no search, no paging — the log loads whole. A room +with tens of thousands of messages would want paging; note it, do not build it +until a room gets there. + +## Files + +- `quartz/.../cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt` — new +- `commons/.../cordn/CordnGroupStore.kt` — append/load/summary/delete +- `commons/.../cordn/FileCordnStores.kt` — the log-backed implementation +- `commons/.../cordn/CordnGroupManager.kt` — append at ingest, ordered before + the cursor +- `commons/.../store/EncryptedAppendLog.kt` — moved (decision A) +- `commons/.../marmot/…` — import update only +- `amethyst/.../model/cordn/CordnRuntime.kt` — summary at login, log on open +- `commons/.../cordn/CordnMigrationStores.kt` — carry messages diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyStoreCordnBlobCipher.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyStoreCordnBlobCipher.kt new file mode 100644 index 0000000000..f6c22543d3 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyStoreCordnBlobCipher.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.amethyst.commons.keystorage.KeyStoreEncryption + +/** + * The Android [CordnBlobCipher]: AES-GCM under a key held in the Android + * KeyStore (StrongBox-backed where the device has it). + * + * ## Why there is no lock here any more + * + * There used to be one. [KeyStoreEncryption] kept a single `Cipher` in a field + * and ran `init(...)` then `doFinal(...)` against it, which is not atomic, so + * two groups saving at once on `Dispatchers.IO` could interleave and write + * bytes encrypted under the wrong IV — unrecoverable for an `MlsGroupState`. + * + * [KeyStoreEncryption] now holds its `Cipher` in a `ThreadLocal`, so the pair + * can no longer interleave and the lock guards nothing. Keeping it would + * serialise every cordn group save and load on this device for no reason, + * which is the opposite of what a thread pool is for. + */ +class KeyStoreCordnBlobCipher : CordnBlobCipher { + // Not a constructor parameter: KeyStoreEncryption is internal to commons, + // so taking one publicly would leak an internal type. Nothing needs to + // inject it either — a test that wanted a different cipher implements + // [CordnBlobCipher] directly, which is what the seam is for. + // Built on first use, not at construction. This cipher is reached from + // CordnRuntime, which Account builds eagerly, so a KeyStoreEncryption that + // throws here takes the whole account down before any UI exists — the app + // sits on "Loading account" forever. Every other user of KeyStoreEncryption + // already guards it (AccountCacheState falls back to an in-memory Marmot + // store); deferring keeps a cordn-only failure inside cordn. + private val encryption by lazy { KeyStoreEncryption() } + + override fun encrypt(bytes: ByteArray): ByteArray = encryption.encrypt(bytes) + + override fun decrypt(bytes: ByteArray): ByteArray = encryption.decrypt(bytes) +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt index fc0ea04b04..bf4b50fd64 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt @@ -24,26 +24,71 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException +import java.security.GeneralSecurityException import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.GCMParameterSpec internal class KeyStoreEncryption { companion object { private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM - private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 - private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + + /** + * GCM is a stream mode: it pads nothing, and `AES/GCM/NoPadding` is the + * only transformation JCA defines for it. [LEGACY_PADDING] is what this + * class has always asked for, and some providers accept it, so the keys + * and ciphertext already on those devices were made under that name. + */ + private const val LEGACY_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val GCM_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT private const val KEY_ALIAS = "AMETHYST_AES_KEY" + + /** GCM's authentication tag, in bits. 128 is the default and the maximum. */ + private const val TAG_BITS = 128 + + /** GCM's nonce, in bytes — what [encrypt] prefixes to its output. */ + private const val IV_BYTES = 12 + + private fun transformationFor(padding: String) = "$ALGORITHM/$BLOCK_MODE/$padding" + + /** + * Which padding name this device's providers actually answer to. + * + * Asking for the legacy name where it works keeps every existing key + * and every encrypted file readable — switching unconditionally would + * orphan them, and one of them holds account secrets. Where no provider + * offers it (seen on a Samsung API 34 tablet, where `Cipher.getInstance` + * throws `NoSuchAlgorithmException`), the correct GCM name is used + * instead. Before this, that throw propagated out of the constructor: + * callers that guarded it silently lost persistence, and the one that + * did not — cordn, from `Account`'s constructor — hung the app on + * "Loading account" forever. + * + * Resolved once per process: the answer cannot change under us, and + * every instance would otherwise repeat the failing lookup. + */ + private val resolvedPadding: String by lazy { + try { + Cipher.getInstance(transformationFor(LEGACY_PADDING)) + LEGACY_PADDING + } catch (_: GeneralSecurityException) { + GCM_PADDING + } + } } + private val padding = resolvedPadding + // One Cipher per thread rather than one shared instance: a Cipher holds the // state of the operation in progress, so two callers on different threads - // through the same object would corrupt each other's output. - private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) } + // through the same object would corrupt each other's output. The + // transformation is resolved once, off [resolvedPadding], so every thread's + // instance is built with the spelling this device actually offers. + private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(transformationFor(padding)) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } @@ -69,7 +114,7 @@ internal class KeyStoreEncryption { KeyGenParameterSpec .Builder(KEY_ALIAS, PURPOSE) .setBlockModes(BLOCK_MODE) - .setEncryptionPaddings(PADDING) + .setEncryptionPaddings(padding) .setIsStrongBoxBacked(true) .build() @@ -88,7 +133,7 @@ internal class KeyStoreEncryption { KeyGenParameterSpec .Builder(KEY_ALIAS, PURPOSE) .setBlockModes(BLOCK_MODE) - .setEncryptionPaddings(PADDING) + .setEncryptionPaddings(padding) .build() val generator = KeyGenerator.getInstance(ALGORITHM, "AndroidKeyStore") @@ -116,11 +161,18 @@ internal class KeyStoreEncryption { fun decrypt(bytes: ByteArray): ByteArray { try { - // Extracts IV and decrypts the data - val iv = bytes.copyOfRange(0, 12) // GCM mode uses 12-byte IV - val data = bytes.copyOfRange(12, bytes.size) + val iv = bytes.copyOfRange(0, IV_BYTES) + val data = bytes.copyOfRange(IV_BYTES, bytes.size) val cipher = ciphers.get() - cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + // A GCMParameterSpec, not an IvParameterSpec. [BLOCK_MODE] is GCM, + // and AndroidKeyStore's GCM implementation rejects anything else + // outright — "Only GCMParameterSpec supported". Encrypting never + // noticed, because that direction lets the cipher choose its own + // nonce and passes no spec at all, so every store built on this + // class could write bytes it was then unable to read back: cordn + // lost its coordinators on each launch, and Marmot and the + // encrypted DataStore read nothing they had saved. + cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(TAG_BITS, iv)) return cipher.doFinal(data) } catch (e: Exception) { cachedKey = null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CoordinatorConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CoordinatorConfig.kt new file mode 100644 index 0000000000..c4e47fa79e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CoordinatorConfig.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * One coordinator this account talks to. + * + * A coordinator is not a relay and the difference matters to the user: relays + * are interchangeable and redundant, a coordinator is the single authority for + * the groups it serves. Losing it loses the ordering; a second one does not + * mirror the first. So this is a named, first-class thing a user chooses, + * never a URL buried in settings. + * + * [relays] is where the coordinator is *reachable* — the ContextVM kind-25910 + * traffic goes over Nostr, so the coordinator has no address of its own beyond + * its pubkey (§8.5: the relay sees the traffic pattern, the coordinator never + * sees an IP). + */ +data class CoordinatorConfig( + val pubKey: HexKey, + val relays: List, + /** How this account came to know about this coordinator. */ + val origin: Origin = Origin.MANUAL, + /** What the user calls it. Never a claim — a coordinator cannot prove a name. */ + val label: String? = null, +) { + init { + require(pubKey.length == PUBKEY_HEX_LENGTH) { "a coordinator pubkey is 32 bytes of hex" } + require(relays.isNotEmpty()) { "a coordinator with no relays cannot be reached" } + } + + /** Where a coordinator came from, because it changes how much to trust it. */ + enum class Origin { + /** Typed or pasted by the user. */ + MANUAL, + + /** Read out of a `cordn1…` group ref someone shared. */ + GROUP_REF, + + /** + * Picked off a CEP-6 announcement the coordinator published. + * + * Weaker than [MANUAL]: nobody vouched for it. The user saw a list, and + * everything in that list except the pubkey was self-asserted. + */ + ANNOUNCEMENT, + + /** The application default. */ + DEFAULT, + } + + companion object { + private const val PUBKEY_HEX_LENGTH = 64 + + /** + * The coordinator a shared `cordn1…` ref points at, or null when the + * ref carries only a `gid`. + * + * A ref without a coordinator is not broken — §2 makes both optional — + * it just means the recipient has to already know which coordinator + * serves that group. + */ + fun from(ref: CordnGroupRef): CoordinatorConfig? { + val pubKey = ref.coordinatorPubKey ?: return null + val relays = ref.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return null + return CoordinatorConfig(pubKey, relays, Origin.GROUP_REF) + } + } +} + +/** + * Whether a coordinator is answering, kept per coordinator. + * + * Deliberately thin. This is not a health *check* — nothing here polls, because + * a poll is a call, and every call to a coordinator is metadata (§8). It + * records what the calls the app was making anyway have observed. + */ +class CoordinatorHealth { + private val _state = MutableStateFlow(State()) + + val state: StateFlow = _state.asStateFlow() + + data class State( + val lastSuccessAt: Long? = null, + val lastFailureAt: Long? = null, + val lastFailure: String? = null, + /** Failures since the last success. Resets on any success. */ + val consecutiveFailures: Int = 0, + ) { + /** + * Nothing has worked since the last success, repeatedly. + * + * A single failure is a network blip and worth no UI at all; the + * threshold is what separates "retrying" from "tell the user their + * groups are not syncing". + */ + val isDown: Boolean get() = consecutiveFailures >= DOWN_AFTER + + /** True before the first call of the session — not the same as down. */ + val isUnknown: Boolean get() = lastSuccessAt == null && lastFailureAt == null + } + + fun recordSuccess(atSeconds: Long) { + _state.value = _state.value.copy(lastSuccessAt = atSeconds, consecutiveFailures = 0, lastFailure = null) + } + + fun recordFailure( + atSeconds: Long, + reason: String?, + ) { + val previous = _state.value + _state.value = + previous.copy( + lastFailureAt = atSeconds, + lastFailure = reason, + consecutiveFailures = previous.consecutiveFailures + 1, + ) + } + + companion object { + const val DOWN_AFTER = 3 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackup.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackup.kt new file mode 100644 index 0000000000..cd3790fc0e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackup.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.nip49PrivKeyEnc.SCrypt +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A passphrase-encrypted export of one account's cordn state. + * + * ## Why cordn needs this when Marmot does not + * + * A Marmot group lives on relays; a client that lost its state can at least + * see the group exists and be re-added to it. A cordn group exists as MLS + * state on **one device** plus an ordered stream on a coordinator that cannot + * read a byte of it. Lose the device and the group is gone — not "gone until + * someone re-invites you", because a fresh invitation starts at a new epoch + * and the history before it stays unreadable forever. That asymmetry is what + * makes an export worth the risk of having one. + * + * ## §5.4, answered: our own format, and portability was never available + * + * `amethyst/plans/2026-09-19-cordn-ui.md` §5.4 left the call open between + * matching cordn-web's backup and doing our own, noting the trade as "better + * for users, worse for portability". The portability half turns out to be + * unavailable, for the reason §4.6 gives about multi-device: the valuable + * content of any cordn backup is **MLS group state**, and that is an engine's + * internal serialization, not a wire format. Theirs is ts-mls's; ours is + * `MlsGroupState`. Neither can read the other whatever file format wraps it, + * so a byte-compatible container would buy a restore that cannot restore. + * + * (The other half also could not be matched cleanly: their client is outside + * the two MIT packages, and the spec prose is unlicensed.) + * + * ## Restoring replaces a device. It does not add one. + * + * An MLS state export is a **cloneable identity**. Two devices holding the + * same state and both committing fork the ratchet tree, and MLS does not + * recover: every message after the fork fails to decrypt for somebody, with + * no error that says why. §5.3 keeps multi-device a non-goal for exactly this + * reason, and a backup does not quietly become multi-device support because + * it is technically possible to restore twice. + * + * So: restore onto a device that has replaced the old one. A UI offering this + * must say that, and must not present it as sync. + * + * ## The file + * + * ``` + * magic "CORDNBAK" | version:u16 | logN:u8 | salt:16 | nonce:12 | ChaCha20-Poly1305(payload) + * ``` + * + * The header is passed as the AEAD's associated data. Today that is belt and + * braces rather than the thing protecting it: every header field either feeds + * the key derivation (`logN`, `salt`), feeds the cipher (`nonce`), or is + * checked outright (`magic`, `version`), so editing any of them already + * yields a wrong key or an outright refusal. The AAD earns its place if this + * header ever gains a field that does neither — at which point forgetting it + * would be the easy mistake. A mutation removing it survives the suite, and + * that is expected rather than a gap. + */ +object CordnBackup { + /** + * Version 2 adds the delivered messages to each group. + * + * Version 1 carried the MLS state, the cursor and the key packages but not + * a single message, and restoring it handed back the groups with an empty + * history: the messages were not in the file, and the cursor that *was* + * in the file told the sync loop it had already read to the end, so the + * coordinator was never asked to resend them. A backup that loses the + * conversation is not the thing the screen promises. + * + * The history could instead be re-pulled by resetting the cursor, since + * the coordinator holds it, but only for epochs the restored state can + * still open. Carrying the plaintext is what makes the restore whole + * regardless of how many times the group has rotated since. + */ + const val VERSION = 2 + + /** The last version that carried no messages; still readable. */ + private const val VERSION_WITHOUT_MESSAGES = 1 + + /** + * scrypt cost, as `log2(N)`. + * + * Matches NIP-49's default. A backup is decrypted once in a while by its + * owner, never in a loop, so the cost belongs at the high end of what a + * phone will tolerate rather than tuned for throughput. + */ + const val DEFAULT_LOG_N = 16 + + private const val R = 8 + private const val P = 1 + private const val KEY_LENGTH = 32 + private const val SALT_LENGTH = 16 + private const val NONCE_LENGTH = 12 + private val MAGIC = "CORDNBAK".encodeToByteArray() + + /** Everything worth carrying to a replacement device. */ + data class Archive( + val accountPubKey: HexKey, + val coordinators: List, + val groups: List, + val keyPackages: List, + ) { + data class Group( + val coordinatorPubKey: HexKey, + val gid: String, + /** An `MlsGroupState` blob. Opaque here, and engine-specific. */ + val state: ByteArray, + val cursor: GroupCursor?, + val joinedViaRequest: Boolean, + /** + * The group's delivered messages, in cursor order. + * + * Empty when the archive was written by version 1, which carried + * none — a v1 restore still yields an empty conversation, and + * there is nothing in the file to do better with. + */ + val messages: List = emptyList(), + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is Group && + coordinatorPubKey == other.coordinatorPubKey && + gid == other.gid && + state.contentEquals(other.state) && + cursor?.fetchCursor == other.cursor?.fetchCursor && + cursor?.lastCursor == other.cursor?.lastCursor && + joinedViaRequest == other.joinedViaRequest && + messages == other.messages + ) + + override fun hashCode(): Int { + var result = coordinatorPubKey.hashCode() + result = 31 * result + gid.hashCode() + result = 31 * result + state.contentHashCode() + result = 31 * result + (cursor?.fetchCursor?.hashCode() ?: 0) + result = 31 * result + joinedViaRequest.hashCode() + result = 31 * result + messages.hashCode() + return result + } + } + + data class KeyPackage( + val coordinatorPubKey: HexKey, + val keyPackageRef: String, + /** A `KeyPackageBundle` blob — private key material. */ + val bundle: ByteArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is KeyPackage && + coordinatorPubKey == other.coordinatorPubKey && + keyPackageRef == other.keyPackageRef && + bundle.contentEquals(other.bundle) + ) + + override fun hashCode(): Int = 31 * (31 * coordinatorPubKey.hashCode() + keyPackageRef.hashCode()) + bundle.contentHashCode() + } + } + + /** Encrypts [archive] under [passphrase]. */ + fun seal( + archive: Archive, + passphrase: String, + logN: Int = DEFAULT_LOG_N, + ): ByteArray { + val salt = RandomInstance.bytes(SALT_LENGTH) + val nonce = RandomInstance.bytes(NONCE_LENGTH) + val header = header(logN, salt, nonce) + val key = deriveKey(passphrase, salt, logN) + + return header + ChaCha20Poly1305.encrypt(encode(archive), header, nonce, key) + } + + /** + * Opens a sealed archive. + * + * Throws on a wrong passphrase, a truncated file or an edited header — all + * of which arrive as an AEAD authentication failure, which is the right + * answer to every one of them and deliberately does not distinguish + * between them. + */ + fun open( + sealed: ByteArray, + passphrase: String, + ): Archive { + require(sealed.size > HEADER_LENGTH) { "not a cordn backup: too short" } + require(sealed.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) { "not a cordn backup" } + + val reader = TlsReader(sealed.copyOfRange(MAGIC.size, HEADER_LENGTH)) + val version = reader.readUint16() + require(version == VERSION || version == VERSION_WITHOUT_MESSAGES) { "unknown cordn backup version $version" } + + val logN = reader.readUint8() + val salt = reader.readBytes(SALT_LENGTH) + val nonce = reader.readBytes(NONCE_LENGTH) + + val key = deriveKey(passphrase, salt, logN) + val header = sealed.copyOfRange(0, HEADER_LENGTH) + val plaintext = ChaCha20Poly1305.decrypt(sealed.copyOfRange(HEADER_LENGTH, sealed.size), header, nonce, key) + + return decode(plaintext, version) + } + + private fun header( + logN: Int, + salt: ByteArray, + nonce: ByteArray, + ): ByteArray { + val writer = TlsWriter() + writer.putBytes(MAGIC) + writer.putUint16(VERSION) + writer.putUint8(logN) + writer.putBytes(salt) + writer.putBytes(nonce) + return writer.toByteArray() + } + + private fun deriveKey( + passphrase: String, + salt: ByteArray, + logN: Int, + ): ByteArray { + require(logN in MIN_LOG_N..MAX_LOG_N) { "unreasonable scrypt cost: 2^$logN" } + var n = 1 + repeat(logN) { n *= 2 } + return SCrypt.scrypt(passphrase.encodeToByteArray(), salt, n, R, P, KEY_LENGTH) + } + + private fun encode(archive: Archive): ByteArray { + val writer = TlsWriter() + writer.putOpaque2(archive.accountPubKey.encodeToByteArray()) + writer.putOpaque4(CoordinatorListCodec.encode(archive.coordinators)) + + writer.putUint16(archive.groups.size) + archive.groups.forEach { + writer.putOpaque2(it.coordinatorPubKey.encodeToByteArray()) + writer.putOpaque2(it.gid.encodeToByteArray()) + writer.putOpaque4(it.state) + writer.putUint8(if (it.cursor != null) 1 else 0) + it.cursor?.let { cursor -> + writer.putUint64(cursor.fetchCursor) + writer.putUint64(cursor.lastCursor) + } + writer.putUint8(if (it.joinedViaRequest) 1 else 0) + // uint32: a long-running group's log is not bounded by 65535. + // Each entry is the same JSON the on-disk message log stores, so + // the archive and the store cannot drift out of step. + writer.putUint32(it.messages.size.toLong()) + it.messages.forEach { message -> + writer.putOpaque4(CordnDeliveredMessageCodec.encode(message).encodeToByteArray()) + } + } + + writer.putUint16(archive.keyPackages.size) + archive.keyPackages.forEach { + writer.putOpaque2(it.coordinatorPubKey.encodeToByteArray()) + writer.putOpaque2(it.keyPackageRef.encodeToByteArray()) + writer.putOpaque4(it.bundle) + } + return writer.toByteArray() + } + + private fun decode( + bytes: ByteArray, + version: Int, + ): Archive { + val reader = TlsReader(bytes) + val accountPubKey = reader.readOpaque2().decodeToString() + val coordinators = CoordinatorListCodec.decode(reader.readOpaque4()) + + val groups = + (0 until reader.readUint16()).map { + val coordinator = reader.readOpaque2().decodeToString() + val gid = reader.readOpaque2().decodeToString() + val state = reader.readOpaque4() + val cursor = if (reader.readUint8() == 1) GroupCursor(reader.readUint64(), reader.readUint64()) else null + val joinedViaRequest = reader.readUint8() == 1 + val messages = + if (version == VERSION_WITHOUT_MESSAGES) { + emptyList() + } else { + // A message that will not parse is dropped rather than + // failing the whole restore: losing one row beats + // refusing to bring the account back at all. + (0 until reader.readUint32()).mapNotNull { + CordnDeliveredMessageCodec.decodeOrNull(reader.readOpaque4().decodeToString()) + } + } + Archive.Group(coordinator, gid, state, cursor, joinedViaRequest, messages) + } + + val keyPackages = + (0 until reader.readUint16()).map { + Archive.KeyPackage( + coordinatorPubKey = reader.readOpaque2().decodeToString(), + keyPackageRef = reader.readOpaque2().decodeToString(), + bundle = reader.readOpaque4(), + ) + } + + return Archive(accountPubKey, coordinators, groups, keyPackages) + } + + private const val MIN_LOG_N = 10 + private const val MAX_LOG_N = 22 + private val HEADER_LENGTH = MAGIC.size + 2 + 1 + SALT_LENGTH + NONCE_LENGTH +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobCipher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobCipher.kt new file mode 100644 index 0000000000..ba89da6ee4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobCipher.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +/** + * Encrypts the blobs the cordn stores put on disk. + * + * A seam, not an abstraction for its own sake. Everything cordn persists is key + * material or derived from it — an `MlsGroupState` carries the ratchet tree and + * epoch secrets, a `KeyPackageBundle` carries the private half that opens a + * Welcome — so "encrypted at rest" is not a preference here, and the stores + * refuse to be built without one. + * + * It exists as an interface for one concrete reason: the file layout, the key + * encoding and the atomic-write behaviour are worth testing, and the Android + * KeyStore cannot run in a JVM unit test. Marmot's equivalent store is welded + * to the KeyStore and consequently has no unit test at all; this one does. + * + * Implementations must be safe to call from several coroutines at once. + */ +interface CordnBlobCipher { + fun encrypt(bytes: ByteArray): ByteArray + + /** @throws Exception if [bytes] were not produced by this cipher. */ + fun decrypt(bytes: ByteArray): ByteArray +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobStore.kt new file mode 100644 index 0000000000..cef0ed5e5e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobStore.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +/** + * A content-addressed store for sealed migration documents (§12). + * + * Injected rather than depended on directly because the only Blossom uploader + * in this codebase needs an Android `Context`, and the migration service has to + * stay runnable from `amy` and from a test with no Android at all. The same + * reason `CordnCoordinatorLinkFactory` is injected. + * + * ## The one requirement that is not about bytes + * + * §12: a Blossom upload's BUD-01 authorization event **MUST be signed by an + * ephemeral key, never the owner `npub`** — consistent with the tip. Signing as + * the owner would publish, in the clear on the storage server, that this + * account is uploading blobs right now, which is exactly the linkage the + * opaque tip exists to prevent. An implementation that authorizes as the owner + * satisfies this interface's types and defeats its purpose. + */ +interface CordnBlobStore { + /** + * Uploads [blob] and returns the servers that accepted it. + * + * The address is `sha256(blob)` and the caller already knows it, so there + * is nothing to return but reachability: a blob nobody hosts is a document + * the other phone cannot fetch, and the tip's `server` list is built from + * this. + */ + suspend fun put(blob: ByteArray): List + + /** + * Fetches the blob at [address], trying [servers] in order. + * + * Returns null when no server served it. Verifying that the bytes hash to + * [address] is the **caller's** job (`CordnDocumentSeal.verifyAddress`) — + * an implementation is a transport and is not trusted to self-certify. + */ + suspend fun get( + address: String, + servers: List, + ): ByteArray? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt new file mode 100644 index 0000000000..7f1314397d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools +import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface +import com.vitorpamplona.quartz.contextvm.cep06Announcements.ServerAnnouncement +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorAdvertisement +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** + * A coordinator found by listening for CEP-6 announcements. + * + * ## Everything here except [pubKey] and [relays] is a claim + * + * [surface] is what the coordinator said about itself in a self-signed event. + * A name is not an identity (`spec/00.md` §8.5 — the pubkey is), a website is + * not a proof of anything, and the capability flags are promises. Present this + * as "it says", never as fact, and never branch on it. + * + * [relays] is the one field the announcement did *not* provide, and the reason + * this type exists rather than a bare [ServerAnnouncement]. A CEP-6 + * announcement carries no routing information at all, so there is nothing in + * it that says where to reach the coordinator. What we know instead is which + * relays *delivered* it — the coordinator publishes there, so a request + * published there is one it is positioned to see. That is an observation, not + * a promise, and it is the best the protocol offers today. + */ +data class DiscoveredCoordinator( + val pubKey: HexKey, + /** Relays that delivered an announcement for this pubkey. Never empty. */ + val relays: List, + val surface: DiscoverySurface, + /** The eleven tools it advertised, plus whatever else it serves. */ + val tools: Set, + /** The newest announcement's `created_at`, for judging staleness. */ + val announcedAt: Long, +) { + /** + * What to add to the account. + * + * [CoordinatorConfig.label] is left null on purpose. A label is the user's + * word for a coordinator; the announced name is the coordinator's word for + * itself, and copying one into the other would launder a claim into + * something the UI presents as the user's own choice. + */ + fun toConfig(): CoordinatorConfig = + CoordinatorConfig( + pubKey = pubKey, + relays = relays, + origin = CoordinatorConfig.Origin.ANNOUNCEMENT, + ) +} + +/** + * Finds coordinators by reading CEP-6 announcements off relays. + * + * ## Why this is a relay query and not a coordinator call + * + * [CoordinatorHealth] never polls, because a call to a coordinator is metadata + * it gets to see (§8). This is the opposite shape: it reads events the + * coordinators already published, and touches no coordinator at all. Nothing + * discovered here learns that this account exists. The relays do see a query + * for kinds 11316/11317, which says "this user is shopping for an MCP server" + * and nothing about which one is chosen. + * + * ## Why the toolset is the filter + * + * There is no cordn marker on an announcement — see [CoordinatorAdvertisement]. + * A server is recognised by serving the eleven tools of `spec/00.md`. Measured + * on the public relays, that separates coordinators from unrelated MCP servers + * cleanly, and it is the only signal available. + * + * ## What this list is not + * + * It is not a directory, a ranking, or a set of recommendations, and an + * announcement is not a sign of life: a coordinator that stopped running a + * month ago still has its replaceable announcement sitting on the relay, and a + * coordinator that never announced (the reference deployment's default is + * off) is absent from it entirely. [announcedAt] is exposed so a caller can + * show the age rather than imply freshness — this deliberately does not filter + * by age, because a quiet coordinator and a dead one look identical from here + * and only the user knows which theirs is. + */ +class CordnCoordinatorDiscovery( + private val client: INostrClient, +) { + /** + * Reads announcements from [relays] and returns the coordinators among them. + * + * [limit] caps each relay's response — these are replaceable events, so the + * cap bounds distinct servers rather than history. Results are newest + * announcement first. + * + * ## One fetch per relay, on purpose + * + * `fetchAllWithHooks` deduplicates by event id across every relay in one + * call, keeping only the first `(relay, event)` pair for an id. That is + * right for reading content and wrong here, where the whole point is to + * learn *every* relay that carries a coordinator's announcement — a single + * call would credit each announcement to whichever relay answered first + * and silently drop the rest of the reachable set. Querying each relay in + * its own call scopes the dedup to that relay, so attribution is correct + * by construction. The calls run concurrently, so this costs no wall time. + */ + suspend fun discover( + relays: Set, + limit: Int = DEFAULT_LIMIT, + idleTimeoutMs: Long = DEFAULT_IDLE_TIMEOUT_MS, + ): Result = + coroutineScope { + if (relays.isEmpty()) return@coroutineScope Result(emptyList(), emptySet()) + + val filters = listOf(Filter(kinds = CvmKinds.ANNOUNCEMENTS.toList(), limit = limit)) + + val perRelay = + relays + .map { relay -> + async { + client.fetchAllWithHooks( + filters = mapOf(relay to filters), + idleTimeoutMs = idleTimeoutMs, + onEvent = { _, _ -> true }, + ) + } + }.awaitAll() + + Result( + coordinators = coordinatorsIn(perRelay.flatMap { it.events }), + unreachable = perRelay.flatMapTo(mutableSetOf()) { it.stalled + it.dead.keys }, + ) + } + + /** + * The coordinators among `(relay, event)` pairs. + * + * Split out from [discover] so the grouping rules are testable without a + * relay: an announcement pair is only a coordinator when the 11317 half + * carries the eleven tools, and a 11316 alone says nothing about what a + * server serves. + */ + fun coordinatorsIn(events: List>): List { + val byPubKey = mutableMapOf>>() + events.forEach { (relay, event) -> + ServerAnnouncement.parseOrNull(event)?.let { + byPubKey.getOrPut(it.pubKey) { mutableListOf() }.add(relay to it) + } + } + + return byPubKey + .mapNotNull { (pubKey, heard) -> + val latest = ServerAnnouncement.latestOf(heard.map { it.second }) + val tools = + latest[CvmKinds.TOOLS_LIST] + ?.let { AnnouncedTools.parseOrNull(it.content) } + ?.takeIf(CoordinatorAdvertisement::matches) + ?: return@mapNotNull null + + val server = latest[CvmKinds.SERVER_ANNOUNCEMENT] + + DiscoveredCoordinator( + pubKey = pubKey, + // Deduplicated and ordered by first hearing, so the relay + // that answered first is the one tried first. + relays = heard.map { it.first }.distinct(), + surface = server?.discovery ?: DiscoverySurface(), + tools = tools.names, + announcedAt = latest.values.maxOf { it.createdAt }, + ) + }.sortedByDescending { it.announcedAt } + } + + /** What one [discover] call learned, including who did not answer. */ + data class Result( + val coordinators: List, + /** + * Relays that stalled or failed. + * + * An empty [coordinators] means "nobody is announcing" only when this + * is empty too; otherwise it means "we were not told". + */ + val unreachable: Set, + ) + + companion object { + const val DEFAULT_LIMIT = 200 + const val DEFAULT_IDLE_TIMEOUT_MS = 8_000L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorLink.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorLink.kt new file mode 100644 index 0000000000..514499921b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorLink.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +/** A live connection to one coordinator, and the way to close it. */ +interface CordnCoordinatorLink { + val coordinator: ICoordinator + + /** + * What the coordinator says about itself, or null. + * + * Deliberately **not** on `ICoordinator`. That interface is "the eleven + * coordinator tools, as a contract" and its KDoc says it adds nothing to + * them; `initialize` is the MCP handshake, not a twelfth tool. It belongs + * to whoever owns the transport, which is this. + * + * The default is null so a substitute link — a fixture, a test double — + * does not have to invent a handshake it never performed. + */ + suspend fun serverInfo(): CoordinatorServerInfo? = null + + suspend fun close() +} + +/** + * Opens the ContextVM transport to a coordinator. + * + * The one piece a scope factory cannot supply on its own: a `CvmTransport` + * needs a relay pool and the account's signers, which belong to whichever + * front end is running. Everything else about a scope — where the bytes go + * and how they are encrypted — is the same on every platform. + */ +fun interface CordnCoordinatorLinkFactory { + suspend fun connect( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorLink +} + +/** + * The production [CordnCoordinatorLinkFactory]: a real ContextVM transport per + * coordinator, over whatever relay client the caller is already running. + * + * Shared rather than per-front-end on purpose. Everything this assembles is a + * protocol decision — which signer signs which call, that gift wrapping stays + * REQUIRED, that `initialize` is not performed at open — and a second copy in + * a second front end is a second place those decisions can drift. Android, the + * desktop app and `amy` all take this one. + * + * ## The ephemeral signer is created here, once, and never persisted + * + * `spec/00.md` §8 splits the identity a coordinator sees: the account key + * signs what must be attributable (publishing a KeyPackage, posting to a + * group), and a throwaway key signs everything else, so the coordinator cannot + * link a session's reads to an account. Which key signs which call is fixed by + * `CoordinatorMethod` and not a choice made here; what IS decided here is that + * the throwaway key lives as long as this factory and no longer. Persisting it + * would quietly undo the split — a "session" key reused across launches is + * just a second account key with worse ergonomics. + * + * One consequence worth naming: a front end that builds a factory per run — + * `amy` does, since every invocation is its own process — gets a fresh + * throwaway key each time, which is the strong end of the split rather than a + * degradation of it. + */ +object CordnLinks { + fun over( + accountSigner: NostrSigner, + client: INostrClient, + ): CordnCoordinatorLinkFactory { + val ephemeralSigner = NostrSignerInternal(KeyPair()) + + return CordnCoordinatorLinkFactory { _, config -> + val transport = + CvmTransport( + relays = NostrClientCvmRelayPool(client, config.relays.toSet()), + signers = DualSigner(accountSigner, ephemeralSigner), + serverPubKey = config.pubKey, + // Left at its default, which is REQUIRED (§8.6). Passing + // anything else here is the one line that would silently + // downgrade every coordinator call to plaintext. + crypto = CvmGiftWrap(), + ) + + object : CordnCoordinatorLink { + override val coordinator = CoordinatorClient(CvmMcpClient(transport)) + + // Handshaken on demand, not at open: `initialize` is a call + // like any other (§8), and a coordinator that only ever hears + // from us when we have something to say tells it less than one + // that is greeted at every launch. + override suspend fun serverInfo() = coordinator.serverInfo() + + override suspend fun close() { + // Nothing to release. CvmTransport opens a subscription + // per request and closes it in the same call — kind 25910 + // is ephemeral, so there is no long-lived stream to tear + // down and no connection of its own. The relays it used + // belong to the shared client, which outlives this link. + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt new file mode 100644 index 0000000000..bd7d8522c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Everything one account needs to talk to one coordinator. + * + * The three pieces are scoped together because they are scoped the same way: + * a `gid` (`spec/00.md` §3), a `kp_ref` (§4.2), and a cursor (`spec/02.md` §7) + * are all only meaningful relative to the coordinator that issued them. Handing + * a manager the wrong store is not a subtle bug — it is one group's MLS state + * answering for another's. + */ +interface CordnCoordinatorScope { + val coordinator: ICoordinator + val groupStore: CordnGroupStore + val keyPackageStore: CordnKeyPackageStore + + /** What the coordinator says about itself, if this scope performed a handshake. */ + suspend fun serverInfo(): CoordinatorServerInfo? = null + + /** Releases the transport. The stores outlive it; only the wire closes. */ + suspend fun close() +} + +/** + * Opens a [CordnCoordinatorScope] — the seam where the real transport enters. + * + * Production supplies relay-backed `CvmTransport` and encrypted-at-rest stores; + * tests supply the fixture. Nothing above this interface knows which. + */ +fun interface CordnCoordinatorScopeFactory { + suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope +} + +/** One account's live connection to one coordinator. */ +class CordnSession( + config: CoordinatorConfig, + val manager: CordnGroupManager, + val keyPackages: CordnKeyPackages, + val health: CoordinatorHealth, + private val scope: CordnCoordinatorScope, + /** Seconds. Injected so tests are not at the mercy of the wall clock. */ + private val clock: () -> Long = { TimeUtils.now() }, +) { + /** + * This coordinator's address and the user's name for it. + * + * Settable only through [relabel], and only for the name: everything else + * here is the address the transport was opened against, so changing it + * without reopening would leave the session pointing somewhere it is not + * connected. + */ + var config: CoordinatorConfig = config + private set + + val coordinatorPubKey: HexKey get() = config.pubKey + + /** + * A new name for the same coordinator, leaving the connection alone. + * + * A label is not part of a coordinator's address -- §8.5 makes the pubkey + * its identity -- and nothing under this class reads one; + * `CordnCoordinatorStore` is the only reader, when it writes the list to + * disk. So a rename must not take the reopen path [replaceConfig] uses for + * a corrected relay: that builds a new [CordnGroupManager], and anything + * still holding the old one (`CordnSyncLoop` captures it for the life of + * the loop) would be left polling a transport that has been closed. + */ + internal fun relabel(newLabel: String?) { + config = config.copy(label = newLabel) + } + + /** + * What this coordinator learns about [gid], answered from everything the + * session holds rather than everything one object happens to see. + * + * The manager alone cannot say whether this account has a KeyPackage + * published here: it only knows what it published itself, this run, so + * after a relaunch it would report "no" about a KeyPackage sitting on the + * coordinator right now. The KeyPackage store knows, and the session is + * the first place that holds both. + */ + suspend fun exposure(gid: String): GroupExposure = manager.exposure(gid, publishedKeyPackage = keyPackages.hasPublished()) + + /** + * What this coordinator says about itself. Claims, never identity (§8.5). + * + * Counts against [health] like any other call. It is a real round trip to + * the coordinator, and it is usually the *first* one a reader makes — the + * settings screen offers it precisely so someone can find out whether a + * coordinator they just added answers at all. Leaving it out had that + * screen say "Nothing asked of it yet" directly underneath what the + * coordinator had just answered. + * + * An answer counts as a success and a throw as a failure; `null` counts as + * neither. A scope returns `null` when it did no handshake at all, so + * treating that as a success would report a coordinator as reachable on + * the strength of a call that never left the device. + */ + suspend fun serverInfo(): CoordinatorServerInfo? = + try { + scope.serverInfo()?.also { health.recordSuccess(clock()) } + } catch (e: Exception) { + health.recordFailure(clock(), e.message) + throw e + } + + /** How many of this session's responses arrived reassembled over CEP-22. */ + val oversizedTransfers: Int get() = scope.coordinator.oversizedTransfers + + internal suspend fun close() = scope.close() +} + +/** + * One [CordnSession] per coordinator, for one account. + * + * ## Why this exists rather than a single manager + * + * `CordnGroupManager`'s KDoc says a `gid` is unique only within a coordinator. + * That is not a caching detail — it is the reason a registry has to be keyed by + * coordinator and can never merge two. Two coordinators can both serve + * `gid = "abc"`, and they are two unrelated groups with different members, + * different epochs and different ratchet trees. A flat map from `gid` to + * manager would let one answer for the other; from the user's side that looks + * like a group whose history changes when the coordinator it came from does. + * + * ## Why it is idempotent, under a lock + * + * [session] returns the *same* session for a coordinator it already holds, and + * serialises concurrent opens. A second `CordnGroupManager` for one coordinator + * would deserialise its own copy of every `MlsGroup` from the same store; both + * would then commit against the same epoch and each would save over the other. + * MLS has no recovery from that — the ratchet tree forks and every message + * after it fails to decrypt. The UI and the sync loop both reach for a session, + * so "two callers at once" is the ordinary case, not a race to dismiss. + * + * Nothing here is Marmot's, and Marmot has no equivalent to borrow. Marmot + * groups live on relays, so one `MarmotManager` serves an account outright and + * there is nothing to key a registry by; a cordn account has one of these per + * coordinator because the coordinator is what makes its ids mean anything. The + * two stay separate under the §3.1 rule in + * `amethyst/plans/2026-09-19-cordn-ui.md`. + */ +class CordnCoordinatorRegistry( + val accountPubKey: HexKey, + private val scopes: CordnCoordinatorScopeFactory, +) { + private val lock = Mutex() + private val sessions = mutableMapOf() + + private val _coordinators = MutableStateFlow>(emptyList()) + + /** The coordinators this account currently has a session for. */ + val coordinators: StateFlow> = _coordinators.asStateFlow() + + /** + * The session for [config], opening one if this account has none. + * + * Idempotent by coordinator pubkey: the same call twice gives the same + * session, never a second manager over the same store. + * + * A [config] that differs from the open one reopens the transport, because + * the transport is bound to the relays — but the same stores come back, so + * the groups and published KeyPackages survive. Correcting a relay or + * renaming a coordinator has not made it a different coordinator. + */ + suspend fun session(config: CoordinatorConfig): CordnSession = + lock.withLock { + sessions[config.pubKey]?.let { existing -> + when { + existing.config == config -> existing + + // Only the name changed. Reopening for that would cost the + // connection and orphan whoever holds the current manager, + // for a field the transport has never read. + existing.config.copy(label = config.label) == config -> + existing.also { + it.relabel(config.label) + publish() + } + + else -> replaceConfig(existing, config) + } + } ?: open(config).also { publish() } + } + + /** The session for [coordinatorPubKey], or null if none is open. */ + fun sessionOrNull(coordinatorPubKey: HexKey): CordnSession? = sessions[coordinatorPubKey] + + /** + * Restores sessions for [configs] and closes any coordinator not in it. + * + * The closing half is what makes this a restore rather than a bulk add: a + * coordinator the user removed on another launch must not come back to life + * because its session happened to still be open. + */ + suspend fun restore(configs: List) { + configs.forEach { session(it) } + lock.withLock { + val keep = configs.map { it.pubKey }.toSet() + sessions.keys.filter { it !in keep }.forEach { sessions.remove(it)?.close() } + publish() + } + } + + /** + * Closes the session for [coordinatorPubKey] and forgets it. + * + * The stores are **not** wiped. Forgetting a coordinator is not the same as + * leaving its groups: cordn-web separates removing a coordinator from + * purging it (`CoordinatorPurgeDialog`), and quietly destroying group state + * here would make an undo impossible. A purge is a store-level operation and + * belongs to whoever owns the stores. + */ + suspend fun forget(coordinatorPubKey: HexKey) { + lock.withLock { + sessions.remove(coordinatorPubKey)?.close() + publish() + } + } + + /** Closes every session. Call when the account logs out. */ + suspend fun close() { + lock.withLock { + sessions.values.forEach { it.close() } + sessions.clear() + publish() + } + } + + private suspend fun replaceConfig( + existing: CordnSession, + config: CoordinatorConfig, + ): CordnSession { + // Same coordinator, different address or label. The transport is bound + // to the relays, so it is reopened; the stores are not, so the groups + // and published KeyPackages survive untouched. + existing.close() + sessions.remove(config.pubKey) + return open(config).also { publish() } + } + + private suspend fun open(config: CoordinatorConfig): CordnSession { + val scope = scopes.open(accountPubKey, config) + val health = CoordinatorHealth() + val session = + CordnSession( + config = config, + manager = + CordnGroupManager( + accountPubKey = accountPubKey, + config = config, + coordinator = scope.coordinator, + store = scope.groupStore, + health = health, + ), + keyPackages = CordnKeyPackages(accountPubKey, scope.coordinator, scope.keyPackageStore), + health = health, + scope = scope, + ) + // Restored here rather than left to the caller. A session that does not + // know its own groups is not a usable session — it reports none, and + // the first commit it makes for a group it forgot about starts from + // epoch zero against a coordinator that is not there. Forgetting to + // call this would look like "my groups are gone", which is also what a + // real failure looks like. + session.manager.restore() + session.keyPackages.restore() + + sessions[config.pubKey] = session + return session + } + + private fun publish() { + _coordinators.value = sessions.values.map { it.config } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorStore.kt new file mode 100644 index 0000000000..444c5ad29e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorStore.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * The coordinators one account talks to, across launches. + * + * Separate from [CordnGroupStore] and [CordnKeyPackageStore] because its scope + * is different: those are per (account, coordinator) — a `gid` and a `kp_ref` + * only mean anything relative to one coordinator — while this is the list of + * coordinators itself, and so belongs to the account alone. + * + * Without it every cordn group on the device is unreachable after a relaunch. + * The MLS state survives on disk, but nothing knows which coordinator to ask + * for its stream, and a `gid` with no coordinator is not a group anyone can + * open. That is why this is storage rather than a preference. + * + * **Encrypt it at rest.** The contents are not secret the way key material is, + * but they are the sharpest metadata cordn produces about this account: which + * coordinators it uses is exactly what §8 spends its length keeping a + * coordinator from learning about *other* coordinators. + */ +interface CordnCoordinatorStore { + suspend fun save(configs: List) + + suspend fun load(): List +} + +/** For tests and for a front end that deliberately keeps nothing. */ +class InMemoryCordnCoordinatorStore( + initial: List = emptyList(), +) : CordnCoordinatorStore { + private var configs = initial + + override suspend fun save(configs: List) { + this.configs = configs + } + + override suspend fun load(): List = configs +} + +/** + * The on-disk layout of a coordinator list. + * + * TLS-framed like everything else that gets persisted here, for the reason + * [com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec] gives: a + * length-prefixed format cannot silently mis-parse a label containing whatever + * separator a line-based one picked, and a version prefix means a future layout + * change is refused rather than guessed at. + */ +object CoordinatorListCodec { + const val VERSION = 1 + + fun encode(configs: List): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putUint16(configs.size) + configs.forEach { config -> + writer.putOpaque2(config.pubKey.encodeToByteArray()) + writer.putOpaque2(config.origin.name.encodeToByteArray()) + writer.putOpaque2(config.label.orEmpty().encodeToByteArray()) + writer.putUint16(config.relays.size) + config.relays.forEach { writer.putOpaque2(it.url.encodeToByteArray()) } + } + return writer.toByteArray() + } + + /** + * Reads a list back, dropping entries that no longer make sense. + * + * A coordinator whose relays all fail to normalise, or whose `origin` this + * build does not know, is skipped rather than throwing: one unreadable + * entry must not take the rest of the account's coordinators with it, and + * a coordinator the app cannot reach is not better represented by a crash + * at login. + */ + fun decode(bytes: ByteArray): List { + val reader = TlsReader(bytes) + val version = reader.readUint16() + require(version == VERSION) { "unknown coordinator list layout version $version" } + + val count = reader.readUint16() + val out = mutableListOf() + repeat(count) { + val pubKey = reader.readOpaque2().decodeToString() + val originName = reader.readOpaque2().decodeToString() + val label = reader.readOpaque2().decodeToString() + val relayCount = reader.readUint16() + val relays = (0 until relayCount).mapNotNull { RelayUrlNormalizer.normalizeOrNull(reader.readOpaque2().decodeToString()) } + + val origin = CoordinatorConfig.Origin.entries.firstOrNull { it.name == originName } + if (relays.isNotEmpty() && origin != null) { + out += CoordinatorConfig(pubKey, relays, origin, label.ifEmpty { null }) + } + } + return out + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt new file mode 100644 index 0000000000..2f6aff4c73 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * What the delivery operator learns, as data rather than as documentation. + * + * §8 of `quartz/plans/2026-09-17-cordn-interop.md` ends with a requirement: + * *"This section should be surfaced in the UI if we ship this, not buried. A + * Marmot group and a cordn group have materially different metadata exposure + * and users cannot infer that from either one looking like a group chat."* + * + * A comment cannot satisfy that; a screen can, and a screen needs a model. So + * the analysis lives here as [GroupExposure], computed from the group's actual + * state rather than written out per group — a group joined from a share link + * really does expose more than one whose members were added directly, and the + * difference is mechanical, not editorial. + * + * The one thing this deliberately does **not** do is rank the two bindings. + * cordn is weaker against the delivery operator and stronger against the + * network (§8.5: the coordinator never sees an IP). Which trade is right + * depends on who runs the coordinator, which is the user's call and not ours. + */ +enum class ExposureLevel { + /** The operator cannot learn this at all. */ + NONE, + + /** Learned, but tied to a throwaway key rather than to the account. */ + PSEUDONYMOUS, + + /** Learned and tied to the real npub. */ + IDENTIFIED, +} + +/** One thing worth telling the user about, with the rule it follows from. */ +enum class ExposureNote { + /** + * §8.1 — admission names both ends. `join_request_store` rides the stable + * identity and carries the `gid`; `welcome_store` names the target's real + * pubkey. For a group joined from a share link the coordinator observes + * real-identity membership directly. Structural, not a bug. + */ + MEMBERSHIP_IS_IDENTIFIED, + + /** + * §8.2 — the ephemeral identity is per-session, not per-message, so one + * pseudonym posts and fetches across every group on that coordinator. The + * set of `gid`s it touches links those groups together and leaks how many + * there are. This is the one the user cannot guess and the reason a plain + * "messages are pseudonymous" would be a lie by omission. + */ + GROUPS_LINKED_BY_SESSION, + + /** + * §8.3 — one operator holds the complete ordered history of every group it + * serves, with timestamps and payload sizes, in one SQLite file. Relays + * are redundant and partitioned; a coordinator is neither. + */ + SINGLE_OPERATOR_HOLDS_HISTORY, + + /** + * §8.4 — `kp_publish` rides the stable identity and the coordinator keeps + * the signed event, by design, to re-serve. That is a verifiable record + * that this account uses cordn, rotation cadence included. + */ + PUBLICATION_IS_A_SIGNED_RECORD, + + /** + * §8.3 — nothing in `spec/03.md` pads the sealed payload, so message sizes + * are visible to the operator. + */ + MESSAGE_SIZES_UNPADDED, + + /** + * §8.6 — the transport is NOT pinned to required encryption, so a + * coordinator that declines to announce `support_encryption` would receive + * plaintext JSON-RPC on public relays. Should be impossible in our client; + * if this ever appears, it is a bug, not a disclosure. + */ + ENCRYPTION_NOT_PINNED, +} + +/** + * The exposure of one cordn group on one coordinator. + * + * @param linkedGroupCount how many groups share this coordinator's ephemeral + * session (§8.2). One is already a link between that group and the account's + * traffic; more is a graph. + */ +data class GroupExposure( + val coordinator: HexKey, + val linkedGroupCount: Int, + val joinedFromShareLink: Boolean, + val publishedKeyPackage: Boolean, + val encryptionPinned: Boolean, +) { + /** §8: double-sealed, and the coordinator is forbidden from parsing. Always. */ + val content: ExposureLevel = ExposureLevel.NONE + + /** §8.1. Admission names real keys on both ends; there is no other way in. */ + val membership: ExposureLevel = ExposureLevel.IDENTIFIED + + /** §8.2. The ephemeral identity covers the message path — and only that. */ + val messaging: ExposureLevel = ExposureLevel.PSEUDONYMOUS + + /** + * The notes worth showing, **most surprising first**. + * + * Order is part of the disclosure, not presentation trivia: a reader gives + * the first two lines real attention and skims the rest. So cross-group + * linkage (§8.2) — the one nobody predicts — comes before message sizes, + * which is the least consequential item here. A bug, if one ever appears, + * outranks everything. + */ + fun notes(): List = + buildList { + if (!encryptionPinned) add(ExposureNote.ENCRYPTION_NOT_PINNED) + add(ExposureNote.MEMBERSHIP_IS_IDENTIFIED) + // Only worth saying once there is actually something to link to. + if (linkedGroupCount > 1) add(ExposureNote.GROUPS_LINKED_BY_SESSION) + add(ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY) + if (publishedKeyPackage) add(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD) + add(ExposureNote.MESSAGE_SIZES_UNPADDED) + } + + /** + * Whether this group's exposure differs from a Marmot group's in a way the + * user should be told before they treat the two the same. + * + * Always true today, and written as a function anyway: the interesting case + * is a self-hosted coordinator, where the answer changes without any of + * this code changing. + */ + fun differsFromMarmot(): Boolean = true +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt new file mode 100644 index 0000000000..dd3e346048 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt @@ -0,0 +1,1181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.KeyPackagePublication +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.cordn.spec02Envelopes.ReceivedMessage +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.cordn.sync.CordnGroupSync +import com.vitorpamplona.quartz.cordn.sync.Ingestion +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The cordn groups this account holds on one coordinator. + * + * The cordn counterpart of `MarmotManager`, and deliberately **not** a reuse of + * it. Marmot's manager is keyed on the Nostr group id throughout and its whole + * delivery model — publish kinds 443/444/445 to relays, subscribe by `h` tag, + * carry publish obligations — has no cordn analogue: here there is one + * coordinator, one ordered stream per `gid`, and a cursor. What the two share + * is the RFC 9420 engine underneath, which is what Stage 1 of the interop plan + * made possible. + * + * ## One manager per coordinator + * + * Not per account. A `gid` is scoped to the coordinator that issued it + * (`spec/00.md` §4-5), so the same string can name two unrelated groups on two + * coordinators, and a cursor from one is meaningless to the other. Keying + * groups by `gid` alone is only safe because the coordinator is fixed here. + * + * It also matches the privacy model: §8.2's ephemeral identity is per session + * per coordinator, so the set of groups one manager touches is exactly the set + * that coordinator can link together. [exposure] reports that from the real + * count rather than from a guess. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnGroupManager( + /** The account these groups belong to, as lowercase hex. */ + val accountPubKey: HexKey, + val config: CoordinatorConfig, + private val coordinator: ICoordinator, + private val store: CordnGroupStore, + val health: CoordinatorHealth = CoordinatorHealth(), + /** Seconds. Injected so tests are not at the mercy of the wall clock. */ + private val clock: () -> Long = { TimeUtils.now() }, +) : CordnSyncSource { + private val groups = mutableMapOf() + private val sync = CordnGroupSync(coordinator) + + private val _gids = MutableStateFlow>(emptySet()) + + /** The groups this manager holds, for a UI to observe. */ + override val gids: StateFlow> = _gids.asStateFlow() + + private var publishedKeyPackage = false + + /** Acknowledgements a failed [retire] left to ride the next `welcome_take`. */ + private val pendingRetirements = mutableListOf() + + /** As [pendingRetirements], for join requests. */ + private val pendingRequestRetirements = mutableListOf() + + /** What happened to one delivered payload. */ + sealed interface Delivery { + /** + * The group it belongs to. + * + * On the interface because every outcome has one and callers route on + * it — a delivery that cannot be filed under a group is not something + * this type can represent. + */ + val gid: String + + /** An application message that passed every §5 check. */ + data class Message( + override val gid: String, + val cursor: Long, + val received: ReceivedMessage, + ) : Delivery + + /** A handshake message; the group has advanced to [epoch]. */ + data class EpochAdvanced( + override val gid: String, + val cursor: Long, + val epoch: Long, + ) : Delivery + + /** Our own message coming back, already accounted for. */ + data class Echo( + override val gid: String, + val cursor: Long, + ) : Delivery + + /** + * A payload this group could not open. + * + * Not fatal and not silent: the cursor moves past it (the alternative + * is a stream that never advances), but a UI that shows nothing here is + * hiding a gap in a conversation. + */ + data class Undecryptable( + override val gid: String, + val cursor: Long, + val reason: String, + ) : Delivery + } + + // ---- membership ------------------------------------------------------ + + /** The group behind [gid], if this manager holds it. */ + fun group(gid: String): MlsGroup? = groups[gid] + + /** Restores every group and cursor the store holds. Call once at startup. */ + suspend fun restore() { + store.listGroups().forEach { gid -> + val blob = store.loadGroup(gid) ?: return@forEach + groups[gid] = MlsGroup.restore(MlsGroupState.decodeTls(blob), CordnGroupPolicy) + store.loadCursor(gid)?.let { sync.restore(gid, it, store.loadEchoState(gid)) } + } + _gids.value = groups.keys.toSet() + } + + /** + * Creates a group this account administers. + * + * [gid] is the caller's to choose and the coordinator never interprets it + * (`spec/00.md` §4). The reference client uses a random UUID; anything + * unique on this coordinator works, and it must not be derived from the MLS + * `group_id`, which is secret. + */ + suspend fun createGroup( + gid: String, + metadata: CordnGroupMetadata, + ): MlsGroup { + require(gid !in groups) { "already in a group with gid $gid" } + val group = + MlsGroup.create( + identity = CordnCredential.of(accountPubKey).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(metadata.toExtension()), + // See [gidFrom]: this is what lets a joiner -- ours or theirs -- + // learn the delivery id from the Welcome alone. + groupId = gid.encodeToByteArray(), + ) + groups[gid] = group + persist(gid) + _gids.value = groups.keys.toSet() + return group + } + + /** + * Adds [targetPubKey] to [gid], taking their KeyPackage from the + * coordinator and leaving them a Welcome. + * + * The order is load-bearing: + * + * 1. Take and **verify** the publication payload. §9/§10 require the client + * to check it even though §8 makes the coordinator check too — a + * coordinator that skipped the check could otherwise hand us any + * account's name over any account's key material, and we would invite + * the wrong person. + * 2. Seal the Commit under the **pre-commit** epoch key (`spec/03.md` §5). + * The engine hands that key back on [CommitResult.preCommitExporterSecret] + * precisely because it is unobtainable afterwards: sealing under the + * epoch the Commit *creates* produces a payload only the sender can + * open, and every other member silently stops advancing. + * 3. Post the Commit, then store the Welcome with the cursor it landed at, + * so the joiner starts from the epoch they can actually decrypt rather + * than replaying history that predates them. + * + * ## Wire format + * + * The Commit goes out **public-framed** — `MlsMessage(PublicMessage)`, + * which is what this engine produces. cordn's reference client emits + * private-framed handshake messages instead, but accepts either: its + * `processMessageBase64` admits wireformat 1 and 2 and hands both to + * ts-mls's `processMessage`. Nothing is weakened by the choice, because the + * coordinator sees only the outer ChaCha seal either way (§8) — the framing + * is visible only to members, who can already tell a Commit from a message. + * [ingest] reads both. + */ + suspend fun invite( + gid: String, + targetPubKey: HexKey, + keyPackageRef: String? = null, + ): InviteResult { + val group = requireGroup(gid) + + // By ref when we have one: a join request names the exact KeyPackage + // its sender published for this purpose, and taking a different one of + // theirs would spend a package they meant for someone else. + val taken = + call { coordinator.takeKeyPackage(keyPackageRef ?: targetPubKey) } + ?: throw CordnGroupException( + "the coordinator holds no KeyPackage for $targetPubKey", + CordnGroupException.Reason.NO_KEY_PACKAGE, + ) + val verified = KeyPackagePublication.verify(taken.publicationEvent) + if (verified.pubKey != targetPubKey) { + throw CordnGroupException( + "the KeyPackage served for $targetPubKey belongs to ${verified.pubKey}", + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER, + ) + } + + val result = group.addMember(verified.bytes) + val welcome = result.welcomeBytes ?: throw CordnGroupException("adding a member produced no Welcome", CordnGroupException.Reason.NO_WELCOME) + + // framedCommitBytes, not commitBytes: the latter is the bare RFC 9420 + // Commit struct with no MLSMessage around it, which no receiver can + // parse. preCommitExporterSecret is the epoch key the Commit leaves. + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + val welcomeAt = + call { + coordinator.storeWelcome( + targetPubKey = targetPubKey, + keyPackageRef = taken.keyPackageRef, + welcomeBase64 = Base64.encode(welcome), + after = posted.cursor, + ) + } + + persist(gid) + return InviteResult(gid, targetPubKey, taken.keyPackageRef, posted.cursor, welcomeAt) + } + + /** + * Invites several people in ONE commit. + * + * Not a loop over [invite], and not an optimisation either -- the loop was + * unsafe. [MlsGroup.addMember] *applies* its Commit locally before anything + * is posted, so an invite whose `postCommit` fails leaves this group an + * epoch ahead of both the coordinator and disk; the next invite in a loop + * then builds on that forked state and cannot succeed. Batching collapses N + * such windows into one. It does not remove the last one -- the engine has + * no rollback, so a failed post still leaves the local group ahead -- but + * the failure can no longer cascade. + * + * It is also what the reference client does, and one epoch rather than N is + * the observable difference. See [MlsGroup.addMembers]. + * + * Every KeyPackage is taken and verified BEFORE the commit, so a target the + * coordinator holds nothing for fails while nothing has changed; those come + * back in [BatchInviteResult.refused]. A Welcome that fails to store after + * the commit is per-target and harmless to the group, and comes back in + * [BatchInviteResult.undelivered]. + */ + suspend fun inviteAll( + gid: String, + targetPubKeys: List, + ): BatchInviteResult { + if (targetPubKeys.isEmpty()) return BatchInviteResult(gid, emptyList(), emptyMap(), emptyMap()) + val group = requireGroup(gid) + + // Phase 1, before anything is committed: take and verify every + // KeyPackage. A failure here spends a KeyPackage for the targets + // already taken and nothing else -- the group has not moved. + val taken = mutableMapOf() + val verified = mutableMapOf() + val refused = mutableMapOf() + + targetPubKeys.distinct().forEach { target -> + val held = call { coordinator.takeKeyPackage(target) } + if (held == null) { + refused[target] = + CordnGroupException( + "the coordinator holds no KeyPackage for $target", + CordnGroupException.Reason.NO_KEY_PACKAGE, + ) + return@forEach + } + val owner = KeyPackagePublication.verify(held.publicationEvent) + if (owner.pubKey != target) { + refused[target] = + CordnGroupException( + "the KeyPackage served for $target belongs to ${owner.pubKey}", + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER, + ) + return@forEach + } + taken[target] = held + verified[target] = owner.bytes + } + + if (verified.isEmpty()) return BatchInviteResult(gid, emptyList(), emptyMap(), refused) + + // Phase 2: one commit, one post. Past this line the group has moved. + val result = group.addMembers(verified.values.toList()) + val welcome = + result.welcomeBytes + ?: throw CordnGroupException("adding members produced no Welcome", CordnGroupException.Reason.NO_WELCOME) + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + + // Phase 3: the same Welcome, addressed once per joiner. RFC 9420 + // §12.4.3.1 puts a separate EncryptedGroupSecrets per added member + // inside it, keyed by KeyPackage reference, so each one finds its own. + val invited = mutableListOf() + val undelivered = mutableMapOf() + + verified.keys.forEach { target -> + val ref = taken.getValue(target).keyPackageRef + try { + val at = + call { + coordinator.storeWelcome( + targetPubKey = target, + keyPackageRef = ref, + welcomeBase64 = Base64.encode(welcome), + after = posted.cursor, + ) + } + invited += InviteResult(gid, target, ref, posted.cursor, at) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The commit is posted, so the group already counts them a + // member -- they simply have no Welcome to join with. Reported + // rather than thrown, because the others succeeded. + undelivered[target] = e + } + } + + persist(gid) + return BatchInviteResult(gid, invited, undelivered, refused) + } + + /** + * Removes [targetPubKey] from [gid]. + * + * Admin-gated, and not by this function: [CordnGroupPolicy.authorizeCommit] + * refuses to build the Remove commit when the local member is not named in + * `admin_pubkeys`, so the check cannot be forgotten here or bypassed by + * another call site. A group in egalitarian mode lets any member do it. + * + * Refuses to remove YOU, as the reference client does. cordn has no + * self-removal: MLS wants a SelfRemove proposal an admin then commits, and + * committing your own Remove would advance the group into an epoch whose + * keys you no longer hold — you would be the only member unable to read + * what happened next. + * + * Sealed under the PRE-commit epoch key like every other commit here + * (`spec/03.md` §5), which is also what lets the person being removed read + * the commit that removes them: they still hold that epoch. + */ + suspend fun removeMember( + gid: String, + targetPubKey: HexKey, + ): RemovalResult { + val group = requireGroup(gid) + + require(targetPubKey != accountPubKey) { + "cordn has no self-removal: committing your own Remove would leave you unable to read the group" + } + + val leafIndex = + CordnCredential + .membersOf(group) + .entries + .firstOrNull { it.value == targetPubKey } + ?.key + ?: throw CordnGroupException( + "$targetPubKey is not a member of $gid", + CordnGroupException.Reason.NOT_A_MEMBER, + ) + + val result = group.removeMember(leafIndex) + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + + persist(gid) + return RemovalResult(gid, targetPubKey, posted.cursor) + } + + /** + * Replaces [gid]'s `cordn_group_metadata` — its name, description, icon, + * image and admin list — with [metadata]. + * + * Admin-gated the same way [removeMember] is, and through the same hook: + * changing metadata is a GroupContextExtensions commit, which is the third + * proposal type `admin_pubkeys` covers. Which means this is also how the + * admin list itself changes, and why an egalitarian group can be given + * admins by any member while an administered one cannot. + * + * A GroupContextExtensions proposal replaces the WHOLE extension list, so + * every other extension the group carries is kept and only `0xC04D` is + * swapped. Sending the metadata alone would silently drop + * `required_capabilities` and the app-data dictionary, and a group whose + * required capabilities vanished is one whose next commit peers reject. + */ + suspend fun updateGroupMetadata( + gid: String, + metadata: CordnGroupMetadata, + ): MetadataUpdateResult { + val group = requireGroup(gid) + + val kept = group.extensions.filterNot { it.extensionType == CordnGroupMetadata.EXTENSION_TYPE } + group.proposeGroupContextExtensions(kept + metadata.toExtension()) + + val result = group.commit() + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + + persist(gid) + return MetadataUpdateResult(gid, posted.cursor) + } + + /** + * Opens every pending Welcome without joining anything. + * + * Splitting "open" from "join" is what makes an accept/decline surface + * possible at all. A Welcome is opaque until it is processed — the `gid`, + * the group's name and who is already in it all live inside it — so a user + * cannot be asked about an invitation that has not been opened, and + * opening must therefore not be the same act as accepting. + * + * [bundleFor] resolves a `kp_ref` to the KeyPackageBundle we published + * under it. A Welcome we have no private half for is skipped and left on + * the coordinator: it belongs to another device of this account, and + * retiring it here would destroy it. + * + * A Welcome for a group this manager already holds is skipped **and** + * retired. That is the stale record [accept] leaves behind when its + * acknowledgement does not reach the coordinator, and letting it pile up + * is what made a second drain destructive. + * + * [gidFor] overrides where the delivery id comes from — see [gidFrom] for + * why it can be missing and what to pass when it is. + */ + suspend fun pendingWelcomes( + bundleFor: suspend (String) -> KeyPackageBundle?, + gidFor: (MlsGroup) -> String? = ::gidFrom, + ): WelcomeInbox { + // Drained into a local first: passing the drain inline emptied the + // queue before the call, so a failing takeWelcomes dropped those acks + // for good and the coordinator kept re-serving what we had consumed. + // The join-request path already re-queues on failure; this matches it. + val acks = drainRetirements() + val pending = + try { + call { coordinator.takeWelcomes(acks) } + } catch (e: Exception) { + pendingRetirements += acks + throw e + } + val opened = mutableListOf() + val skipped = mutableListOf() + val stale = mutableListOf() + + pending.forEach { welcome -> + val bundle = bundleFor(welcome.keyPackageRef) + if (bundle == null) { + skipped += SkippedWelcome(welcome.keyPackageRef, NO_PRIVATE_HALF) + return@forEach + } + val group = + try { + MlsGroup.processWelcome(Base64.decode(welcome.welcomeBase64), bundle, CordnGroupPolicy) + } catch (e: Exception) { + skipped += SkippedWelcome(welcome.keyPackageRef, e.message ?: "the Welcome did not open") + return@forEach + } + val gid = gidFor(group) + if (gid == null) { + skipped += SkippedWelcome(welcome.keyPackageRef, UNKNOWN_GID) + return@forEach + } + if (gid in groups) { + skipped += SkippedWelcome(welcome.keyPackageRef, ALREADY_A_MEMBER) + stale += ConsumedWelcomeRef(welcome.keyPackageRef, welcome.at) + return@forEach + } + + opened += + OpenedWelcome( + gid = gid, + keyPackageRef = welcome.keyPackageRef, + at = welcome.at, + metadata = CordnGroupMetadata.fromExtensions(group.extensions), + // Who is in the group, which is NOT the same as who + // invited us: a Welcome carries the ratchet tree, not the + // identity of whoever signed the Commit that created it. + // Naming an inviter here would be a guess dressed as a + // fact, and in a group of three it would usually be wrong. + members = CordnCredential.memberIdentities(group), + epoch = group.epoch, + group = group, + after = welcome.after, + ) + } + + retire(stale) + return WelcomeInbox(opened, skipped) + } + + /** + * Joins the group [welcome] opens, and retires it. + * + * Refuses a `gid` this manager already holds rather than replacing it. + * Installing a Welcome over a live group rolls its epoch back to the one + * it was issued at, and MLS does not recover from that: every message + * after the rolled-back epoch stops decrypting, silently and permanently. + */ + suspend fun accept(welcome: OpenedWelcome): String { + require(welcome.gid !in groups) { "already in a group with gid ${welcome.gid}" } + + groups[welcome.gid] = welcome.group + // `after` is the inviter saying where this member's history starts. + // Without it a joiner replays epochs from before it existed and + // every one lands as Undecryptable. + welcome.after?.let { sync.restore(welcome.gid, sync.inbox(welcome.gid).cursor.advancedTo(it)) } + persist(welcome.gid) + _gids.value = groups.keys.toSet() + + retire(listOf(ConsumedWelcomeRef(welcome.keyPackageRef, welcome.at))) + return welcome.gid + } + + /** + * Retires [welcome] without joining it. + * + * Declining is permanent and there is no undo, because there is no undo to + * build: a retired Welcome is gone from the coordinator, and the KeyPackage + * it was addressed to has been spent. Re-joining means being invited again. + */ + suspend fun decline(welcome: OpenedWelcome) { + retire(listOf(ConsumedWelcomeRef(welcome.keyPackageRef, welcome.at))) + } + + /** + * Opens every pending Welcome and accepts all of them. + * + * The unattended path — `amy`, tests, anything with no one to ask. A UI + * uses [pendingWelcomes] and [accept]/[decline] instead, because accepting + * an invitation on someone's behalf is a decision, not a sync step. + */ + suspend fun joinPendingWelcomes( + bundleFor: suspend (String) -> KeyPackageBundle?, + gidFor: (MlsGroup) -> String? = ::gidFrom, + ): JoinResults { + val inbox = pendingWelcomes(bundleFor, gidFor) + val joined = inbox.pending.map { accept(it) } + return JoinResults(joined, inbox.skipped) + } + + /** + * Acknowledges [refs] so the coordinator stops serving them. + * + * `welcome_take` carries acknowledgements for the *previous* round rather + * than taking them as their own call, so a retirement can only ride the + * next take. Sending one immediately keeps the common case prompt; a + * failure parks it in [pendingRetirements] instead of being lost, and + * [pendingWelcomes] flushes it on its next call. Failing to retire must + * never fail the join it follows — the group is already installed and + * persisted, and a Welcome served twice is now merely redundant rather + * than destructive. + */ + private suspend fun retire(refs: List) { + if (refs.isEmpty()) return + try { + call { coordinator.takeWelcomes(refs) } + } catch (e: Exception) { + pendingRetirements += refs + } + } + + private fun drainRetirements(): List { + val queued = pendingRetirements.toList() + pendingRetirements.clear() + return queued + } + + // ---- join requests -------------------------------------------------- + + /** + * Asks to be added to [gid], offering [keyPackageRef] as the way in. + * + * The requester side of a share link. It tells the coordinator, and + * through it the group's members, that this account wants in and which + * KeyPackage to use — nothing more. Only a member can actually add anyone, + * and nothing here obliges them to. + * + * This is an attributable call: the coordinator learns this account is + * interested in this group whether or not anyone ever accepts. That is + * unavoidable — there is no way to ask to join without asking — and it is + * the cost the exposure disclosure names. + */ + suspend fun requestToJoin( + gid: String, + keyPackageRef: String, + ): Long = + call { coordinator.storeJoinRequest(gid, keyPackageRef) } + // Written whether or not anyone ever answers. The exposure is the + // asking, not the joining -- the coordinator has the npub either + // way -- so recording it only on success would understate it in + // exactly the case where nothing else on screen mentions it. + .also { store.saveJoinedViaRequest(gid) } + + /** + * Everyone asking to join a group this manager holds. + * + * Only this manager's own `gid`s are asked about, because those are the + * only ones it could act on. Like Welcomes, acknowledgements ride the next + * call, so a previous round's decisions are flushed here. + * + * **Any member can answer these, not only an admin.** `spec/01.md` §5.3 + * makes `admin_pubkeys` presentation metadata and neither the spec nor the + * reference coordinator restricts who may commit — see + * [CordnGroupPolicy]'s "why there is no authorization hook". A UI that + * hid this behind an admin check would be inventing a boundary cordn does + * not have. + */ + suspend fun pendingJoinRequests(): List { + // Only groups this account can actually admit someone to, as both + // reference clients do. Accepting a request is an Add commit, so in a + // group that names admins a non-admin asking for the list would be + // shown people it can only fail to let in. Egalitarian groups name no + // admins and so are all still here. + val administered = groups.filterValues { CordnGroupPolicy.isLocalAdmin(it.view()) }.keys.toList() + if (administered.isEmpty()) return emptyList() + return call { coordinator.takeJoinRequests(administered, drainRequestRetirements()) } + } + + /** + * Adds the account behind [request] to its group, and retires the request. + * + * Retiring only after the invite lands: a request dropped before the + * Welcome exists is a person who asked, was told nothing, and has no way + * to ask again without a fresh link. + */ + suspend fun acceptJoinRequest(request: JoinRequest): InviteResult { + val result = invite(request.gid, request.pubKey, request.keyPackageRef) + retireRequests(listOf(ConsumedJoinRequestRef(request.gid, request.pubKey, request.at))) + return result + } + + /** Retires [request] without adding anyone. */ + suspend fun declineJoinRequest(request: JoinRequest) { + retireRequests(listOf(ConsumedJoinRequestRef(request.gid, request.pubKey, request.at))) + } + + /** As [retire], for join requests: `join_request_take_many` carries the acks. */ + private suspend fun retireRequests(refs: List) { + if (refs.isEmpty() || groups.isEmpty()) return + try { + call { coordinator.takeJoinRequests(groups.keys.toList(), refs) } + } catch (e: Exception) { + pendingRequestRetirements += refs + } + } + + private fun drainRequestRetirements(): List { + val queued = pendingRequestRetirements.toList() + pendingRequestRetirements.clear() + return queued + } + + // ---- messages -------------------------------------------------------- + + /** + * Sends a message that refers to another one — a reply, reaction, edit, + * deletion or pin. + * + * The kind and the tags are decided by + * [CordnMessageReferences.outbound], not here: a kind switch beside a tag + * switch is how the two stop agreeing, and that file holds both halves + * with tests over the round trip. + * + * ## It refuses what the fold would ignore + * + * `CordnAnnotationIndex` enforces cordn's authorization rules when folding + * annotations onto their targets, and edits and deletions are author-only + * there. Without the same check here, editing someone else's message would + * appear to work — the message goes out, the coordinator stores it, and + * every client including ours silently drops it. Failing at the call is + * the difference between a bug someone can report and one nobody can see. + * + * Reactions and pins are deliberately not checked: §5.1 makes a reaction + * anyone's and a pin any member's. + */ + suspend fun post( + gid: String, + content: String = "", + replyTo: CordnMessageReferences.Target? = null, + reactionTo: CordnMessageReferences.Target? = null, + editTo: CordnMessageReferences.Target? = null, + deleteTo: CordnMessageReferences.Target? = null, + pinTo: CordnMessageReferences.Target? = null, + pinOp: CordnMessageReferences.PinOp = CordnMessageReferences.PinOp.ADD, + ): CordnDeliveredMessage { + editTo?.let { require(it.pubKey == accountPubKey) { "only a message's author can edit it" } } + deleteTo?.let { require(it.pubKey == accountPubKey) { "only a message's author can delete it" } } + + val outbound = + CordnMessageReferences.outbound( + content = content, + replyTo = replyTo, + reactionTo = reactionTo, + editTo = editTo, + deleteTo = deleteTo, + pinTo = pinTo, + pinOp = pinOp, + ) + return send(gid, outbound.content, outbound.kind, outbound.tags) + } + + /** + * Sends [content] to [gid] as a cordn application message. + * + * Returns the message as the room will hold it, cursor included. The + * cursor is the coordinator's, taken from the post's own response, so a + * caller that shows the message immediately places it in the same order + * the echo would have — [CordnGroupChatroom.ORDER] sorts on it, and a + * guessed one would put your own message in the wrong place until the + * echo corrected it. + */ + suspend fun send( + gid: String, + content: String, + kind: Int = CHAT_KIND, + tags: Array> = emptyArray(), + ): CordnDeliveredMessage { + val group = requireGroup(gid) + val envelope = + CordnEnvelope.build( + pubKey = accountPubKey, + createdAt = clock(), + kind = kind, + tags = tags, + content = content, + ) + val sealed = CordnApplicationMessage.seal(group, accountPubKey, envelope) + val posted = call { sync.postMessage(gid, sealed) } + // Our own message never arrives as a Delivery.Message — it comes back + // as an Echo, which by design carries nothing — so the only place it + // can be recorded is here, where we still hold the plaintext. + queueForStore(gid, CordnDeliveredMessage(envelope, posted.cursor)) + persist(gid) + return CordnDeliveredMessage(envelope, posted.cursor) + } + + /** Drains history for every group this manager holds. */ + override suspend fun catchUp(onDelivery: (Delivery) -> Unit): Int { + if (groups.isEmpty()) return 0 + return call { sync.catchUp(groups.keys.toList()) { gid, ingestion -> onDelivery(ingest(gid, ingestion)) } } + .also { persistAll() } + } + + /** + * Subscribes to live delivery for every group. Suspends until the + * coordinator closes the stream, so give it its own coroutine. + * + * Persists on the way out, like [catchUp], and for the same reason: + * delivering a message advances the ratchet and the cursor, so a + * subscription that ended without writing would leave that progress only + * in memory. A long-lived host survives that; a process-per-command client + * does not, and neither does a crash. + */ + override suspend fun subscribe( + timeoutMs: Long, + onDelivery: (Delivery) -> Unit, + ) { + if (groups.isEmpty()) return + try { + call { sync.subscribe(groups.keys.toList(), timeoutMs) { gid, ingestion -> onDelivery(ingest(gid, ingestion)) } } + } finally { + // finally, not after: a subscription normally ends by timing out or + // being cancelled, and those are the cases with progress to keep. + // + // NonCancellable because the cancelled case is the one this exists + // for and the one it could not serve: persistAll suspends, and a + // suspend call in a cancelled coroutine throws before it writes + // anything. Without this the `finally` looked like it saved on + // cancellation and silently did not. + withContext(NonCancellable) { persistAll() } + } + } + + private fun ingest( + gid: String, + ingestion: Ingestion, + ): Delivery { + val group = groups[gid] ?: return Delivery.Undecryptable(gid, ingestion.cursor, "no such group") + + val sealed = + when (ingestion) { + is Ingestion.OwnMessage -> return Delivery.Echo(gid, ingestion.cursor) + // Our own Commit, already applied locally when we posted it. + is Ingestion.SelfEchoConfirmed -> return Delivery.Echo(gid, ingestion.cursor) + // Our own Commit that we posted but had not applied -- the echo + // is the instruction to apply it, which is how a client that + // died mid-post recovers. + is Ingestion.SelfEchoUnapplied -> ingestion.sealedBase64 + is Ingestion.Process -> ingestion.sealedBase64 + } + + return try { + // Our current epoch key opens both an application message sent at + // this epoch and a Commit leaving it, which is the same key by + // construction (spec/03.md §5). + val opened = SealedPayload.open(sealed, SealedPayload.applicationKey(group)) + when (MlsMessage.decodeTls(TlsReader(opened)).wireFormat) { + // Public-framed handshake: what this engine emits, and what + // Marmot uses throughout. + WireFormat.PUBLIC_MESSAGE -> { + group.processFramedCommit(opened) + Delivery.EpochAdvanced(gid, ingestion.cursor, group.epoch) + } + // Private-framed: what cordn's reference client emits, for both + // application messages and handshake traffic. + else -> { + val decrypted = group.decrypt(opened) + when (decrypted.contentType) { + ContentType.APPLICATION -> { + val received = CordnApplicationMessage.open(decrypted) + queueForStore(gid, CordnDeliveredMessage(received.envelope, ingestion.cursor)) + Delivery.Message(gid, ingestion.cursor, received) + } + // decrypt() applies a Commit, so the epoch has moved already. + else -> Delivery.EpochAdvanced(gid, ingestion.cursor, group.epoch) + } + } + } + } catch (e: Exception) { + // The cursor has already advanced past it. Reporting rather than + // throwing is what keeps one bad payload from stalling every other + // group in the same page. + Delivery.Undecryptable(gid, ingestion.cursor, e.message ?: "could not open payload") + } + } + + // ---- sharing and disclosure ----------------------------------------- + + /** The `cordn1…` ref to share this group, pointing at this coordinator. */ + fun shareRef(gid: String): CordnGroupRef { + requireGroup(gid) + return CordnGroupRef( + gid = gid, + coordinatorPubKey = config.pubKey, + relays = config.relays.map { it.url }, + ) + } + + /** + * What this coordinator learns about [gid]. See [GroupExposure]. + * + * Every field is read back from durable state rather than from what this + * process happens to have watched. The distinction is the whole point of + * the disclosure: a coordinator does not forget at app restart, so an + * exposure surface that resets to a cheerful default at launch is not a + * softer version of the truth, it is the wrong answer. + * + * @param publishedKeyPackage whether this account has a KeyPackage + * published here, which only whoever holds the KeyPackage store can say + * (§8.4). `CordnSession.exposure` supplies it; the default is the + * weaker "at least what this session did". + */ + suspend fun exposure( + gid: String, + publishedKeyPackage: Boolean = this.publishedKeyPackage, + ): GroupExposure { + requireGroup(gid) + return GroupExposure( + coordinator = config.pubKey, + linkedGroupCount = groups.size, + // §8.1: a join request names the asker's real npub against a + // specific group. Persisted at the moment it happens, because it + // is a thing the coordinator now knows permanently. + joinedFromShareLink = store.loadJoinedViaRequest(gid), + publishedKeyPackage = publishedKeyPackage, + // :contextvm's CvmGiftWrap defaults to REQUIRED and CoordinatorClient + // does not undo it, so this is a fact about our transport, not a + // setting. It is reported rather than assumed so that a future + // configurable transport cannot quietly make it false (§8.6). + encryptionPinned = true, + ) + } + + /** Publishes a KeyPackage so others can add this account. §8.4 applies. */ + suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ) = call { coordinator.publishKeyPackage(keyPackageRef, keyPackageBase64) } + .also { publishedKeyPackage = true } + + /** + * The screen-side state saved for [gid] — an unsent draft and a read + * position. Neither is protocol; both are encrypted anyway, because a + * draft is the plaintext of a message that was about to be sealed. + */ + suspend fun roomState(gid: String): CordnRoomState = store.loadRoomState(gid) + + suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) = store.saveRoomState(gid, state) + + // ---- plumbing -------------------------------------------------------- + + private fun requireGroup(gid: String) = groups[gid] ?: throw CordnGroupException("not a member of $gid") + + private suspend fun persist(gid: String) { + val group = groups[gid] ?: return + store.saveGroup(gid, group.saveState().encodeTls()) + + // Messages BEFORE the cursor, and deliberately so. Crash between the + // two and the message is on disk while the cursor still points before + // it: the next catch-up re-delivers it and appendMessage's dedup drops + // it, costing nothing. The other order loses the message permanently — + // both cordn seal keys are epoch-derived, so the copy the coordinator + // still holds can no longer be opened, and the cursor would not offer + // it again anyway. + // + // Living here rather than at the call sites is the point: the ordering + // is the correctness property, so it sits next to the cursor write + // where it cannot be forgotten. + pendingMessages.remove(gid)?.forEach { store.appendMessage(gid, it) } + + sync.cursors()[gid]?.let { store.saveCursor(gid, it) } + // On the same beat as the cursor, because the two are only meaningful + // together: a cursor that outlives the process while the record of + // what is ours does not leaves the next run re-reading its own + // traffic with no way to recognise it. See EchoState. + sync.echoes()[gid]?.let { store.saveEchoState(gid, it) } + } + + /** + * Messages delivered but not yet written, per group. + * + * [ingest] is not a suspend function — it runs inside the sync's own + * delivery callback — so it cannot write. It queues here instead, and + * [persist] drains the queue immediately before the cursor. Held in memory + * only for as long as the cursor is: neither is on disk until a run ends, + * so a crash mid-run loses both together and the next catch-up refetches + * from the last cursor that *was* written. + */ + private val pendingMessages = mutableMapOf>() + + private fun queueForStore( + gid: String, + message: CordnDeliveredMessage, + ) { + pendingMessages.getOrPut(gid) { mutableListOf() }.add(message) + } + + /** Every message this group has stored, oldest first. */ + suspend fun storedMessages(gid: String): List = store.loadMessages(gid) + + /** The newest stored message and the count, without reading the log. */ + suspend fun storedMessageSummary(gid: String): CordnMessageSummary? = store.loadMessageSummary(gid) + + private suspend fun persistAll() { + groups.keys.forEach { persist(it) } + } + + /** Runs a coordinator call, recording the outcome against [health]. */ + private suspend fun call(block: suspend () -> T): T = + try { + block().also { health.recordSuccess(clock()) } + } catch (e: Exception) { + health.recordFailure(clock(), e.message) + throw e + } + + companion object { + /** `spec/02.md` §6: a cordn chat message is a NIP-C7 kind 9. */ + const val CHAT_KIND = 9 + + // Why a Welcome was left in the inbox. Each is actionable and each + // reaches a user, so they are constants rather than strings written at + // the throw site: a reason nobody can act on is just an error message. + + /** The Welcome is for another device of this account. Leave it alone. */ + const val NO_PRIVATE_HALF = "no KeyPackage private half for this ref" + + /** Opened, but it does not say where to fetch this group's stream from. */ + const val UNKNOWN_GID = "cannot tell which delivery group this Welcome is for" + + /** A stale invitation to a group we are already in. Nothing to decide. */ + const val ALREADY_A_MEMBER = "already a member of this group" + + /** + * The delivery `gid` a Welcome implies, or null when it implies none. + * + * **A Welcome does not carry the `gid`.** It carries the MLS + * `group_id`, and `spec/03.md` §2 is explicit that the two are + * decoupled — the coordinator's delivery id is not the MLS group id and + * must not be assumed to be. So in principle a joiner has no way to + * learn where to fetch from, and needs the `cordn1…` ref out of band. + * + * In practice the reference client closes that gap by convention: + * cordn-web sets `group_id = utf8(gid)` when it creates a group and + * reads the `gid` straight back out of the group context on join + * (`chatGroupLifecycle.svelte.ts`). Verified against staircase's + * fixtures, whose `group_id` decodes to exactly their published `gid`. + * + * This follows that convention — [createGroup] writes it and this reads + * it — while treating it as what it is: an observation about one + * implementation, not a guarantee. A group id that is not valid UTF-8 + * cannot be a `gid`, and a conformant peer is free to produce one, so + * the answer is null and the caller is told rather than handed a + * mojibake key that would quietly fetch nothing forever. Pass `gidFor` + * to supply the id from a share ref instead. + */ + fun gidFrom(group: MlsGroup): String? = + try { + group.groupId.decodeToString(throwOnInvalidSequence = true).takeIf { it.isNotEmpty() } + } catch (e: CharacterCodingException) { + null + } + } +} + +/** + * Something went wrong that is this manager's to explain, not MLS's. + * + * [reason] exists so a screen can say what happened in its own words. The + * [message] is written for a log — it names pubkeys and gids in full, which is + * what you want when reading one and never what you want in a chat — so a UI + * that rendered `e.message` showed a person who had just tapped a face a + * 64-character hex string. Branching on the reason lets it name the person + * instead, without matching on English that is free to change. + */ +class CordnGroupException( + message: String, + val reason: Reason = Reason.OTHER, +) : IllegalStateException(message) { + enum class Reason { + /** Nobody has published a KeyPackage for this person to this coordinator. */ + NO_KEY_PACKAGE, + + /** The coordinator served a KeyPackage belonging to somebody else. */ + WRONG_KEY_PACKAGE_OWNER, + + /** The add produced no Welcome, so the invitee could never open the group. */ + NO_WELCOME, + + /** The person named is not in this group. */ + NOT_A_MEMBER, + + /** Anything with no better wording than the message itself. */ + OTHER, + } +} + +/** + * A Welcome that has been opened but not joined. + * + * Everything on it was read out of the Welcome itself, so it is what can be + * shown before a decision is made. [group] is the MLS group the Welcome + * produces; holding it is why [CordnGroupManager.accept] does not have to + * re-open the Welcome and spend the KeyPackage twice. + */ +class OpenedWelcome internal constructor( + val gid: String, + val keyPackageRef: String, + val at: Long, + val metadata: CordnGroupMetadata?, + /** Who is already in the group. Not the inviter — see [CordnGroupManager.pendingWelcomes]. */ + val members: Set, + val epoch: Long, + internal val group: MlsGroup, + internal val after: Long?, +) + +/** Every pending Welcome, split into the ones that opened and the ones that did not. */ +data class WelcomeInbox( + val pending: List, + val skipped: List, +) + +/** What [CordnGroupManager.joinPendingWelcomes] did with each pending Welcome. */ +data class JoinResults( + val joined: List, + /** + * Welcomes left pending, with why. Not an error list: a Welcome for a + * KeyPackage this device never held belongs to another device of the same + * account, and draining it here would destroy it. + */ + val skipped: List, +) + +/** One Welcome that stayed in the inbox, and the reason. */ +data class SkippedWelcome( + val keyPackageRef: String, + val reason: String, +) + +/** The result of adding a member: where the Commit and the Welcome landed. */ +data class InviteResult( + val gid: String, + val invited: HexKey, + /** + * The KeyPackage this invite consumed. + * + * Worth reporting rather than swallowing: a KeyPackage is one-time, so + * this names something the invitee can no longer be invited with by + * anyone else. It is also the handle their client needs to find the + * Welcome we just left them. + */ + val keyPackageRef: String, + val commitCursor: Long, + val welcomeAt: Long, +) + +/** What [CordnGroupManager.removeMember] did. */ +data class RemovalResult( + val gid: String, + val removed: HexKey, + /** Where the coordinator filed the Remove commit. */ + val cursor: Long, +) + +/** What [CordnGroupManager.updateGroupMetadata] did. */ +data class MetadataUpdateResult( + val gid: String, + /** Where the coordinator filed the metadata commit. */ + val cursor: Long, +) + +/** + * What one [CordnGroupManager.inviteAll] did, per person. + * + * Three outcomes rather than success-or-throw, because each needs a different + * answer: [refused] never reached the group and can be retried or replaced with + * a share link; [undelivered] are in the group already and need only the Welcome + * resent; [invited] are done. + */ +data class BatchInviteResult( + val gid: String, + val invited: List, + val undelivered: Map, + val refused: Map, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupStore.kt new file mode 100644 index 0000000000..3102e3c744 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupStore.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.EchoState +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.cordn.sync.PendingEpochOperation +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter + +/** + * Local storage for cordn group state, keyed by the delivery `gid`. + * + * **Implementations MUST encrypt at rest.** The blobs are + * `MlsGroupState.encodeTls()` output: private keys and epoch secrets. + * + * Separate from Marmot's `MlsGroupStateStore` rather than shared, and the key + * is the reason. That interface is keyed on the Nostr group id — the MIP-01 `h` + * tag — which cordn has no equivalent of; cordn's key is the coordinator's + * delivery `gid`, which is opaque, is not the MLS `group_id`, and is not even + * unique across coordinators. The two look alike and mean different things, so + * one interface serving both would be an invitation to hand the wrong id to the + * wrong store and silently find no group. + * + * The cursor lives here too because it is worthless apart from the state it + * belongs to: a cursor restored against a group at a different epoch replays + * messages the group can no longer decrypt. + */ +interface CordnGroupStore { + suspend fun saveGroup( + gid: String, + state: ByteArray, + ) + + suspend fun loadGroup(gid: String): ByteArray? + + suspend fun deleteGroup(gid: String) + + /** Every `gid` with saved state, for restoring memberships at startup. */ + suspend fun listGroups(): List + + suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) + + suspend fun loadCursor(gid: String): GroupCursor? + + /** + * Saves the record of which stream entries are this client's own. + * + * Beside the cursor because it is only meaningful with it, and durable for + * the same reason: posting does not advance the cursor (a lower one may + * still hold somebody else's unprocessed message), so the next run + * re-reads what this one posted. Without this it cannot tell that it is + * its own — a Commit sealed under an epoch key it has since left, a + * message from a ratchet generation already consumed — and reports a gap + * in its own conversation. See `EchoState`. + */ + suspend fun saveEchoState( + gid: String, + state: EchoState, + ) + + /** The saved echo bookkeeping for [gid], or an empty one. */ + suspend fun loadEchoState(gid: String): EchoState + + /** + * Records that admission to [gid] went through `join_request_store`. + * + * Per-group side state beside the cursor, and durable for the same reason: + * it describes something the coordinator will not forget. §8.1 — a join + * request names the asker's real npub against a specific group, so once it + * has happened the coordinator can tie this account to this group forever. + * Whether *we* still remember changes nothing about what it knows, which + * is why this outlives the session that did it rather than resetting to a + * cheerful default at every launch. + */ + suspend fun saveJoinedViaRequest(gid: String) + + /** Whether [gid] was admitted through a join request. */ + suspend fun loadJoinedViaRequest(gid: String): Boolean + + /** + * Saves the per-room state a screen needs but the protocol does not. + * + * An unsent draft and a read position are not MLS state and never leave + * the device — but they go through the same store, and so the same + * encryption, for one reason: a draft is the plaintext of a message that + * was about to be end-to-end encrypted. Writing it somewhere softer than + * the conversation it belongs to would make the composer the weakest point + * in the whole feature. + */ + suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) + + /** The saved room state for [gid], or a blank one. */ + suspend fun loadRoomState(gid: String): CordnRoomState + + /** + * Records one delivered message, as the only copy there will ever be. + * + * Not a cache. Both of a cordn payload's seal keys are derived per epoch, + * so once the group ratchets forward the ciphertext the coordinator still + * holds cannot be opened again — and the cursor has advanced past it in any + * case, so it will not even be offered. Ingestion is the one moment the + * message is in the clear. + * + * MUST be idempotent on the envelope's id. A client that dies between this + * call and [saveCursor] re-fetches the message on the next catch-up, which + * is the crash window the append order is chosen to land in — see + * `commons/plans/2026-09-24-cordn-message-store.md`. + */ + suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) + + /** Every stored message for [gid], oldest first. Empty when there are none. */ + suspend fun loadMessages(gid: String): List + + /** + * The newest message and the count, without reading the whole log. + * + * The inbox needs a preview line for every room before any room is opened, + * and reading every group's history at login is what + * `CordnRuntime.restoreRoomState` deliberately avoids. This is the small + * read that makes the preview possible: one key per group, written on the + * same beat as [appendMessage]. + */ + suspend fun loadMessageSummary(gid: String): CordnMessageSummary? +} + +/** + * What the inbox needs to know about a room without opening it. + * + * @param newest the last message delivered, for the preview line. + * @param count how many are stored, for anything that wants to say "empty" and + * mean it rather than meaning "not loaded yet". + */ +data class CordnMessageSummary( + val newest: CordnDeliveredMessage, + val count: Int, +) + +/** + * What a cordn room remembers between visits. + * + * @param draft text typed and not sent. + * @param lastReadCursor the newest cursor this account has seen in the room. + * A cursor rather than a timestamp, because the coordinator's order is the + * only one every member agrees on (`spec/00.md` §4) and a sender's clock is + * a claim — the same reason the room itself sorts on it. + */ +data class CordnRoomState( + val draft: String = "", + val lastReadCursor: Long = 0L, +) { + val isBlank: Boolean get() = draft.isEmpty() && lastReadCursor == 0L +} + +/** + * The on-disk form of a [CordnMessageSummary]. + * + * The message itself rides as its own JSON entry rather than being taken apart + * into TLS fields, so there is exactly one definition of what a stored message + * looks like and the summary cannot drift from the log it summarises. + */ +object CordnMessageSummaryCodec { + const val VERSION = 1 + + fun encode( + newest: CordnDeliveredMessage, + count: Int, + ): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putUint32(count.toLong()) + writer.putOpaque2(CordnDeliveredMessageCodec.encode(newest).encodeToByteArray()) + return writer.toByteArray() + } + + /** Null on anything unreadable: a preview line is not worth failing a login over. */ + fun decode(bytes: ByteArray): CordnMessageSummary? = + try { + val reader = TlsReader(bytes) + if (reader.readUint16() != VERSION) { + null + } else { + val count = reader.readUint32().toInt() + val newest = CordnDeliveredMessageCodec.decode(reader.readOpaque2().decodeToString()) + CordnMessageSummary(newest, count) + } + } catch (e: Exception) { + null + } +} + +/** The on-disk layout of a [CordnRoomState]. */ +object CordnRoomStateCodec { + const val VERSION = 1 + + fun encode(state: CordnRoomState): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putOpaque2(state.draft.encodeToByteArray()) + writer.putUint64(state.lastReadCursor) + return writer.toByteArray() + } + + /** Blank on anything unreadable: a lost draft must not cost the room. */ + fun decode(bytes: ByteArray): CordnRoomState = + try { + val reader = TlsReader(bytes) + if (reader.readUint16() != VERSION) { + CordnRoomState() + } else { + CordnRoomState(reader.readOpaque2().decodeToString(), reader.readUint64()) + } + } catch (e: Exception) { + CordnRoomState() + } +} + +/** + * The on-disk layout of an [EchoState]. + * + * ``` + * version:u16 + * pending_commits: vector2 of { sealed:opaque2, applied:u8 } + * own_cursors: vector2 of u64 + * ``` + * + * Empty on anything unreadable, which is the safe direction: losing the record + * costs a client one run of reporting its own traffic as a gap, while + * accepting a half-decoded one could let it skip somebody else's message as + * though it were its own. + */ +object EchoStateCodec { + const val VERSION = 1 + + fun encode(state: EchoState): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + + val commits = TlsWriter() + state.pendingCommits.forEach { + commits.putOpaque2(it.sealedBase64.encodeToByteArray()) + commits.putUint8(if (it.localStateApplied) 1 else 0) + } + writer.putOpaque2(commits.toByteArray()) + + val cursors = TlsWriter() + state.ownMessageCursors.forEach { cursors.putUint64(it) } + writer.putOpaque2(cursors.toByteArray()) + + return writer.toByteArray() + } + + fun decode(bytes: ByteArray): EchoState = + try { + val reader = TlsReader(bytes) + if (reader.readUint16() != VERSION) { + EchoState() + } else { + val commits = TlsReader(reader.readOpaque2()) + val pending = mutableListOf() + while (commits.hasRemaining) { + val sealed = commits.readOpaque2().decodeToString() + pending += PendingEpochOperation(sealed, commits.readUint8() == 1) + } + + val cursors = TlsReader(reader.readOpaque2()) + val own = mutableListOf() + while (cursors.hasRemaining) { + own += cursors.readUint64() + } + + EchoState(pending, own) + } + } catch (e: Exception) { + EchoState() + } +} + +/** A [CordnGroupStore] that keeps everything in memory. Tests, and nothing else. */ +class InMemoryCordnGroupStore : CordnGroupStore { + private val groups = mutableMapOf() + private val cursors = mutableMapOf() + private val viaRequest = mutableSetOf() + private val roomStates = mutableMapOf() + private val echoStates = mutableMapOf() + private val messages = mutableMapOf>() + + override suspend fun saveGroup( + gid: String, + state: ByteArray, + ) { + groups[gid] = state + } + + override suspend fun loadGroup(gid: String): ByteArray? = groups[gid] + + override suspend fun deleteGroup(gid: String) { + groups.remove(gid) + cursors.remove(gid) + viaRequest.remove(gid) + roomStates.remove(gid) + echoStates.remove(gid) + // Leaving a group that left its history behind would keep the plaintext + // of an end-to-end encrypted conversation on disk after the one thing + // that could read it was thrown away. + messages.remove(gid) + } + + override suspend fun listGroups(): List = groups.keys.toList() + + override suspend fun saveJoinedViaRequest(gid: String) { + viaRequest += gid + } + + override suspend fun loadJoinedViaRequest(gid: String): Boolean = gid in viaRequest + + override suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) { + roomStates[gid] = state + } + + override suspend fun loadRoomState(gid: String): CordnRoomState = roomStates[gid] ?: CordnRoomState() + + override suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) { + val log = messages.getOrPut(gid) { mutableListOf() } + if (log.none { it.envelope.id == message.envelope.id }) log.add(message) + } + + override suspend fun loadMessages(gid: String): List = messages[gid]?.toList() ?: emptyList() + + override suspend fun loadMessageSummary(gid: String): CordnMessageSummary? = messages[gid]?.lastOrNull()?.let { CordnMessageSummary(it, messages[gid]?.size ?: 0) } + + override suspend fun saveEchoState( + gid: String, + state: EchoState, + ) { + echoStates[gid] = state + } + + override suspend fun loadEchoState(gid: String): EchoState = echoStates[gid] ?: EchoState() + + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) { + cursors[gid] = cursor + } + + override suspend fun loadCursor(gid: String): GroupCursor? = cursors[gid] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffState.kt new file mode 100644 index 0000000000..da28d2a449 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffState.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether this device has handed its cordn state to another one. + * + * ## The property that makes migration safe + * + * `multi-device.md` §10 has one unresolved case: two devices of an identity + * committing inside a single delivery round-trip reach epoch N+1 with different + * states, and §15 concedes *equal-epoch MLS states have no merge function*. The + * whole reason migration is buildable where continuous sync is not is that a + * handoff has one writer. + * + * That is only true if the old phone actually stops. Two phones holding one + * leaf and both committing fork the ratchet tree, and MLS does not recover — + * after the fork every message silently fails to decrypt for somebody. So the + * handoff is not finished when the code is scanned; it is finished when this + * device has stood down, and [handedOff] is what says so. + * + * ## Why it is reversible + * + * A migration can fail after the export: the other phone is out of battery, + * the QR does not scan, the user changes their mind. Locking irreversibly + * would strand an account on a device that still holds the only copy of its + * state. [resume] exists for that, and is safe exactly while the new device + * has not started committing — which is why the UI asks rather than assumes. + * + * ## What it does not do + * + * It does not tell the coordinator anything, and it does not delete anything. + * The state stays on disk so [resume] can work, and so a user who migrated by + * mistake has not lost their groups. + */ +class CordnHandoffState( + private val store: CordnHandoffStore, +) { + private val _handedOff = MutableStateFlow(false) + + /** True once this device has exported and stood down. */ + val handedOff: StateFlow = _handedOff.asStateFlow() + + /** Reads the flag off disk. Call before the first sync loop starts. */ + suspend fun restore() { + _handedOff.value = store.load() + } + + /** + * Records that this device has handed off. + * + * The caller is responsible for having stopped the sync loops first: this + * records a decision, it does not enforce one. + */ + suspend fun markHandedOff() { + store.save(true) + _handedOff.value = true + } + + /** Takes the device back, for a migration that did not complete. */ + suspend fun resume() { + store.save(false) + _handedOff.value = false + } + + /** + * Throws if this device has handed off. + * + * Called on every path that would advance an epoch. A read is still + * allowed: showing the user the conversations they had is harmless, and + * refusing it would make a failed migration look like data loss. + */ + fun requireNotHandedOff() { + if (_handedOff.value) { + throw CordnHandedOffException( + "this device handed its cordn groups to another one; " + + "sending from both would fork the group state", + ) + } + } +} + +/** An attempt to write from a device that has handed off. */ +class CordnHandedOffException( + message: String, +) : Exception(message) + +/** Where the handoff flag lives between runs. */ +interface CordnHandoffStore { + suspend fun load(): Boolean + + suspend fun save(handedOff: Boolean) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackages.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackages.kt new file mode 100644 index 0000000000..f86800ce4d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackages.kt @@ -0,0 +1,396 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The private halves of the KeyPackages this account has published. + * + * **Implementations MUST encrypt at rest.** A bundle is key material: whoever + * holds it can open the Welcome that admits its owner to a group. + * + * Separate from Marmot's `KeyPackageBundleStore` and not a refactor of it. That + * one persists a single snapshot carrying Marmot's rotation state alongside the + * bundles; cordn has no rotation state, no kind-443 event, and no publish + * obligations — its KeyPackages live entirely in coordinator calls + * (`spec/00.md` §4.2). The shapes differ because the protocols do. + */ +interface CordnKeyPackageStore { + suspend fun save( + keyPackageRef: String, + bundle: ByteArray, + ) + + suspend fun load(keyPackageRef: String): ByteArray? + + suspend fun delete(keyPackageRef: String) + + /** Every ref with a stored private half. */ + suspend fun list(): List +} + +/** In-memory [CordnKeyPackageStore]. Tests, and nothing else. */ +class InMemoryCordnKeyPackageStore : CordnKeyPackageStore { + private val bundles = mutableMapOf() + + override suspend fun save( + keyPackageRef: String, + bundle: ByteArray, + ) { + bundles[keyPackageRef] = bundle + } + + override suspend fun load(keyPackageRef: String): ByteArray? = bundles[keyPackageRef] + + override suspend fun delete(keyPackageRef: String) { + bundles.remove(keyPackageRef) + } + + override suspend fun list(): List = bundles.keys.toList() +} + +/** + * Publishing KeyPackages so other people can add this account to groups. + * + * ## Why this is not Marmot's rotation manager with the names changed + * + * Marmot publishes a KeyPackage as a kind-443 event to relays: a real Nostr + * event, replaceable, discoverable, with its own relay list and its own publish + * obligations. cordn has **no KeyPackage event kind at all** (`spec/00.md` + * §4.2). Publishing is a `kp_publish` call, and the "signed publication + * payload" §7 requires is the ContextVM request event the transport happens to + * sign — which the coordinator stores and serves back verbatim. There is no + * relay copy, nothing to rotate against, and nothing to re-publish if a relay + * forgets. None of Marmot's machinery transfers; all of it would be wrong here. + * + * ## `kp_ref` is the RFC 9420 KeyPackageRef and nothing else + * + * It is the coordinator's primary key for a KeyPackage and the address a + * Welcome is delivered to. Two implementations that compute it differently do + * not fail loudly: every `kp_take` misses, every Welcome lands at an address + * nobody is listening on, and the group simply never forms. This class owns the + * computation so no caller invents one — [MlsKeyPackage.reference] is checked + * against ts-mls's in `CordnLifecycleInteropTest`. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnKeyPackages( + private val accountPubKey: HexKey, + private val coordinator: ICoordinator, + private val store: CordnKeyPackageStore, +) { + private val _published = MutableStateFlow>(emptySet()) + + /** Refs this account has published and still holds the private half for. */ + val published: StateFlow> = _published.asStateFlow() + + /** One published KeyPackage, as this account sees it. */ + data class Published( + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + ) + + private val snapshotLock = Mutex() + private var snapshot: Snapshot? = null + + /** + * What `kp_list` reported, the last time we asked. + * + * `kp_list` takes no arguments and returns every KeyPackage the coordinator + * holds for **every** identity (`spec/00.md` leaves retrieval scoping to the + * coordinator; the reference implementation answers with the whole table). + * So one call answers a question about anybody, and asking it once per + * person would be the same download repeated. + * + * [identities] keeps only pubkeys, not the entries. Another account's + * `kp_ref` is useless to us -- a Welcome is addressed to a ref we obtained + * by *taking* the package, never to one we read out of a listing -- and on + * a busy coordinator those refs are the bulk of the response. Dropping them + * bounds what we retain even though nothing bounds what arrives. + */ + class Snapshot( + /** Every identity the coordinator holds at least one KeyPackage for. */ + val identities: Set, + /** The full entries for this account, which are the ones we act on. */ + val mine: List, + /** When this was fetched, for [snapshot]'s age check. */ + val at: Long, + ) + + /** + * Refetches unconditionally and replaces the snapshot. + * + * Every decision about whether to *publish* reads through here rather than + * through [snapshot]. Minting against a stale listing is the one place + * staleness does damage: the coordinator keeps a single last-resort package + * per identity, so publishing a second silently evicts the first and + * strands every invite that referenced it. + */ + suspend fun refresh(): Snapshot = snapshotLock.withLock { fetchLocked() } + + /** + * The snapshot, refetched if it is older than [maxAgeSeconds]. + * + * For reads that only *describe* what the coordinator holds. A minute of + * staleness costs at most a missing badge for somebody who published within + * it, and the act itself still fails loudly at the coordinator if the + * listing was wrong -- whereas every refresh is another full-table + * download, so asking less often is the cheap side of the trade. + */ + suspend fun snapshot(maxAgeSeconds: Long = SNAPSHOT_TTL_SECONDS): Snapshot = + snapshotLock.withLock { + snapshot?.takeIf { TimeUtils.now() - it.at < maxAgeSeconds } ?: fetchLocked() + } + + /** + * Which identities the coordinator can deliver a Welcome to, or null. + * + * Null means **we do not know** -- the coordinator is unreachable, throttled + * us, or answered with more than the transport admits. It does not mean + * nobody has published. Callers must keep those apart: rendering a failed + * lookup as "this person cannot be added" states something about a person + * on the strength of a network error. + */ + suspend fun identitiesWithKeyPackages(): Set? = + try { + snapshot().identities + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + /** Must hold [snapshotLock]. */ + private suspend fun fetchLocked(): Snapshot { + val all = coordinator.listKeyPackages() + return Snapshot( + identities = all.mapTo(mutableSetOf()) { it.pubKey }, + mine = all.filter { it.pubKey == accountPubKey }, + at = TimeUtils.now(), + ).also { snapshot = it } + } + + /** Forgets the snapshot after we changed what the coordinator holds. */ + private suspend fun invalidate() = snapshotLock.withLock { snapshot = null } + + /** Reloads which refs we hold private halves for. Call at startup. */ + suspend fun restore() { + _published.value = store.list().toSet() + } + + /** + * Whether this account has a KeyPackage published on this coordinator. + * + * Read from the store rather than from [published], so it is right before + * [restore] has run and right after a relaunch. It answers the §8.4 half + * of the exposure disclosure, which is about what the coordinator holds — + * not about what this process has done since it started. + * + * A private half with no published counterpart is possible (the publish + * failed and the store was not cleaned), and reporting that as "published" + * overstates the exposure by one KeyPackage rather than understating it by + * all of them — the direction a privacy surface should err in. + */ + suspend fun hasPublished(): Boolean = store.list().isNotEmpty() + + /** + * What the coordinator currently serves for this account. + * + * The coordinator's answer, not ours. `kp_take` consumes a single-use + * package, so our own record of what we published drifts upward from what + * is actually available the moment anyone invites us — and it is what is + * available that decides whether the next invitation can happen. + */ + suspend fun listPublished(): List = refresh().mine + + /** + * Generates a KeyPackage, keeps its private half, and publishes it. + * + * The private half is stored **before** the call, not after: a publish that + * succeeds on the coordinator and then fails locally would leave a + * KeyPackage other people can use to invite an account that can no longer + * open the Welcome. Storing first makes the failure the harmless direction — + * an unused bundle on disk. + * + * @param lastResort marks it reusable. `spec/00.md` lets a last-resort + * package back several Welcomes, which is why a Welcome record is keyed + * by `(kp_ref, at)` rather than by ref alone. + */ + suspend fun publishNew(lastResort: Boolean = false): Published { + val bundle = generate(lastResort) + val ref = refOf(bundle) + + store.save(ref, KeyPackageBundleCodec.encode(bundle)) + _published.value = _published.value + ref + + val result = + try { + coordinator.publishKeyPackage(ref, Base64.encode(bundle.keyPackage.toTlsBytes())) + } catch (e: Exception) { + // The coordinator never took it, so nobody can invite us with + // it. Drop the local half rather than accumulate bundles that + // will never be used. + store.delete(ref) + _published.value = _published.value - ref + throw e + } + + invalidate() + return Published(result.keyPackageRef, result.lastResort || lastResort, result.at) + } + + /** + * The private half for [keyPackageRef], if this device published it. + * + * Hand this to [CordnGroupManager.joinPendingWelcomes]. Null is ordinary and + * not an error: a Welcome addressed to a KeyPackage another device of the + * same account published belongs to that device, and draining it here would + * destroy it. + */ + suspend fun bundleFor(keyPackageRef: String): KeyPackageBundle? = + store.load(keyPackageRef)?.let { + try { + KeyPackageBundleCodec.decode(it) + } catch (e: IllegalArgumentException) { + // A bundle we cannot read is a bundle we cannot use. Reporting + // null sends the Welcome back to the inbox, where another + // device — or a later version of this one — may manage it. + null + } + } + + /** + * Withdraws [keyPackageRefs] from the coordinator and forgets their halves. + * + * Order matters the other way here: the coordinator is told first, because + * deleting locally while the coordinator still serves the package is the + * bad direction — somebody invites us and we cannot open the Welcome. + */ + suspend fun withdraw(keyPackageRefs: List): List { + if (keyPackageRefs.isEmpty()) return emptyList() + val removed = coordinator.removeKeyPackages(keyPackageRefs) + removed.forEach { store.delete(it) } + _published.value = _published.value - removed.toSet() + invalidate() + return removed + } + + /** + * Tops up to [minimum] single-use KeyPackages on the coordinator. + * + * Counts what the coordinator actually holds for this account rather than + * what we think we published: `kp_take` consumes a single-use package, so + * the coordinator's count falls as people invite us and ours does not. + * + * @return the refs published by this call. + */ + suspend fun topUp(minimum: Int = DEFAULT_POOL): List { + require(minimum >= 0) { "a KeyPackage pool cannot be negative" } + val theirs = refresh().mine.filter { !it.lastResort } + val missing = minimum - theirs.size + if (missing <= 0) return emptyList() + return List(missing) { publishNew().keyPackageRef } + } + + /** + * Ensures exactly one last-resort KeyPackage is published. + * + * Its job is that an invite can always be made, even after the single-use + * pool is drained. More than one is not better — each is an extra reusable + * package, and `spec/00.md` treats a last-resort package as the fallback, + * not the norm. + */ + suspend fun ensureLastResort(): String? { + val existing = refresh().mine.filter { it.lastResort } + // Only one, and only one we can still open: a last-resort package whose + // private half this device never had is useless to it. + val usable = existing.firstOrNull { store.load(it.keyPackageRef) != null } + if (usable != null) return usable.keyPackageRef + return publishNew(lastResort = true).keyPackageRef + } + + /** A fresh bundle carrying this account's cordn credential. */ + private fun generate(lastResort: Boolean): KeyPackageBundle { + // A scratch group only to reach the engine's KeyPackage builder; it is + // never joined, committed to, or stored. + val identity = CordnCredential.of(accountPubKey).identity + val scratch = MlsGroup.create(identity, policy = CordnGroupPolicy) + return scratch.createKeyPackage( + identity = identity, + // Ignored by the engine, which generates the leaf signature keypair + // itself and signs with that. Every caller in the tree passes empty + // for the same reason; it is a dead parameter, not a key we are + // failing to supply. + signingKey = ByteArray(0), + keyPackageExtensions = if (lastResort) listOf(CordnGroupPolicy.lastResortExtension()) else emptyList(), + // A last-resort package carries its marker inside app_data_dictionary + // (0x0006), so its leaf has to say it understands that carrier. + // CordnGroupPolicy has had the right set for this all along and + // nothing was calling it, which left every last-resort KeyPackage + // advertising one type short of what it used. + capabilities = + if (lastResort) CordnGroupPolicy.lastResortLeafCapabilities() else CordnGroupPolicy.defaultLeafCapabilities, + ) + } + + /** The RFC 9420 KeyPackageRef, hex. See the class KDoc. */ + fun refOf(bundle: KeyPackageBundle): String = bundle.keyPackage.reference().toHexKey() + + companion object { + /** + * How many single-use KeyPackages to keep available. + * + * Each one is one invite somebody can make without falling back to the + * reusable package. Small because they cost a round trip each and the + * last-resort package is the safety net. + */ + const val DEFAULT_POOL = 5 + + /** + * How long a [Snapshot] may be reused by [snapshot]. + * + * Bounded by what a refresh costs rather than by how fast the answer + * changes: `kp_list` is unpaginated, so each one downloads the + * coordinator's whole table. A minute is long enough that opening a + * screen repeatedly costs one call and short enough that somebody who + * has just published becomes visible while you are still looking. + */ + const val SNAPSHOT_TTL_SECONDS = 60L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt new file mode 100644 index 0000000000..6d8cd6fbb3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef + +/** + * What a pasted `cordn1…` link turns out to be. + * + * A group ref is the one cordn artifact a person handles directly — it arrives + * in a chat message or a QR code — and it is also the moment where §8 matters: + * before joining, not after. So the parse and the disclosure are computed + * together here, in headless code a test can drive, and the screen only draws + * the result. + */ +sealed interface CordnLinkInspection { + /** Not a cordn link, with the rule it broke, in the decoder's own words. */ + data class Invalid( + val reason: String, + ) : CordnLinkInspection + + /** + * A well-formed link. + * + * [coordinator] and [exposure] are null together, and legitimately so: + * `spec/applications/group-ref.md` §2 makes the coordinator optional, and a + * ref carrying only a `gid` names no operator — so there is no one to + * disclose anything about, and pretending otherwise would be inventing a + * threat model for a server we cannot identify. + */ + data class Valid( + val ref: CordnGroupRef, + val coordinator: CoordinatorConfig?, + val exposure: GroupExposure?, + ) : CordnLinkInspection { + /** Whether this link can be acted on without asking the sender for more. */ + val isFollowable: Boolean get() = coordinator != null + } + + companion object { + /** + * Inspects [input]. + * + * @param existingGroupsOnCoordinator how many groups this account + * already has on that coordinator, so §8.2's linkage count describes + * what joining would actually create rather than a hypothetical. The + * default assumes none, which is the conservative reading — it + * under-reports linkage rather than inventing it. + */ + fun of( + input: String, + existingGroupsOnCoordinator: Int = 0, + publishedKeyPackage: Boolean = false, + ): CordnLinkInspection { + val trimmed = input.trim() + if (trimmed.isEmpty()) return Invalid("empty") + + val ref = + try { + CordnGroupRef.decode(trimmed) + } catch (e: IllegalArgumentException) { + return Invalid(e.message ?: "not a cordn group reference") + } + + val coordinator = CoordinatorConfig.from(ref) + return Valid( + ref = ref, + coordinator = coordinator, + exposure = + coordinator?.let { + GroupExposure( + coordinator = it.pubKey, + // The group being inspected is the one that would be added. + linkedGroupCount = existingGroupsOnCoordinator + 1, + // A link is how a stranger joins, which is exactly the + // `join_request_store` path §8.1 describes. + joinedFromShareLink = true, + publishedKeyPackage = publishedKeyPackage, + encryptionPinned = true, + ) + }, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentions.kt new file mode 100644 index 0000000000..817cc8e818 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentions.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +/** + * Splitting a cordn message into the parts a screen renders differently. + * + * ## Why cordn needs its own, small version of this + * + * Amethyst's rich-text rendering is built on `Note`, and a `Note` comes from + * `LocalCache`. Nothing cordn receives may go in there — a cordn message is an + * MLS payload that never touched a relay, and giving it a `Note` would make + * private group chat searchable and notifiable alongside published events. + * So the parsing happens here, on the envelope's own content, and the result + * is plain data a composable can walk. + * + * Resolving a mentioned pubkey to a **display name** is a different question + * and a safe one: a profile is public relay data that the cache holds anyway, + * and reading one puts nothing of the conversation into it. The rule is about + * what goes in, not what is looked up. + * + * ## Scope + * + * Mentions only — `nostr:npub1…` and `nostr:nprofile1…`. Not URLs, not + * hashtags, not embedded events. Each of those renders as something, and + * something that renders is something that can leak: an auto-loaded preview + * would fetch a URL the moment a private message arrived, telling its host + * that a specific person opened a specific message. That belongs with the + * media work, behind the same decisions, not smuggled in with mentions. + */ +object CordnMentions { + /** One run of a message: either text to print, or a person to name. */ + sealed interface Segment { + data class Text( + val value: String, + ) : Segment + + data class Mention( + val pubKey: HexKey, + /** The exact text matched, for a renderer that wants to fall back to it. */ + val raw: String, + ) : Segment + } + + /** + * Splits [content] into text and mention runs, in order. + * + * Concatenating every segment's original text reproduces [content] exactly + * — there is a test for that. A renderer that drops a segment type would + * otherwise silently eat part of someone's message, which is worse than + * not rendering mentions at all. + */ + fun segment(content: String): List { + if (content.isEmpty()) return emptyList() + + val out = mutableListOf() + var last = 0 + + MENTION.findAll(content).forEach { match -> + val pubKey = decodePublicKeyAsHexOrNull(match.value.removePrefix(NOSTR_PREFIX)) ?: return@forEach + + if (match.range.first > last) { + out += Segment.Text(content.substring(last, match.range.first)) + } + out += Segment.Mention(pubKey, match.value) + last = match.range.last + 1 + } + + if (last < content.length) out += Segment.Text(content.substring(last)) + return out + } + + /** Every account mentioned in [content], deduplicated, in first-seen order. */ + fun mentioned(content: String): List = + segment(content) + .filterIsInstance() + .map { it.pubKey } + .distinct() + + private const val NOSTR_PREFIX = "nostr:" + + /** + * `nostr:` is required, unlike NIP-19's looser scan. + * + * A bare `npub1…` in the middle of a sentence is as likely to be someone + * quoting a key as mentioning a person, and turning it into a name changes + * what they wrote. The bech32 alphabet excludes `1`, `b`, `i` and `o`, + * which is what keeps the match from running past the entity into ordinary + * words. + */ + private val MENTION = Regex("nostr:(npub1|nprofile1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt new file mode 100644 index 0000000000..ce25ee73f8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDeviceTip +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentException +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentSeal +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnGroupDocument +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnLastResortKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnMetaDocument +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipEntry +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipInventory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Moving one account's cordn state from the phone it is on to a new one. + * + * ## Why this is safe when continuous sync is not + * + * `multi-device.md` §10 leaves one case unresolved: two devices of one + * identity committing inside a single delivery round-trip both reach epoch N+1 + * with different states, and §15 concedes that *equal-epoch MLS states have no + * merge function*. That race needs two live writers. A handoff has one — the + * old phone writes a snapshot and then stops (see [CordnMigrationSnapshot] and + * the handoff lock) — so the race is out of reach by construction rather than + * by mitigation. + * + * Everything here is therefore §9 seeding plus the §6/§7 transport. There are + * no `prev` chains (§8.5), no sibling-Commit convergence (§10) and no + * publish-on-every-Commit (§10.5): each of those exists to keep two *live* + * devices in step. + * + * ## The honest cost + * + * The sealed documents leave the device. They are NIP-44 v2 ciphertext under a + * DEK reachable only through a seal to the owner's own `npub`, so a storage + * server learns nothing but size and timing — but the group state, including + * leaf private keys, is on someone else's disk until the blobs are deleted. + * That is the trade the tip transport makes, and a UI should say so rather + * than let a user discover it. + */ +class CordnMigration( + private val client: INostrClient, + private val signer: NostrSigner, + private val blobs: CordnBlobStore, +) { + /** + * Seals, uploads and advertises [snapshot]; returns the code to scan. + * + * The ephemeral keypair and the `d` value are both minted here and both + * random. §6 forbids deriving the signing key from the owner identity: a + * public derivation would let anyone compute the tip's author from an + * `npub` and go looking for it, which is the linkage the opaque tip exists + * to prevent. + * + * @param relays where to publish the tip. + * @throws CordnMigrationException when nothing could be stored or published. + */ + suspend fun publish( + snapshot: CordnMigrationSnapshot, + relays: Set, + ): CordnHandoffCode { + require(relays.isNotEmpty()) { "a migration needs at least one relay to publish the tip on" } + + val dek = CordnDocumentSeal.newKey() + val issuedAt = TimeUtils.now() * MILLIS_PER_SECOND + + val hosts = mutableSetOf() + + val groupEntries = + snapshot.groups.map { group -> + val document = + CordnGroupDocument( + gid = group.gid, + coordinator = group.coordinatorPubKey, + clientState = group.clientStateBase64, + cursor = group.cursor, + issuedAt = issuedAt, + roomState = group.roomStateBase64, + echoState = group.echoStateBase64, + joinedViaRequest = group.joinedViaRequest, + messages = group.messages.takeIf { it.isNotEmpty() }, + coordinatorRelays = group.coordinatorRelays, + ) + val blob = CordnDocumentSeal.seal(document, dek) + val servers = blobs.put(blob) + if (servers.isEmpty()) { + throw CordnMigrationException("no server accepted the document for group ${group.gid}") + } + hosts += servers + CordnTipEntry(address = CordnDocumentSeal.address(blob), gid = group.gid) + } + + val metaBlob = + CordnDocumentSeal.seal( + CordnMetaDocument( + lastResortKeyPackage = snapshot.lastResortKeyPackage, + keyPackages = snapshot.keyPackages, + issuedAt = issuedAt, + ), + dek, + ) + val metaServers = blobs.put(metaBlob) + if (metaServers.isEmpty()) throw CordnMigrationException("no server accepted the meta document") + hosts += metaServers + + val inventory = + CordnTipInventory( + groups = groupEntries, + meta = CordnDocumentSeal.address(metaBlob), + dekPrivateKey = dek.privKey!!.toHexString(), + servers = hosts.toList(), + ) + + val ephemeral = KeyPair() + val dTag = RandomInstance.randomChars(D_TAG_LENGTH) + + val inner = signer.signInner(inventory) + val sealedInner = signer.nip44Encrypt(JacksonMapper.toJson(inner), signer.pubKey) + + val outer = + NostrSignerInternal(ephemeral).sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.OUTER_KIND, + tags = arrayOf(arrayOf(CordnDeviceTip.TAG_D, dTag)), + content = sealedInner, + ) + + if (!client.publishAndConfirm(outer, relays)) { + throw CordnMigrationException("no relay accepted the tip") + } + + return CordnHandoffCode( + ephemeralPubKey = ephemeral.pubKey.toHexString(), + dTag = dTag, + relays = relays.map { it.url }, + ) + } + + /** + * Reads back what [code] points at. + * + * The outer event is signed by a key anyone holding the code could hold, so + * it establishes nothing. Confidentiality and authorship both come from the + * seal: the content is NIP-44 to the owner's own pubkey, and only the owner + * can compute that conversation key — so an outsider can neither read the + * tip nor write one the owner will read. + * + * The inner signature check on top of that is defence in depth against a + * writer-side bug rather than an outsider: adopting MLS state whose + * credential names another account would leave the new phone making Commits + * the rest of every group rejects. Each blob's address is checked too, and + * before decryption, because that one IS an outsider's opening — the + * storage server chooses the bytes. + */ + suspend fun fetch(code: CordnHandoffCode): CordnMigrationSnapshot { + val relays = code.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + if (relays.isEmpty()) throw CordnMigrationException("the handoff code names no usable relay") + + val outer = + client.fetchFirst( + filters = + relays.associateWith { + listOf( + Filter( + kinds = listOf(code.kind), + authors = listOf(code.ephemeralPubKey), + tags = mapOf(CordnDeviceTip.TAG_D to listOf(code.dTag)), + limit = 1, + ), + ) + }, + ) ?: throw CordnMigrationException("no relay had a tip for this code") + + val innerJson = + try { + signer.nip44Decrypt(outer.content, signer.pubKey) + } catch (e: Exception) { + throw CordnMigrationException("the tip did not decrypt — is this the same account?") + } + + val inner = + try { + JacksonMapper.fromJson(innerJson) + } catch (e: Exception) { + throw CordnMigrationException("the tip's inner event is not readable: ${e.message}") + } + + // The authenticity root. Without it, anyone holding the code could + // repoint the tip at an inventory of their own choosing. + if (inner.pubKey != signer.pubKey) { + throw CordnMigrationException("the tip was signed by a different account") + } + if (!inner.verify()) { + throw CordnMigrationException("the tip's inner signature does not verify") + } + + val inventory = CordnDeviceTip.parse(inner) + val dek = KeyPair(privKey = inventory.dekPrivateKey.hexToByteArray()) + + val groups = + inventory.groups.map { entry -> + val document = fetchDocument(entry.address, inventory.servers, dek) + if (document !is CordnGroupDocument) { + throw CordnMigrationException("the document for ${entry.gid} is not a group document") + } + if (!document.isReadableHere) { + throw CordnMigrationException( + "group ${entry.gid} was written by a different MLS engine " + + "(${document.clientStateFormat ?: "unmarked"}) and cannot be read here", + ) + } + CordnMigrationGroup( + coordinatorPubKey = document.coordinator, + coordinatorRelays = document.coordinatorRelays, + gid = document.gid, + clientStateBase64 = document.clientState, + cursor = document.cursor, + roomStateBase64 = document.roomState, + echoStateBase64 = document.echoState, + joinedViaRequest = document.joinedViaRequest, + messages = document.messages.orEmpty(), + ) + } + + val meta = + inventory.meta?.let { + fetchDocument(it, inventory.servers, dek) as? CordnMetaDocument + ?: throw CordnMigrationException("the meta address does not hold a meta document") + } + + return CordnMigrationSnapshot( + accountPubKey = signer.pubKey, + groups = groups, + lastResortKeyPackage = meta?.lastResortKeyPackage, + keyPackages = meta?.keyPackages.orEmpty(), + ) + } + + private suspend fun fetchDocument( + address: String, + servers: List, + dek: KeyPair, + ) = run { + val blob = + blobs.get(address, servers) + ?: throw CordnMigrationException("no server served the document at $address") + + // §6: check the address BEFORE decrypting, so a store serving the + // wrong bytes never gets its plaintext in front of the parser. + if (!CordnDocumentSeal.verifyAddress(blob, address)) { + throw CordnMigrationException("the blob at $address does not hash to its address") + } + + try { + CordnDocumentSeal.open(blob, dek) + } catch (e: CordnDocumentException) { + throw CordnMigrationException("the document at $address is unreadable: ${e.message}") + } + } + + private suspend fun NostrSigner.signInner(inventory: CordnTipInventory): Event = + sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.INNER_KIND, + tags = CordnDeviceTip.tags(inventory), + content = "", + ) + + companion object { + private const val D_TAG_LENGTH = 16 + private const val MILLIS_PER_SECOND = 1000L + } +} + +/** A migration that could not be completed, with the step that stopped it. */ +class CordnMigrationException( + message: String, +) : Exception(message) + +/** + * Everything a replacement device needs, read from disk rather than memory. + * + * Reading from the stores is deliberate and copied from `exportArchive`: a + * coordinator whose session failed to open today is still migrated, because a + * handoff that silently omitted the groups the app could not reach would be + * wrong precisely when it matters. + */ +data class CordnMigrationSnapshot( + val accountPubKey: HexKey, + val groups: List, + val lastResortKeyPackage: CordnLastResortKeyPackage? = null, + val keyPackages: List = emptyList(), +) + +/** One group in a migration. All blobs are base64 of their on-disk encoding. */ +data class CordnMigrationGroup( + val coordinatorPubKey: HexKey, + val coordinatorRelays: List, + val gid: String, + val clientStateBase64: String, + val cursor: Long, + val roomStateBase64: String? = null, + val echoStateBase64: String? = null, + val joinedViaRequest: Boolean = false, + /** The conversation, as `CordnDeliveredMessageCodec` entries, oldest first. */ + val messages: List = emptyList(), +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoop.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoop.kt new file mode 100644 index 0000000000..29c7313912 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoop.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select + +/** + * The three things a sync loop needs from a group manager. + * + * [CordnGroupManager] implements it and is the only production implementation; + * the interface exists so the loop can be tested for what it actually does. + * Everything interesting about it — not spinning on an empty account, not + * dying on a coordinator outage, resetting its backoff, re-subscribing when a + * group is joined — is a reaction to a coordinator that is slow, failing or + * changing underfoot. Driving those through a real transport would mean + * provoking a network failure on a schedule, and the timing test that resulted + * would be the flakiest thing in the suite. + */ +interface CordnSyncSource { + /** The groups to sync. The loop re-subscribes when this changes. */ + val gids: StateFlow> + + suspend fun catchUp(onDelivery: (CordnGroupManager.Delivery) -> Unit): Int + + /** Suspends until the coordinator closes the stream. */ + suspend fun subscribe( + timeoutMs: Long, + onDelivery: (CordnGroupManager.Delivery) -> Unit, + ) +} + +/** + * Keeps one coordinator's groups up to date for as long as it runs. + * + * The shape is `spec/02.md`'s: drain history with `catch_up`, then hold a live + * `subscribe`, and when that stream closes, do it again. The cursor makes the + * seam safe — a subscription resumes from where the catch-up stopped, so the + * two phases cannot leave a hole between them. + * + * ## What this class is actually for + * + * `catchUp` and `subscribe` are one call each; wrapping them would be pointless + * if the happy path were the whole story. It is not. Three things make an + * unattended loop different from a call: + * + * - **A failure must not end the loop.** A coordinator that is down for a + * minute is ordinary. A loop that propagates that exception stops syncing for + * the rest of the session and looks, from the UI, exactly like a quiet group. + * So every attempt is caught, recorded on [CoordinatorHealth], and retried + * with a backoff that **resets on success** — without the reset, one bad + * stretch leaves the loop at its maximum delay forever. + * - **An account with no groups must not spin.** Both calls return immediately + * when the manager holds nothing, so the obvious `while (true)` burns a core + * on a brand-new account. This one parks on [CordnGroupManager.gids] until + * there is something to sync. + * - **A group joined mid-subscription must not wait.** The subscription is + * opened for a fixed set of `gid`s, so a group joined a moment later is not + * in it. Rather than leave the user staring at an empty room until the + * stream times out, a change to the group set cancels the subscription and + * re-opens it. + * + * Nothing here is Marmot's sync. Marmot subscribes to relays by filter and lets + * the relay decide what to send; cordn asks one coordinator for one group's + * stream from one cursor. They are different enough that a shared loop would be + * a conditional, not an abstraction. + */ +class CordnSyncLoop( + private val source: CordnSyncSource, + /** + * Where deliveries go. + * + * A callback rather than a `SharedFlow`, deliberately: a flow with no + * replay drops what it emits when nothing is collecting, and "the app was + * backgrounded so those messages are gone" is not a thing a chat client may + * do. The caller decides what durable place these land in. + */ + private val onDelivery: (CordnGroupManager.Delivery) -> Unit, + private val subscribeTimeoutMs: Long = DEFAULT_SUBSCRIBE_TIMEOUT_MS, + private val minBackoffMs: Long = DEFAULT_MIN_BACKOFF_MS, + private val maxBackoffMs: Long = DEFAULT_MAX_BACKOFF_MS, +) { + private var job: Job? = null + + private val _state = MutableStateFlow(State.Stopped) + + /** What the loop is doing, for the UI to show without inventing it. */ + val state: StateFlow = _state.asStateFlow() + + sealed interface State { + data object Stopped : State + + /** Nothing to sync: this account holds no groups on this coordinator. */ + data object NoGroups : State + + data object CatchingUp : State + + /** A subscription is open. */ + data object Live : State + + /** + * The last attempt failed and the next is [inMs] away. + * + * Carries the reason because the alternative is a UI that can only say + * "not syncing", which is the same thing it says when the loop is fine + * and the group is quiet. + */ + data class Retrying( + val attempt: Int, + val inMs: Long, + val reason: String?, + ) : State + } + + /** + * Starts the loop in [scope]; a second call while it runs does nothing. + * + * Two loops on one session would both `catch_up` the same groups and both + * feed [onDelivery], so every message would surface twice. + */ + fun start(scope: CoroutineScope) { + if (job?.isActive == true) return + job = scope.launch { run() } + } + + /** + * Stops the loop and waits for it to actually be stopped. Safe to call + * when it is not running. + * + * `cancel()` alone returns while the loop's `finally` blocks are still + * running, and one of those persists the group state a subscription + * ingested. `CordnRuntime.importArchive` stops the loops, deletes the + * account's directory and restores from the archive — so a persist that + * landed a moment late wrote a group back onto disk after the delete, and + * the restore then read it in again. That is the merge importArchive + * exists to prevent. + * + * Nothing was noticed while the persist silently failed on cancellation; + * making it survive cancellation is what made the missing join matter. + */ + suspend fun stop() { + job?.cancelAndJoin() + job = null + _state.value = State.Stopped + } + + private suspend fun run() = + coroutineScope { + var attempt = 0 + while (isActive) { + try { + val gids = source.gids.value + if (gids.isEmpty()) { + // Park rather than spin: both calls below are no-ops + // with no groups, so looping here would be a busy wait. + _state.value = State.NoGroups + source.gids.first { it.isNotEmpty() } + continue + } + + _state.value = State.CatchingUp + source.catchUp(onDelivery) + + _state.value = State.Live + subscribeUntilGroupsChange(gids) + + // Any completed pass is a working coordinator, including a + // subscription that simply timed out. + attempt = 0 + } catch (e: CancellationException) { + // Only OUR cancellation may end the loop. A coordinator + // that does not answer inside the RPC budget surfaces as + // `TimeoutCancellationException`, which IS a + // `CancellationException` -- rethrowing it ended the loop + // for the rest of the session, so one 20-second hiccup + // turned into permanent silence with the UI still saying + // "catching up". Measured on device: the first + // `msg_fetch_many` timeout stopped cordn sync outright. + if (!isActive) throw e + + attempt++ + val wait = backoffFor(attempt) + _state.value = State.Retrying(attempt, wait, e.message) + delay(wait) + } catch (e: Exception) { + attempt++ + val wait = backoffFor(attempt) + _state.value = State.Retrying(attempt, wait, e.message) + delay(wait) + } + } + } + + /** + * Holds a subscription until it closes or the group set changes. + * + * The watcher is what makes a freshly joined group live immediately instead + * of at the next timeout. + */ + private suspend fun subscribeUntilGroupsChange(opened: Set) = + coroutineScope { + val subscription = + async { + // `msg_sub_many` spends a total-time budget and throws when + // it runs out. That is the stream ending on schedule, not + // an outage -- the loop's job is to open the next one -- so + // it must not reach the backoff, and it must not reach the + // cancellation clause above either. + try { + source.subscribe(subscribeTimeoutMs, onDelivery) + } catch (e: TimeoutCancellationException) { + // budget spent; fall through and re-subscribe + } + } + val changed = async { source.gids.first { it != opened } } + + select { + subscription.onAwait { changed.cancel() } + changed.onAwait { subscription.cancel() } + } + } + + /** Exponential, capped, and reset by the caller on any success. */ + private fun backoffFor(attempt: Int): Long { + var wait = minBackoffMs + repeat(attempt - 1) { + if (wait >= maxBackoffMs) return maxBackoffMs + wait *= 2 + } + return wait.coerceAtMost(maxBackoffMs) + } + + companion object { + /** + * How long one subscription stays open before the loop re-opens it. + * + * A ceiling on how long a silently dead stream goes unnoticed, not a + * polling interval — the coordinator pushes within it. + */ + const val DEFAULT_SUBSCRIBE_TIMEOUT_MS = 60_000L + + const val DEFAULT_MIN_BACKOFF_MS = 1_000L + + /** + * Long enough not to hammer a coordinator that is down, short enough + * that a user who reopens the app is not waiting on a dead timer. + */ + const val DEFAULT_MAX_BACKOFF_MS = 60_000L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipher.kt new file mode 100644 index 0000000000..d79fa347aa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipher.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A [CordnBlobCipher] under a key the caller supplies: ChaCha20-Poly1305 with + * a fresh random nonce per blob, framed `nonce:12 || ciphertext || tag:16`. + * + * For every platform that has no OS-held key store to hide the key in — the + * desktop app and `amy`. It is the honest half of "encrypted at rest": the + * cipher is real, and where the key lives decides what that buys. Android's + * [CordnBlobCipher] keeps its key in hardware, so a blob read off the device + * is useless; a caller that keeps the key in a file beside the blobs is + * protecting against a stolen backup, not against a process running as the + * same user. Say which one you are doing where the key is created, not here. + * + * ## The nonce is random, not a counter + * + * One key covers every blob this cipher writes, and the stores rewrite the + * same group's state on every epoch change, so a counter would have to be + * persisted and survive a crash to stay unique. A 96-bit random nonce does + * not: at the volumes a client writes (thousands of blobs, not billions) the + * collision probability is negligible, and a repeat would be a confidentiality + * break rather than a corrupted file. Nonce reuse under ChaCha20-Poly1305 + * leaks the XOR of two plaintexts and the Poly1305 key — for an + * `MlsGroupState` that is epoch secrets, so this is the one parameter here + * worth being careful about. + * + * No associated data: the frame has no header worth binding, and the blob's + * path is not authenticated on purpose. The stores move a file into place + * atomically and a blob that lands at the wrong path fails to parse as the + * thing that path expects, which is where that belongs. + * + * Stateless, so safe to call from several coroutines at once. + */ +class KeyedCordnBlobCipher( + private val key: ByteArray, +) : CordnBlobCipher { + init { + require(key.size == KEY_LENGTH) { "a cordn blob key is $KEY_LENGTH bytes, got ${key.size}" } + } + + override fun encrypt(bytes: ByteArray): ByteArray { + val nonce = RandomInstance.bytes(NONCE_LENGTH) + return nonce + ChaCha20Poly1305.encrypt(bytes, EMPTY, nonce, key) + } + + override fun decrypt(bytes: ByteArray): ByteArray { + require(bytes.size > NONCE_LENGTH) { "not a cordn blob: ${bytes.size} bytes" } + return ChaCha20Poly1305.decrypt( + bytes.copyOfRange(NONCE_LENGTH, bytes.size), + EMPTY, + bytes.copyOfRange(0, NONCE_LENGTH), + key, + ) + } + + companion object { + const val KEY_LENGTH = 32 + const val NONCE_LENGTH = 12 + + private val EMPTY = ByteArray(0) + + /** A new key, for a caller about to persist one. */ + fun newKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPool.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPool.kt new file mode 100644 index 0000000000..83f7010d30 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPool.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.transport.CvmRelayPool +import com.vitorpamplona.quartz.contextvm.transport.CvmSubscription +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A Nostr relay client, as the [CvmRelayPool] ContextVM expects. + * + * The adapter between the two halves: [INostrClient] is what every Amethyst + * front end already runs, and [CvmRelayPool] is the two operations ContextVM + * needs from one. Nothing here is platform-specific, so Android, the desktop + * app and `amy` share it. + * + * [relays] is the coordinator's own relay list, not the account's. A + * coordinator has no address beyond its pubkey (`spec/00.md` §8.5), so the + * only place its traffic can be found is the relays whoever published the + * coordinator said to use. Sending it to the account's outbox relays instead + * would both miss the coordinator and tell relays that have no business + * knowing that this account talks to it. + */ +class NostrClientCvmRelayPool( + private val client: INostrClient, + private val relays: Set, +) : CvmRelayPool { + override fun subscribe( + pubKey: HexKey, + kinds: IntArray, + onEvent: (Event) -> Unit, + ): CvmSubscription { + // Random rather than a counter, which is how the rest of the + // relay client names subscriptions (`newSubId`). A shared `nextId++` + // is not atomic, and `CvmTransport` opens one subscription per request + // and closes it in the same call — the sync loop and the UI issue + // requests concurrently, so two coroutines reading the same value is + // ordinary. Two subscriptions sharing an id means the first `close()` + // unsubscribes both, and the other request waits out its full + // 20-second timeout for a response the relay stopped sending. + val subId = "cordn-${pubKey.take(8)}-${RandomInstance.randomChars(8)}" + val filter = Filter(kinds = kinds.toList(), tags = mapOf("p" to listOf(pubKey))) + + client.subscribe( + subId = subId, + filters = relays.associateWith { listOf(filter) }, + listener = + object : SubscriptionListener { + override suspend fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Stored-vs-live is not a distinction kind 25910 has: + // it is ephemeral, so anything that arrives at all is + // live by definition. Filtering on isLive here would + // drop responses on a relay that replays its buffer. + onEvent(event) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + Log.d(TAG) { "coordinator subscription closed by $relay: $message" } + } + }, + ) + + return object : CvmSubscription { + override fun close() { + client.unsubscribe(subId) + } + } + } + + override suspend fun publish(event: Event) { + client.publish(event, relays) + } + + companion object { + private const val TAG = "CordnRelayPool" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt index a5edbd0bd2..d90de943c3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b2acf3c3fe..29a82221cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -51,6 +51,13 @@ import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotGroupSnapshot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager @@ -59,18 +66,15 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult -import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishGate import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligation import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.marmot.protocolCore.PublishOutcome +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -324,9 +328,11 @@ class MarmotManager( ): ByteArray? = try { val preCommitKey = - MlsGroup - .restore(obligation.priorState) - .exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MarmotGroupPolicy.commitExporter.let { exporter -> + MlsGroup + .restore(obligation.priorState, MarmotGroupPolicy) + .exporterSecret(exporter.label, exporter.context, exporter.length) + } GroupEventEncryption.decrypt(event.content, preCommitKey) } catch (e: Exception) { Log.w( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 67d5dc3186..d4aaf2d354 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -139,6 +139,8 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -3780,6 +3782,8 @@ open class EventCache : // ============================================================ is AcceptedBadgeSetEvent, is AdvertisedRelayListEvent, + is CvmServerAnnouncementEvent, + is CvmToolsListEvent, is AppDefinitionEvent, is AppRecommendationEvent, is AppSpecificDataEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt index 3e5a581a72..ab864a0adf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt @@ -49,6 +49,17 @@ enum class ChatFeedType( /** Concord encrypted communities (gift-wrapped plane streams). */ CONCORD("concord"), + /** + * cordn — MLS group chat coordinated by an MCP server rather than a relay. + * + * Listed beside Marmot and sharing nothing with it: the two are separate + * protocols that happen to both be MLS, and the inbox is one of the few + * places they meet at all (see §3.1 of + * `amethyst/plans/2026-09-19-cordn-ui.md`). Toggling this off hides cordn + * rows and stops its sync loops; it does not touch Marmot. + */ + CORDN("cordn"), + /** Geohash location channels (kind 20000). */ GEOHASH("geohash"), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroom.kt new file mode 100644 index 0000000000..3a093273b8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroom.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NotesGatherer +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * One cordn group, as a screen sees it. + * + * ## Keyed by envelope id, never by cursor + * + * `spec/02.md` §7 is explicit that the envelope id is the message identity and + * the cursor is a delivery primitive. They are easy to confuse because both are + * unique within a group and both arrive together, and confusing them is not a + * cosmetic bug: a cursor is coordinator-local, so the same message re-delivered + * after a re-sync carries a different one. Keying on it would duplicate every + * message the client ever re-fetches, and dedupe would silently stop working + * exactly when a group is recovering. + * + * ## Ordered by the cursor, not by the sender's clock + * + * `created_at` is a claim the sender makes about their own device. Sorting on + * it is the classic Nostr failure: one wrong clock scatters that member's + * messages through the room, and a sender who wants to can pin a message to + * the top of the history permanently by backdating it. The cursor has neither + * problem — the coordinator assigns it, so it is the one order every member + * already agrees on, and no sender can choose their own. + * + * This does trust the coordinator to order the stream, which it is trusted for + * anyway: `spec/00.md` §4 makes it the sole authority for a group's stream. It + * is **not** trusted for content, and is not being trusted for any more here — + * MLS authenticates what a message says and who sent it, the coordinator says + * only when it arrived. `created_at` is still shown; it is just not what the + * list is built from. + * + * ## Not a Marmot room with the names changed + * + * `MarmotGroupChatroom` holds `Note`s, carries Marmot's GroupContext state + * (lifecycle, outbound gates, legacy-profile flags, encrypted-media policy) and + * is fed from `LocalCache`. None of that exists here: cordn has no lifecycle + * extension, no outbound gate, no profile split, and its messages arrive as + * envelopes from a coordinator rather than events from a relay. The two look + * alike only in that both are group chats — see §3.1 of + * `amethyst/plans/2026-09-19-cordn-ui.md`, which the isolation guards enforce. + */ +@Stable +class CordnGroupChatroom( + val gid: String, + val coordinatorPubKey: HexKey, + /** Whose room this is, so [unreadCount] can tell news from an echo. */ + val accountPubKey: HexKey = "", + /** + * Told when [newest] becomes a different message, and only then. + * + * The inbox sorts rooms on this room's newest message, so it has to rebuild + * when that changes -- and the change can come from anywhere: a delivery + * filed through [CordnGroupList], an optimistic send that calls [add] on + * this room directly, or a restore. Hanging the signal off + * `CordnGroupList.add` caught only the first of those, which left the + * commonest case -- you send a message -- not re-sorting at all. + * + * Not called for a reaction, an edit or a re-delivered echo: those leave the + * newest message as it was, and rebuilding the whole inbox for one would be + * a rebuild per annotation for no visible change. + */ + private val onNewestChanged: () -> Unit = {}, +) : NotesGatherer { + private val byId = LinkedHashMap() + + private val _messages = MutableStateFlow>(emptyList()) + + /** Renderable messages, oldest first, annotations already removed. */ + val messages: StateFlow> = _messages.asStateFlow() + + private val _annotations = MutableStateFlow(CordnAnnotationIndex.of(emptyList())) + + /** Reactions, edits, deletions and pins folded onto their targets. */ + val annotations: StateFlow = _annotations.asStateFlow() + + private val _name = MutableStateFlow(null) + private val _description = MutableStateFlow(null) + private val _adminPubkeys = MutableStateFlow>(emptyList()) + private val _members = MutableStateFlow>(emptyList()) + private val _epoch = MutableStateFlow(0L) + + val name: StateFlow = _name.asStateFlow() + val description: StateFlow = _description.asStateFlow() + + /** Admins, or empty for egalitarian — `spec/01.md` §5.3 makes that permanent. */ + val adminPubkeys: StateFlow> = _adminPubkeys.asStateFlow() + + /** Member account pubkeys, from each leaf's cordn credential (`spec/01.md`). */ + val members: StateFlow> = _members.asStateFlow() + val epoch: StateFlow = _epoch.asStateFlow() + + /** + * Re-reads the room's group state off [group]. + * + * Everything above is derived, never stored: the group's name, its admins + * and its membership live in MLS state — the metadata extension + * (`spec/01.md`) and the ratchet tree — and the only honest way to show + * them is to read them back out after every change. A Commit can rename a + * group or remove a member, so a cached copy is a copy that goes stale in + * exactly the cases that matter. + * + * Deliberately not `MlsGroup.memberIdentityHex`: that hex-encodes the + * credential bytes, and a cordn credential already IS hex, so it would + * report 128 characters of hex-of-hex. [CordnCredential.memberIdentities] + * is the cordn-aware reader. + */ + fun refreshFrom(group: MlsGroup) { + val metadata = CordnGroupMetadata.fromExtensions(group.extensions) + _name.value = metadata?.name + _description.value = metadata?.description + _adminPubkeys.value = metadata?.adminPubkeys.orEmpty() + // Leaf order, which is stable across reads, so a member list does not + // reshuffle itself under the user on an unrelated delivery. + _members.value = CordnCredential.memberIdentities(group).toList() + _epoch.value = group.epoch + } + + /** + * The newest message, for an inbox row. + * + * Annotations are excluded, so a room whose last traffic was a reaction + * still previews the message that was reacted to rather than a "+" nobody + * can read. + */ + private val _newest = MutableStateFlow(null) + val newest: StateFlow = _newest.asStateFlow() + + /** Text typed here and not sent. Restored from the store when the room opens. */ + val draft = MutableStateFlow("") + + private val _lastReadCursor = MutableStateFlow(0L) + + /** The newest cursor this account has seen. See [unreadCount]. */ + val lastReadCursor: StateFlow = _lastReadCursor.asStateFlow() + + private val _unreadCount = MutableStateFlow(0) + + /** + * How many messages have arrived past [lastReadCursor]. + * + * Counted on the cursor rather than on `created_at` for the reason the + * room is ordered on it: a sender's clock is a claim, and one wrong clock + * would otherwise make a room permanently unread or silently swallow new + * messages. Annotations are excluded — a reaction to something already + * read is not an unread message — and so is this account's own traffic, + * because arriving back as an echo does not make it news. + */ + val unreadCount: StateFlow = _unreadCount.asStateFlow() + + /** Marks everything currently in the room as read. */ + fun markRead() { + _lastReadCursor.value = maxOf(_lastReadCursor.value, byId.values.maxOfOrNull { it.cursor } ?: 0L) + recountUnread() + } + + /** + * Seeds the inbox's preview line without loading the conversation. + * + * The inbox needs a last message for every room before any room is opened, + * and reading every group's whole history at login to get one would be + * paying for the rooms nobody visits. The store keeps a one-entry summary + * for exactly this. + * + * Ignored once the room holds anything, so a summary read cannot overwrite + * a newer message that live delivery already put here. + */ + fun restorePreview(newest: CordnDeliveredMessage) { + if (byId.isEmpty()) setNewest(newest) + } + + /** Restores what the store remembered for this room. */ + fun restoreState( + draft: String, + lastReadCursor: Long, + ) { + this.draft.value = draft + _lastReadCursor.value = lastReadCursor + recountUnread() + } + + private fun recountUnread() { + val read = _lastReadCursor.value + _unreadCount.value = + byId.values.count { message -> + message.cursor > read && + message.envelope.pubKey != accountPubKey && + message.envelope.kind in CONVERSATIONAL_KINDS + } + } + + /** + * Adds [delivered], returning false when this room already had it. + * + * Idempotent because a re-sync re-delivers: `catch_up` after a restart + * walks the stream from a persisted cursor and hands back messages the + * room may already hold. + */ + fun add(delivered: CordnDeliveredMessage): Boolean { + if (byId.containsKey(delivered.envelope.id)) return false + byId[delivered.envelope.id] = delivered + recompute() + return true + } + + /** Adds several, recomputing once. Returns how many were new. */ + fun addAll(delivered: Collection): Int { + val added = delivered.count { byId.putIfAbsentCompat(it.envelope.id, it) } + if (added > 0) recompute() + return added + } + + /** Every message this room holds, annotations included. For the fold. */ + fun all(): List = byId.values.toList() + + private fun recompute() { + recountUnread() + + val everything = byId.values.toList() + _annotations.value = CordnAnnotationIndex.of(everything) + + val visible = + everything + .filterNot { CordnMessageKinds.isAnnotation(it.envelope.kind) } + .sortedWith(ORDER) + + _messages.value = visible + setNewest(visible.lastOrNull()) + } + + /** + * Publishes a new preview, and says so exactly once per real change. + * + * Keyed on the envelope id rather than the message: [recompute] runs for + * every arrival, annotations included, and re-publishing an identical + * newest would tell the inbox to re-sort over and over for nothing. + */ + private fun setNewest(value: CordnDeliveredMessage?) { + if (_newest.value?.envelope?.id == value?.envelope?.id) return + + _newest.value = value + onNewestChanged() + } + + private var cachedRow: Note? = null + + /** + * The `Note` that carries this room into the unified Messages inbox. + * + * The inbox is a list of `Note`s and cordn messages are not Notes — they + * are MLS envelopes, and the whole point of `spec/02.md` is that they never + * touch a relay. So rather than fabricate a Note per message and keep two + * copies of every conversation, one Note stands for the whole room: the + * inbox finds the room through [Note.inGatherers] and reads the name and + * preview from the room itself, which is the live data. + * + * Marmot uses the same trick, but only for a group with no messages yet + * (`MarmotGroupChatroom.placeholderNote`). For cordn it is the permanent + * mechanism, because there is no second representation to fall back to — + * and that is the point: nothing here puts a cordn message into + * `LocalCache`, where it would become searchable, notifiable, and + * indistinguishable from an event that was actually published somewhere. + */ + fun inboxRow(): Note = + cachedRow ?: CordnInboxRowNote(this).also { + it.addGatherer(this) + cachedRow = it + } + + /** + * Nothing to do: the inbox row is not one of this room's messages, and + * this room's messages are not Notes. Present because the inbox reaches a + * room through [NotesGatherer]. + */ + override fun removeNote(note: Note) = Unit + + companion object { + /** Distinct per (coordinator, gid), because a `gid` alone is not unique (§4). */ + fun rowIdHex( + coordinatorPubKey: HexKey, + gid: String, + ): String = "cordn-$coordinatorPubKey-$gid" + + /** + * What counts as an unread message. + * + * Only what someone said. A reaction, an edit, a deletion or a pin + * landing on something already read is not a new message, and badging + * the room for one would train people to ignore the badge. + */ + val CONVERSATIONAL_KINDS = setOf(CordnMessageKinds.TEXT, CordnMessageKinds.THREAD_REPLY) + + /** + * The coordinator's order, with the sender's clock only as a tiebreak. + * + * See the class KDoc. The tiebreak should never fire — a coordinator + * assigns each message its own cursor — but leaving the comparator + * total costs nothing and keeps two clients from disagreeing if one + * ever does. + */ + val ORDER: Comparator = + compareBy { it.cursor }.thenBy { it.envelope.createdAt } + } +} + +/** `putIfAbsent` returning whether it inserted, on every KMP target. */ +private fun MutableMap.putIfAbsentCompat( + key: K, + value: V, +): Boolean { + if (containsKey(key)) return false + put(key, value) + return true +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupList.kt new file mode 100644 index 0000000000..f7f284a7b4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupList.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * One account's cordn rooms, across every coordinator it talks to. + * + * ## Keyed by (coordinator, gid), never by gid alone + * + * A `gid` is unique only within one coordinator (`spec/00.md` §4) — two of them + * can both serve `gid = "abc"` as unrelated groups with different members and + * different ratchet trees. A map keyed by `gid` would let one answer for the + * other, and from the user's side that reads as a room whose history changes + * depending on which coordinator last synced. The same rule keys + * `CordnCoordinatorRegistry` and the on-disk stores; this is the third place + * it shows up, and it is the same rule each time. + */ +@Stable +class CordnGroupList( + /** Whose rooms these are; handed to every room for its unread count. */ + private val accountPubKey: HexKey = "", +) { + /** A room's identity: the coordinator that serves it, plus its `gid`. */ + data class RoomKey( + val coordinatorPubKey: HexKey, + val gid: String, + ) + + private val rooms = LinkedHashMap() + + private val _all = MutableStateFlow>(emptyList()) + + /** Every room, for the inbox and the group list. */ + val all: StateFlow> = _all.asStateFlow() + + private val _revision = MutableStateFlow(0L) + + /** + * `update` rather than `value++`: deliveries arrive off the sync loop while + * a send can be filing one from the UI, and a read-modify-write would drop + * bumps under exactly the interleaving the inbox needs to notice. + */ + private fun bumpRevision() = _revision.update { it + 1 } + + /** + * Bumps on every change the inbox cares about: a room added or dropped, + * **and** every message filed into one. + * + * [all] cannot serve that second purpose, which is the trap this exists to + * close. It re-emits only when the room *set* changes -- a message for a + * room that already exists returns early from [getOrCreate] and never + * reassigns it -- so a feed rebuilt off `all` alone rebuilds when a room is + * joined and never again. The rows then keep whatever order that first + * build gave them no matter what arrives, which is invisible until the + * inbox is sorted by recency. + * + * The Concord control plane exposes its own `revision` for the same reason; + * both are meant to be `sample()`d, since a re-sync files a burst. + */ + val revision: StateFlow = _revision.asStateFlow() + + fun getOrCreate( + coordinatorPubKey: HexKey, + gid: String, + ): CordnGroupChatroom { + val key = RoomKey(coordinatorPubKey, gid) + rooms[key]?.let { return it } + val room = + CordnGroupChatroom( + gid = gid, + coordinatorPubKey = coordinatorPubKey, + accountPubKey = accountPubKey, + // The room is the only place that sees every way its newest + // message can change, so it is the room that reports one. + onNewestChanged = { bumpRevision() }, + ) + rooms[key] = room + _all.value = rooms.values.toList() + bumpRevision() + return room + } + + fun get( + coordinatorPubKey: HexKey, + gid: String, + ): CordnGroupChatroom? = rooms[RoomKey(coordinatorPubKey, gid)] + + /** + * Files [delivered] into its room, creating it if needed. + * + * Returns false when the room already held it — a re-sync re-delivers, so + * a caller that counts unread messages must not count this one twice. + */ + fun add( + coordinatorPubKey: HexKey, + gid: String, + delivered: CordnDeliveredMessage, + ): Boolean = getOrCreate(coordinatorPubKey, gid).add(delivered) + + /** Drops a room. The caller decides whether the stored state goes too. */ + fun forget( + coordinatorPubKey: HexKey, + gid: String, + ) { + if (rooms.remove(RoomKey(coordinatorPubKey, gid)) != null) { + _all.value = rooms.values.toList() + bumpRevision() + } + } + + /** Drops every room served by [coordinatorPubKey]. For a purge. */ + fun forgetCoordinator(coordinatorPubKey: HexKey) { + rooms.keys.filter { it.coordinatorPubKey == coordinatorPubKey }.forEach { rooms.remove(it) } + _all.value = rooms.values.toList() + bumpRevision() + } + + fun clear() { + rooms.clear() + _all.value = emptyList() + bumpRevision() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowNote.kt new file mode 100644 index 0000000000..80977855bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowNote.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note + +/** + * The Messages-list row for a cordn room, carrying the one thing a plain [Note] + * cannot work out for itself: when the room last said something. + * + * A cordn row has no event. Its messages are MLS envelopes from a coordinator + * and never enter `LocalCache`, so `Note.createdAt()` — which reads + * `event?.createdAt` — returns null for it. The Messages feed sorts on exactly + * that value and maps null to `0L`, so every cordn room used to sink to the + * bottom of the inbox permanently, tie-broken alphabetically by [idHex] against + * the other cordn rooms. The row itself displayed the right time the whole + * while; only the sort could not see it. + * + * Overriding [createdAt] is how the other event-less inbox rows already solve + * this — see `RelayGroupServerRoomNote` and `ConcordServerRoomNote`, both of + * which mirror their newest message for the same reason. cordn is the one that + * was missing it. + */ +@Stable +class CordnInboxRowNote( + val room: CordnGroupChatroom, +) : Note(CordnGroupChatroom.rowIdHex(room.coordinatorPubKey, room.gid)) { + override fun createdAt(): Long? = + room.newest.value + ?.envelope + ?.createdAt +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt index 14fea0e82d..e2c745f525 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt @@ -68,6 +68,7 @@ enum class NavBarItem { RELAY_GROUPS, CONCORD, MARMOT_GROUPS, + CORDN_GROUPS, GEOHASH_CHATS, FOLLOW_PACKS, LIVE_STREAMS, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index 76cf349bf5..70b8daf092 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -634,6 +634,44 @@ sealed class Route { @Serializable object EditNestsServers : Route() + @Serializable object CordnLink : Route() + + /** + * One cordn room. + * + * Both halves are the address: a `gid` is unique only within one + * coordinator (`spec/00.md` §4), so a route carrying the gid alone would + * open whichever of two same-named groups happened to be found first. + */ + @Serializable data class CordnGroupChat( + val coordinatorPubKey: HexKey, + val gid: String, + ) : Route() + + @Serializable data class CordnGroupInfo( + val coordinatorPubKey: HexKey, + val gid: String, + ) : Route() + + @Serializable object CordnGroupList : Route() + + @Serializable object CordnCreateGroup : Route() + + @Serializable object CordnCreateGroupMembers : Route() + + @Serializable object CordnInvitations : Route() + + @Serializable object CordnCoordinators : Route() + + @Serializable object CordnKeyPackages : Route() + + @Serializable object CordnBackup : Route() + + /** The one cordn entry in settings; everything else hangs off it. */ + @Serializable object CordnHub : Route() + + @Serializable object CordnMigrate : Route() + @Serializable data class AgentConsole( val relayUrl: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CordnGroupDraft.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CordnGroupDraft.kt new file mode 100644 index 0000000000..da1f281e75 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CordnGroupDraft.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A cordn group being filled in, across the screens that fill it in. + * + * The form outgrew one screen. Choosing who is in the group is a search, a + * result list and a roster with a per-person admin toggle -- a screen's worth + * of surface, and the thing every later step depends on, because which + * coordinator can serve the group is decided by which of these people it holds + * a KeyPackage for. + * + * Held in a ViewModel rather than in `remember` because a Compose Navigation + * destination is disposed when you navigate off it: a half-typed name and a + * finished discovery run would both be gone on the way back from picking + * people. Scoped to the Activity by its caller (the same thing the chess lobby + * and board do) so both screens see one draft, keyed per account so switching + * accounts cannot inherit another one's roster. + * + * Deliberately headless and free of protocol: it holds what the user has typed + * and nothing derived from a coordinator. Coverage -- who a coordinator can + * actually reach -- is a live query each screen makes for itself, because + * caching a "cannot be reached" here would outlive the truth of it. + */ +class CordnGroupDraft : ViewModel() { + var name by mutableStateOf("") + var description by mutableStateOf("") + + /** + * Who the group is for, in the order they were added. + * + * Ordered rather than a set so the roster does not reshuffle under the + * user's finger as they build it, and so the invitations go out in the + * order they were asked for. + */ + var roster by mutableStateOf>(emptyList()) + private set + + /** + * Members who may also add and remove, NOT counting the creator. + * + * The creator is added back when the metadata is built: `spec/01.md` §5.3 + * makes a non-empty admin list permanent, so one that left out the person + * creating the group would produce a group nobody present could administer. + */ + var coAdmins by mutableStateOf>(emptySet()) + private set + + /** Empty `admin_pubkeys`, which §5.3 makes egalitarian permanently. */ + var egalitarian by mutableStateOf(false) + + /** The chosen coordinator, or null while the manual fields are in use. */ + var selected by mutableStateOf(null) + + /** + * Whether the user chose the coordinator themselves. + * + * Once they have, the best-covering one stops being offered: a selection + * that moved on its own after the person had made one would be the screen + * overruling them. + */ + var userPicked by mutableStateOf(false) + + var pubKeyInput by mutableStateOf("") + var relaysInput by mutableStateOf("") + + /** + * How the coordinator section was left. + * + * Here rather than in the screen because the screen does not survive a + * trip to a profile, and the coordinator rows are the one place on this + * form you can tap through to one. Coming back to a collapsed picker with + * the discovery thrown away reads as having lost your work, and the + * discovery is a ~30-second round trip to every relay -- the most + * expensive thing on the page to have to do twice. + */ + var coordinatorOpen by mutableStateOf(false) + + /** The last discovery run, kept so returning does not mean running it again. */ + var discovered by mutableStateOf(null) + + var showStale by mutableStateOf(false) + var showAllLive by mutableStateOf(false) + + fun add(pubKey: HexKey) { + if (pubKey !in roster) roster = roster + pubKey + } + + fun remove(pubKey: HexKey) { + roster = roster - pubKey + coAdmins = coAdmins - pubKey + } + + fun toggleAdmin(pubKey: HexKey) { + coAdmins = if (pubKey in coAdmins) coAdmins - pubKey else coAdmins + pubKey + } + + /** + * `admin_pubkeys` for the group about to be created. + * + * Empty in egalitarian mode and the creator plus their choices otherwise -- + * never the choices alone, for the reason on [coAdmins]. + */ + fun adminPubKeys(creator: HexKey): List = if (egalitarian) emptyList() else listOf(creator) + coAdmins.filterNot { it == creator } + + /** Forgets the draft, so the next new group does not start as this one. */ + fun clear() { + name = "" + description = "" + roster = emptyList() + coAdmins = emptySet() + egalitarian = false + selected = null + userPicked = false + pubKeyInput = "" + relaysInput = "" + coordinatorOpen = false + discovered = null + showStale = false + showAllLive = false + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipherTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipherTest.kt new file mode 100644 index 0000000000..cb4449fc72 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipherTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The blob cipher every platform without an OS key store uses. + * + * What is worth asserting is not that ChaCha20-Poly1305 works — quartz tests + * that — but the three things this wrapper decides: that the nonce is fresh + * per blob, that a blob is authenticated rather than merely scrambled, and + * that the plaintext is nowhere in the output. The first matters most: one key + * covers every blob, and a repeated nonce leaks the XOR of two `MlsGroupState` + * serialisations, which is epoch secrets. + */ +class KeyedCordnBlobCipherTest { + private val key = ByteArray(KeyedCordnBlobCipher.KEY_LENGTH) { it.toByte() } + private val cipher = KeyedCordnBlobCipher(key) + private val blob = "ratchet tree and epoch secrets".encodeToByteArray() + + @Test + fun `a blob round-trips`() { + assertContentEquals(blob, cipher.decrypt(cipher.encrypt(blob))) + } + + @Test + fun `an empty blob round-trips`() { + // A group with nothing saved yet writes one, and a frame that is all + // nonce and tag has to survive the trip. + assertContentEquals(ByteArray(0), cipher.decrypt(cipher.encrypt(ByteArray(0)))) + } + + @Test + fun `the same blob encrypted twice never repeats a nonce`() { + val first = cipher.encrypt(blob) + val second = cipher.encrypt(blob) + + val nonceA = first.copyOfRange(0, KeyedCordnBlobCipher.NONCE_LENGTH) + val nonceB = second.copyOfRange(0, KeyedCordnBlobCipher.NONCE_LENGTH) + + assertFalse(nonceA.contentEquals(nonceB), "a repeated nonce leaks the XOR of two group states") + assertFalse(first.contentEquals(second)) + } + + @Test + fun `the plaintext does not appear in the output`() { + val sealed = cipher.encrypt(blob) + + assertFalse(sealed.decodeToString().contains("ratchet tree")) + assertNotEquals(blob.size, sealed.size) + } + + @Test + fun `a flipped byte is refused rather than decrypted`() { + val sealed = cipher.encrypt(blob) + sealed[sealed.size - 1] = (sealed[sealed.size - 1].toInt() xor 0x01).toByte() + + // An MlsGroupState cannot be re-derived from anywhere, so a silently + // corrupted one is worse than a loud failure. + assertFails { cipher.decrypt(sealed) } + } + + @Test + fun `a flipped nonce byte is refused`() { + val sealed = cipher.encrypt(blob) + sealed[0] = (sealed[0].toInt() xor 0x01).toByte() + + assertFails { cipher.decrypt(sealed) } + } + + @Test + fun `another key cannot open it`() { + val sealed = cipher.encrypt(blob) + val other = KeyedCordnBlobCipher(ByteArray(KeyedCordnBlobCipher.KEY_LENGTH) { (it + 1).toByte() }) + + assertFails { other.decrypt(sealed) } + } + + @Test + fun `a truncated blob is refused with a message that says so`() { + // A short blob throws either way: the AEAD underneath rejects a + // ciphertext shorter than its tag, and slicing 12 nonce bytes out of a + // 0-byte array is an illegal range. So the guard buys no new failure — + // what it buys is the reason. Asserting the message is therefore the + // only way to pin it, and the reason is worth pinning: "fromIndex(12) + // > toIndex(0)" from inside a copyOfRange reads as a bug in Amethyst, + // while the size of the file that is not a blob points at the file. + val short = assertFailsWith { cipher.decrypt(ByteArray(KeyedCordnBlobCipher.NONCE_LENGTH)) } + val empty = assertFailsWith { cipher.decrypt(ByteArray(0)) } + + assertTrue(short.message!!.contains("not a cordn blob"), "unhelpful: ${short.message}") + assertTrue(empty.message!!.contains("not a cordn blob"), "unhelpful: ${empty.message}") + } + + @Test + fun `a key of the wrong length is refused at construction`() { + // Failing here rather than at the first encrypt means a misconfigured + // front end cannot get as far as writing blobs under a 16-byte key. + assertFails { KeyedCordnBlobCipher(ByteArray(16)) } + assertFails { KeyedCordnBlobCipher(ByteArray(0)) } + } + + @Test + fun `a fresh key is the right length and not a constant`() { + val a = KeyedCordnBlobCipher.newKey() + val b = KeyedCordnBlobCipher.newKey() + + assertTrue(a.size == KeyedCordnBlobCipher.KEY_LENGTH) + assertFalse(a.contentEquals(b)) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt new file mode 100644 index 0000000000..d48d7eebbc --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.io.File +import java.util.Base64 + +/** + * Reading a handoff snapshot off the cordn stores, and writing one back. + * + * Here rather than in the Android runtime because `amy` needs exactly the same + * two operations, and duplicating them would let the CLI and the app disagree + * about what a migration carries — which the user would discover as a + * conversation that arrived on one path and not the other. + */ +object CordnMigrationStores { + /** + * Collects every group on disk for [configs]. + * + * Reads the stores rather than any in-memory state: a coordinator whose + * session failed to open today is still migrated, because a handoff that + * silently omitted the groups the app could not reach would be wrong + * precisely when it matters. + */ + suspend fun read( + root: File, + accountPubKey: HexKey, + cipher: CordnBlobCipher, + configs: List, + ): CordnMigrationSnapshot { + val groups = mutableListOf() + val keyPackages = mutableListOf() + + configs.forEach { config -> + val dir = CordnStorageLayout.directoryFor(root, accountPubKey, config.pubKey) + val groupStore = FileCordnGroupStore(dir, cipher) + val keyPackageStore = FileCordnKeyPackageStore(dir, cipher) + + groupStore.listGroups().forEach { gid -> + val state = groupStore.loadGroup(gid) ?: return@forEach + groups += + CordnMigrationGroup( + coordinatorPubKey = config.pubKey, + coordinatorRelays = config.relays.map { it.url }, + gid = gid, + clientStateBase64 = state.toBase64(), + cursor = groupStore.loadCursor(gid)?.fetchCursor ?: 0L, + roomStateBase64 = CordnRoomStateCodec.encode(groupStore.loadRoomState(gid)).toBase64(), + echoStateBase64 = EchoStateCodec.encode(groupStore.loadEchoState(gid)).toBase64(), + joinedViaRequest = groupStore.loadJoinedViaRequest(gid), + messages = carriedMessages(groupStore, gid), + ) + } + + keyPackageStore.list().forEach { ref -> + val bundle = keyPackageStore.load(ref) ?: return@forEach + keyPackages += CordnCarriedKeyPackage(config.pubKey, ref, bundle.toBase64()) + } + } + + return CordnMigrationSnapshot(accountPubKey, groups, keyPackages = keyPackages) + } + + /** + * Roughly how much conversation one group contributes to a handoff. + * + * A migration document is sealed and uploaded to blob hosts whose limits we + * do not know, and a handoff that fails because one group is chatty is a + * worse outcome than one that carries a deep but bounded history. Budgeted + * in bytes rather than messages because a single long message can cost as + * much as a hundred short ones. + */ + private const val MESSAGE_BUDGET_BYTES = 512 * 1024 + + /** + * The newest messages that fit the budget, back in oldest-first order. + * + * Newest-first while accumulating: if something has to be left behind it + * should be the oldest part of the conversation, which is the part least + * likely to be missed and the part a reader scrolls to last. + */ + private suspend fun carriedMessages( + store: FileCordnGroupStore, + gid: String, + ): List { + var budget = MESSAGE_BUDGET_BYTES + return store + .loadMessages(gid) + .asReversed() + .map { CordnDeliveredMessageCodec.encode(it) } + .takeWhile { entry -> + budget -= entry.length + budget > 0 + }.asReversed() + } + + /** + * Replaces this device's cordn tree with [snapshot]'s, returning the + * coordinator list to start. + * + * **Replaces, and is not a merge.** Two devices holding one group's state + * and both committing fork the ratchet tree, and MLS does not recover — + * merging would produce that on purpose. The old tree goes first, so a + * `gid` present in both cannot end up half from each. + */ + suspend fun write( + root: File, + accountPubKey: HexKey, + cipher: CordnBlobCipher, + snapshot: CordnMigrationSnapshot, + ): List { + require(snapshot.accountPubKey == accountPubKey) { + "this migration belongs to a different account" + } + + File(root, "cordn/$accountPubKey").deleteRecursively() + + snapshot.groups.forEach { group -> + val store = FileCordnGroupStore(CordnStorageLayout.directoryFor(root, accountPubKey, group.coordinatorPubKey), cipher) + store.saveGroup(group.gid, group.clientStateBase64.fromBase64()) + // Before the cursor, for the same reason the live path writes them in + // that order: a seeding that wrote the cursor and then failed would + // leave a device holding a cursor past a conversation it never + // wrote, with no way to ask for it again. + group.messages.forEach { entry -> + CordnDeliveredMessageCodec.decodeOrNull(entry)?.let { store.appendMessage(group.gid, it) } + } + // Both halves of the cursor: the writer's snapshot was consistent at + // fetchCursor (§4.1), and starting behind it would re-fetch + // messages the state has already advanced past. + store.saveCursor(group.gid, GroupCursor(fetchCursor = group.cursor, lastCursor = group.cursor)) + group.roomStateBase64?.let { store.saveRoomState(group.gid, CordnRoomStateCodec.decode(it.fromBase64())) } + group.echoStateBase64?.let { store.saveEchoState(group.gid, EchoStateCodec.decode(it.fromBase64())) } + if (group.joinedViaRequest) store.saveJoinedViaRequest(group.gid) + } + + snapshot.keyPackages.forEach { keyPackage -> + FileCordnKeyPackageStore(CordnStorageLayout.directoryFor(root, accountPubKey, keyPackage.coordinatorPubKey), cipher) + .save(keyPackage.keyPackageRef, keyPackage.bundle.fromBase64()) + } + + return snapshot.groups + .groupBy { it.coordinatorPubKey } + .mapNotNull { (pubKey, groups) -> + val relays = + groups + .flatMap { it.coordinatorRelays } + .distinct() + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + // A coordinator with no reachable relay cannot be talked to, and + // CoordinatorConfig refuses to be built without one. + if (relays.isEmpty()) null else CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL) + } + } + + private fun ByteArray.toBase64() = Base64.getEncoder().encodeToString(this) + + private fun String.fromBase64(): ByteArray = Base64.getDecoder().decode(this) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileBackedCordnScopeFactory.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileBackedCordnScopeFactory.kt new file mode 100644 index 0000000000..6a4dec3613 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileBackedCordnScopeFactory.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import java.io.File + +/** + * The production [CordnCoordinatorScopeFactory]: encrypted files on disk, plus + * whatever transport [links] opens. + * + * Splitting it this way is what lets the storage half be tested. The transport + * half needs a relay and an account; the storage half needs a directory, and + * everything that can go wrong with it — the wrong account reading another's + * groups, two coordinators colliding on one `gid`, a `gid` that walks out of + * the directory — goes wrong silently and is worth a test. See + * `FileCordnStoresTest`. + * + * @param root the app's private files directory. Everything lands under + * `/cordn//`; see [CordnStorageLayout]. + */ +class FileBackedCordnScopeFactory( + private val root: File, + private val cipher: CordnBlobCipher, + private val links: CordnCoordinatorLinkFactory, +) : CordnCoordinatorScopeFactory { + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope { + val dir = CordnStorageLayout.directoryFor(root, accountPubKey, config.pubKey) + val link = links.connect(accountPubKey, config) + + return object : CordnCoordinatorScope { + override val coordinator = link.coordinator + + override suspend fun serverInfo() = link.serverInfo() + + override val groupStore = FileCordnGroupStore(dir, cipher) + override val keyPackageStore = FileCordnKeyPackageStore(dir, cipher) + + // Only the transport closes. The files outlive the session by + // design — closing a coordinator is not leaving its groups, and + // the registry's `forget` says the same thing. + override suspend fun close() = link.close() + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStores.kt new file mode 100644 index 0000000000..914fb8c555 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStores.kt @@ -0,0 +1,496 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.amethyst.commons.storage.EncryptedAppendLog +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.EchoState +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.ByteBuffer +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Where one account's state for one coordinator lives on disk. + * + * Both halves of the key matter and neither is optional. Per account, because + * two accounts on one device must not read each other's groups. Per + * coordinator, because a `gid` is unique only within one (`spec/00.md` §4) — + * two coordinators can both serve `gid = "abc"` as unrelated groups, and a + * layout that ignored the coordinator would have one silently overwrite the + * other's ratchet tree. + */ +@OptIn(ExperimentalEncodingApi::class) +object CordnStorageLayout { + /** + * `/cordn//`. + * + * Both keys are validated as hex before they reach a path. They always are + * — they are Nostr pubkeys — so this costs nothing and closes the one place + * a caller could smuggle `..` into a directory name. + */ + fun directoryFor( + root: File, + accountPubKey: HexKey, + coordinatorPubKey: HexKey, + ): File { + require(accountPubKey.matches(HEX)) { "account pubkey must be hex" } + require(coordinatorPubKey.matches(HEX)) { "coordinator pubkey must be hex" } + return File(root, "cordn/$accountPubKey/$coordinatorPubKey") + } + + /** + * `/cordn/` — the account's own directory, above any + * coordinator's. + * + * The coordinator list lives here rather than inside a coordinator's + * directory, because it is the list OF them: storing it under one would + * make that coordinator's removal delete the record of the others. + */ + fun accountDirectoryFor( + root: File, + accountPubKey: HexKey, + ): File { + require(accountPubKey.matches(HEX)) { "account pubkey must be hex" } + return File(root, "cordn/$accountPubKey") + } + + /** + * A filename for an arbitrary caller-chosen key. + * + * Base64url rather than the key itself, and deliberately NOT the hex + * validation Marmot's store uses. A Marmot group id is a hash and is + * always hex; a cordn `gid` is whatever the group's creator picked (§4 — + * the coordinator never interprets it, and the reference client happens to + * use a UUID). So a `gid` can contain `/`, `..`, a NUL, or a name that is + * special on some filesystem, and rejecting those would refuse groups the + * protocol allows. Encoding accepts every one of them and can still be + * reversed, which is what lets [FileCordnGroupStore.listGroups] give the + * real `gid` back rather than an opaque hash. + */ + fun encodeKey(key: String): String { + val encoded = Base64.UrlSafe.encode(key.encodeToByteArray()).trimEnd('=') + require(encoded.length <= MAX_NAME) { "key is too long to store: ${key.length} chars" } + return encoded + } + + /** The inverse of [encodeKey]; null when the name was not written by us. */ + fun decodeKey(name: String): String? = + try { + Base64.UrlSafe.decode(name.padEnd((name.length + 3) / 4 * 4, '=')).decodeToString() + } catch (e: IllegalArgumentException) { + // A stray file in our directory. Ignoring it is better than failing + // the whole listing and hiding every real group behind it. + null + } + + private val HEX = Regex("^[0-9a-fA-F]{64}$") + + /** Comfortably under the 255-byte limit every filesystem we target has. */ + private const val MAX_NAME = 200 +} + +private const val TAG = "CordnStores" + +/** + * Writes a blob to [file] so that a crash leaves either the old bytes or the + * new ones, never half of each. + * + * MLS makes this sharper than ordinary durability: a truncated `MlsGroupState` + * is not a stale group, it is an unreadable one, and the group cannot be + * re-derived from anywhere else on this device. + */ +private fun atomicWrite( + file: File, + data: ByteArray, +) { + file.parentFile?.mkdirs() + val temp = File(file.parentFile, "${file.name}.tmp") + temp.writeBytes(data) + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + temp.delete() + } +} + +/** + * A [CordnGroupStore] on the filesystem, encrypted through [cipher]. + * + * ``` + * /groups//state — encrypted MlsGroupState + * /groups//cursor — encrypted GroupCursor + * /groups//via-request — present iff admitted by request + * /groups//room — encrypted draft + read position + * ``` + * + * Scope [dir] with [CordnStorageLayout.directoryFor]; this class trusts that it + * already belongs to exactly one (account, coordinator) pair. + * + * Not a refactor of Marmot's `AndroidMlsGroupStateStore` and not shareable with + * it: that one keys by a hex Nostr group id and persists retained epoch secrets + * for Marmot's own rotation, neither of which exists here. The resemblance is + * that both encrypt blobs, which is not an abstraction worth having. + */ +class FileCordnGroupStore( + private val dir: File, + private val cipher: CordnBlobCipher, +) : CordnGroupStore { + private fun groupDir(gid: String) = File(dir, "groups/${CordnStorageLayout.encodeKey(gid)}") + + private fun stateFile(gid: String) = File(groupDir(gid), "state") + + private fun cursorFile(gid: String) = File(groupDir(gid), "cursor") + + private fun joinOriginFile(gid: String) = File(groupDir(gid), "via-request") + + private fun roomStateFile(gid: String) = File(groupDir(gid), "room") + + private fun echoStateFile(gid: String) = File(groupDir(gid), "echoes") + + /** + * Inside [groupDir] so [deleteGroup]'s recursive delete already covers it: + * a group that left its history behind would keep the plaintext of an + * end-to-end encrypted conversation after the key that read it was gone. + */ + private fun messagesFile(gid: String) = File(groupDir(gid), "messages") + + private fun summaryFile(gid: String) = File(groupDir(gid), "newest") + + /** + * Appending a segment rather than rewriting the conversation, so the cost + * of receiving a message does not grow with how much has been said. + */ + private val messageLog = + EncryptedAppendLog( + encrypt = cipher::encrypt, + // The log treats null as "this segment is unreadable" and carries on + // with the rest, which is what one corrupt segment should cost. + decrypt = { runCatching { cipher.decrypt(it) }.getOrNull() }, + ) + + private val messageLock = Mutex() + + /** + * Envelope ids already in a group's log. + * + * Dedup cannot be the log's own whole-entry comparison: the same message + * re-delivered after a crash carries the same envelope but not necessarily + * the same cursor, so the entries differ as strings while naming one + * message. Built once per group from the log the first time it is touched. + */ + private val seenIds = mutableMapOf>() + + private fun idsFor(gid: String): MutableSet = + seenIds.getOrPut(gid) { + messageLog + .readAll(messagesFile(gid)) + .mapNotNullTo(mutableSetOf()) { CordnDeliveredMessageCodec.decodeOrNull(it)?.envelope?.id } + } + + override suspend fun saveGroup( + gid: String, + state: ByteArray, + ) = withContext(Dispatchers.IO) { + atomicWrite(stateFile(gid), cipher.encrypt(state)) + } + + override suspend fun loadGroup(gid: String): ByteArray? = + withContext(Dispatchers.IO) { + val file = stateFile(gid) + if (!file.exists()) null else cipher.decrypt(file.readBytes()) + } + + override suspend fun deleteGroup(gid: String) { + withContext(Dispatchers.IO) { + messageLock.withLock { + // The log caches a file's entries by path, so dropping the + // directory alone would leave a re-join of the same gid reading + // the previous membership's messages out of memory. + messageLog.forget(messagesFile(gid)) + seenIds.remove(gid) + } + // The cursor goes with it. Leaving one behind would mean a later + // re-join of the same gid resumes from a cursor belonging to a + // group it is no longer in, skipping everything before it. + groupDir(gid).deleteRecursively() + } + } + + override suspend fun listGroups(): List = + withContext(Dispatchers.IO) { + File(dir, "groups") + .listFiles() + .orEmpty() + .filter { it.isDirectory && File(it, "state").exists() } + .mapNotNull { CordnStorageLayout.decodeKey(it.name) } + } + + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) = withContext(Dispatchers.IO) { + val buffer = ByteBuffer.allocate(16).putLong(cursor.fetchCursor).putLong(cursor.lastCursor) + atomicWrite(cursorFile(gid), cipher.encrypt(buffer.array())) + } + + override suspend fun loadCursor(gid: String): GroupCursor? = + withContext(Dispatchers.IO) { + val file = cursorFile(gid) + if (!file.exists()) return@withContext null + val bytes = cipher.decrypt(file.readBytes()) + if (bytes.size < 16) return@withContext null + val buffer = ByteBuffer.wrap(bytes) + GroupCursor(fetchCursor = buffer.long, lastCursor = buffer.long) + } + + // Existence IS the flag, so there is nothing to encrypt and nothing to + // read back wrong. It only ever goes from absent to present -- a join + // request cannot be unsent -- and it is removed with the group because a + // later re-join of the same gid is a different admission. + override suspend fun saveJoinedViaRequest(gid: String) { + withContext(Dispatchers.IO) { + atomicWrite(joinOriginFile(gid), ByteArray(0)) + } + } + + override suspend fun loadJoinedViaRequest(gid: String): Boolean = withContext(Dispatchers.IO) { joinOriginFile(gid).exists() } + + override suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) = withContext(Dispatchers.IO) { + // Deleted rather than blanked when there is nothing to remember, so an + // emptied draft leaves no plaintext behind in an old file. + if (state.isBlank) { + roomStateFile(gid).delete() + return@withContext + } + atomicWrite(roomStateFile(gid), cipher.encrypt(CordnRoomStateCodec.encode(state))) + } + + override suspend fun loadRoomState(gid: String): CordnRoomState = + withContext(Dispatchers.IO) { + val file = roomStateFile(gid) + if (!file.exists()) return@withContext CordnRoomState() + try { + CordnRoomStateCodec.decode(cipher.decrypt(file.readBytes())) + } catch (e: Exception) { + CordnRoomState() + } + } + + override suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) = withContext(Dispatchers.IO) { + messageLock.withLock { + val ids = idsFor(gid) + // Idempotent on the envelope id. The crash window between this and + // saveCursor is deliberate — see the store interface — and it is + // this check that makes re-delivery free rather than duplicating. + if (!ids.add(message.envelope.id)) return@withContext + + groupDir(gid).mkdirs() + messageLog.append(messagesFile(gid), CordnDeliveredMessageCodec.encode(message)) + // Written on the same beat, so the inbox preview cannot disagree + // with the room. Whole-blob rather than appended: it is one entry + // that is always overwritten. + atomicWrite(summaryFile(gid), cipher.encrypt(CordnMessageSummaryCodec.encode(message, ids.size))) + } + } + + override suspend fun loadMessages(gid: String): List = + withContext(Dispatchers.IO) { + messageLock.withLock { + // One unreadable entry costs that message, not the conversation + // behind it in the file. + messageLog.readAll(messagesFile(gid)).mapNotNull { CordnDeliveredMessageCodec.decodeOrNull(it) } + } + } + + override suspend fun loadMessageSummary(gid: String): CordnMessageSummary? = + withContext(Dispatchers.IO) { + val file = summaryFile(gid) + if (!file.exists()) return@withContext null + try { + CordnMessageSummaryCodec.decode(cipher.decrypt(file.readBytes())) + } catch (e: Exception) { + // A summary is a derived convenience; losing one costs a preview + // line until the next message, not the history it summarises. + Log.w("FileCordnGroupStore", "unreadable message summary for $gid: ${e.message}", e) + null + } + } + + override suspend fun saveEchoState( + gid: String, + state: EchoState, + ) = withContext(Dispatchers.IO) { + // Deleted when there is nothing pending, so the common steady state is + // no file rather than an empty one. + if (state.isEmpty) { + echoStateFile(gid).delete() + return@withContext + } + atomicWrite(echoStateFile(gid), cipher.encrypt(EchoStateCodec.encode(state))) + } + + override suspend fun loadEchoState(gid: String): EchoState = + withContext(Dispatchers.IO) { + val file = echoStateFile(gid) + if (!file.exists()) return@withContext EchoState() + try { + EchoStateCodec.decode(cipher.decrypt(file.readBytes())) + } catch (e: Exception) { + EchoState() + } + } +} + +/** + * A [CordnKeyPackageStore] on the filesystem, encrypted through [cipher]. + * + * ``` + * /keypackages/ — encrypted KeyPackageBundle + * ``` + * + * Scope [dir] per coordinator like the group store: a `kp_ref` is the + * coordinator's primary key for a KeyPackage (§4.2) and the same account + * publishes different ones to different coordinators. + */ +class FileCordnKeyPackageStore( + private val dir: File, + private val cipher: CordnBlobCipher, +) : CordnKeyPackageStore { + private fun bundleFile(keyPackageRef: String) = File(dir, "keypackages/${CordnStorageLayout.encodeKey(keyPackageRef)}") + + override suspend fun save( + keyPackageRef: String, + bundle: ByteArray, + ) = withContext(Dispatchers.IO) { + atomicWrite(bundleFile(keyPackageRef), cipher.encrypt(bundle)) + } + + override suspend fun load(keyPackageRef: String): ByteArray? = + withContext(Dispatchers.IO) { + val file = bundleFile(keyPackageRef) + if (!file.exists()) null else cipher.decrypt(file.readBytes()) + } + + override suspend fun delete(keyPackageRef: String) { + withContext(Dispatchers.IO) { + bundleFile(keyPackageRef).delete() + } + } + + override suspend fun list(): List = + withContext(Dispatchers.IO) { + File(dir, "keypackages") + .listFiles() + .orEmpty() + .filter { it.isFile } + // No explicit ".tmp" exclusion, because the encoding already + // is one: '.' is not in the base64url alphabet, so a temp file + // left behind by a crashed write cannot decode to a ref and + // [CordnStorageLayout.decodeKey] drops it. A second filter + // saying the same thing would be a branch no test can reach. + .mapNotNull { CordnStorageLayout.decodeKey(it.name) } + } +} + +/** + * A [CordnCoordinatorStore] on the filesystem, encrypted through [cipher]. + * + * ``` + * /coordinators — encrypted CoordinatorListCodec blob + * ``` + * + * Scope [dir] with [CordnStorageLayout.accountDirectoryFor]: one file per + * account, sitting above the per-coordinator directories it names. + */ +class FileCordnCoordinatorStore( + private val dir: File, + private val cipher: CordnBlobCipher, +) : CordnCoordinatorStore { + private val file get() = File(dir, "coordinators") + + override suspend fun save(configs: List) = + withContext(Dispatchers.IO) { + atomicWrite(file, cipher.encrypt(CoordinatorListCodec.encode(configs))) + } + + override suspend fun load(): List = + withContext(Dispatchers.IO) { + val stored = file + if (!stored.exists()) return@withContext emptyList() + try { + CoordinatorListCodec.decode(cipher.decrypt(stored.readBytes())) + } catch (e: Exception) { + // A list written by a future build, or one the keystore can no + // longer decrypt. Returning nothing loses the coordinators but + // keeps the account usable; throwing here would fail login. + // + // Say so, though. Losing this list loses every cordn group with + // it — the MLS state stays on disk but a gid whose coordinator + // is unknown is not a group anyone can open — and silently it + // looks exactly like an account that never had a coordinator: + // the rooms are gone from the inbox and the invitations screen + // says there is nowhere to look. One line is the difference + // between a diagnosable fault and a mystery. + Log.w(TAG, "could not read ${stored.length()} bytes of coordinators, losing them: ${e.message}", e) + emptyList() + } + } +} + +/** + * The handoff flag, as the presence of a file. + * + * `/handed-off`. A zero-byte marker rather than an encrypted blob: + * it carries no secret, and a flag that failed to decrypt would fail open — + * which here means a device that quietly resumes committing after handing its + * groups to another one, the exact fork the flag exists to prevent. + */ +class FileCordnHandoffStore( + private val accountDir: File, +) : CordnHandoffStore { + override suspend fun load(): Boolean = marker().exists() + + override suspend fun save(handedOff: Boolean) { + val file = marker() + if (handedOff) { + file.parentFile?.mkdirs() + file.writeBytes(ByteArray(0)) + } else { + file.delete() + } + } + + private fun marker() = File(accountDir, "handed-off") +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt similarity index 96% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt index b8ef668cb8..d81d5f37f3 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.marmot +package com.vitorpamplona.amethyst.commons.storage import java.io.File import java.io.FileOutputStream @@ -34,10 +34,17 @@ import java.io.RandomAccessFile * plain := uint32 count, (uint32 len, byte[len])* * ``` * + * Protocol-neutral on purpose: it takes [encrypt]/[decrypt] lambdas and stores + * opaque strings, so both group-chat implementations keep their own ciphers and + * their own formats on top of one file layout. It used to live in the `marmot` + * package, which made it unreachable from cordn — the independence guard + * forbids either feature importing the other by name — for no reason other + * than where it happened to be written first. + * * Segments are the whole point. The format this replaces was a single blob * covering the entire history, so recording one line meant pushing every line * ever written back through the cipher and out to disk again — work that grew - * with the log and, for Marmot's message log, was paid on the send path. A + * with the log and was paid on the send path. A * conversation a few thousand messages long was moving hundreds of KB through * a hardware-backed cipher to append a couple of hundred bytes. * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackupTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackupTest.kt new file mode 100644 index 0000000000..bcaafed52a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackupTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CordnBackupTest { + private val account = "a".repeat(64) + private val coordinator = "b".repeat(64) + + /** A cheap cost: these tests exercise the format, not the KDF's strength. */ + private val cheap = 10 + + private val archive = + CordnBackup.Archive( + accountPubKey = account, + coordinators = + listOf( + CoordinatorConfig( + coordinator, + listOf(RelayUrlNormalizer.normalizeOrNull("wss://one.example.com")!!), + CoordinatorConfig.Origin.MANUAL, + "Work", + ), + ), + groups = + listOf( + CordnBackup.Archive.Group( + coordinatorPubKey = coordinator, + gid = "room-1", + state = byteArrayOf(1, 2, 3, 4), + cursor = GroupCursor(fetchCursor = 7, lastCursor = 9), + joinedViaRequest = true, + messages = listOf(delivered("hello", 7), delivered("again", 8)), + ), + CordnBackup.Archive.Group( + coordinatorPubKey = coordinator, + gid = "room-2", + state = byteArrayOf(5, 6), + cursor = null, + joinedViaRequest = false, + ), + ), + keyPackages = + listOf( + CordnBackup.Archive.KeyPackage(coordinator, "kp-ref", byteArrayOf(9, 9, 9)), + ), + ) + + @Test + fun `an archive round-trips`() { + val opened = CordnBackup.open(CordnBackup.seal(archive, "correct horse", cheap), "correct horse") + + assertEquals(archive, opened) + } + + @Test + fun `a group with no cursor stays a group with no cursor`() { + // The optional field is the one a length-prefixed format gets wrong by + // writing a zero and reading it back as a real position, which would + // make a restored room resume from the start of its history. + val opened = CordnBackup.open(CordnBackup.seal(archive, "pw", cheap), "pw") + + assertEquals(null, opened.groups.single { it.gid == "room-2" }.cursor) + assertEquals( + 7L, + opened.groups + .single { it.gid == "room-1" } + .cursor + ?.fetchCursor, + ) + } + + @Test + fun `the wrong passphrase fails, and says nothing about how wrong`() { + val sealed = CordnBackup.seal(archive, "correct horse", cheap) + + assertFailsWith { CordnBackup.open(sealed, "correct hors") } + } + + @Test + fun `rewriting the KDF cost down does not weaken the file`() { + // The attack this refuses: edit logN to something trivially cheap and + // brute-force against that instead of the real key. It fails because + // logN feeds the derivation, so a changed cost is a changed key -- + // not because the header is under the AEAD, which is defence in depth + // for header fields this format does not have yet. + val sealed = CordnBackup.seal(archive, "pw", cheap) + val weakened = sealed.copyOf().also { it[MAGIC_LENGTH + 2] = 1 } + + assertFailsWith { CordnBackup.open(weakened, "pw") } + } + + @Test + fun `a salt swapped between two files does not open either`() { + val mine = CordnBackup.seal(archive, "pw", cheap) + val theirs = CordnBackup.seal(archive, "pw", cheap) + val spliced = mine.copyOf().also { theirs.copyOfRange(SALT_AT, SALT_AT + 16).copyInto(it, SALT_AT) } + + assertFailsWith { CordnBackup.open(spliced, "pw") } + } + + @Test + fun `a truncated file fails rather than restoring half a group`() { + val sealed = CordnBackup.seal(archive, "pw", cheap) + + assertFailsWith { CordnBackup.open(sealed.copyOf(sealed.size - 8), "pw") } + } + + @Test + fun `something that is not a backup is refused by name`() { + val notABackup = ByteArray(200) { it.toByte() } + + val failure = assertFailsWith { CordnBackup.open(notABackup, "pw") } + assertTrue(failure.message!!.contains("not a cordn backup")) + } + + @Test + fun `MLS state and key material never appear in the clear`() { + // The state blob is a ratchet tree and the bundle is private key + // material. A backup that leaked either is worse than no backup. + val sealed = CordnBackup.seal(archive, "pw", cheap) + + assertFalse(sealed.asList().windowed(4).any { it == listOf(1, 2, 3, 4) }, "group state in the clear") + assertFalse(sealed.asList().windowed(3).any { it == listOf(9, 9, 9) }, "key material in the clear") + assertFalse(sealed.decodeToString().contains("room-1"), "a gid in the clear") + } + + @Test + fun `two seals of one archive differ, so a file never reveals a repeat`() { + val first = CordnBackup.seal(archive, "pw", cheap) + val second = CordnBackup.seal(archive, "pw", cheap) + + assertFalse(first.contentEquals(second), "a fresh salt and nonce per seal") + } + + @Test + fun `an unreasonable cost is refused instead of hanging the device`() { + assertFailsWith { CordnBackup.seal(archive, "pw", logN = 40) } + } + + /** + * The id has to hash the contents: `CordnEnvelope.fromJsonObject` checks + * it, so an envelope with a made-up id decodes to null and the archive + * appears to have lost the message. + */ + private fun delivered( + content: String, + cursor: Long, + ): CordnDeliveredMessage { + val unsigned = + CordnEnvelope( + id = "", + pubKey = account, + createdAt = 1_700_000_000L + cursor, + kind = 9, + tags = arrayOf(arrayOf("h", "room-1")), + content = content, + ) + return CordnDeliveredMessage(unsigned.copy(id = unsigned.computedId()), cursor) + } + + @Test + fun `messages survive the round trip`() { + val opened = CordnBackup.open(CordnBackup.seal(archive, "pw", cheap), "pw") + + val room1 = opened.groups.first { it.gid == "room-1" } + assertEquals(listOf("hello", "again"), room1.messages.map { it.envelope.content }) + assertEquals(listOf(7L, 8L), room1.messages.map { it.cursor }) + // The group carrying none still round-trips as none. + assertEquals(emptyList(), opened.groups.first { it.gid == "room-2" }.messages) + } + + private companion object { + const val MAGIC_LENGTH = 8 + + /** magic(8) + version(2) + logN(1). */ + const val SALT_AT = 11 + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscoveryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscoveryTest.kt new file mode 100644 index 0000000000..a10658e78e --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscoveryTest.kt @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorAdvertisement +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Finding coordinators in a relay's worth of MCP announcements. + * + * Three things here fail as a plausible-looking list rather than as an error, + * which is why they are tested: an unrelated MCP server presented as a + * coordinator, a coordinator credited with only one of the relays that carry + * it, and a lagging relay's older announcement overwriting a newer one. + */ +class CordnCoordinatorDiscoveryTest { + private val relayA = relay("wss://a.example") + private val relayB = relay("wss://b.example") + + @Test + fun `a server serving the eleven tools is a coordinator`() { + val found = discovery().coordinatorsIn(announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS)) + + assertEquals(1, found.size) + assertEquals(COORDINATOR, found[0].pubKey) + assertEquals(listOf(relayA), found[0].relays) + assertEquals(CoordinatorConfig.Origin.ANNOUNCEMENT, found[0].toConfig().origin) + } + + @Test + fun `an unrelated MCP server is not`() { + // The case this whole filter exists for: the public relays carry far + // more non-cordn MCP servers than coordinators, and nothing on the + // announcement distinguishes them except the tool names. + val found = + discovery().coordinatorsIn( + announcementPair(relayA, OTHER_SERVER, toolNames = listOf("search", "fetch_page")), + ) + + assertTrue(found.isEmpty()) + } + + @Test + fun `a server missing one of the eleven is not`() { + val found = + discovery().coordinatorsIn( + announcementPair(relayA, OTHER_SERVER, toolNames = CORDN_TOOLS - "msg_sub_many"), + ) + + assertTrue(found.isEmpty()) + } + + @Test + fun `serving more than the eleven still counts`() { + val found = + discovery().coordinatorsIn( + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS + "vendor_extra"), + ) + + assertEquals(1, found.size) + assertTrue("vendor_extra" in found[0].tools) + } + + @Test + fun `a bare server announcement with no tool list is not a coordinator`() { + // A kind 11316 says who a server claims to be, never what it serves. + val found = discovery().coordinatorsIn(listOf(relayA to serverAnnouncement(COORDINATOR, name = "looks real"))) + + assertTrue(found.isEmpty()) + } + + @Test + fun `every relay that carried an announcement is kept`() { + // The reachable relay set is the one thing a CEP-6 announcement does + // not carry, so losing a relay here is losing the only routing + // information discovery produces. + val found = + discovery().coordinatorsIn( + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS) + + announcementPair(relayB, COORDINATOR, toolNames = CORDN_TOOLS), + ) + + assertEquals(1, found.size) + assertEquals(listOf(relayA, relayB), found[0].relays) + } + + @Test + fun `a lagging relay's older copy does not win`() { + val newest = + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 2_000, name = "current") + val stale = + announcementPair(relayB, COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 1_000, name = "outdated") + + val found = discovery().coordinatorsIn(stale + newest) + + assertEquals("current", found[0].surface.name) + assertEquals(2_000L, found[0].announcedAt) + } + + @Test + fun `the announced name is never copied into the user's label`() { + // A label is what the user calls a coordinator. The announced name is + // what the coordinator calls itself, and a coordinator cannot prove a + // name (spec/00.md §8.5). + val found = discovery().coordinatorsIn(announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS, name = "Official")) + + assertEquals("Official", found[0].surface.name) + assertNull(found[0].toConfig().label) + } + + @Test + fun `results are newest first`() { + val found = + discovery().coordinatorsIn( + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 100) + + announcementPair(relayA, SECOND_COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 900), + ) + + assertEquals(listOf(SECOND_COORDINATOR, COORDINATOR), found.map { it.pubKey }) + } + + @Test + fun `each relay is queried in its own call`() = + runTest { + // fetchAllWithHooks deduplicates by event id across the relays in + // one call, so a single multi-relay call would credit a shared + // announcement to whichever relay answered first and drop the + // others. One call per relay scopes that dedup. + val client = ServingClient(this) + client.serve(relayA, announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS).map { it.second }) + client.serve(relayB, announcementPair(relayB, COORDINATOR, toolNames = CORDN_TOOLS).map { it.second }) + + val result = CordnCoordinatorDiscovery(client).discover(setOf(relayA, relayB)) + + assertEquals(setOf(setOf(relayA), setOf(relayB)), client.requestedRelaySets.toSet()) + assertEquals(1, result.coordinators.size) + assertEquals(setOf(relayA, relayB), result.coordinators[0].relays.toSet()) + assertTrue(result.unreachable.isEmpty()) + } + + @Test + fun `a relay that never answers is reported, not silently empty`() = + runTest { + // An empty list means "nobody is announcing" only when every relay + // answered. Conflating the two would present a failed scan as a + // network with no coordinators on it. + val client = ServingClient(this) + client.serve(relayA, emptyList()) + + val result = CordnCoordinatorDiscovery(client).discover(setOf(relayA, relayB), idleTimeoutMs = 50) + + assertTrue(result.coordinators.isEmpty()) + assertEquals(setOf(relayB), result.unreachable) + } + + @Test + fun `no relays is not a query`() = + runTest { + val client = ServingClient(this) + + val result = CordnCoordinatorDiscovery(client).discover(emptySet()) + + assertTrue(result.coordinators.isEmpty()) + assertTrue(client.requestedRelaySets.isEmpty()) + } + + @Test + fun `the required toolset is derived from the protocol, not retyped`() { + // If a twelfth tool is ever added to CoordinatorMethod, this predicate + // must tighten with it rather than keep matching an older server. + assertEquals(11, CoordinatorAdvertisement.REQUIRED_TOOLS.size) + assertEquals(CORDN_TOOLS.toSet(), CoordinatorAdvertisement.REQUIRED_TOOLS) + assertFalse("kp_publish" in CoordinatorAdvertisement.missingFrom(tools(CORDN_TOOLS))) + } + + private fun discovery() = CordnCoordinatorDiscovery(EmptyNostrClient()) + + private fun tools(names: List) = + com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools + .parseOrNull(toolsJson(names))!! + + /** Serves a fixed set of events per relay, then EOSEs. */ + private class ServingClient( + private val scope: CoroutineScope, + ) : INostrClient by EmptyNostrClient() { + private val byRelay = mutableMapOf>() + val requestedRelaySets = mutableListOf>() + + fun serve( + relay: NormalizedRelayUrl, + events: List, + ) { + byRelay[relay] = events + } + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + requestedRelaySets += filters.keys + val target = listener ?: return + scope.launch(Dispatchers.Unconfined) { + filters.keys.forEach { relay -> + // A relay with nothing registered never answers at all, + // which is the "stalled" case discover must report. + val events = byRelay[relay] ?: return@forEach + events.forEach { target.onEvent(it, false, relay, null) } + target.onEose(relay, null) + } + } + } + + override fun unsubscribe(subId: String) = Unit + } + + companion object { + private val COORDINATOR = "aa".repeat(32) + private val SECOND_COORDINATOR = "bb".repeat(32) + private val OTHER_SERVER = "cc".repeat(32) + + private val CORDN_TOOLS = + listOf( + "kp_publish", + "kp_remove", + "kp_list", + "kp_take", + "welcome_store", + "welcome_take", + "join_request_store", + "join_request_take_many", + "msg_post", + "msg_fetch_many", + "msg_sub_many", + ) + + private fun relay(url: String) = RelayUrlNormalizer.normalize(url) + + private fun toolsJson(names: List) = + names.joinToString( + prefix = """{"tools":[""", + postfix = "]}", + ) { """{"name":"$it","inputSchema":{"type":"object"}}""" } + + /** The 11316 + 11317 pair a real coordinator publishes. */ + private fun announcementPair( + relay: NormalizedRelayUrl, + pubKey: HexKey, + toolNames: List, + createdAt: Long = 1_000, + name: String = "a coordinator", + ) = listOf( + relay to serverAnnouncement(pubKey, name, createdAt), + relay to + event( + pubKey = pubKey, + kind = CvmKinds.TOOLS_LIST, + createdAt = createdAt, + content = toolsJson(toolNames), + ), + ) + + private fun serverAnnouncement( + pubKey: HexKey, + name: String, + createdAt: Long = 1_000, + ) = event( + pubKey = pubKey, + kind = CvmKinds.SERVER_ANNOUNCEMENT, + createdAt = createdAt, + content = """{"protocolVersion":"2025-11-25"}""", + tags = arrayOf(arrayOf("name", name)), + ) + + /** + * Unsigned: nothing in discovery verifies a signature, because the + * relay already did and an announcement proves nothing either way. + */ + private fun event( + pubKey: HexKey, + kind: Int, + createdAt: Long, + content: String, + tags: Array = emptyArray(), + ) = Event( + id = "${kind}_${pubKey.take(4)}_$createdAt", + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "00".repeat(32), + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt new file mode 100644 index 0000000000..6557f632c9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * One session per coordinator, for one account. + * + * The rules under test are all consequences of one fact: a `gid`, a `kp_ref` + * and a cursor mean nothing outside the coordinator that issued them. Break + * that and the failures are not loud — one group answers for another, or two + * managers fork the same ratchet tree — so each rule gets a test that fails + * for the right reason. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnCoordinatorRegistryTest : CordnTransportHarness() { + private val gid = "1c4d3e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f" + + /** One identity across every coordinator, as an account really is. */ + private val account = Account() + + /** Counts opens, so "the same session" can be told from "an equal one". */ + private inner class CountingFactory : CordnCoordinatorScopeFactory { + var opened = 0 + var closed = 0 + private val groupStores = mutableMapOf() + private val keyStores = mutableMapOf() + + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope { + // Opening really suspends: production reaches a relay and an + // encrypted store. Without this the in-memory fixture runs to + // completion atomically and no concurrency test can observe + // anything — a second caller never gets to look at the map. + yield() + opened++ + return object : CordnCoordinatorScope { + override val coordinator: ICoordinator = account.clientFor(config.pubKey) + + // Keyed by coordinator, and kept across a reopen: the stores are + // what survives a transport change, which is the whole reason a + // relay edit does not cost the user their groups. + override val groupStore = groupStores.getOrPut(config.pubKey) { InMemoryCordnGroupStore() } + override val keyPackageStore = keyStores.getOrPut(config.pubKey) { InMemoryCordnKeyPackageStore() } + + override suspend fun close() { + closed++ + } + } + } + } + + private fun registry(factory: CordnCoordinatorScopeFactory) = CordnCoordinatorRegistry(account.pubKey, factory) + + @Test + fun `asking twice for one coordinator returns the same session`() = + runTest { + // Not a cache: a second CordnGroupManager would deserialise its own + // copy of every MlsGroup from the same store, and both would commit + // against the same epoch. MLS does not recover from a forked + // ratchet tree — every later message simply fails to decrypt. + val factory = CountingFactory() + val registry = registry(factory) + + val first = driving { registry.session(config) } + val second = driving { registry.session(config) } + + assertSame(first, second) + assertSame(first.manager, second.manager) + assertEquals(1, factory.opened, "the transport must not be opened twice") + } + + @Test + fun `concurrent callers do not get two sessions`() = + runTest { + // The ordinary case, not a race worth dismissing: the group list and + // the sync loop both reach for a session at launch. + val factory = CountingFactory() + val registry = registry(factory) + + val sessions = + driving { + coroutineScope { + List(8) { async { registry.session(config) } }.awaitAll() + } + } + + sessions.forEach { assertSame(sessions.first(), it) } + assertEquals(1, factory.opened) + } + + @Test + fun `the same gid on two coordinators is two groups`() = + runTest { + // spec/00.md §4: a gid is the caller's to choose and unique only + // within one coordinator. Two of them picking "abc" is expected, and + // they are unrelated groups. A registry keyed by gid instead of by + // coordinator would let one answer for the other. + val factory = CountingFactory() + val registry = registry(factory) + val other = config.copy(pubKey = secondCoordinatorPubKey) + + val here = driving { registry.session(config) } + val there = driving { registry.session(other) } + + driving { here.manager.createGroup(gid, CordnGroupMetadata(name = "Ours", adminPubkeys = listOf(registry.accountPubKey))) } + + assertEquals(setOf(gid), here.manager.gids.value) + assertTrue( + there.manager.gids.value + .isEmpty(), + "the other coordinator's session must not see it", + ) + assertNull(there.manager.group(gid)) + assertEquals(2, factory.opened) + } + + @Test + fun `editing a coordinator's relays keeps its groups`() = + runTest { + // A user correcting a relay or renaming a coordinator has not made + // it a different coordinator. The transport is bound to the relays + // so it reopens; the stores are not, so the groups come back. + val factory = CountingFactory() + val registry = registry(factory) + + val before = driving { registry.session(config) } + driving { before.manager.createGroup(gid, CordnGroupMetadata(name = "Kept", adminPubkeys = listOf(registry.accountPubKey))) } + + val moved = config.copy(relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://other.example.com")!!), label = "Renamed") + val after = driving { registry.session(moved) } + + assertEquals(2, factory.opened, "the transport is bound to the relays, so it reopens") + assertEquals(1, factory.closed, "and the old one is released, not leaked") + assertEquals(setOf(gid), after.manager.gids.value, "but the group state survived") + assertNotNull(after.manager.group(gid)) + assertEquals( + "Renamed", + registry.coordinators.value + .single() + .label, + ) + } + + @Test + fun `forgetting a coordinator closes it and keeps its state`() = + runTest { + // cordn-web separates removing a coordinator from purging it. Wiping + // the stores here would make the undo impossible, so forget only + // closes the wire. + val factory = CountingFactory() + val registry = registry(factory) + + val session = driving { registry.session(config) } + driving { session.manager.createGroup(gid, CordnGroupMetadata(name = "Undo me", adminPubkeys = listOf(registry.accountPubKey))) } + + registry.forget(config.pubKey) + + assertEquals(1, factory.closed) + assertNull(registry.sessionOrNull(config.pubKey)) + assertTrue(registry.coordinators.value.isEmpty()) + + val readded = driving { registry.session(config) } + assertEquals(setOf(gid), readded.manager.gids.value, "re-adding must find the groups again") + } + + @Test + fun `restore closes the coordinators that are no longer configured`() = + runTest { + // Otherwise a coordinator the user removed on another launch comes + // back to life because its session happened to still be open. + val factory = CountingFactory() + val registry = registry(factory) + val other = config.copy(pubKey = secondCoordinatorPubKey) + + driving { registry.restore(listOf(config, other)) } + assertEquals(2, registry.coordinators.value.size) + + driving { registry.restore(listOf(other)) } + + assertEquals(listOf(other.pubKey), registry.coordinators.value.map { it.pubKey }) + assertNull(registry.sessionOrNull(config.pubKey)) + assertEquals(1, factory.closed) + } + + @Test + fun `a session knows its groups without being told to restore`() = + runTest { + // A session that reports no groups is indistinguishable from a + // failure, and the first commit it makes for a group it forgot + // starts from epoch zero. Leaving restore() to the caller makes + // that a one-line omission away. + val factory = CountingFactory() + val first = registry(factory) + val second = CordnCoordinatorRegistry(first.accountPubKey, factory) + + val opened = driving { first.session(config) } + driving { opened.manager.createGroup(gid, CordnGroupMetadata(name = "Found", adminPubkeys = listOf(first.accountPubKey))) } + driving { opened.keyPackages.publishNew() } + val refs = opened.keyPackages.published.value + + // A fresh registry over the same stores — what a relaunch looks like. + val relaunched = driving { second.session(config) } + + assertEquals(setOf(gid), relaunched.manager.gids.value) + assertEquals(refs, relaunched.keyPackages.published.value) + } + + @Test + fun `logging out closes every coordinator`() = + runTest { + val factory = CountingFactory() + val registry = registry(factory) + + driving { registry.restore(listOf(config, config.copy(pubKey = secondCoordinatorPubKey))) } + registry.close() + + assertEquals(2, factory.closed) + assertTrue(registry.coordinators.value.isEmpty()) + assertNull(registry.sessionOrNull(config.pubKey)) + } + + /** + * A scope whose handshake the test decides: an answer, silence, or a throw. + */ + private inner class InfoFactory( + private val info: CoordinatorServerInfo?, + private val blowUp: Boolean = false, + ) : CordnCoordinatorScopeFactory { + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope = + object : CordnCoordinatorScope { + override val coordinator: ICoordinator = account.clientFor(config.pubKey) + override val groupStore: CordnGroupStore = InMemoryCordnGroupStore() + override val keyPackageStore: CordnKeyPackageStore = InMemoryCordnKeyPackageStore() + + override suspend fun serverInfo(): CoordinatorServerInfo? { + if (blowUp) throw IllegalStateException("coordinator unreachable") + return info + } + + override suspend fun close() = Unit + } + } + + private val anInfo = CoordinatorServerInfo(name = "cordn-server", version = "0.1.0", protocolVersion = "2025-11-25", capabilities = null) + + /** + * The settings screen offers "ask who it is" so someone can find out + * whether a coordinator answers. Before this, a successful handshake left + * the health line reading "Nothing asked of it yet" directly above the + * answer it had just printed. + */ + @Test + fun `a handshake that answers counts as a healthy call`() = + runTest { + val registry = registry(InfoFactory(anInfo)) + val session = driving { registry.session(config) } + + assertTrue(session.health.state.value.isUnknown, "nothing has been asked yet") + + assertNotNull(driving { session.serverInfo() }) + + assertTrue(!session.health.state.value.isUnknown, "the handshake should have been observed") + assertNotNull(session.health.state.value.lastSuccessAt) + } + + @Test + fun `a handshake that throws counts against health`() = + runTest { + val registry = registry(InfoFactory(null, blowUp = true)) + val session = driving { registry.session(config) } + + runCatching { driving { session.serverInfo() } } + + assertEquals(1, session.health.state.value.consecutiveFailures) + assertEquals("coordinator unreachable", session.health.state.value.lastFailure) + } + + /** + * A scope that did no handshake returns null, and a call that never left + * the device must not report the coordinator as reachable. + */ + @Test + fun `silence is neither a success nor a failure`() = + runTest { + val registry = registry(InfoFactory(null)) + val session = driving { registry.session(config) } + + assertNull(driving { session.serverInfo() }) + + assertTrue(session.health.state.value.isUnknown, "silence should leave health unknown") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupExceptionReasonTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupExceptionReasonTest.kt new file mode 100644 index 0000000000..ccfa485e68 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupExceptionReasonTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The reason a screen branches on. + * + * `CordnGroupException`'s message is written for a log: it names pubkeys and + * gids in full, which is what you want when reading one and never what you + * want in a chat. A UI that rendered it answered somebody who had just tapped + * a face with 64 hex characters. [CordnGroupException.Reason] is how the + * screen says it in its own words instead, so it has to survive as a value + * rather than as English anyone is free to reword. + */ +class CordnGroupExceptionReasonTest { + @Test + fun `the reason travels with the exception`() { + val e = CordnGroupException("the coordinator holds no KeyPackage for abc", CordnGroupException.Reason.NO_KEY_PACKAGE) + + assertEquals(CordnGroupException.Reason.NO_KEY_PACKAGE, e.reason) + } + + @Test + fun `an exception raised without one is OTHER, not a crash`() { + // Most throw sites have no better wording than their own message, and + // must keep compiling and keep rendering that message. + val e = CordnGroupException("something else went wrong") + + assertEquals(CordnGroupException.Reason.OTHER, e.reason) + } + + @Test + fun `the message is left alone for the log`() { + // The reason is additive. Whatever a maintainer reads in logcat must + // still be the precise thing, pubkey and all. + val e = CordnGroupException("abc is not a member of gid-1", CordnGroupException.Reason.NOT_A_MEMBER) + + assertEquals("abc is not a member of gid-1", e.message) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManagerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManagerTest.kt new file mode 100644 index 0000000000..bbee86ce3a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManagerTest.kt @@ -0,0 +1,1050 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [CordnGroupManager] over a full two-party lifecycle. + * + * Alice creates a group, takes Bob's published KeyPackage off the coordinator, + * adds him, and sends a message; Bob joins from the Welcome the coordinator + * held for him, catches up, and reads it. Both managers run against one + * [FakeCoordinator], so the only thing joining them is the wire — which is the + * point: a cursor or epoch mistake shows up as Bob failing to read, not as an + * assertion about internals. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnGroupManagerTest { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + private val coordinatorKey = "cc".repeat(32) + + private val aliceSigner = NostrSignerSync(KeyPair()) + private val bobSigner = NostrSignerSync(KeyPair()) + private val alice: HexKey get() = aliceSigner.pubKey + private val bob: HexKey get() = bobSigner.pubKey + + private val config = + CoordinatorConfig( + pubKey = coordinatorKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + origin = CoordinatorConfig.Origin.DEFAULT, + ) + + private fun manager( + account: HexKey, + coordinator: FakeCoordinator, + store: CordnGroupStore = InMemoryCordnGroupStore(), + ) = CordnGroupManager( + accountPubKey = account, + config = config, + coordinator = coordinator, + store = store, + clock = { 1_757_000_000L }, + ) + + /** + * Bob's KeyPackage plus the signed `kp_publish` request event that binds it + * to him — `spec/00.md` §7's "signed publication payload", which for cordn + * *is* the ContextVM request event. Built here rather than faked because + * `invite` verifies it, and a fake would verify nothing. + */ + private fun bobsPublication(): Pair { + val scratch = MlsGroup.create(CordnCredential.of(bob).identity, policy = CordnGroupPolicy) + val bundle = scratch.createKeyPackage(CordnCredential.of(bob).identity, ByteArray(0)) + val bytes = bundle.keyPackage.toTlsBytes() + val base64 = Base64.encode(bytes) + val ref = + bundle.keyPackage + .toTlsBytes() + .take(16) + .joinToString("") { "%02x".format(it) } + + val content = + """{"jsonrpc":"2.0","id":1,"method":"tools/call",""" + + """"params":{"name":"kp_publish","arguments":{"kp_ref":"$ref","kp_64":"$base64"}}}""" + val event: Event = + bobSigner.sign( + EventTemplate( + createdAt = 1_757_000_000L, + kind = 25910, + tags = arrayOf(arrayOf("p", coordinatorKey)), + content = content, + ), + ) + + return bundle to FakeCoordinator.StoredKeyPackage(bob, ref, base64, event) + } + + /** As [bobsPublication], for a third member. */ + private fun carolsPublication(): Pair { + val carolSigner = NostrSignerSync(KeyPair()) + val carol = carolSigner.pubKey + val scratch = MlsGroup.create(CordnCredential.of(carol).identity, policy = CordnGroupPolicy) + val bundle = scratch.createKeyPackage(CordnCredential.of(carol).identity, ByteArray(0)) + val base64 = Base64.encode(bundle.keyPackage.toTlsBytes()) + val ref = "ca".repeat(16) + val content = + """{"jsonrpc":"2.0","id":1,"method":"tools/call",""" + + """"params":{"name":"kp_publish","arguments":{"kp_ref":"$ref","kp_64":"$base64"}}}""" + val event: Event = + carolSigner.sign( + EventTemplate( + createdAt = 1_757_000_000L, + kind = 25910, + tags = arrayOf(arrayOf("p", coordinatorKey)), + content = content, + ), + ) + return bundle to FakeCoordinator.StoredKeyPackage(carol, ref, base64, event) + } + + @Test + fun `alice creates, invites, sends, and bob joins and reads`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4", adminPubkeys = listOf(alice))) + assertEquals(setOf(gid), aliceManager.gids.value) + + val invite = aliceManager.invite(gid, bob) + assertEquals(bob, invite.invited) + assertEquals(1L, aliceManager.group(gid)!!.epoch, "adding a member advances the epoch") + + aliceManager.send(gid, "hello bob") + + // Bob's side, from nothing but what the coordinator holds. + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + + val results = bobManager.joinPendingWelcomes({ ref -> bobBundle.takeIf { ref == stored.keyPackageRef } }) + assertEquals(listOf(gid), results.joined, "Bob recovers the gid from the group id") + assertTrue(results.skipped.isEmpty()) + + val delivered = mutableListOf() + bobManager.catchUp { delivered += it } + + val messages = delivered.filterIsInstance() + assertEquals(1, messages.size, "got ${delivered.map { it::class.simpleName }}") + assertEquals("hello bob", messages[0].received.envelope.content) + assertEquals(alice, messages[0].received.sender, "the sender is MLS-authenticated, not claimed") + assertEquals(CordnGroupManager.CHAT_KIND, messages[0].received.envelope.kind) + } + + @Test + fun `an admin removes a member and the group advances past them`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Removal", adminPubkeys = listOf(alice))) + aliceManager.invite(gid, bob) + val epochAfterInvite = aliceManager.group(gid)!!.epoch + + val removal = aliceManager.removeMember(gid, bob) + + assertEquals(bob, removal.removed) + assertEquals(epochAfterInvite + 1, aliceManager.group(gid)!!.epoch, "a Remove is its own epoch") + assertTrue( + bob !in CordnCredential.membersOf(aliceManager.group(gid)!!).values, + "bob must be gone from the tree, not merely marked", + ) + } + + @Test + fun `removing yourself is refused`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Self", adminPubkeys = listOf(alice))) + + // Committing your own Remove advances the group into an epoch whose + // keys you no longer hold, so the reference client refuses it too. + assertFailsWith { aliceManager.removeMember(gid, alice) } + } + + @Test + fun `removing someone who is not a member fails loudly`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Absent", adminPubkeys = listOf(alice))) + + assertFailsWith { aliceManager.removeMember(gid, bob) } + } + + @Test + fun `a non-admin cannot remove anyone`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + // Egalitarian first, so alice can invite; then she hands admin to + // bob alone, which is a real sequence and the only one that leaves + // her a member who may not remove anyone. Creating the group with + // admins = [bob] would have failed at the invite instead — the gate + // firing there is correct, but it tests the wrong call. + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Handover")) + aliceManager.invite(gid, bob) + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "Handover", adminPubkeys = listOf(bob))) + + assertFailsWith { aliceManager.removeMember(gid, bob) } + } + + @Test + fun `updating metadata keeps every other extension`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Before", adminPubkeys = listOf(alice))) + + val before = + aliceManager + .group(gid)!! + .extensions + .map { it.extensionType } + .toSet() + + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "After", adminPubkeys = listOf(alice, bob))) + + val group = aliceManager.group(gid)!! + val metadata = CordnGroupMetadata.fromExtensions(group.extensions) + assertEquals("After", metadata?.name) + assertEquals(listOf(alice, bob), metadata?.adminPubkeys, "the admin list travels with the metadata") + + // A GroupContextExtensions proposal replaces the WHOLE list, so + // required_capabilities would vanish if only the metadata were sent + // — and a group that lost it is one whose next commit peers reject. + assertEquals(before, group.extensions.map { it.extensionType }.toSet()) + } + + @Test + fun `a non-admin cannot rewrite metadata`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Gated", adminPubkeys = listOf(bob))) + + assertFailsWith { + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "Hijacked", adminPubkeys = listOf(alice))) + } + } + + @Test + fun `an egalitarian group lets any member do both`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + // No admins named: spec/01.md §5.3 egalitarian mode, where the gate + // opens rather than closes. + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Flat")) + aliceManager.invite(gid, bob) + + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "Still flat")) + aliceManager.removeMember(gid, bob) + + assertEquals("Still flat", CordnGroupMetadata.fromExtensions(aliceManager.group(gid)!!.extensions)?.name) + } + + @Test + fun `join requests are only asked for where they could be accepted`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + + // Alice is a member but not an admin, so accepting a request here + // would be an Add commit the policy refuses. Asking for the list at + // all would only offer her something that cannot work. + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Someone else's", adminPubkeys = listOf(bob))) + + assertTrue(aliceManager.pendingJoinRequests().isEmpty()) + } + + @Test + fun `the welcome cursor keeps bob from replaying epochs he cannot read`() = + runTest { + // Without `after`, catch-up starts at 0 and hands Bob the Commit + // that created him plus everything before it -- all Undecryptable, + // and all indistinguishable from real loss. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + aliceManager.send(gid, "before bob existed") + aliceManager.invite(gid, bob) + aliceManager.send(gid, "after bob joined") + + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + bobManager.joinPendingWelcomes({ bobBundle.takeIf { _ -> true } }) + + val delivered = mutableListOf() + bobManager.catchUp { delivered += it } + + val messages = delivered.filterIsInstance() + assertEquals(listOf("after bob joined"), messages.map { it.received.envelope.content }) + assertTrue( + delivered.none { it is CordnGroupManager.Delivery.Undecryptable }, + "nothing from before Bob's epoch should have been offered: ${delivered.map { it::class.simpleName }}", + ) + } + + @Test + fun `an existing member applies a later commit, sealed under the pre-commit epoch`() = + runTest { + // `spec/03.md` §5: a Commit is sealed with the exporter of the + // epoch it LEAVES, because that is the only key its recipients + // have. Sealing under the epoch it creates produces a payload the + // sender can read and nobody else can -- and it cannot be caught + // by the join path, where the Welcome's cursor skips the Commit + // that created the joiner. It needs a member already in the group + // when a later Commit lands, which is what this is. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Three")) + aliceManager.invite(gid, bob) + + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + bobManager.joinPendingWelcomes({ bobBundle }) + bobManager.catchUp { } + assertEquals(1L, bobManager.group(gid)!!.epoch) + + // Carol arrives. Bob was already here, so the Commit is his to apply. + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(carolStored) + aliceManager.invite(gid, carolStored.pubKey) + aliceManager.send(gid, "carol is in") + + bobCoordinator.streams.clear() + bobCoordinator.streams.putAll(coordinator.streams) + val delivered = mutableListOf() + bobManager.catchUp { delivered += it } + + assertTrue( + delivered.none { it is CordnGroupManager.Delivery.Undecryptable }, + "Bob could not open a Commit addressed to his own epoch: " + + delivered.filterIsInstance().map { it.reason }, + ) + assertEquals(2L, bobManager.group(gid)!!.epoch, "the Commit must have advanced Bob's epoch") + assertEquals( + listOf("carol is in"), + delivered.filterIsInstance().map { it.received.envelope.content }, + "and the message sent at the new epoch must still open", + ) + } + + @Test + fun `a group survives a restart`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val first = manager(alice, coordinator, store) + first.createGroup(gid, CordnGroupMetadata(name = "Persisted")) + first.send(gid, "one") + + val restarted = manager(alice, coordinator, store) + restarted.restore() + + assertEquals(setOf(gid), restarted.gids.value) + assertEquals("Persisted", CordnGroupMetadata.fromExtensions(restarted.group(gid)!!.extensions)?.name) + + // Our own message comes back, because posting deliberately does + // not advance the cursor — a lower cursor may still hold somebody + // else's unprocessed message. So it must come back recognised. + // + // This assertion used to be `none { it is Message }`, and it + // passed while the delivery was `Undecryptable`: not a Message, so + // not a failure, so nobody noticed that a sender reported a gap in + // its own conversation. Only a run against a real coordinator + // made it visible. Assert what it IS, not what it is not. + val delivered = mutableListOf() + restarted.catchUp { delivered += it } + assertEquals( + listOf("Echo"), + delivered.map { it::class.simpleName }, + "our own message must come back as an echo, not as a gap", + ) + } + + @Test + fun `a message is stored before the cursor that would skip it`() = + runTest { + // The ordering IS the correctness property. Write the cursor first + // and a crash in between loses the message permanently: both cordn + // seal keys are epoch-derived, so the copy the coordinator still + // holds can no longer be opened, and a cursor past it means it is + // never offered again. Write the message first and the same crash + // costs nothing — the message is on disk, the cursor still points + // before it, and the re-delivery dedups. + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = OrderRecordingStore() + val m = manager(alice, coordinator, store) + m.createGroup(gid, CordnGroupMetadata(name = "Order")) + m.send(gid, "one") + + val append = store.calls.indexOf("appendMessage") + val cursor = store.calls.indexOf("saveCursor") + assertTrue(append >= 0, "the message was never stored at all: ${store.calls}") + assertTrue(cursor >= 0, "the cursor was never stored at all: ${store.calls}") + assertTrue( + append < cursor, + "the cursor was written before the message, so a crash between them loses it: ${store.calls}", + ) + } + + @Test + fun `the same message delivered twice is stored once`() = + runTest { + // The crash window above lands here: a re-fetch after a restart + // re-delivers a message that is already on disk, and it arrives + // carrying a cursor of its own. Dedup on the whole stored entry + // would not catch that — same envelope, different cursor, different + // bytes — so it has to key on the envelope id. + val store = InMemoryCordnGroupStore() + val envelope = + CordnEnvelope.build( + pubKey = alice, + createdAt = 1_757_000_000L, + kind = 9, + content = "once", + ) + + store.appendMessage(gid, CordnDeliveredMessage(envelope, cursor = 7)) + store.appendMessage(gid, CordnDeliveredMessage(envelope, cursor = 9)) + + assertEquals(1, store.loadMessages(gid).size, "the same envelope was stored twice") + } + + @Test + fun `a stored conversation reloads as the one that was ingested`() = + runTest { + // Equality of the raw list is not enough: what the room shows is + // the annotation fold — edits applied, deletions withdrawn, + // reactions counted. A reload that produced the same messages but a + // different fold would look right in a list assertion and wrong on + // screen. + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val m = manager(alice, coordinator, store) + m.createGroup(gid, CordnGroupMetadata(name = "Fold")) + + val first = m.send(gid, "original") + m.post( + gid = gid, + content = "edited", + editTo = + CordnMessageReferences.Target( + id = first.envelope.id, + pubKey = first.envelope.pubKey, + kind = first.envelope.kind, + tags = first.envelope.tags, + ), + ) + m.post(gid, "\uD83D\uDC4D", reactionTo = CordnMessageReferences.Target(first.envelope.id, first.envelope.pubKey, first.envelope.kind, first.envelope.tags)) + + val live = CordnAnnotationIndex.of(listOf(first) + m.storedMessages(gid).drop(1)) + val reloaded = CordnAnnotationIndex.of(m.storedMessages(gid)) + + assertEquals("edited", reloaded.contentOf(first.envelope.id), "the edit did not survive the reload") + assertTrue(reloaded.isEdited(first.envelope.id)) + assertEquals( + live.reactions[first.envelope.id]?.keys, + reloaded.reactions[first.envelope.id]?.keys, + "the reaction fold differs after a reload", + ) + } + + @Test + fun `the summary names the newest message without loading the log`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val m = manager(alice, coordinator) + m.createGroup(gid, CordnGroupMetadata(name = "Summary")) + m.send(gid, "first") + val last = m.send(gid, "last") + + val summary = m.storedMessageSummary(gid) + assertEquals(last.envelope.id, summary?.newest?.envelope?.id) + assertEquals(2, summary?.count) + } + + @Test + fun `leaving a group takes its messages with it`() = + runTest { + // A group whose history outlived it would keep the plaintext of an + // end-to-end encrypted conversation on disk after the MLS state + // that could read it was thrown away. + val store = InMemoryCordnGroupStore() + val envelope = CordnEnvelope.build(alice, 1_757_000_000L, 9, content = "secret") + store.saveGroup(gid, ByteArray(1)) + store.appendMessage(gid, CordnDeliveredMessage(envelope, cursor = 1)) + + store.deleteGroup(gid) + + assertEquals(emptyList(), store.loadMessages(gid)) + assertEquals(null, store.loadMessageSummary(gid)) + } + + /** Records the order writes arrive in, so an ordering invariant can be asserted. */ + private class OrderRecordingStore( + private val inner: CordnGroupStore = InMemoryCordnGroupStore(), + ) : CordnGroupStore by inner { + val calls = mutableListOf() + + override suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) { + calls += "appendMessage" + inner.appendMessage(gid, message) + } + + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) { + calls += "saveCursor" + inner.saveCursor(gid, cursor) + } + } + + @Test + fun `a posted commit is still recognised as ours after a restart`() = + runTest { + // The same bug, one layer worse. A Commit is sealed under the + // PRE-commit epoch key (spec/03.md §5), which the poster no longer + // has once it has advanced — so when the echo arrives it cannot be + // opened at all, and without the record that it is ours it is + // indistinguishable from a payload from an epoch we never had. + // + // Feeding it back through the engine instead would be worse than a + // gap: it would advance the epoch twice and desynchronise us from + // every other member, with the failure surfacing epochs later. + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val (bundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + val first = manager(alice, coordinator, store) + first.createGroup(gid, CordnGroupMetadata(name = "Restart")) + first.invite(gid, bob, stored.keyPackageRef) + assertEquals(1, first.group(gid)!!.epoch) + + val restarted = manager(alice, coordinator, store) + restarted.restore() + + val delivered = mutableListOf() + restarted.catchUp { delivered += it } + + assertEquals( + listOf("Echo"), + delivered.map { it::class.simpleName }, + "a restarted client must recognise the Commit it posted: ${delivered.map { it::class.simpleName }}", + ) + assertEquals(1, restarted.group(gid)!!.epoch, "and must not apply it a second time") + assertTrue(bundle.keyPackage.toTlsBytes().isNotEmpty()) + } + + @Test + fun `inviteAll adds everybody in one epoch, not one each`() = + runTest { + // The reason inviteAll exists. A loop of single invites reaches + // epoch 2 for two people; more importantly, each Add is applied + // locally BEFORE its Commit is posted, so one failed post left the + // group forked and every later invite in the loop building on it. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, bobStored) = bobsPublication() + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(bobStored) + coordinator.seedKeyPackage(carolStored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Batch", adminPubkeys = listOf(alice))) + val result = aliceManager.inviteAll(gid, listOf(bob, carolStored.pubKey)) + + assertEquals(2, result.invited.size, "both were invited") + assertTrue(result.refused.isEmpty(), "nobody was refused: ${result.refused}") + assertTrue(result.undelivered.isEmpty(), "every Welcome was stored: ${result.undelivered}") + assertEquals(1L, aliceManager.group(gid)!!.epoch, "one commit, so one epoch") + } + + @Test + fun `inviteAll refuses the one with no KeyPackage and still adds the rest`() = + runTest { + // The refusal happens before the commit, so it costs the group + // nothing: the others are added in the same single epoch they would + // have reached on their own. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, bobStored) = bobsPublication() + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(bobStored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Batch", adminPubkeys = listOf(alice))) + val result = aliceManager.inviteAll(gid, listOf(bob, carolStored.pubKey)) + + assertEquals(listOf(bob), result.invited.map { it.invited }) + assertEquals( + CordnGroupException.Reason.NO_KEY_PACKAGE, + result.refused[carolStored.pubKey]?.reason, + "the one with nothing published is named, not the whole batch", + ) + assertEquals(1L, aliceManager.group(gid)!!.epoch) + } + + @Test + fun `inviteAll with nobody to invite leaves the group where it was`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Batch")) + + val result = aliceManager.inviteAll(gid, emptyList()) + + assertTrue(result.invited.isEmpty()) + assertEquals(0L, aliceManager.group(gid)!!.epoch, "an empty batch is not a commit") + } + + @Test + fun `inviting someone the coordinator has no KeyPackage for fails loudly`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + + val failure = assertFailsWith { aliceManager.invite(gid, bob) } + assertTrue(failure.message!!.contains("holds no KeyPackage")) + assertEquals(0L, aliceManager.group(gid)!!.epoch, "a failed invite must not advance the group") + } + + @Test + fun `a KeyPackage published under someone else's name is refused`() = + runTest { + // §9/§10: the coordinator checks this too, and we check it anyway. + // A coordinator that skipped the check could otherwise hand us any + // account's name over any account's key material. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + + val (_, stored) = bobsPublication() + val impostor = "ee".repeat(32) + coordinator.seedKeyPackage( + FakeCoordinator.StoredKeyPackage(impostor, stored.keyPackageRef, stored.base64, stored.publicationEvent), + ) + + assertFailsWith { aliceManager.invite(gid, impostor) } + assertEquals(0L, aliceManager.group(gid)!!.epoch) + } + + @Test + fun `health tracks the coordinator without polling it`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + + assertTrue(aliceManager.health.state.value.isUnknown, "no call yet is not the same as down") + + aliceManager.send(gid, "works") + assertEquals(0, aliceManager.health.state.value.consecutiveFailures) + + coordinator.failNext = CoordinatorHealth.DOWN_AFTER + repeat(CoordinatorHealth.DOWN_AFTER) { + runCatching { aliceManager.send(gid, "fails") } + } + assertTrue(aliceManager.health.state.value.isDown) + + aliceManager.send(gid, "works again") + assertEquals(0, aliceManager.health.state.value.consecutiveFailures, "a success clears the streak") + assertTrue(!aliceManager.health.state.value.isDown) + } + + @Test + fun `exposure reports the real linked-group count`() = + runTest { + // Section 8.2 is the one a user cannot guess: one ephemeral session + // per coordinator means every group on it is linked to the others. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "One")) + + val alone = aliceManager.exposure(gid) + assertEquals(1, alone.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION !in alone.notes()) + assertEquals(ExposureLevel.NONE, alone.content) + assertEquals(ExposureLevel.IDENTIFIED, alone.membership) + assertTrue(ExposureNote.ENCRYPTION_NOT_PINNED !in alone.notes(), "our transport pins REQUIRED (§8.6)") + + aliceManager.createGroup("second-gid", CordnGroupMetadata(name = "Two")) + val linked = aliceManager.exposure(gid) + assertEquals(2, linked.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION in linked.notes()) + + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD !in linked.notes()) + aliceManager.publishKeyPackage("ref", "a2s=") + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD in aliceManager.exposure(gid).notes(), "§8.4") + } + + @Test + fun `a share ref round-trips through this coordinator`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Shared")) + + val ref = aliceManager.shareRef(gid) + assertEquals(gid, ref.gid) + assertEquals(coordinatorKey, ref.coordinatorPubKey) + assertEquals(CoordinatorConfig.from(ref)?.pubKey, coordinatorKey) + assertNull(aliceManager.group("nope")) + } + + @Test + fun `a second drain does not roll a live group back to its welcome epoch`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Rollback")) + aliceManager.invite(gid, bob) + + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + bobManager.joinPendingWelcomes({ bobBundle }) + bobManager.catchUp { } + val epochAfterJoin = bobManager.group(gid)!!.epoch + + // Carol arrives; Bob applies the Commit and advances. + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(carolStored) + aliceManager.invite(gid, carolStored.pubKey) + bobCoordinator.streams.putAll(coordinator.streams) + bobManager.catchUp { } + val epochAfterCommit = bobManager.group(gid)!!.epoch + assertTrue(epochAfterCommit > epochAfterJoin, "precondition: Bob advanced") + + // A second drain, which is what a relaunch or a retry does. + bobManager.joinPendingWelcomes({ bobBundle }) + + // Before the accept/decline split this replaced Bob's live group + // with the one the Welcome was issued at, and every message after + // that epoch stopped decrypting -- silently, and for good. + assertEquals(epochAfterCommit, bobManager.group(gid)!!.epoch, "a second drain rolled Bob's group back") + } + + @Test + fun `a welcome can be read before it is answered`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Book club", description = "Thursdays")) + aliceManager.invite(gid, bob) + + val bobManager = manager(bob, bobsCoordinatorOver(coordinator)) + val inbox = bobManager.pendingWelcomes({ bobBundle }) + + val welcome = inbox.pending.single() + assertEquals(gid, welcome.gid) + assertEquals("Book club", welcome.metadata?.name) + // Who is already in it -- deliberately not "who invited you", + // which a Welcome does not carry. + assertEquals(setOf(alice, bob), welcome.members) + assertTrue(bobManager.gids.value.isEmpty(), "reading an invitation must not join it") + } + + @Test + fun `accepting joins the group and stops the coordinator serving the welcome`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Accepted")) + aliceManager.invite(gid, bob) + + val bobCoordinator = bobsCoordinatorOver(coordinator) + val bobManager = manager(bob, bobCoordinator) + val welcome = bobManager.pendingWelcomes({ bobBundle }).pending.single() + assertEquals(gid, bobManager.accept(welcome)) + + assertEquals(setOf(gid), bobManager.gids.value) + // Retired at the coordinator, not merely filtered out on the way + // back: an un-retired welcome is served to every future session of + // this account forever, and each one has to re-open it to find out + // it is stale. + assertTrue(bobCoordinator.welcomes[bob].isNullOrEmpty(), "the welcome was not retired") + } + + @Test + fun `declining retires the welcome without joining anything`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Declined")) + aliceManager.invite(gid, bob) + + val bobManager = manager(bob, bobsCoordinatorOver(coordinator)) + bobManager.decline(bobManager.pendingWelcomes({ bobBundle }).pending.single()) + + assertTrue(bobManager.gids.value.isEmpty()) + assertTrue( + bobManager.pendingWelcomes({ bobBundle }).pending.isEmpty(), + "a declined welcome must not be offered again", + ) + } + + @Test + fun `a welcome this device has no key package for is left for the device that does`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Other device")) + aliceManager.invite(gid, bob) + + val bobManager = manager(bob, bobsCoordinatorOver(coordinator)) + val first = bobManager.pendingWelcomes({ null }) + assertEquals(CordnGroupManager.NO_PRIVATE_HALF, first.skipped.single().reason) + + // Still there: retiring it would destroy the other device's only + // copy of an invitation it can actually open. + assertEquals(1, bobManager.pendingWelcomes({ null }).skipped.size) + } + + @Test + fun `a welcome for a group we are already in is retired rather than offered as a choice`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stale")) + aliceManager.invite(gid, bob) + + val bobCoordinator = bobsCoordinatorOver(coordinator) + val bobManager = manager(bob, bobCoordinator) + // Joined, but the acknowledgement never landed -- a dropped + // connection between accepting and retiring leaves exactly this + // record still being served. + val served = bobCoordinator.welcomes[bob]!!.toList() + bobManager.accept(bobManager.pendingWelcomes({ bobBundle }).pending.single()) + bobCoordinator.welcomes[bob] = served.toMutableList() + + val inbox = bobManager.pendingWelcomes({ bobBundle }) + + assertTrue(inbox.pending.isEmpty(), "nobody should be asked about a group they are in") + assertEquals(CordnGroupManager.ALREADY_A_MEMBER, inbox.skipped.single().reason) + } + + @Test + fun `asking to join is remembered across a restart, because the coordinator remembers`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val first = manager(alice, coordinator, store) + first.createGroup(gid, CordnGroupMetadata(name = "Asked")) + + assertFalse(first.exposure(gid).joinedFromShareLink) + first.requestToJoin(gid, "kp-ref") + assertTrue(first.exposure(gid).joinedFromShareLink) + + // A new manager over the same store, which is what a relaunch is. + // The coordinator still has the npub tied to this group, so an + // exposure card that came back clean here would be lying. + val second = manager(alice, coordinator, store) + second.restore() + assertTrue(second.exposure(gid).joinedFromShareLink) + } + + @Test + fun `a failed join request still counts as asked`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Asked")) + + aliceManager.requestToJoin(gid, "kp-ref") + // Nobody answers. The exposure is the asking, not the joining. + assertTrue(aliceManager.exposure(gid).joinedFromShareLink) + assertTrue(aliceManager.group(gid)!!.memberCount == 1) + } + + @Test + fun `a cancelled subscription still saves what it ingested`() = + runTest { + // The case the `finally` exists for, and the one it could not + // serve: persistAll suspends, and a suspend call inside a + // cancelled coroutine throws before writing anything. Cancelling + // is also the normal way this ends — CordnSyncLoop cancels the + // subscription whenever the group set changes. + val aliceCoordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, aliceCoordinator) + val (bundle, stored) = bobsPublication() + aliceCoordinator.keyPackages[stored.keyPackageRef] = stored + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Cancelled")) + aliceManager.invite(gid, bob, stored.keyPackageRef) + aliceManager.send(gid, "a message bob has not seen") + + // Bob joins with an empty store, so any cursor in it afterwards + // can only have come from the subscription. + val bobStore = SuspendingStore() + val bobManager = + CordnGroupManager( + accountPubKey = bob, + config = config, + coordinator = HangingCoordinator(bobsCoordinatorOver(aliceCoordinator)), + store = bobStore, + clock = { 1_757_000_000L }, + ) + bobManager.pendingWelcomes({ bundle }).pending.forEach { bobManager.accept(it) } + val before = bobStore.loadCursor(gid) + + // Delivers, then hangs — so the cancellation lands while the + // subscription is open, which is the whole point. A fake that + // returns normally leaves the `finally` running in a live + // coroutine and proves nothing. + val job = launch { bobManager.subscribe(timeoutMs = 60_000) { } } + advanceUntilIdle() + job.cancelAndJoin() + + assertNotEquals( + before, + bobStore.loadCursor(gid), + "a cursor the subscription advanced must reach the store even when cancelled", + ) + } + + /** + * A store whose writes actually suspend, like the file-backed one. + * + * [InMemoryCordnGroupStore]'s methods are `suspend` but never reach a + * suspension point, and cancellation is only observed at one — so against + * it a cancelled `finally` writes happily and proves nothing. The real + * store goes through `Dispatchers.IO`, where the same code throws before + * it writes. One `yield()` is the difference. + */ + private class SuspendingStore( + private val inner: CordnGroupStore = InMemoryCordnGroupStore(), + ) : CordnGroupStore by inner { + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) { + yield() + inner.saveCursor(gid, cursor) + } + + override suspend fun saveGroup( + gid: String, + state: ByteArray, + ) { + yield() + inner.saveGroup(gid, state) + } + } + + /** Delivers whatever it is wrapping, then never returns. */ + private class HangingCoordinator( + private val inner: FakeCoordinator, + ) : ICoordinator by inner { + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + inner.subscribeMessages(cursors, timeoutMs, onMessage) + awaitCancellation() + } + } + + /** Bob's own view of the coordinator, carrying whatever Alice's has stored. */ + private fun bobsCoordinatorOver(alices: FakeCoordinator): FakeCoordinator { + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(alices.welcomes) + bobCoordinator.streams.putAll(alices.streams) + return bobCoordinator + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffStateTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffStateTest.kt new file mode 100644 index 0000000000..f41b719896 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffStateTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * The flag the whole migration design rests on. + * + * Two phones holding one MLS leaf and both committing fork the ratchet tree, + * and MLS does not recover: after the fork every message silently fails to + * decrypt for somebody. Migration is only safe because the old phone stops — + * so the cases that matter are the ones where it fails to stop, or stops and + * cannot be brought back. + */ +class CordnHandoffStateTest { + @get:Rule val folder = TemporaryFolder() + + @Test + fun `a fresh device has not handed off`() = + runTest { + val state = state() + state.restore() + + assertFalse(state.handedOff.value) + state.requireNotHandedOff() + } + + @Test + fun `a handed-off device refuses to write`() = + runTest { + val state = state() + state.markHandedOff() + + assertTrue(state.handedOff.value) + val thrown = assertThrows(CordnHandedOffException::class.java) { state.requireNotHandedOff() } + assertTrue(thrown.message!!.contains("fork")) + } + + @Test + fun `the flag survives a restart`() = + runTest { + // The case that matters: a device that forgot it had handed off + // would resume committing on the next launch, which is the fork. + state().markHandedOff() + + val afterRestart = state() + afterRestart.restore() + + assertTrue(afterRestart.handedOff.value) + } + + @Test + fun `a handoff can be undone, and the undo survives a restart`() = + runTest { + // A migration can fail after the export — a flat battery, a QR that + // will not scan. Locking irreversibly would strand the account on + // the device that still holds the only copy of its state. + val state = state() + state.markHandedOff() + + state.resume() + + assertFalse(state.handedOff.value) + state.requireNotHandedOff() + + val afterRestart = state() + afterRestart.restore() + assertFalse(afterRestart.handedOff.value) + } + + @Test + fun `restore reflects what is on disk, not what this instance did`() = + runTest { + val first = state() + first.restore() + assertFalse(first.handedOff.value) + + state().markHandedOff() + first.restore() + + assertTrue(first.handedOff.value) + } + + private fun state() = CordnHandoffState(FileCordnHandoffStore(folder.root)) +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnIndependenceTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnIndependenceTest.kt new file mode 100644 index 0000000000..df0b4ccfb6 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnIndependenceTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The app half of the Marmot/cordn boundary. + * + * `quartz`'s `BindingIsolationTest` holds the protocol layer apart. This holds + * the layer above it apart, and the rule is the same and deliberate: **Marmot is + * frozen, and neither binding may acquire a reason to change when the other + * does.** The two protocols do not interoperate and neither is a layer of the + * other, so a shared model, state holder or chatroom type would be a coupling + * with nothing to justify it. + * + * What cordn *may* share is what any feature shares: the design system, the + * theme, and generic components — buttons, avatars, text fields, QR, media + * viewers. Those are app furniture, not group chat. The line this test draws is + * `marmot`-named code, because that is where the coupling would actually land. + */ +class CordnIndependenceTest { + private val commonsRoot: File by lazy { + // Resolved rather than assumed: a wrong root makes an architecture test + // pass for the wrong reason, which is worse than not having one. + generateSequence(File(".").absoluteFile) { it.parentFile } + .map { if (it.name == "commons") it else File(it, "commons") } + .firstOrNull { File(it, "src/commonMain/kotlin/com/vitorpamplona/amethyst/commons").isDirectory } + ?: fail("cannot locate the commons module from ${File(".").absolutePath}") + } + + /** Main source sets only; interop tests may legitimately drive both. */ + private fun kotlinFilesIn(pkg: String): List = + File(commonsRoot, "src") + .listFiles() + .orEmpty() + .filter { it.isDirectory && !it.name.endsWith("Test") } + .map { File(it, "kotlin/com/vitorpamplona/amethyst/commons/$pkg") } + .filter { it.isDirectory } + .flatMap { it.walkTopDown().filter { f -> f.isFile && f.extension == "kt" }.toList() } + + private fun importsMatching( + pkg: String, + forbidden: Regex, + ): List = + kotlinFilesIn(pkg).flatMap { file -> + file.readLines().withIndex().mapNotNull { (i, line) -> + val trimmed = line.trimStart() + if (trimmed.startsWith("import ") && forbidden.containsMatchIn(trimmed)) { + "${file.relativeTo(commonsRoot)}:${i + 1} $trimmed" + } else { + null + } + } + } + + /** + * Plain substring, NOT `\bmarmot\b`. + * + * The word-boundary form cannot match the imports that matter: in + * `…model.marmotGroups.MarmotGroupChatroom` the character after `marmot` + * is a word character both times, so `\b` fails and the guard waves + * through exactly the dependency it exists to stop. This guard shipped + * with that bug and was only caught by trying to break it on purpose — + * which is why [the matcher itself is tested] below. + * + * A substring is safe here because only `import` lines are scanned: the + * word "marmot" inside an import IS a Marmot dependency. + */ + private val marmotImport = Regex("marmot", RegexOption.IGNORE_CASE) + + private val cordnImport = Regex("cordn", RegexOption.IGNORE_CASE) + + @Test + fun `the matcher catches the import it exists to catch`() { + // The meta-test. Without it, a guard that can never fire looks exactly + // like a codebase that never violates the rule. + val realImport = "import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom" + assertTrue( + marmotImport.containsMatchIn(realImport), + "the Marmot matcher would not flag $realImport — the guard cannot fire", + ) + assertTrue( + cordnImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager"), + "the cordn matcher cannot fire either", + ) + } + + @Test + fun `cordn does not reach into Marmot`() { + val offences = importsMatching("cordn", marmotImport) + assertTrue( + offences.isEmpty(), + "cordn must not import Marmot — the two are independent and Marmot is frozen\n " + + offences.joinToString("\n "), + ) + } + + @Test + fun `Marmot does not reach into cordn`() { + // The direction that protects the shipped feature: Marmot must not gain + // a dependency on the newer, less settled binding. + val offences = importsMatching("marmot", cordnImport) + assertTrue( + offences.isEmpty(), + "Marmot must not import cordn — Marmot is frozen and cordn moves\n " + offences.joinToString("\n "), + ) + } + + @Test + fun `the scan sees real files and real imports`() { + listOf("cordn", "marmot").forEach { + assertTrue(kotlinFilesIn(it).size > 3, "found ${kotlinFilesIn(it).size} files under $it — the scan is broken, not the code") + } + assertTrue( + importsMatching("cordn", Regex("quartz", RegexOption.IGNORE_CASE)).isNotEmpty(), + "the scanner found no quartz imports in cordn, so it would not find a marmot one either", + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnJoinRequestTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnJoinRequestTest.kt new file mode 100644 index 0000000000..6651a26c3f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnJoinRequestTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Stage C's stated verification: **link → request → accept → first message**. + * + * The four steps are one flow and only mean anything together. A share ref is + * useless if the `gid` it names cannot be requested; a request is useless if + * nobody can turn it into a Welcome; a Welcome is useless if the joiner cannot + * then read the room. Each half has its own unit tests elsewhere — this is the + * seam between them, which is where a protocol built out of four independent + * coordinator calls actually breaks. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnJoinRequestTest { + private val coordinatorKey = "d".repeat(64) + private val aliceSigner = NostrSignerSync(KeyPair()) + private val bobSigner = NostrSignerSync(KeyPair()) + private val alice: HexKey get() = aliceSigner.pubKey + private val bob: HexKey get() = bobSigner.pubKey + + private val config = + CoordinatorConfig( + pubKey = coordinatorKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + origin = CoordinatorConfig.Origin.DEFAULT, + ) + + private fun manager( + account: HexKey, + coordinator: FakeCoordinator, + ) = CordnGroupManager( + accountPubKey = account, + config = config, + coordinator = coordinator, + store = InMemoryCordnGroupStore(), + clock = { 1_757_000_000L }, + ) + + @Test + fun `a share ref becomes a request, an invite, and a readable room`() = + runTest { + // One coordinator, two callers. Each manager sees it as its own + // account, which is what makes the Welcome routing meaningful. + val alicesView = FakeCoordinator(callerPubKey = alice) + val bobsView = alicesView.viewAs(bob) + + val aliceManager = manager(alice, alicesView) + val bobManager = manager(bob, bobsView) + + // 1. Alice makes a group and shares its ref. + val gid = "stage-c-room" + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage C")) + val ref = aliceManager.shareRef(gid) + val fromLink = CordnLinkInspection.of(ref.encode()) + assertTrue(fromLink is CordnLinkInspection.Valid, "the ref Alice shares must parse as a link") + assertEquals(gid, fromLink.ref.gid) + + // 2. Bob publishes a KeyPackage to that coordinator and asks in. + val (bobBundle, bobsKeyPackage) = publicationFor(bobSigner) + bobsView.seedKeyPackage(bobsKeyPackage) + bobManager.requestToJoin(fromLink.ref.gid, bobsKeyPackage.keyPackageRef) + + // 3. Alice sees the request and accepts it. + val request = aliceManager.pendingJoinRequests().single() + assertEquals(bob, request.pubKey) + assertEquals(bobsKeyPackage.keyPackageRef, request.keyPackageRef) + aliceManager.acceptJoinRequest(request) + + assertTrue( + aliceManager.pendingJoinRequests().isEmpty(), + "an accepted request must not be offered a second time", + ) + + // 4. Bob opens the Welcome and reads what Alice says next. + aliceManager.send(gid, "welcome in") + val welcome = bobManager.pendingWelcomes({ bobBundle }).pending.single() + assertEquals(gid, welcome.gid) + assertEquals(gid, bobManager.accept(welcome)) + + val delivered = mutableListOf() + bobManager.catchUp { delivered += it } + val messages = delivered.filterIsInstance() + + assertEquals(listOf("welcome in"), messages.map { it.received.envelope.content }) + assertEquals(alice, messages.single().received.sender, "the sender is MLS-authenticated, not claimed") + } + + @Test + fun `declining a request adds nobody and does not offer it again`() = + runTest { + val alicesView = FakeCoordinator(callerPubKey = alice) + val bobsView = alicesView.viewAs(bob) + val aliceManager = manager(alice, alicesView) + val bobManager = manager(bob, bobsView) + + val gid = "closed-room" + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Closed")) + val (bobBundle, bobsKeyPackage) = publicationFor(bobSigner) + bobsView.seedKeyPackage(bobsKeyPackage) + bobManager.requestToJoin(gid, bobsKeyPackage.keyPackageRef) + + aliceManager.declineJoinRequest(aliceManager.pendingJoinRequests().single()) + + assertTrue(aliceManager.pendingJoinRequests().isEmpty()) + assertEquals(0L, aliceManager.group(gid)!!.epoch, "declining must not commit anything") + assertTrue( + bobManager.pendingWelcomes({ bobBundle }).pending.isEmpty(), + "a declined request must not produce a Welcome", + ) + } + + @Test + fun `a request is answered with the key package it named`() = + runTest { + // Bob has two packages published. The request names one; taking the + // other would spend a package he meant for a different inviter, and + // the Welcome would be addressed to a private half he still holds + // but did not expect to use here. + val alicesView = FakeCoordinator(callerPubKey = alice) + val bobsView = alicesView.viewAs(bob) + val aliceManager = manager(alice, alicesView) + val bobManager = manager(bob, bobsView) + + val gid = "two-packages" + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Two")) + + val (_, spare) = publicationFor(bobSigner, ref = "aa".repeat(16)) + val (named, asked) = publicationFor(bobSigner, ref = "bb".repeat(16)) + bobsView.seedKeyPackage(spare) + bobsView.seedKeyPackage(asked) + + bobManager.requestToJoin(gid, asked.keyPackageRef) + aliceManager.acceptJoinRequest(aliceManager.pendingJoinRequests().single()) + + val welcome = bobManager.pendingWelcomes({ ref -> named.takeIf { ref == asked.keyPackageRef } }).pending.single() + assertEquals(gid, welcome.gid) + } + + /** A KeyPackage plus the signed `kp_publish` event that binds it to [signer]. */ + private fun publicationFor( + signer: NostrSignerSync, + ref: String? = null, + ): Pair { + val identity = CordnCredential.of(signer.pubKey).identity + val scratch = MlsGroup.create(identity, policy = CordnGroupPolicy) + val bundle = scratch.createKeyPackage(identity, ByteArray(0)) + val base64 = Base64.encode(bundle.keyPackage.toTlsBytes()) + val keyPackageRef = + ref ?: bundle.keyPackage + .toTlsBytes() + .take(16) + .joinToString("") { "%02x".format(it) } + + val content = + """{"jsonrpc":"2.0","id":1,"method":"tools/call",""" + + """"params":{"name":"kp_publish","arguments":{"kp_ref":"$keyPackageRef","kp_64":"$base64"}}}""" + val event: Event = + signer.sign( + EventTemplate( + createdAt = 1_757_000_000L, + kind = 25910, + tags = arrayOf(arrayOf("p", coordinatorKey)), + content = content, + ), + ) + return bundle to FakeCoordinator.StoredKeyPackage(signer.pubKey, keyPackageRef, base64, event) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackagesTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackagesTest.kt new file mode 100644 index 0000000000..c4553df8a5 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackagesTest.kt @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.KeyPackagePublication +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Publishing KeyPackages so other people can invite this account. + * + * Driven over the real transport rather than a fake coordinator, because two of + * the things worth checking only exist on the wire: that `kp_ref` is the RFC + * 9420 KeyPackageRef the coordinator will key on, and that the publication + * payload a `kp_take` serves back verifies — which it can only do if the signed + * request event round-tripped (`spec/00.md` §7). + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnKeyPackagesTest : CordnTransportHarness() { + @Test + fun `a published ref is the RFC 9420 KeyPackageRef`() = + runTest { + // The coordinator's primary key and the address a Welcome is + // delivered to. Computing it differently from everyone else fails + // silently: every kp_take misses and no group ever forms. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + + val bundle = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertEquals(bundle.keyPackage.reference().toHexKey(), published.keyPackageRef) + } + + @Test + fun `what the coordinator serves back verifies as ours`() = + runTest { + // §9: the client re-runs every check even though §8 makes the + // coordinator run them too. That is only meaningful if the signed + // request event really is what comes back. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + + val taken = assertNotNull(driving { alice.coordinatorClient.takeKeyPackage(published.keyPackageRef) }) + val verified = KeyPackagePublication.verify(taken.publicationEvent) + + assertEquals(alice.pubKey, verified.pubKey) + assertEquals( + published.keyPackageRef, + MlsKeyPackage.decodeTls(TlsReader(verified.bytes)).reference().toHexKey(), + ) + assertEquals(alice.pubKey, CordnCredential.identityOrNull(verified.keyPackage.leafNode)) + } + + @Test + fun `the private half survives a restart`() = + runTest { + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + val before = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + + // Same store, new manager — what a process restart looks like. + val restarted = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, alice.keyPackageStore) + restarted.restore() + + assertEquals(setOf(published.keyPackageRef), restarted.published.value) + val after = assertNotNull(restarted.bundleFor(published.keyPackageRef)) + assertContentEquals(before.initPrivateKey, after.initPrivateKey) + assertContentEquals(before.signaturePrivateKey, after.signaturePrivateKey) + assertContentEquals(before.encryptionPrivateKey, after.encryptionPrivateKey) + } + + @Test + fun `a failed publish leaves nothing behind`() = + runTest { + // The half that matters: a bundle we kept for a package the + // coordinator never took is dead weight, and accumulating one per + // failed attempt is how a key store grows without bound. + val alice = Account() + coordinator.rejectPublication = true + + assertFailsWith { driving { alice.keyPackages.publishNew() } } + + assertTrue( + alice.keyPackages.published.value + .isEmpty(), + ) + assertTrue(alice.keyPackageStore.list().isEmpty()) + } + + @Test + fun `a last-resort package is published once and reused`() = + runTest { + val alice = Account() + val first = driving { alice.keyPackages.ensureLastResort() } + val second = driving { alice.keyPackages.ensureLastResort() } + + assertEquals(first, second, "a second call must not publish another reusable package") + + val bundle = assertNotNull(alice.keyPackages.bundleFor(assertNotNull(first))) + assertTrue(bundle.keyPackage.isLastResort(), "it must actually carry the marker") + } + + @Test + fun `a second device publishes its own last-resort package`() = + runTest { + // kp_list shows the account's packages, not this device's, so a + // second device sees the first device's reusable package and could + // mistake it for its own. Reusing it would send every fallback + // Welcome to a KeyPackage only the other device can open — the + // invite looks accepted and the group never forms here. + val alice = Account() + val first = assertNotNull(driving { alice.keyPackages.ensureLastResort() }) + + // Same account and coordinator, empty key store: a new install. + val secondDevice = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, InMemoryCordnKeyPackageStore()) + val second = assertNotNull(driving { secondDevice.ensureLastResort() }) + + assertTrue(first != second, "it must not adopt a key it has no private half for") + assertNotNull(secondDevice.bundleFor(second)) + + // And it settles: a second call on that device reuses its own. + assertEquals(second, driving { secondDevice.ensureLastResort() }) + } + + @Test + fun `an ordinary package is not marked last resort`() = + runTest { + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + val bundle = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertTrue(!bundle.keyPackage.isLastResort()) + } + + @Test + fun `top-up counts what the coordinator holds, not what we published`() = + runTest { + // kp_take consumes a single-use package, so the coordinator's count + // falls as people invite us while ours does not. Counting ours + // would let the pool silently empty. + val alice = Account() + driving { alice.keyPackages.topUp(minimum = 3) } + assertEquals(3, coordinator.availableCount(alice.pubKey)) + + // Already at the floor: nothing more to do. + val none = driving { alice.keyPackages.topUp(minimum = 3) } + assertTrue(none.isEmpty()) + + // Somebody invites us, consuming one. + driving { alice.coordinatorClient.takeKeyPackage(alice.pubKey) } + val refilled = driving { alice.keyPackages.topUp(minimum = 3) } + assertEquals(1, refilled.size, "the pool must refill to the floor") + } + + @Test + fun `the last-resort package does not count toward the single-use floor`() = + runTest { + // Startup calls both, so this is the ordinary state, not a corner. + // The reusable package is the fallback for when the pool is drained; + // letting it fill a slot in the pool means every account ships one + // fewer single-use package than it asked for, and the fallback gets + // used a round earlier than it should. + val alice = Account() + driving { alice.keyPackages.ensureLastResort() } + driving { alice.keyPackages.topUp(minimum = 3) } + + assertEquals(3, coordinator.availableCount(alice.pubKey), "the pool is the single-use packages alone") + assertEquals(4, coordinator.keyPackagesOf(alice.pubKey).size, "plus the one reusable package") + } + + @Test + fun `withdrawing tells the coordinator before forgetting the key`() = + runTest { + // Order matters: dropping the private half while the coordinator + // still serves the package means somebody invites us and we cannot + // open the Welcome. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + + val removed = driving { alice.keyPackages.withdraw(listOf(published.keyPackageRef)) } + + assertEquals(listOf(published.keyPackageRef), removed) + assertNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertTrue( + alice.keyPackages.published.value + .isEmpty(), + ) + assertNull(driving { alice.coordinatorClient.takeKeyPackage(published.keyPackageRef) }) + } + + @Test + fun `a withdrawal the coordinator refuses keeps the key we still need`() = + runTest { + // The other half of the ordering rule, and the only half a working + // coordinator can show. While the coordinator still serves the + // package, somebody can still invite us with it — so the private + // half has to survive a removal that did not happen. Forgetting it + // first would leave an invite we can never open. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + coordinator.rejectRemoval = true + + assertFailsWith { driving { alice.keyPackages.withdraw(listOf(published.keyPackageRef)) } } + + assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertEquals(setOf(published.keyPackageRef), alice.keyPackages.published.value) + + // And the invite it can still back really does open. + coordinator.rejectRemoval = false + val taken = assertNotNull(driving { alice.coordinatorClient.takeKeyPackage(published.keyPackageRef) }) + assertEquals(published.keyPackageRef, taken.keyPackageRef) + } + + @Test + fun `a bundle we cannot decode sends the Welcome back to the inbox`() = + runTest { + // Rather than throwing: another device of the same account, or a + // later version of this one, may be able to handle it. + val alice = Account() + alice.keyPackageStore.save("corrupt", byteArrayOf(9, 9, 9)) + assertNull(alice.keyPackages.bundleFor("corrupt")) + } + + @Test + fun `the bundle codec round-trips every private half`() = + runTest { + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + val bundle = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + + val again = KeyPackageBundleCodec.decode(KeyPackageBundleCodec.encode(bundle)) + + assertContentEquals(bundle.keyPackage.toTlsBytes(), again.keyPackage.toTlsBytes()) + assertContentEquals(bundle.initPrivateKey, again.initPrivateKey) + assertContentEquals(bundle.encryptionPrivateKey, again.encryptionPrivateKey) + assertContentEquals(bundle.signaturePrivateKey, again.signaturePrivateKey) + } + + @Test + fun `an unknown bundle layout is refused, not guessed at`() { + // A misread bundle yields key material that is silently wrong, which + // surfaces much later as a Welcome that will not open. + val forwardVersion = byteArrayOf(0x00, 0x63) + ByteArray(32) + assertFailsWith { KeyPackageBundleCodec.decode(forwardVersion) } + } + + @Test + fun `publishing names the account, by design`() = + runTest { + // §8.4: kp_publish rides the stable identity and the coordinator + // keeps the signed event to re-serve. That is the disclosure the + // exposure card reports; it must not quietly become ephemeral. + val alice = Account() + driving { alice.keyPackages.publishNew() } + + val publish = assertNotNull(coordinator.calls.lastOrNull { it.method == "kp_publish" }) + assertEquals(alice.pubKey, publish.callerPubKey) + } + + @Test + fun `whether a key package is published is read from the store, not from this session`() = + runTest { + val alice = Account() + val store = InMemoryCordnKeyPackageStore() + val first = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, store) + assertFalse(first.hasPublished(), "nothing published yet") + + driving { first.publishNew() } + + // A second instance over the same store -- which is what a relaunch + // is. The coordinator still holds the KeyPackage, so an exposure + // card reporting "not published" here would understate what it + // knows, and would do it on every launch after the first. + val second = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, store) + assertTrue(second.hasPublished(), "before restore()") + second.restore() + assertTrue(second.hasPublished(), "after restore()") + } + + @Test + fun `listPublished reports what the coordinator serves, not what we think we sent`() = + runTest { + val alice = Account() + val first = driving { alice.keyPackages.publishNew() } + driving { alice.keyPackages.publishNew() } + + // Somebody invites Alice: kp_take consumes the single-use package. + // Our own record still has two; the coordinator has one, and it is + // the coordinator's count that decides whether the next invitation + // can happen at all. + driving { alice.coordinatorClient.takeKeyPackage(first.keyPackageRef) } + + val listed = driving { alice.keyPackages.listPublished() } + assertEquals(2, alice.keyPackages.published.value.size, "our local record is unchanged") + assertEquals(1, listed.size, "the coordinator has one left") + assertFalse(listed.any { it.keyPackageRef == first.keyPackageRef }) + } + + @Test + fun `listPublished is scoped to this account`() = + runTest { + val alice = Account() + val bob = Account() + driving { alice.keyPackages.publishNew() } + driving { bob.keyPackages.publishNew() } + + assertTrue(driving { alice.keyPackages.listPublished() }.all { it.pubKey == alice.pubKey }) + } + + @Test + fun `the identity set spans everybody, where listPublished is scoped to us`() = + runTest { + // The two read one `kp_list` response and keep different halves of + // it. Scoping the identity set the way listPublished is scoped + // would answer "can I add Bob" with our own packages, which is + // always no. + val alice = Account() + val bob = Account() + driving { alice.keyPackages.publishNew() } + driving { bob.keyPackages.publishNew() } + + val reachable = assertNotNull(driving { alice.keyPackages.identitiesWithKeyPackages() }) + assertTrue(alice.pubKey in reachable, "ourselves") + assertTrue(bob.pubKey in reachable, "somebody else") + } + + @Test + fun `publishing shows up straight away rather than at the end of the snapshot's life`() = + runTest { + // The snapshot is reused for a minute, so our own publish has to + // drop it. Otherwise the account that just published a package is + // told for the next minute that it has none. + val alice = Account() + assertTrue(driving { alice.keyPackages.identitiesWithKeyPackages() }?.contains(alice.pubKey) == false) + + driving { alice.keyPackages.publishNew() } + + assertTrue(driving { alice.keyPackages.identitiesWithKeyPackages() }?.contains(alice.pubKey) == true) + } + + @Test + fun `a coordinator that cannot answer is unknown, not nobody`() = + runTest { + // The distinction the whole return type exists for. An empty set + // says the coordinator holds nothing for anyone; null says we never + // found out. A caller that folds them together renders a network + // failure as a claim that somebody cannot be added. + val alice = Account() + driving { alice.keyPackages.publishNew() } + + val blind = + CordnKeyPackages( + accountPubKey = alice.pubKey, + coordinator = UnreachableList(alice.coordinatorClient), + store = InMemoryCordnKeyPackageStore(), + ) + + assertNull(driving { blind.identitiesWithKeyPackages() }) + } + + /** A coordinator whose `kp_list` is the one call that fails. */ + private class UnreachableList( + delegate: ICoordinator, + ) : ICoordinator by delegate { + override suspend fun listKeyPackages(): List = throw IllegalStateException("kp_list unreachable") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt new file mode 100644 index 0000000000..2da3dfc0cc --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The parse-and-disclose step behind the "someone sent me a cordn link" screen. + * + * Headless on purpose: the screen draws whatever this returns, so the rules + * that matter — what counts as a link, what a link without a coordinator means, + * and what §8 says about joining through one — are checkable without a device. + */ +class CordnLinkInspectionTest { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + private val coordinator = "cc".repeat(32) + + private fun link( + withCoordinator: Boolean = true, + relays: List = listOf("wss://relay.example.com/"), + ) = CordnGroupRef( + gid = gid, + coordinatorPubKey = if (withCoordinator) coordinator else null, + relays = if (withCoordinator) relays else emptyList(), + ).encode() + + @Test + fun `a full link resolves to a coordinator and a disclosure`() { + val result = CordnLinkInspection.of(link()) + assertTrue(result is CordnLinkInspection.Valid, "got $result") + assertEquals(gid, result.ref.gid) + assertTrue(result.isFollowable) + + val config = assertNotNull(result.coordinator) + assertEquals(coordinator, config.pubKey) + assertEquals(CoordinatorConfig.Origin.GROUP_REF, config.origin, "a pasted link is not a coordinator the user chose") + + val exposure = assertNotNull(result.exposure) + assertEquals(ExposureLevel.NONE, exposure.content) + assertEquals(ExposureLevel.IDENTIFIED, exposure.membership) + assertTrue(exposure.joinedFromShareLink, "joining by link is the §8.1 path that names you") + } + + @Test + fun `a link naming no coordinator is valid but not followable`() { + // spec/applications/group-ref.md §2 makes the coordinator optional. The + // ref still identifies a group; it just does not say who serves it, and + // there is nobody to disclose anything about. + val result = CordnLinkInspection.of(link(withCoordinator = false)) + assertTrue(result is CordnLinkInspection.Valid) + assertEquals(gid, result.ref.gid) + assertTrue(!result.isFollowable) + assertNull(result.coordinator) + assertNull(result.exposure, "inventing a threat model for an unnamed server would be worse than saying nothing") + } + + @Test + fun `the linkage count describes what joining would create`() { + // §8.2: one throwaway key covers every group on a coordinator, so the + // warning is only true once there is a second group to link to. The + // count has to include the group being joined. + val alone = CordnLinkInspection.of(link()) as CordnLinkInspection.Valid + assertEquals(1, alone.exposure!!.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION !in alone.exposure!!.notes()) + + val joining = CordnLinkInspection.of(link(), existingGroupsOnCoordinator = 2) as CordnLinkInspection.Valid + assertEquals(3, joining.exposure!!.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION in joining.exposure!!.notes()) + } + + @Test + fun `a published key package is disclosed, because the coordinator can re-serve it`() { + val quiet = CordnLinkInspection.of(link()) as CordnLinkInspection.Valid + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD !in quiet.exposure!!.notes()) + + val published = CordnLinkInspection.of(link(), publishedKeyPackage = true) as CordnLinkInspection.Valid + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD in published.exposure!!.notes(), "§8.4") + } + + @Test + fun `pasted whitespace and uppercase still resolve`() { + // What actually arrives from a clipboard: a trailing newline from a + // chat app, or a ref someone typed in caps. Bech32 permits the + // uppercase form and cordn's own decoder accepts it. + assertTrue(CordnLinkInspection.of(" ${link()}\n") is CordnLinkInspection.Valid) + assertTrue(CordnLinkInspection.of(link().uppercase()) is CordnLinkInspection.Valid) + } + + @Test + fun `everything else is refused, with the reason the decoder gave`() { + listOf( + "", + " ", + "nostr1qqqqq", + "cordn1qqqqq", + "npub1xxxx", + "https://cordn.net/g/abc", + ).forEach { + val result = CordnLinkInspection.of(it) + assertTrue(result is CordnLinkInspection.Invalid, "must refuse '$it', got $result") + assertTrue(result.reason.isNotEmpty(), "a refusal with no reason is a dead end for the user") + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentionsTest.kt new file mode 100644 index 0000000000..53cb043bb4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentionsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CordnMentionsTest { + private val alice = KeyPair() + private val aliceHex = alice.pubKey.toHexKey() + private val aliceNpub = NPub.create(aliceHex) + + /** Every segment's original text, concatenated. */ + private fun rebuild(segments: List) = + segments.joinToString("") { + when (it) { + is CordnMentions.Segment.Text -> it.value + is CordnMentions.Segment.Mention -> it.raw + } + } + + @Test + fun `a message with no mentions is one run of text`() { + val segments = CordnMentions.segment("just talking") + + assertEquals(listOf(CordnMentions.Segment.Text("just talking")), segments) + } + + @Test + fun `a mention is split out of the text around it`() { + val content = "hey nostr:$aliceNpub what do you think" + val segments = CordnMentions.segment(content) + + assertEquals(3, segments.size, "text, mention, text") + assertEquals(aliceHex, (segments[1] as CordnMentions.Segment.Mention).pubKey) + assertEquals(content, rebuild(segments), "segmenting must not lose a character") + } + + @Test + fun `a message that is only a mention has no empty text runs around it`() { + val segments = CordnMentions.segment("nostr:$aliceNpub") + + assertEquals(1, segments.size) + assertTrue(segments.single() is CordnMentions.Segment.Mention) + } + + @Test + fun `a bare npub is left as text`() { + // Without `nostr:` this is as likely to be someone quoting a key as + // mentioning a person, and turning it into a name changes what they + // wrote. + val content = "my key is $aliceNpub, check it" + val segments = CordnMentions.segment(content) + + assertEquals(listOf(CordnMentions.Segment.Text(content)), segments) + } + + @Test + fun `an unparseable nostr uri stays text rather than vanishing`() { + // The regex can match something bech32 cannot decode. Dropping it + // would delete part of the message on screen. + val content = "look at nostr:npub1qqqqqqqqqq here" + val segments = CordnMentions.segment(content) + + assertEquals(content, rebuild(segments)) + } + + @Test + fun `punctuation right after a mention is not eaten`() { + val content = "thanks nostr:$aliceNpub!" + val segments = CordnMentions.segment(content) + + assertEquals(content, rebuild(segments)) + assertEquals(CordnMentions.Segment.Text("!"), segments.last()) + } + + @Test + fun `the same person mentioned twice is listed once`() { + val mentioned = CordnMentions.mentioned("nostr:$aliceNpub and again nostr:$aliceNpub") + + assertEquals(listOf(aliceHex), mentioned) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt new file mode 100644 index 0000000000..4148e8d558 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The send side of cordn's annotation kinds. + * + * The read side has been able to fold reactions, edits, deletions and pins + * since the protocol work landed; until now nothing could produce them. What + * is worth testing here is not the tag shapes — `CordnMessageReferences` owns + * those and has its own round-trip tests — but that the manager refuses the + * sends whose results would be silently discarded by the very fold that + * receives them. + */ +class CordnMessageActionsTest { + private val coordinatorKey = "d".repeat(64) + private val aliceSigner = NostrSignerSync(KeyPair()) + private val alice: HexKey get() = aliceSigner.pubKey + private val bob: HexKey = "b".repeat(64) + private val gid = "actions" + + private fun manager(coordinator: FakeCoordinator) = + CordnGroupManager( + accountPubKey = alice, + config = + CoordinatorConfig( + pubKey = coordinatorKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + origin = CoordinatorConfig.Origin.DEFAULT, + ), + coordinator = coordinator, + store = InMemoryCordnGroupStore(), + clock = { 1_757_000_000L }, + ) + + private fun target( + author: HexKey, + id: String = "aa".repeat(32), + ) = CordnMessageReferences.Target(id = id, pubKey = author, kind = CordnMessageKinds.TEXT) + + @Test + fun `a reply goes out as a NIP-22 thread reply`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + val sent = manager.post(gid, "in reply", replyTo = target(bob)).envelope + + assertEquals(CordnMessageKinds.THREAD_REPLY, sent.kind) + assertEquals("in reply", sent.content) + assertTrue(sent.tags.any { it.firstOrNull() == "E" }, "a reply carries a thread root") + } + + @Test + fun `a reaction is anyone's to send`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + val sent = manager.post(gid, "👍", reactionTo = target(bob)).envelope + + assertEquals(CordnMessageKinds.REACTION, sent.kind) + assertEquals("👍", sent.content) + } + + @Test + fun `editing someone else's message fails at the call instead of on delivery`() = + runTest { + // CordnAnnotationIndex is author-only for edits, so this would be + // accepted by the coordinator, stored, delivered -- and then + // dropped by every client including the sender's. A send nobody + // can see fail is worse than one that throws. + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + assertFailsWith { + manager.post(gid, "not mine to change", editTo = target(bob)) + } + } + + @Test + fun `deleting someone else's message fails at the call`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + assertFailsWith { + manager.post(gid, deleteTo = target(bob)) + } + } + + @Test + fun `editing and deleting your own message is allowed`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + assertEquals(CordnMessageKinds.EDIT, manager.post(gid, "fixed", editTo = target(alice)).envelope.kind) + assertEquals(CordnMessageKinds.DELETION, manager.post(gid, deleteTo = target(alice)).envelope.kind) + } + + @Test + fun `pinning someone else's message is allowed, because a pin is any member's`() = + runTest { + // §5.1: reaction is anyone's, pin is any member's, only edit and + // deletion are author-only. Checking all five the same way would + // invent a rule cordn does not have. + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + val sent = manager.post(gid, pinTo = target(bob)).envelope + + assertEquals(CordnMessageKinds.PIN, sent.kind) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt new file mode 100644 index 0000000000..76ffe85e4f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDeviceTip +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentSeal +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnGroupDocument +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnLastResortKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipEntry +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipInventory +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The whole handoff, old phone to new phone, without a phone. + * + * The cases here are the ones where a migration fails as *plausible data* + * rather than as an error: a group silently missing, state that decrypts but + * was written by a stranger, or bytes that are not the ones the tip named. + */ +class CordnMigrationTest { + private val account = NostrSignerInternal(KeyPair()) + + /** Real codec output, so this travels the same bytes the store writes. */ + private val entryOne = + CordnDeliveredMessageCodec.encode( + CordnDeliveredMessage(CordnEnvelope.build("aa".repeat(32), 1_757_000_000L, 9, content = "first"), cursor = 1), + ) + + private val entryTwo = + CordnDeliveredMessageCodec.encode( + CordnDeliveredMessage(CordnEnvelope.build("aa".repeat(32), 1_757_000_060L, 9, content = "second"), cursor = 2), + ) + private val relay = RelayUrlNormalizer.normalize("wss://tip.example") + + private val snapshot = + CordnMigrationSnapshot( + accountPubKey = account.pubKey, + groups = + listOf( + group("gid-1", cursor = 7), + group("gid-2", cursor = 0, joinedViaRequest = true), + ), + lastResortKeyPackage = CordnLastResortKeyPackage("a2s=", "cHJpdg=="), + keyPackages = listOf(CordnCarriedKeyPackage("cc".repeat(32), "ref-1", "YnVuZGxl")), + ) + + @Test + fun `a snapshot survives the round trip`() = + runTest { + val world = World(this) + + val code = world.old().publish(snapshot, setOf(relay)) + val received = world.new(account).fetch(code) + + assertEquals(snapshot, received) + } + + @Test + fun `every group makes it, with its cursor and its flags`() = + runTest { + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals(listOf("gid-1", "gid-2"), received.groups.map { it.gid }) + assertEquals(7L, received.groups[0].cursor) + assertTrue(received.groups[1].joinedViaRequest) + } + + @Test + fun `the draft, read position and coordinator relays survive`() = + runTest { + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals("cm9vbQ==", received.groups[0].roomStateBase64) + assertEquals("ZWNobw==", received.groups[0].echoStateBase64) + assertEquals(listOf("wss://coord.example"), received.groups[0].coordinatorRelays) + } + + @Test + fun `the conversation travels, because nothing else can carry it`() = + runTest { + // The one piece of a handoff that has no second source. A group's + // MLS state can be re-derived from the coordinator's stream and a + // KeyPackage can be republished, but a cordn message is readable + // exactly once — at ingest — and the cursor in this very document + // has already moved past it. A device seeded without the messages + // arrives holding every group and no conversation, for good. + val world = World(this) + val withHistory = + snapshot.copy( + groups = listOf(group("gid-1", cursor = 7, messages = listOf(entryOne, entryTwo))), + ) + + val received = world.new(account).fetch(world.old().publish(withHistory, setOf(relay))) + + assertEquals(listOf(entryOne, entryTwo), received.groups[0].messages) + } + + @Test + fun `a group with no history carries no messages field`() = + runTest { + // The field is additive, so an empty list must not become an empty + // array in the document and come back as something other than what + // went in — the round-trip equality above depends on it. + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals(emptyList(), received.groups[0].messages) + } + + @Test + fun `the key packages travel, so a Welcome in flight is not lost`() = + runTest { + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals(snapshot.keyPackages, received.keyPackages) + assertEquals(snapshot.lastResortKeyPackage, received.lastResortKeyPackage) + } + + @Test + fun `an account that is not the writer cannot read the tip`() = + runTest { + // The tip is NIP-44 sealed to the owner, so a different account + // fails at the decrypt before any signature check. + val world = World(this) + val code = world.old().publish(snapshot, setOf(relay)) + + val thrown = + runCatching { world.new(NostrSignerInternal(KeyPair())).fetch(code) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + } + + @Test + fun `a blob that does not match its address is refused`() = + runTest { + // The §6 check. Without it a storage server could substitute MLS + // state of its choosing and the new phone would adopt it. + val world = World(this) + val code = world.old().publish(snapshot, setOf(relay)) + world.store.corruptOne() + + val thrown = runCatching { world.new(account).fetch(code) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("does not hash")) + } + + @Test + fun `a missing blob fails loudly rather than migrating a subset`() = + runTest { + // A partial migration is the worst outcome: the user arrives on the + // new phone with some conversations and no indication any are gone. + val world = World(this) + val code = world.old().publish(snapshot, setOf(relay)) + world.store.dropOne() + + val thrown = runCatching { world.new(account).fetch(code) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("no server served")) + } + + @Test + fun `a migration written by another MLS engine is refused with a reason`() = + runTest { + // The cordn-web case. §4.2 leaves clientState library-private, so + // their ClientState is unreadable here — and it arrives at a valid + // address under a correctly owner-signed tip, so every other check + // passes and only the format marker catches it. Built by hand + // because our own publish() can never produce it. + val world = World(this) + world.publishForeignTip("ts-mls") + + val thrown = + runCatching { + world.new(account).fetch( + CordnHandoffCode( + ephemeralPubKey = world.published.last().pubKey, + dTag = + world.published + .last() + .tags + .first { it[0] == "d" }[1], + relays = listOf(relay.url), + ), + ) + }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("different MLS engine")) + } + + @Test + fun `a tip whose inner event was signed by someone else is refused`() = + runTest { + // Not reachable by an outsider — the outer content is a self-seal, + // so only the owner can produce something the owner will decrypt. + // It IS reachable by a bug on the writing side, and adopting MLS + // state under a credential naming someone else would make every + // Commit the new phone sent get rejected by the whole group. + val world = World(this) + world.publishTipSignedBy(NostrSignerInternal(KeyPair())) + + val thrown = runCatching { world.new(account).fetch(world.lastCode()) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("different account")) + } + + @Test + fun `a code with no relays cannot be published`() = + runTest { + val world = World(this) + + val thrown = runCatching { world.old().publish(snapshot, emptySet()) }.exceptionOrNull() + + assertTrue(thrown is IllegalArgumentException) + } + + @Test + fun `nothing stored means nothing advertised`() = + runTest { + // Publishing a tip that names blobs no server holds would hand the + // user a code that fails on the other phone, after the old one is + // already locked. + val world = World(this, acceptUploads = false) + + val thrown = runCatching { world.old().publish(snapshot, setOf(relay)) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + } + + @Test + fun `the code grants no write and names no owner key`() = + runTest { + val world = World(this) + + val code = world.old().publish(snapshot, setOf(relay)) + + assertTrue(!code.grantsWrite) + assertTrue(code.ephemeralPubKey != account.pubKey) + } + + @Test + fun `the tip event leaks neither the owner nor cordn`() = + runTest { + val world = World(this) + world.old().publish(snapshot, setOf(relay)) + + val tip = world.published.single() + + assertTrue(tip.pubKey != account.pubKey) + assertEquals(CordnDeviceTip.OUTER_KIND, tip.kind) + assertTrue(!tip.content.contains(account.pubKey)) + assertTrue(!tip.content.contains("gid-1")) + assertEquals(listOf("d"), tip.tags.map { it[0] }) + } + + private fun group( + gid: String, + cursor: Long, + joinedViaRequest: Boolean = false, + messages: List = emptyList(), + ) = CordnMigrationGroup( + coordinatorPubKey = "cc".repeat(32), + coordinatorRelays = listOf("wss://coord.example"), + gid = gid, + clientStateBase64 = "c3RhdGUt$gid", + cursor = cursor, + roomStateBase64 = "cm9vbQ==", + echoStateBase64 = "ZWNobw==", + joinedViaRequest = joinedViaRequest, + messages = messages, + ) + + /** The two phones, one relay and one storage server, in memory. */ + private inner class World( + scope: CoroutineScope, + acceptUploads: Boolean = true, + ) { + val store = FakeBlobStore(acceptUploads) + val published = mutableListOf() + val client = LoopbackClient(scope, published) + + fun old() = CordnMigration(client, account, store) + + fun new(signer: NostrSignerInternal) = CordnMigration(client, signer, store) + + fun lastCode() = + CordnHandoffCode( + ephemeralPubKey = published.last().pubKey, + dTag = published.last().tags.first { it[0] == "d" }[1], + relays = listOf(relay.url), + ) + + /** A tip the owner sealed but somebody else signed inside. */ + suspend fun publishTipSignedBy(other: NostrSignerInternal) { + val dek = CordnDocumentSeal.newKey() + val inventory = + CordnTipInventory( + groups = emptyList(), + meta = null, + dekPrivateKey = dek.privKey!!.toHexString(), + servers = listOf(FakeBlobStore.SERVER), + ) + val inner = + other.sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.INNER_KIND, + tags = CordnDeviceTip.tags(inventory), + content = "", + ) + val outer = + NostrSignerInternal(KeyPair()).sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.OUTER_KIND, + tags = arrayOf(arrayOf("d", "mismatched-d")), + content = account.nip44Encrypt(JacksonMapper.toJson(inner), account.pubKey), + ) + client.publish(outer, setOf(relay)) + } + + /** + * Publishes a tip a foreign client would have written: a real document + * at a real address under a real owner signature, whose clientState + * only its own engine can read. + */ + suspend fun publishForeignTip(format: String) { + val dek = CordnDocumentSeal.newKey() + val document = + CordnGroupDocument( + gid = "gid-theirs", + coordinator = "cc".repeat(32), + clientState = "dGhlaXJz", + cursor = 0, + clientStateFormat = format, + ) + val blob = CordnDocumentSeal.seal(document, dek) + store.put(blob) + + val inventory = + CordnTipInventory( + groups = listOf(CordnTipEntry(CordnDocumentSeal.address(blob), "gid-theirs")), + meta = null, + dekPrivateKey = dek.privKey!!.toHexString(), + servers = listOf(FakeBlobStore.SERVER), + ) + + val inner = + account.sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.INNER_KIND, + tags = CordnDeviceTip.tags(inventory), + content = "", + ) + val ephemeral = KeyPair() + val outer = + NostrSignerInternal(ephemeral).sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.OUTER_KIND, + tags = arrayOf(arrayOf("d", "theirs-d")), + content = account.nip44Encrypt(JacksonMapper.toJson(inner), account.pubKey), + ) + client.publish(outer, setOf(relay)) + } + + /** The DEK the last publish minted, read back out of the tip. */ + suspend fun dek(): KeyPair { + val inner = account.nip44Decrypt(published.last().content, account.pubKey) + val parsed = + CordnDeviceTip.parse( + com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper + .fromJson(inner), + ) + return KeyPair(privKey = parsed.dekPrivateKey.hexToByteArray()) + } + } + + private class FakeBlobStore( + private val accept: Boolean, + ) : CordnBlobStore { + val blobs = LinkedHashMap() + + override suspend fun put(blob: ByteArray): List { + if (!accept) return emptyList() + blobs[sha256(blob).toHexString()] = blob + return listOf(SERVER) + } + + override suspend fun get( + address: String, + servers: List, + ): ByteArray? = blobs[address] + + /** Serve different bytes under the first group's address. */ + fun corruptOne() { + val key = blobs.keys.first() + blobs[key] = "not the document".encodeToByteArray() + } + + fun dropOne() { + blobs.remove(blobs.keys.first()) + } + + companion object { + const val SERVER = "https://blobs.example" + } + } + + /** + * One relay, in memory: accepts a publish with an OK and serves matching + * events back on a REQ. + */ + private class LoopbackClient( + private val scope: CoroutineScope, + private val published: MutableList, + ) : INostrClient by EmptyNostrClient() { + private val listeners = mutableListOf() + + override fun addConnectionListener(listener: RelayConnectionListener) { + listeners += listener + } + + override fun removeConnectionListener(listener: RelayConnectionListener) { + listeners -= listener + } + + override fun publish( + event: Event, + relayList: Set, + ) { + published += event + // publishAndConfirm waits for an OK; a relay that only swallowed + // the event would look like a timeout. + val ok = OkMessage.accepted(event.id) + relayList.forEach { url -> + scope.launch(Dispatchers.Unconfined) { + listeners.toList().forEach { it.onIncomingMessage(FakeRelay(url), "", ok) } + } + } + } + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + val target = listener ?: return + scope.launch(Dispatchers.Unconfined) { + filters.forEach { (relay, list) -> + published + .filter { event -> list.any { it.match(event) } } + .forEach { target.onEvent(it, false, relay, null) } + target.onEose(relay, null) + } + } + } + + override fun unsubscribe(subId: String) = Unit + } + + private class FakeRelay( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun disconnect() = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun sendOrConnectAndSync(cmd: Command) = Unit + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoopTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoopTest.kt new file mode 100644 index 0000000000..38efb8ca15 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoopTest.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * The loop that keeps one coordinator's groups current. + * + * Every test here is about the loop misbehaving rather than working: an + * account with no groups burning a core, a coordinator outage ending the + * session's sync silently, a backoff that never comes back down, a group + * joined into a subscription that was already open. The happy path is one + * `catch_up` and one `subscribe`, and it is not what this class is for. + */ +class CordnSyncLoopTest { + /** + * A [CordnSyncSource] that does exactly what it is told. + * + * `subscribe` parks on a signal rather than returning, because a real one + * holds the stream open — a fake that returned immediately would turn every + * test below into a spin and hide the very bug the no-spin test is for. + */ + private class FakeSource : CordnSyncSource { + private val _gids = MutableStateFlow>(emptySet()) + override val gids: StateFlow> = _gids.asStateFlow() + + var catchUps = 0 + var subscribes = 0 + var failCatchUpTimes = 0 + + /** + * How many catchUps blow their RPC budget. + * + * A real one throws [TimeoutCancellationException] -- a + * `CancellationException` -- which is a different failure from + * [failCatchUpTimes]'s plain exception and was, for a while, fatal to + * the loop. Produced with a real `withTimeout` rather than a + * hand-constructed instance, so the test cannot drift from what the + * coordinator client actually throws. + */ + var timeOutCatchUpTimes = 0 + + /** + * How many subscribes spend their budget instead of parking. Bounded, + * because a fake that always spends it turns the loop into a spin in + * virtual time and the test never returns. + */ + var subscribeSpendsBudgetTimes = 0 + + /** Handed to onDelivery once per catchUp, to prove the wiring. */ + var deliverOnCatchUp: CordnGroupManager.Delivery? = null + + /** Completed when the test wants the open subscription to close. */ + private val closeStream = MutableStateFlow(0) + + /** The gid sets each subscribe was opened for, in order. */ + val subscribedWith = mutableListOf>() + + fun setGroups(vararg gid: String) { + _gids.value = gid.toSet() + } + + fun closeOpenStream() { + closeStream.value++ + } + + override suspend fun catchUp(onDelivery: (CordnGroupManager.Delivery) -> Unit): Int { + catchUps++ + if (timeOutCatchUpTimes > 0) { + timeOutCatchUpTimes-- + withTimeout(1) { delay(1_000) } + } + if (failCatchUpTimes > 0) { + failCatchUpTimes-- + throw CordnGroupException("coordinator is down") + } + deliverOnCatchUp?.let(onDelivery) + return 0 + } + + override suspend fun subscribe( + timeoutMs: Long, + onDelivery: (CordnGroupManager.Delivery) -> Unit, + ) { + subscribes++ + subscribedWith += _gids.value + if (subscribeSpendsBudgetTimes > 0) { + subscribeSpendsBudgetTimes-- + withTimeout(1) { delay(1_000) } + } + val opened = closeStream.value + closeStream.first { it != opened } + } + } + + private fun loopOver( + source: FakeSource, + onDelivery: (CordnGroupManager.Delivery) -> Unit = {}, + ) = CordnSyncLoop(source, onDelivery, minBackoffMs = 1_000, maxBackoffMs = 8_000) + + @Test + fun `an account with no groups parks instead of spinning`() = + runTest { + // catchUp and subscribe are both no-ops with no groups, so a plain + // while(true) burns a core on every fresh account — and does it + // silently, because nothing fails. + val source = FakeSource() + val loop = loopOver(source) + loop.start(this) + + advanceTimeBy(60_000) + runCurrent() + + assertEquals(CordnSyncLoop.State.NoGroups, loop.state.value) + assertEquals(0, source.catchUps, "it called the coordinator with nothing to sync") + assertEquals(0, source.subscribes) + loop.stop() + } + + @Test + fun `it starts syncing as soon as the first group appears`() = + runTest { + // The other half: parking must not mean sleeping through the join. + val source = FakeSource() + val loop = loopOver(source) + loop.start(this) + runCurrent() + assertEquals(CordnSyncLoop.State.NoGroups, loop.state.value) + + source.setGroups("g1") + runCurrent() + + assertEquals(1, source.catchUps) + assertEquals(CordnSyncLoop.State.Live, loop.state.value) + loop.stop() + } + + @Test + fun `catch-up runs before the subscription`() = + runTest { + // Order, not preference: a subscription opened first would deliver + // from the live edge while catch-up is still walking history, and + // the UI would show today above last week. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + assertEquals(1, source.catchUps) + assertEquals(1, source.subscribes) + loop.stop() + } + + @Test + fun `a coordinator outage does not end the loop`() = + runTest { + // The failure this class exists for. An exception that escapes + // stops syncing for the rest of the session, and from the UI that + // is indistinguishable from a group where nobody is talking. + val source = FakeSource() + source.failCatchUpTimes = 3 + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + assertIs(loop.state.value) + + advanceTimeBy(30_000) + runCurrent() + + assertEquals(CordnSyncLoop.State.Live, loop.state.value, "it never recovered") + assertEquals(1, source.subscribes) + loop.stop() + } + + @Test + fun `the backoff grows, caps, and resets after a success`() = + runTest { + // Growing matters so a dead coordinator is not hammered. Resetting + // matters more: without it one bad stretch leaves the loop at its + // maximum delay for the rest of the session, so the next outage + // costs a full cap even though the coordinator is fine. + val source = FakeSource() + source.failCatchUpTimes = 5 + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + val waits = mutableListOf() + repeat(5) { + val state = loop.state.value + if (state is CordnSyncLoop.State.Retrying) { + waits += state.inMs + advanceTimeBy(state.inMs + 1) + runCurrent() + } + } + + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L, 8_000L), waits, "growth or cap is wrong") + assertEquals(CordnSyncLoop.State.Live, loop.state.value) + + // Now fail once more: if the backoff had not reset, this would be + // the cap rather than the floor. + source.failCatchUpTimes = 1 + source.closeOpenStream() + runCurrent() + + val after = loop.state.value + assertIs(after) + assertEquals(1_000L, after.inMs, "the backoff did not reset on success") + loop.stop() + } + + @Test + fun `a closed stream is re-subscribed, not treated as a failure`() = + runTest { + // subscribe() returns normally on timeout. Counting that as an + // error would put a healthy loop into permanent backoff. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + assertEquals(1, source.subscribes) + + source.closeOpenStream() + runCurrent() + + assertEquals(2, source.subscribes) + assertEquals(CordnSyncLoop.State.Live, loop.state.value, "a timeout was treated as an outage") + loop.stop() + } + + @Test + fun `an RPC that times out is retried, not the end of the loop`() = + runTest { + // A coordinator that does not answer inside the 20-second RPC + // budget throws TimeoutCancellationException, which IS a + // CancellationException. Rethrowing it ended run() -- the job + // completed, the state stayed CatchingUp, and the account received + // no cordn message again until the app was restarted. Observed on + // device: one msg_fetch_many timeout, then silence. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + source.timeOutCatchUpTimes = 1 + loop.start(this) + // The budget is virtual time, so it only expires once time moves. + advanceTimeBy(10) + runCurrent() + + assertIs(loop.state.value, "a timeout ended the loop") + + advanceTimeBy(1_100) + runCurrent() + + assertEquals(2, source.catchUps, "it never tried again") + assertEquals(CordnSyncLoop.State.Live, loop.state.value) + loop.stop() + } + + @Test + fun `a subscription that spends its budget re-subscribes`() = + runTest { + // msg_sub_many is given a total-time budget and throws when it runs + // out; the stream ending on schedule is the normal case the loop + // exists to re-open. As a CancellationException it did neither -- + // it ended the loop. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + source.subscribeSpendsBudgetTimes = 1 + loop.start(this) + advanceTimeBy(10) + runCurrent() + + assertEquals(CordnSyncLoop.State.Live, loop.state.value, "a spent budget was treated as an outage") + assertEquals(2, source.subscribes, "it did not re-subscribe") + loop.stop() + } + + @Test + fun `joining a group re-opens the subscription with it`() = + runTest { + // A subscription is opened for a fixed set of gids, so a group + // joined a moment later is not in it. Without this the new room + // stays empty until the stream times out — up to a minute of a + // chat that looks broken. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + assertEquals(listOf(setOf("g1")), source.subscribedWith) + + source.setGroups("g1", "g2") + runCurrent() + + assertEquals(setOf("g1", "g2"), source.subscribedWith.last(), "the new group was not picked up") + assertEquals(2, source.subscribes) + loop.stop() + } + + @Test + fun `stopping ends the loop and leaves nothing running`() = + runTest { + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + loop.stop() + val subscribesAtStop = source.subscribes + source.closeOpenStream() + advanceTimeBy(120_000) + runCurrent() + + assertEquals(CordnSyncLoop.State.Stopped, loop.state.value) + assertEquals(subscribesAtStop, source.subscribes, "it kept running after stop()") + } + + @Test + fun `starting twice does not double every message`() = + runTest { + // Two loops on one session would both catch up and both deliver, + // so every message would appear twice in the room. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + + loop.start(this) + loop.start(this) + runCurrent() + + assertEquals(1, source.catchUps) + assertEquals(1, source.subscribes) + loop.stop() + } + + @Test + fun `deliveries reach the caller`() = + runTest { + // The loop's whole output. A loop that syncs perfectly and drops + // what it syncs is the same as one that does not run. + val delivered = mutableListOf() + val source = FakeSource() + source.deliverOnCatchUp = CordnGroupManager.Delivery.Undecryptable("g1", 7, "sample") + val loop = loopOver(source) { delivered += it } + source.setGroups("g1") + loop.start(this) + runCurrent() + + assertEquals(1, delivered.size, "nothing reached the caller") + val only = delivered.single() + assertIs(only) + assertEquals("g1", only.gid) + assertEquals(7L, only.cursor) + loop.stop() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportHarness.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportHarness.kt new file mode 100644 index 0000000000..2e2812df14 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportHarness.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield + +/** + * Accounts wired to a coordinator through the **real** ContextVM stack. + * + * `CvmTransport` → `InMemoryRelayPool` → `CvmFixtureServer` → + * `CordnFixtureCoordinator`. The only stand-ins are the relay (in memory) and + * the coordinator (ours, because the reference one is unlicensed — see + * `quartz/plans/2026-09-17-cordn-interop.md` §7). Everything between an account + * and the relay is the shipped code. + * + * **Encryption is left at its default**, which is `EncryptionMode.REQUIRED` + * (§8.6). Passing `DISABLED` for convenience would test a transport we do not + * ship, and it is the kind of convenience that survives into the one test that + * was supposed to catch a downgrade. + */ +abstract class CordnTransportHarness { + protected val relays = InMemoryRelayPool() + protected val serverSigner = NostrSignerInternal(KeyPair()) + protected val coordinator = CordnFixtureCoordinator() + + /** + * A second, unrelated coordinator. + * + * Present because several rules are only visible with two — a `gid` is + * unique within one coordinator (`spec/00.md` §4), so "these are two + * different groups" cannot be stated at all against a single fixture. + */ + protected val secondServerSigner = NostrSignerInternal(KeyPair()) + protected val secondCoordinator = CordnFixtureCoordinator() + + protected val secondCoordinatorPubKey: HexKey get() = secondServerSigner.pubKey + + protected val config = + CoordinatorConfig( + pubKey = serverSigner.pubKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + ) + + /** One account: a stable identity, a per-session ephemeral one, and its state. */ + protected inner class Account { + val stable = NostrSignerInternal(KeyPair()) + val ephemeral = NostrSignerInternal(KeyPair()) + val pubKey: HexKey get() = stable.pubKey + + /** + * The identity split of `spec/00.md` §8, wired as production does it. + * Which key signs which call is fixed by `CoordinatorMethod`, so a test + * cannot accidentally widen it — only this pairing can be wrong. + */ + val coordinatorClient = clientFor(serverSigner.pubKey) + + /** This account's client for whichever coordinator [serverPubKey] names. */ + fun clientFor(serverPubKey: HexKey) = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stable, ephemeral), + serverPubKey = serverPubKey, + crypto = CvmGiftWrap(), + ), + ), + ) + + val groupStore = InMemoryCordnGroupStore() + val keyPackageStore = InMemoryCordnKeyPackageStore() + + val keyPackages = CordnKeyPackages(stable.pubKey, coordinatorClient, keyPackageStore) + + val manager = + CordnGroupManager( + accountPubKey = stable.pubKey, + config = config, + coordinator = coordinatorClient, + store = groupStore, + clock = { 1_757_000_000L }, + ) + } + + private fun serve() = + listOf(serverSigner to coordinator, secondServerSigner to secondCoordinator).map { (signer, fixture) -> + CvmFixtureServer( + relays = relays, + signer = signer, + crypto = CvmGiftWrap(), + // CEP-16: how the coordinator learns who is calling, and what every + // identity-split assertion rests on. + injectClientPubkey = true, + handler = fixture::handle, + ).also { server -> + server.start() + // `msg_sub_many` answers with stream frames rather than a + // result, so the fixture needs a way back onto the transport. + // Wired here because only this layer holds both halves. + fixture.emitStream = { frames, clientPubKey, requestEventId -> + frames.forEach { server.reply(it, clientPubKey, requestEventId) } + } + } + } + + /** + * Runs [block] while pumping the server. + * + * Explicit because a relay callback cannot suspend. The spin guard turns a + * call nobody answers into a named failure instead of a test that looks + * merely slow. + */ + protected suspend fun driving(block: suspend () -> T): T = + coroutineScope { + val servers = serve() + val work = async { block() } + var spins = 0 + while (!work.isCompleted) { + yield() + servers.forEach { it.pump() } + yield() + check(++spins < 100_000) { "the fixture never answered — something is not replying" } + } + work.await() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportIntegrationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportIntegrationTest.kt new file mode 100644 index 0000000000..8a4aea054d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportIntegrationTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Two accounts, one coordinator, and the **real** ContextVM stack between them. + * + * `CordnGroupManagerTest` drives the manager against a plain Kotlin object. That + * proves its logic and nothing about the wire: no gift wrap, no relay, no + * JSON-RPC framing, no CEP-16 `_meta`, no stable/ephemeral identity split. Every + * one of those is a place the whole feature can be broken while that suite stays + * green. + * + * This is the same lifecycle over `CvmTransport` → `InMemoryRelayPool` → + * `CvmFixtureServer` → `CordnFixtureCoordinator`. The only thing standing in for + * production is the relay (in memory) and the coordinator (ours, because the + * reference one is unlicensed — see `quartz/plans/2026-09-17-cordn-interop.md` + * §7). Everything between the manager and the relay is the shipped code. + * + * **Encryption is left at its default.** `CvmGiftWrap` defaults to + * `EncryptionMode.REQUIRED` and §8.6 of that plan says it must; a test that + * passed `DISABLED` for convenience would be testing a transport we do not ship. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnTransportIntegrationTest : CordnTransportHarness() { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + + /** A KeyPackage bundle for [account], published through the real transport. */ + private suspend fun publishKeyPackage(account: Account): Pair { + val published = driving { account.keyPackages.publishNew() } + val bundle = account.keyPackages.bundleFor(published.keyPackageRef)!! + return bundle to published.keyPackageRef + } + + @Test + fun `alice invites bob over the wire, and bob joins and reads`() = + runTest { + val alice = Account() + val bob = Account() + + // Bob publishes a KeyPackage. The coordinator keeps the signed + // kind-25910 request event, because spec/00.md §7 has no KeyPackage + // event kind and that request IS the publication payload. + val (bobBundle, bobRef) = publishKeyPackage(bob) + + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Over the wire", adminPubkeys = listOf(alice.pubKey))) + + // invite() takes Bob's KeyPackage back off the coordinator and runs + // the §9 verification on the publication event before adding him. + // That check only means something because the event round-tripped. + val invite = driving { alice.manager.invite(gid, bob.pubKey) } + assertEquals(bob.pubKey, invite.invited) + assertEquals(1L, alice.manager.group(gid)!!.epoch) + + driving { alice.manager.send(gid, "hello over the wire") } + + val joined = driving { bob.manager.joinPendingWelcomes({ ref -> bobBundle.takeIf { ref == bobRef } }) } + assertEquals(listOf(gid), joined.joined, "skipped: ${joined.skipped}") + + val delivered = mutableListOf() + driving { bob.manager.catchUp { delivered += it } } + + val messages = delivered.filterIsInstance() + assertEquals(1, messages.size, "got ${delivered.map { it::class.simpleName }}") + assertEquals("hello over the wire", messages[0].received.envelope.content) + assertEquals(alice.pubKey, messages[0].received.sender) + } + + @Test + fun `nothing readable crosses the relay`() = + runTest { + // The claim §8 makes about content, checked against the actual bytes + // on the actual wire rather than against the manager's intent. + val alice = Account() + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Secret")) + driving { alice.manager.send(gid, "a very distinctive plaintext") } + + assertTrue(relays.published.isNotEmpty(), "nothing was published — this test would pass vacuously") + relays.published.forEach { event -> + assertTrue( + !event.content.contains("a very distinctive plaintext"), + "message text reached the relay in the clear", + ) + assertTrue(!event.content.contains(gid), "the group id reached the relay in the clear") + } + } + + @Test + fun `the message path never names the account`() = + runTest { + // spec/00.md §8: msg_post, msg_fetch_many and kp_take ride a + // throwaway key. A client that signed them with the account key + // would work perfectly and tie every message to a real npub on a + // server that keeps ordered history forever. + val alice = Account() + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Quiet")) + + driving { + alice.manager.send(gid, "one") + alice.manager.catchUp { } + } + + val messagePathCalls = coordinator.calls.filter { it.method in setOf("msg_post", "msg_fetch_many") } + assertTrue(messagePathCalls.isNotEmpty()) + messagePathCalls.forEach { + assertEquals(alice.ephemeral.pubKey, it.callerPubKey, "${it.method} must not be attributable to the account") + } + + // ...while publishing a KeyPackage deliberately does name it (§8.4). + driving { alice.manager.publishKeyPackage("ref", "a2s=") } + val publish = assertNotNull(coordinator.calls.lastOrNull { it.method == "kp_publish" }) + assertEquals(alice.pubKey, publish.callerPubKey, "kp_publish is a signed record of this account, by design") + } + + @Test + fun `a welcome addressed to bob is invisible to alice`() = + runTest { + // welcome-delivery.md: the coordinator serves a Welcome to the member + // it names. If it broadcast them, the join test above would pass for + // the wrong reason and any account could join any group. + val alice = Account() + val bob = Account() + val (_, bobRef) = publishKeyPackage(bob) + + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Private")) + driving { alice.manager.invite(gid, bob.pubKey) } + + val aliceSees = driving { alice.manager.joinPendingWelcomes({ null }) } + assertTrue(aliceSees.joined.isEmpty()) + assertTrue( + aliceSees.skipped.isEmpty(), + "alice was offered a Welcome addressed to bob: ${aliceSees.skipped}", + ) + + // Bob is offered it, and only lacks the private half here. + val bobSees = driving { bob.manager.joinPendingWelcomes({ null }) } + assertEquals(1, bobSees.skipped.size) + assertEquals(bobRef, bobSees.skipped.single().keyPackageRef) + } + + @Test + fun `a live subscription delivers over the wire, not just a catch-up fetch`() = + runTest { + // The path that had no coverage at all. `msg_sub_many` is the one + // coordinator tool whose answer is not its result: it holds the + // call open and pushes each message as a CEP-41 stream fragment, + // which `CoordinatorClient` reassembles through `onStreamFragment`. + // Everything else about cordn delivery was exercised through + // `msg_fetch_many`, so the half the sync loop actually lives in was + // the untested one. + val alice = Account() + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Live")) + driving { alice.manager.send(gid, "pushed, not polled") } + + val delivered = mutableListOf() + driving { alice.manager.subscribe(timeoutMs = 2_000) { delivered += it } } + + // Alice's own message comes back as an Echo -- she sent it, so she + // cannot decrypt it, and that is the shape the sync loop files. + assertTrue(delivered.isNotEmpty(), "nothing arrived over the subscription") + assertTrue( + delivered.all { it.gid == gid }, + "a subscription must not deliver another group's stream: ${delivered.map { it.gid }}", + ) + } + + @Test + fun `a subscriber reads what another member said, live`() = + runTest { + val alice = Account() + val bob = Account() + val (bobBundle, bobRef) = publishKeyPackage(bob) + + val gid = "live-room" + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Live")) + driving { alice.manager.invite(gid, bob.pubKey) } + driving { bob.manager.joinPendingWelcomes({ ref -> bobBundle.takeIf { ref == bobRef } }) } + driving { bob.manager.catchUp { } } + + driving { alice.manager.send(gid, "said out loud") } + + val delivered = mutableListOf() + driving { bob.manager.subscribe(timeoutMs = 2_000) { delivered += it } } + + val messages = delivered.filterIsInstance() + assertEquals( + listOf("said out loud"), + messages.map { it.received.envelope.content }, + "got ${delivered.map { it::class.simpleName }}", + ) + assertEquals(alice.pubKey, messages.single().received.sender, "MLS-authenticated, not claimed") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FakeCoordinator.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FakeCoordinator.kt new file mode 100644 index 0000000000..9470e587b7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FakeCoordinator.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A coordinator that behaves, in memory. + * + * It models the two things `spec/00.md` makes the coordinator responsible for + * and that the manager's correctness depends on: a **monotonic per-group + * cursor** and **per-account Welcome inboxes**. Everything else it stores + * verbatim, which is also what a real one does — §8: it cannot parse payloads + * and does not try. + * + * Wire shapes are not this fake's job. Those are pinned against cordn's own zod + * schemas in `quartz`'s `CoordinatorContractVectorTest`; here the point is the + * manager's behaviour on top of them. + */ +class FakeCoordinator( + /** The account the manager under test is calling as, for Welcome routing. */ + private val callerPubKey: HexKey, +) : ICoordinator { + /** + * A second account's view of *this* coordinator's storage. + * + * The caller is fixed at construction because Welcome routing depends on + * it, so two accounts on one coordinator need two instances over one set + * of maps rather than one instance with a mutable caller. Everything + * stored is shared; only who is asking differs — which is exactly the + * situation a join request exists to handle. + */ + fun viewAs(otherPubKey: HexKey): FakeCoordinator = + FakeCoordinator(otherPubKey).also { + it.keyPackages = keyPackages + it.welcomes = welcomes + it.streams = streams + it.joinRequests = joinRequests + } + + class StoredKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val base64: String, + /** + * The signed `kp_publish` request event (`spec/00.md` §7), or null when + * this fake recorded the publish itself. + * + * Null is structural, not laziness. `ICoordinator.publishKeyPackage` + * carries a ref and bytes and nothing else — the publication event is + * the *transport's* request event, which `CoordinatorClient` supplies + * and a fake sitting directly on the interface never sees. So this fake + * can say a package exists but cannot make it takeable; + * `CordnFixtureCoordinator`, which handles the request itself, can. + * Seed one with [seedKeyPackage] when a test needs an invite to work. + */ + val publicationEvent: Event?, + val lastResort: Boolean = false, + ) + + var keyPackages = mutableMapOf() + var welcomes = mutableMapOf>() + var streams = mutableMapOf>() + var joinRequests = mutableMapOf>() + + /** Every method name called, in order. */ + val calls = mutableListOf() + + private var cursor = 0L + private var clock = 1_757_000_000L + + /** Fails the next [failNext] calls, to exercise the health surface. */ + var failNext = 0 + + /** Pre-loads a KeyPackage as if its owner had published it. */ + fun seedKeyPackage(stored: StoredKeyPackage) { + keyPackages[stored.keyPackageRef] = stored + } + + /** Everything posted to [gid], oldest first. */ + fun posted(gid: String): List = streams[gid].orEmpty().map { it.sealedBase64 } + + private fun record(method: String) { + calls += method + if (failNext > 0) { + failNext-- + throw IllegalStateException("$method failed") + } + } + + override suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage { + record("kp_publish") + // Stored, so `kp_list` serves it back the way a real coordinator does. + // It used to return a receipt and keep nothing, which is invisible + // until someone tests `topUp` -- that counts what the coordinator + // holds, so against a fake that forgets, it publishes forever. + keyPackages[keyPackageRef] = + StoredKeyPackage(callerPubKey, keyPackageRef, keyPackageBase64, publicationEvent = null) + return PublishedKeyPackage(keyPackageRef, false, clock++) + } + + override suspend fun removeKeyPackages(keyPackageRefs: List): List { + record("kp_remove") + return keyPackageRefs.filter { keyPackages.remove(it) != null } + } + + override suspend fun listKeyPackages(): List { + record("kp_list") + return keyPackages.values.map { AvailableKeyPackage(it.pubKey, it.keyPackageRef, it.lastResort, clock) } + } + + override suspend fun takeKeyPackage(id: String): TakenKeyPackage? { + record("kp_take") + // `id` accepts a ref or an account hex, like the real one. + // Only a seeded package is takeable: without the publication event + // there is nothing for §9 verification to check, and handing back a + // package that cannot be verified would test a path no real + // coordinator can produce. See [StoredKeyPackage.publicationEvent]. + val stored = + keyPackages[id]?.takeIf { it.publicationEvent != null } + ?: keyPackages.values.firstOrNull { it.pubKey == id && it.publicationEvent != null } + ?: return null + return TakenKeyPackage(stored.pubKey, stored.keyPackageRef, stored.lastResort, clock++, stored.publicationEvent!!) + } + + override suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long?, + ): Long { + record("welcome_store") + val at = clock++ + welcomes.getOrPut(targetPubKey) { mutableListOf() } += PendingWelcome(keyPackageRef, welcomeBase64, at, after) + return at + } + + override suspend fun takeWelcomes(consumed: List): List { + record("welcome_take") + val mine = welcomes[callerPubKey].orEmpty() + consumed.forEach { ack -> welcomes[callerPubKey]?.removeAll { it.keyPackageRef == ack.keyPackageRef && it.at == ack.at } } + return mine.toList() + } + + override suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long { + record("join_request_store") + val at = clock++ + // Keyed by group, not by caller: any member of the group can serve it, + // which is what `spec/01.md` §5.3 leaves open. + joinRequests.getOrPut(gid) { mutableListOf() } += JoinRequest(gid, callerPubKey, keyPackageRef, at) + return at + } + + override suspend fun takeJoinRequests( + gids: List, + consumed: List, + ): List { + record("join_request_take_many") + consumed.forEach { ack -> + joinRequests[ack.gid]?.removeAll { it.gid == ack.gid && it.pubKey == ack.pubKey && it.at == ack.at } + } + return gids.flatMap { joinRequests[it].orEmpty() } + } + + override suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage { + record("msg_post") + // Monotonic across the whole coordinator, which is stricter than + // spec/00.md §4 requires (per group) and so a safe stand-in. + val assigned = ++cursor + streams.getOrPut(gid) { mutableListOf() } += GroupMessage(gid, assigned, sealedBase64, clock++) + return PostedMessage(gid, assigned, clock) + } + + override suspend fun fetchMessages(cursors: Map): List { + record("msg_fetch_many") + return cursors.entries + .flatMap { (gid, after) -> streams[gid].orEmpty().filter { it.cursor > (after ?: 0L) } } + .sortedBy { it.cursor } + } + + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + record("msg_sub_many") + fetchMessages(cursors).forEach(onMessage) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStoresTest.kt new file mode 100644 index 0000000000..a82edde651 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStoresTest.kt @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The on-disk cordn stores. + * + * Driven through a fake cipher rather than the Android KeyStore, which is the + * point of the [CordnBlobCipher] seam: everything below the cipher — the + * layout, the key encoding, the atomic write, the account/coordinator scoping + * — is ordinary code that can fail in ordinary ways, and none of it is + * testable on a device-only primitive. The fake really transforms the bytes, + * so "the plaintext never reached the disk" is a claim these tests can make. + */ +class FileCordnStoresTest { + /** XOR: reversible, and unmistakably not the input. */ + private class XorCipher : CordnBlobCipher { + var encryptCalls = 0 + + override fun encrypt(bytes: ByteArray): ByteArray { + encryptCalls++ + return ByteArray(bytes.size) { (bytes[it].toInt() xor MASK).toByte() } + } + + override fun decrypt(bytes: ByteArray) = ByteArray(bytes.size) { (bytes[it].toInt() xor MASK).toByte() } + + companion object { + const val MASK = 0x5A + } + } + + private val root = + File.createTempFile("cordn-store", "").also { + it.delete() + it.mkdirs() + } + private val cipher = XorCipher() + + private val account = "a".repeat(64) + private val coordinator = "b".repeat(64) + + private fun dir() = CordnStorageLayout.directoryFor(root, account, coordinator) + + private fun groups() = FileCordnGroupStore(dir(), cipher) + + private fun coordinators() = FileCordnCoordinatorStore(CordnStorageLayout.accountDirectoryFor(root, account), cipher) + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + private fun keyPackages() = FileCordnKeyPackageStore(dir(), cipher) + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + @Test + fun `a group round-trips through a restart`() = + runTest { + val state = byteArrayOf(1, 2, 3, 4, 5) + groups().saveGroup("g1", state) + + // A new instance over the same directory: what a relaunch is. + assertContentEquals(state, groups().loadGroup("g1")) + assertEquals(listOf("g1"), groups().listGroups()) + } + + @Test + fun `the plaintext never reaches the disk`() = + runTest { + // The one claim the whole class exists to make. An MlsGroupState is + // the ratchet tree and every epoch secret; on disk in the clear it + // is every message the group ever sent. + val state = "ratchet-tree-and-epoch-secrets".encodeToByteArray() + groups().saveGroup("g1", state) + + val onDisk = dir().walkTopDown().filter { it.isFile }.toList() + assertTrue(onDisk.isNotEmpty(), "nothing was written at all") + onDisk.forEach { + val raw = it.readBytes() + assertTrue(!raw.contentEquals(state), "${it.name} holds the plaintext") + assertTrue( + !raw.decodeToString().contains("ratchet-tree"), + "${it.name} holds recognisable plaintext", + ) + } + } + + @Test + fun `a gid that is a path traversal is stored, not rejected`() = + runTest { + // A cordn gid is caller-chosen and the coordinator never interprets + // it (spec/00.md §4), so "../../etc/passwd" is a gid the protocol + // allows. Marmot's store validates hex because a Marmot group id is + // a hash; doing that here would refuse legitimate groups. Encoding + // accepts it AND keeps it inside our directory. + val hostile = "../../../etc/passwd" + groups().saveGroup(hostile, byteArrayOf(9)) + + assertEquals(listOf(hostile), groups().listGroups(), "the gid must come back verbatim") + assertContentEquals(byteArrayOf(9), groups().loadGroup(hostile)) + + val escaped = File(root, "cordn/etc").exists() || File(root.parentFile, "etc").exists() + assertTrue(!escaped, "the write escaped the store directory") + assertTrue(dir().walkTopDown().any { it.isFile }, "it was not written anywhere at all") + } + + @Test + fun `gids that differ only in case are different groups`() = + runTest { + // Base64url is case-sensitive; a case-folding encoding would merge + // these two and let one group's state answer for the other. + groups().saveGroup("Group", byteArrayOf(1)) + groups().saveGroup("group", byteArrayOf(2)) + + assertContentEquals(byteArrayOf(1), groups().loadGroup("Group")) + assertContentEquals(byteArrayOf(2), groups().loadGroup("group")) + assertEquals(2, groups().listGroups().size) + } + + @Test + fun `two coordinators serving the same gid do not collide`() = + runTest { + // The rule the layout exists for. Two coordinators can both serve + // gid "shared" as unrelated groups with different ratchet trees. + val other = CordnStorageLayout.directoryFor(root, account, "c".repeat(64)) + FileCordnGroupStore(dir(), cipher).saveGroup("shared", byteArrayOf(1)) + FileCordnGroupStore(other, cipher).saveGroup("shared", byteArrayOf(2)) + + assertContentEquals(byteArrayOf(1), groups().loadGroup("shared")) + assertContentEquals(byteArrayOf(2), FileCordnGroupStore(other, cipher).loadGroup("shared")) + } + + @Test + fun `two accounts on one device do not see each other`() = + runTest { + val theirs = CordnStorageLayout.directoryFor(root, "d".repeat(64), coordinator) + groups().saveGroup("g1", byteArrayOf(1)) + + assertTrue(FileCordnGroupStore(theirs, cipher).listGroups().isEmpty()) + assertNull(FileCordnGroupStore(theirs, cipher).loadGroup("g1")) + } + + @Test + fun `a non-hex pubkey never becomes a directory`() { + assertFailsWith { CordnStorageLayout.directoryFor(root, "../escape", coordinator) } + assertFailsWith { CordnStorageLayout.directoryFor(root, account, "../escape") } + } + + @Test + fun `deleting a group takes its cursor with it`() = + runTest { + // Otherwise a later re-join of the same gid resumes from a cursor + // belonging to a group it is no longer in, and silently skips + // everything before it. + groups().saveGroup("g1", byteArrayOf(1)) + groups().saveCursor("g1", GroupCursor(fetchCursor = 42, lastCursor = 99)) + + groups().deleteGroup("g1") + + assertNull(groups().loadGroup("g1")) + assertNull(groups().loadCursor("g1"), "the cursor outlived the group") + assertTrue(groups().listGroups().isEmpty()) + } + + @Test + fun `a cursor round-trips both of its fields`() = + runTest { + // They are not the same number and conflating them is invisible + // until a fetch re-reads or skips a stretch of the stream. + groups().saveCursor("g1", GroupCursor(fetchCursor = 7, lastCursor = 1234567890123L)) + + val restored = assertNotNull(groups().loadCursor("g1")) + assertEquals(7L, restored.fetchCursor) + assertEquals(1234567890123L, restored.lastCursor) + } + + @Test + fun `a group with no state yet is not listed`() = + runTest { + // A cursor alone means a directory exists with nothing restorable + // in it; reporting it as a group would make restore() build an + // empty MlsGroup for a gid we do not actually hold. + groups().saveCursor("ghost", GroupCursor(fetchCursor = 1)) + + assertTrue(groups().listGroups().isEmpty()) + } + + @Test + fun `a key package bundle round-trips and can be withdrawn`() = + runTest { + val bundle = byteArrayOf(7, 7, 7) + keyPackages().save("ref1", bundle) + + assertContentEquals(bundle, keyPackages().load("ref1")) + assertEquals(listOf("ref1"), keyPackages().list()) + + keyPackages().delete("ref1") + assertNull(keyPackages().load("ref1")) + assertTrue(keyPackages().list().isEmpty()) + } + + @Test + fun `a half-written temp file is not served as a bundle`() = + runTest { + // atomicWrite leaves one behind if the process dies between write + // and rename. Listing it would hand joinPendingWelcomes a ref that + // decodes to nothing and burn the Welcome it was meant to open. + // + // What excludes it is the encoding, not a name check: '.' is not in + // the base64url alphabet. So this test is really a guard on the + // encoding — swap it for something that passes names through and + // this fails, which is the regression worth catching. + keyPackages().save("ref1", byteArrayOf(1)) + File(dir(), "keypackages/${CordnStorageLayout.encodeKey("ref2")}.tmp").writeBytes(byteArrayOf(2)) + + assertEquals(listOf("ref1"), keyPackages().list()) + } + + @Test + fun `a stray file in the directory does not break the listing`() = + runTest { + // Ignoring one unreadable name is better than failing the listing + // and hiding every real group behind it. + groups().saveGroup("g1", byteArrayOf(1)) + File(dir(), "groups/not-base64-@@@").mkdirs() + File(dir(), "groups/not-base64-@@@/state").writeBytes(byteArrayOf(0)) + + assertEquals(listOf("g1"), groups().listGroups()) + } + + @Test + fun `an empty store lists nothing rather than failing`() = + runTest { + assertTrue(groups().listGroups().isEmpty()) + assertTrue(keyPackages().list().isEmpty()) + assertNull(groups().loadGroup("nope")) + assertNull(groups().loadCursor("nope")) + assertNull(keyPackages().load("nope")) + } + + @Test + fun `concurrent saves of different groups all survive`() = + runTest { + // Dispatchers.IO is a pool, and a sync loop saving several groups + // at once is the ordinary case. + val store = groups() + withContext(Dispatchers.IO) { + (1..24).map { async { store.saveGroup("g$it", byteArrayOf(it.toByte())) } }.awaitAll() + } + + assertEquals(24, store.listGroups().size) + (1..24).forEach { assertContentEquals(byteArrayOf(it.toByte()), store.loadGroup("g$it")) } + } + + @Test + fun `a rewritten group leaves no temp file behind`() = + runTest { + val store = groups() + store.saveGroup("g1", byteArrayOf(1)) + store.saveGroup("g1", byteArrayOf(2, 2)) + + assertContentEquals(byteArrayOf(2, 2), store.loadGroup("g1")) + assertTrue( + dir().walkTopDown().none { it.name.endsWith(".tmp") }, + "a temp file survived the rename", + ) + } + + @Test + fun `the coordinator list survives a relaunch, encrypted`() = + runTest { + val configs = + listOf( + CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com")), CoordinatorConfig.Origin.MANUAL, "Work"), + CoordinatorConfig("c".repeat(64), listOf(relay("wss://two.example.com"), relay("wss://three.example.com")), CoordinatorConfig.Origin.GROUP_REF), + ) + coordinators().save(configs) + + assertEquals(configs, coordinators().load()) + val onDisk = File(CordnStorageLayout.accountDirectoryFor(root, account), "coordinators").readBytes() + assertFalse(onDisk.decodeToString().contains(coordinator), "the pubkey went to disk in the clear") + } + + @Test + fun `a label with a separator in it round-trips`() = + runTest { + // The reason the format is length-prefixed rather than delimited: + // a label is free text a user typed, so any separator a line-based + // format picked is one they can put in it. + val messy = "tab\there\nnewline\u0000nul" + coordinators().save(listOf(CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com")), label = messy))) + + assertEquals(messy, coordinators().load().single().label) + } + + @Test + fun `an account with no stored list gets an empty one, not a failure`() = + runTest { + assertEquals(emptyList(), coordinators().load()) + } + + @Test + fun `an unreadable list loses the coordinators rather than the login`() = + runTest { + coordinators().save(listOf(CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com"))))) + File(CordnStorageLayout.accountDirectoryFor(root, account), "coordinators").writeBytes(byteArrayOf(9, 9, 9)) + + assertEquals(emptyList(), coordinators().load()) + } + + @Test + fun `the coordinator list sits above the per-coordinator directories, so forgetting one keeps the rest`() = + runTest { + // If it lived inside a coordinator's own directory, removing that + // coordinator would delete the record of every other one with it. + val listFile = File(CordnStorageLayout.accountDirectoryFor(root, account), "coordinators") + assertEquals(dir().parentFile, listFile.parentFile) + } + + @Test + fun `purging one coordinator leaves another coordinator's groups untouched`() = + runTest { + // What CordnRuntime.purge deletes. It has to be the coordinator's + // own directory and nothing above it: a gid is unique only within + // one coordinator, so two of them routinely hold state for the + // same gid and deleting a level too high takes both. + val other = "e".repeat(64) + groups().saveGroup("shared-gid", byteArrayOf(1, 2, 3)) + FileCordnGroupStore(CordnStorageLayout.directoryFor(root, account, other), cipher) + .saveGroup("shared-gid", byteArrayOf(4, 5, 6)) + + CordnStorageLayout.directoryFor(root, account, coordinator).deleteRecursively() + + assertNull(groups().loadGroup("shared-gid")) + assertContentEquals( + byteArrayOf(4, 5, 6), + FileCordnGroupStore(CordnStorageLayout.directoryFor(root, account, other), cipher).loadGroup("shared-gid"), + ) + } + + @Test + fun `purging a coordinator leaves the account's coordinator list alone`() = + runTest { + // The list names the coordinator being purged, so it has to be + // rewritten by whoever purges -- but it must not be destroyed by + // the delete itself, or purging one coordinator would forget every + // other one with it. + coordinators().save(listOf(CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com"))))) + groups().saveGroup("gid", byteArrayOf(1)) + + CordnStorageLayout.directoryFor(root, account, coordinator).deleteRecursively() + + assertEquals(1, coordinators().load().size) + } + + @Test + fun `a join-request marker is written once and goes with its group`() = + runTest { + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + assertFalse(store.loadJoinedViaRequest("gid")) + + store.saveJoinedViaRequest("gid") + store.saveJoinedViaRequest("gid") + assertTrue(store.loadJoinedViaRequest("gid")) + + // A later re-join of the same gid is a different admission, so the + // old marker must not outlive the group it described. + store.deleteGroup("gid") + assertFalse(store.loadJoinedViaRequest("gid")) + } + + @Test + fun `a draft is encrypted at rest, like the messages it was going to become`() = + runTest { + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + store.saveRoomState("gid", CordnRoomState(draft = "the secret plan", lastReadCursor = 7)) + + assertEquals(CordnRoomState("the secret plan", 7), store.loadRoomState("gid")) + val onDisk = File(dir(), "groups/${CordnStorageLayout.encodeKey("gid")}/room").readBytes() + assertFalse(onDisk.decodeToString().contains("the secret plan"), "a draft went to disk in the clear") + } + + @Test + fun `clearing a draft removes the file rather than blanking it`() = + runTest { + // Overwriting in place would leave the old plaintext in whatever + // the filesystem still holds; there is nothing to keep once both + // fields are empty. + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + store.saveRoomState("gid", CordnRoomState(draft = "typed")) + store.saveRoomState("gid", CordnRoomState()) + + assertFalse(File(dir(), "groups/${CordnStorageLayout.encodeKey("gid")}/room").exists()) + assertEquals(CordnRoomState(), store.loadRoomState("gid")) + } + + @Test + fun `an unreadable room file costs the draft, never the room`() = + runTest { + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + store.saveRoomState("gid", CordnRoomState(draft = "typed", lastReadCursor = 3)) + File(dir(), "groups/${CordnStorageLayout.encodeKey("gid")}/room").writeBytes(byteArrayOf(7, 7, 7)) + + assertEquals(CordnRoomState(), store.loadRoomState("gid")) + assertNotNull(store.loadGroup("gid")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPoolTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPoolTest.kt new file mode 100644 index 0000000000..8bd6dfbd75 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPoolTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The adapter between Amethyst's relay client and what ContextVM asks of one. + * + * Small enough to look obviously right and wrong in three ways that a test is + * the only thing that catches, because each one fails as silence rather than + * as an error: a REQ sent to the account's relays instead of the + * coordinator's, a response dropped because it arrived as a stored event, and + * a subscription left open after the call that made it returned. + */ +class NostrClientCvmRelayPoolTest { + private val coordinatorRelay = relay("wss://coordinator.example") + private val otherRelay = relay("wss://elsewhere.example") + + @Test + fun `subscribes only to the relays it was given`() { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay)) + + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { } + + // A coordinator has no address beyond its pubkey (spec/00.md §8.5), so + // the relays whoever published it named are the only place its traffic + // exists. Widening this to the account's own relays would miss the + // coordinator AND tell relays with no part in the conversation that + // this account talks to it. + assertEquals( + setOf(coordinatorRelay), + client.subscribed + .single() + .filters.keys, + ) + } + + @Test + fun `filters on kind and on the recipient p-tag`() { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay, otherRelay)) + + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE, CvmKinds.GIFT_WRAP)) { } + + val filter = + client.subscribed + .single() + .filters + .getValue(coordinatorRelay) + .single() + assertEquals(listOf(CvmKinds.MESSAGE, CvmKinds.GIFT_WRAP), filter.kinds) + // Addressed-to, not authored-by: a gift-wrapped response is signed by a + // one-time key, so `authors` would match nothing. + assertEquals(mapOf("p" to listOf(SERVER)), filter.tags) + assertNull(filter.authors) + } + + @Test + fun `delivers a stored event as readily as a live one`() = + runTest { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay)) + + val seen = mutableListOf() + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { seen += it } + + // Kind 25910 is ephemeral, so "stored" is not a state it can be + // in — but a relay that replays its own send buffer reports + // isLive=false anyway. Branching on it would drop the one response + // the call is waiting for and the call would time out instead. + client.subscribed + .single() + .listener + .onEvent(response(), isLive = false, coordinatorRelay, null) + + assertEquals(1, seen.size) + } + + @Test + fun `closing a subscription unsubscribes that id and no other`() { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay)) + + val first = pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { } + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { } + + val ids = client.subscribed.map { it.subId } + assertEquals("two subscriptions must not share an id", 2, ids.toSet().size) + + first.close() + + // CvmTransport opens one of these per request and closes it in the same + // call. Unsubscribing the wrong id would leave a REQ open on the + // coordinator's relay for the life of the process and silently break + // the next response that a shared id was still listening for. + assertEquals(listOf(ids.first()), client.unsubscribed) + } + + @Test + fun `publishes to the coordinator relays`() = + runTest { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay, otherRelay)) + + pool.publish(response()) + + val (event, relays) = client.published.single() + assertEquals(setOf(coordinatorRelay, otherRelay), relays) + assertTrue(event.kind == CvmKinds.MESSAGE) + } + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + private fun response() = + Event( + id = "aa".repeat(32), + pubKey = SERVER, + createdAt = 1, + kind = CvmKinds.MESSAGE, + tags = arrayOf(arrayOf("p", SERVER)), + content = "", + sig = "bb".repeat(32), + ) + + /** Records what the pool asked of the client, and nothing else. */ + private class RecordingClient : INostrClient by EmptyNostrClient() { + class Req( + val subId: String, + val filters: Map>, + val listener: SubscriptionListener, + ) + + val subscribed = mutableListOf() + val unsubscribed = mutableListOf() + val published = mutableListOf>>() + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscribed += Req(subId, filters, listener!!) + } + + override fun unsubscribe(subId: String) { + unsubscribed += subId + } + + override fun publish( + event: Event, + relayList: Set, + ) { + published += event to relayList + } + } + + companion object { + private val SERVER = "cc".repeat(32) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt index b6c1a7643a..8dd2d9b75d 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt @@ -92,7 +92,7 @@ class LegacyGroupContractTest { ) } - private class ProbeStateStore : com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore { + private class ProbeStateStore : com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore { private val states = mutableMapOf() private val retained = mutableMapOf>() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt index 91f8afb146..8f89009f85 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStat import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicException import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicStream import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CompletableDeferred diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt index 68a0166fd7..bd33a29b0d 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt index 51c248077f..427810d0f8 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -39,7 +39,7 @@ import kotlin.test.assertTrue /** * End-to-end leave + rejoin round-trip at the [MarmotManager] layer. * - * One level above [com.vitorpamplona.quartz.marmot.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd] + * One level above [com.vitorpamplona.quartz.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd] * in quartz, which only exercises the MLS engine. This one drives the full * commons-layer pipeline: * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt index a923b2ec31..c1fc52ea02 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt index c63f506200..7e20aa987a 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt @@ -439,7 +439,7 @@ class MarmotPublishBeforeApplyTest { assertEquals(listOf(relay), fx.manager.groupRelays(fx.groupId)) } - private class InMemoryStateStore : com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore { + private class InMemoryStateStore : com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore { private val states = mutableMapOf() private val retained = mutableMapOf>() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt index e06891794c..dc592a16c5 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt index 1953e9909f..48b2e5d479 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt index 7fb7350c55..0202f1816e 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore -import com.vitorpamplona.quartz.marmot.mls.group.OwnSenderRatchet +import com.vitorpamplona.quartz.mls.group.OwnSenderRatchet import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt index 898a2a6341..158ccc81a9 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stand-ins for the durable stores a MarmotManager needs. diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroomTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroomTest.kt new file mode 100644 index 0000000000..dff1b93179 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroomTest.kt @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The room a cordn screen renders. + * + * The rules under test are the ones that fail quietly. A room that keys on the + * cursor duplicates history the moment a client re-syncs; a room that sorts on + * one clock alone either lets a coordinator reorder the past or lets one bad + * device clock scatter its owner's messages through it. Neither throws. + */ +class CordnGroupChatroomTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val coordinator = "c".repeat(64) + + private fun room() = CordnGroupChatroom("gid-1", coordinator) + + /** The target an annotation points at: id, its author, and its kind. */ + private fun target( + id: String, + author: HexKey, + kind: Int = CordnMessageKinds.TEXT, + ) = CordnMessageReferences.Target(id = id.padEnd(64, '0'), pubKey = author, kind = kind) + + private fun message( + id: String, + at: Long, + cursor: Long, + author: HexKey = alice, + content: String = "msg-$id", + kind: Int = CordnMessageKinds.TEXT, + tags: Array> = emptyArray(), + ) = CordnDeliveredMessage( + CordnEnvelope(id = id.padEnd(64, '0'), pubKey = author, createdAt = at, kind = kind, tags = tags, content = content), + cursor, + ) + + @Test + fun `messages are keyed by envelope id, so a re-sync does not duplicate them`() { + // spec/02.md §7: the envelope id is the identity, the cursor is a + // delivery primitive. A catch_up after a restart re-walks the stream + // and hands back messages we already have — with a NEW cursor, because + // a cursor is coordinator-local. Keying on it would double the room + // every time it recovers, which is exactly when it must not. + val room = room() + assertTrue(room.add(message("1", at = 10, cursor = 1))) + + assertFalse(room.add(message("1", at = 10, cursor = 99)), "same message, different cursor") + + assertEquals(1, room.messages.value.size) + } + + @Test + fun `messages are ordered as the coordinator delivered them`() { + val room = room() + room.add(message("3", at = 30, cursor = 3)) + room.add(message("1", at = 10, cursor = 1)) + room.add(message("2", at = 20, cursor = 2)) + + assertEquals(listOf("msg-1", "msg-2", "msg-3"), room.messages.value.map { it.envelope.content }) + } + + @Test + fun `a sender's clock cannot move their message in the room`() { + // The rule the ordering exists for, and the only test that can tell + // the two orders apart — everywhere else the cursor and created_at + // agree. A device with a wrong clock, or a member who simply sets + // whatever they like, must not be able to reorder the room: sorting on + // created_at would let this message sit at the top of the history + // forever, above things said long before it. + val room = room() + room.add(message("1", at = 1_000, cursor = 1, content = "said first")) + room.add(message("2", at = 5, cursor = 2, content = "backdated to 1970")) + + assertEquals( + listOf("said first", "backdated to 1970"), + room.messages.value.map { it.envelope.content }, + "a backdated message jumped the queue", + ) + } + + @Test + fun `annotations are folded, not rendered as messages`() { + // A reaction is not a line in the chat. Showing one would put a bare + // "+" in the transcript and, worse, make it the room's preview. + val room = room() + room.add(message("1", at = 10, cursor = 1, content = "hello")) + room.add( + message( + "2", + at = 11, + cursor = 2, + author = bob, + kind = CordnMessageKinds.REACTION, + content = "+", + tags = CordnMessageReferences.reactionTags(target("1", alice)), + ), + ) + + assertEquals(listOf("hello"), room.messages.value.map { it.envelope.content }) + assertEquals( + "hello", + room.newest.value + ?.envelope + ?.content, + "a reaction must not become the preview", + ) + + val reacted = room.annotations.value.reactions["1".padEnd(64, '0')] + assertEquals(setOf(bob), reacted?.get("+"), "the reaction still has to land on its target") + } + + @Test + fun `an edit changes the rendered text without adding a row`() { + val room = room() + room.add(message("1", at = 10, cursor = 1, content = "typo")) + room.add( + message( + "2", + at = 11, + cursor = 2, + content = "fixed", + kind = CordnMessageKinds.EDIT, + tags = CordnMessageReferences.editTags(target("1", alice)), + ), + ) + + assertEquals(1, room.messages.value.size) + assertEquals("fixed", room.annotations.value.contentOf("1".padEnd(64, '0'))) + } + + @Test + fun `the newest message drives the inbox preview`() { + val room = room() + room.add(message("1", at = 10, cursor = 1, content = "older")) + room.add(message("2", at = 20, cursor = 2, content = "newer")) + + assertEquals( + "newer", + room.newest.value + ?.envelope + ?.content, + ) + } + + @Test + fun `an empty room previews nothing rather than failing`() { + val room = room() + assertNull(room.newest.value) + assertTrue(room.messages.value.isEmpty()) + } + + @Test + fun `addAll reports how many were actually new`() { + // The count feeds unread badges, so counting a re-delivery would make + // a recovering room look like it had new traffic. + val room = room() + room.add(message("1", at = 10, cursor = 1)) + + val added = room.addAll(listOf(message("1", at = 10, cursor = 50), message("2", at = 20, cursor = 51))) + + assertEquals(1, added) + assertEquals(2, room.messages.value.size) + } + + @Test + fun `a new room knows nothing until it is pointed at an MLS group`() { + val room = room() + + assertNull(room.name.value) + assertNull(room.description.value) + assertEquals(emptyList(), room.members.value) + assertEquals(0L, room.epoch.value) + } + + @Test + fun `refreshFrom reads the name, description and admins out of the metadata extension`() { + val room = room() + val metadata = CordnGroupMetadata(name = "Stage B", description = "the visible half", adminPubkeys = listOf(alice)) + + room.refreshFrom(groupOf(alice, metadata)) + + assertEquals("Stage B", room.name.value) + assertEquals("the visible half", room.description.value) + assertEquals(listOf(alice), room.adminPubkeys.value) + } + + @Test + fun `refreshFrom reports the creator as a member by account pubkey, not by hex-of-hex`() { + val room = room() + + room.refreshFrom(groupOf(alice, CordnGroupMetadata(name = "Stage B"))) + + // The trap MlsGroup.memberIdentityHex falls into: a cordn credential is + // already 64 chars of hex, so hex-encoding it again gives 128. + assertEquals(listOf(alice), room.members.value) + } + + @Test + fun `a group carrying no metadata extension leaves the room unnamed rather than mis-named`() { + val room = room() + room.refreshFrom(groupOf(alice, CordnGroupMetadata(name = "Stage B"))) + + room.refreshFrom(MlsGroup.create(CordnCredential.of(bob).identity, policy = CordnGroupPolicy)) + + // Not "Stage B" left over: the fields are derived from whatever group + // they were last pointed at, never accumulated across groups. + assertNull(room.name.value) + assertEquals(emptyList(), room.adminPubkeys.value) + } + + @Test + fun `an empty admin list stays empty, because egalitarian is a choice and not a gap`() { + val room = room() + + room.refreshFrom(groupOf(alice, CordnGroupMetadata(name = "Flat", adminPubkeys = emptyList()))) + + assertTrue(room.adminPubkeys.value.isEmpty()) + } + + @Test + fun `forgetting a coordinator drops its rooms and keeps everyone else's`() { + // CordnRuntime.purge deletes one coordinator's files; the in-memory + // list has to lose exactly the same rooms. A gid is unique only within + // a coordinator, so two of them can hold the same gid as unrelated + // groups -- dropping by gid would take a stranger's room with it. + val list = CordnGroupList() + val other = "d".repeat(64) + list.getOrCreate(coordinator, "shared-gid") + list.getOrCreate(other, "shared-gid") + list.getOrCreate(other, "another") + + list.forgetCoordinator(coordinator) + + assertNull(list.get(coordinator, "shared-gid")) + assertEquals(2, list.all.value.size) + assertEquals( + setOf(other), + list.all.value + .map { it.coordinatorPubKey } + .toSet(), + ) + } + + @Test + fun `unread counts what other people said, past the read position`() { + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.addAll( + listOf( + message("01", at = 10, cursor = 1, author = bob), + message("02", at = 11, cursor = 2, author = bob), + ), + ) + assertEquals(2, room.unreadCount.value) + + room.markRead() + assertEquals(0, room.unreadCount.value) + + room.add(message("03", at = 12, cursor = 3, author = bob)) + assertEquals(1, room.unreadCount.value) + } + + @Test + fun `your own messages are not unread, however they come back`() { + // A message echoes back from the coordinator with a cursor past the + // read position, exactly like someone else's. Counting it would badge + // every room the moment its owner spoke in it. + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.add(message("01", at = 10, cursor = 1, author = alice)) + + assertEquals(0, room.unreadCount.value) + } + + @Test + fun `a reaction to something already read is not an unread message`() { + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.add(message("01", at = 10, cursor = 1, author = bob)) + room.markRead() + + room.add(message("02", at = 11, cursor = 2, author = bob, content = "\uD83D\uDC4D", kind = CordnMessageKinds.REACTION, tags = arrayOf(arrayOf("e", "01".padEnd(64, '0'))))) + + // Badging a room for a thumbs-up trains people to ignore the badge. + assertEquals(0, room.unreadCount.value) + } + + @Test + fun `a restored read position is honoured before anything new arrives`() { + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.addAll( + listOf( + message("01", at = 10, cursor = 1, author = bob), + message("02", at = 11, cursor = 2, author = bob), + ), + ) + room.restoreState(draft = "half typed", lastReadCursor = 1) + + assertEquals("half typed", room.draft.value) + assertEquals(1, room.unreadCount.value, "only the message past the saved cursor") + } + + /** A real cordn group, created the way [CordnGroupManager.createGroup] does. */ + private fun groupOf( + creator: HexKey, + metadata: CordnGroupMetadata, + ) = MlsGroup.create( + identity = CordnCredential.of(creator).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(metadata.toExtension()), + groupId = "gid-1".encodeToByteArray(), + ) +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowTest.kt new file mode 100644 index 0000000000..3255803b78 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Where a cordn room lands in the Messages list. + * + * Both rules here failed silently and in the same direction: the inbox showed + * every cordn room with the right preview and the right time, at the wrong + * place in the list. A test that only reads a room is blind to it, because + * nothing about the room was wrong -- the row it hands the feed was. + */ +class CordnInboxRowTest { + private val alice = "a".repeat(64) + private val coordinator = "c".repeat(64) + + private fun message( + id: String, + at: Long, + cursor: Long, + author: HexKey = alice, + ) = CordnDeliveredMessage( + CordnEnvelope( + id = id.padEnd(64, '0'), + pubKey = author, + createdAt = at, + kind = CordnMessageKinds.TEXT, + tags = emptyArray(), + content = "msg-$id", + ), + cursor, + ) + + @Test + fun `the row's time is the newest message's, so the inbox can sort on it`() { + // The feed sorts on Note.createdAt() and maps null to 0L. A row that + // returns null is not "undated", it is pinned to the bottom of the + // Messages list forever, under every DM and every other group chat. + val room = CordnGroupChatroom("gid-1", coordinator) + room.add(message("1", at = 1_700_000_000, cursor = 1)) + + assertEquals(1_700_000_000L, room.inboxRow().createdAt()) + } + + @Test + fun `a room with no messages has no time rather than a wrong one`() { + // 0L would be a claim about when the room last spoke. Null says there + // is nothing to sort on, which is what a just-joined room means. + assertNull(CordnGroupChatroom("gid-1", coordinator).inboxRow().createdAt()) + } + + @Test + fun `the row follows the coordinator's newest, not the last one handed to it`() { + // A catch_up walks the stream in order, but a live subscription can + // interleave, and the room orders by cursor rather than arrival. The + // row has to agree with the room about which message is newest, or the + // inbox sorts a group by whichever message happened to land last. + val room = CordnGroupChatroom("gid-1", coordinator) + room.add(message("2", at = 20, cursor = 2)) + room.add(message("1", at = 10, cursor = 1)) + + assertEquals(20L, room.inboxRow().createdAt()) + } + + @Test + fun `the row is one stable instance, so the feed does not see a new room each rebuild`() { + val room = CordnGroupChatroom("gid-1", coordinator) + + assertSame(room.inboxRow(), room.inboxRow()) + } + + @Test + fun `a message for a room the list already holds still tells the inbox to rebuild`() { + // The half of the bug that no amount of correct sorting fixes: the feed + // rebuilds off a signal from the list, and the room *set* does not + // change when a message arrives for a room already in it. Without this + // the inbox sorted cordn rooms once, at join, and never again. + val list = CordnGroupList() + assertTrue(list.add(coordinator, "gid-1", message("1", at = 10, cursor = 1))) + + val roomsBefore = list.all.value + val revisionBefore = list.revision.value + + assertTrue(list.add(coordinator, "gid-1", message("2", at = 20, cursor = 2))) + + assertSame(roomsBefore, list.all.value, "the room set did not change, which is the trap") + assertTrue(list.revision.value > revisionBefore, "but the inbox still has to re-sort") + } + + @Test + fun `a message put straight onto the room still tells the inbox to rebuild`() { + // The case that matters most and was missed: sending does not go through + // CordnGroupList at all -- the chat screen adds the optimistic message to + // the room directly, and the coordinator's echo is then already held by + // id so it files as a duplicate. Hanging the signal off the list meant + // your own messages never re-sorted the inbox, only other people's. + val list = CordnGroupList() + val room = list.getOrCreate(coordinator, "gid-1") + val revisionBefore = list.revision.value + + assertTrue(room.add(message("1", at = 10, cursor = 1))) + + assertTrue(list.revision.value > revisionBefore, "an optimistic send has to re-sort the inbox") + } + + @Test + fun `an annotation does not tell the inbox to rebuild`() { + // A reaction leaves the newest message exactly as it was, so re-sorting + // for one would be a rebuild per reaction for no visible change. + val list = CordnGroupList() + val room = list.getOrCreate(coordinator, "gid-1") + room.add(message("1", at = 10, cursor = 1)) + + val revisionBefore = list.revision.value + room.add( + CordnDeliveredMessage( + CordnEnvelope( + id = "2".padEnd(64, '0'), + pubKey = alice, + createdAt = 20, + kind = CordnMessageKinds.REACTION, + tags = arrayOf(arrayOf("e", "1".padEnd(64, '0'))), + content = "+", + ), + 2, + ), + ) + + assertEquals(revisionBefore, list.revision.value) + } + + @Test + fun `a re-delivered message does not tell the inbox to rebuild`() { + // A re-sync re-walks the whole stream. Bumping per echo would rebuild + // the Messages feed once per message for no visible change. + val list = CordnGroupList() + list.add(coordinator, "gid-1", message("1", at = 10, cursor = 1)) + + val revisionBefore = list.revision.value + list.add(coordinator, "gid-1", message("1", at = 10, cursor = 99)) + + assertEquals(revisionBefore, list.revision.value) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLogTest.kt similarity index 99% rename from commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLogTest.kt index acf4be7de4..87629543b7 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLogTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.marmot +package com.vitorpamplona.amethyst.commons.storage import java.io.File import java.nio.file.Files diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts index 5cadbc3ea5..4d54405f96 100644 --- a/commonsUI/build.gradle.kts +++ b/commonsUI/build.gradle.kts @@ -138,6 +138,17 @@ kotlin { } } + jvmTest { + dependencies { + // Compose Desktop on the test classpath so composables can be + // rendered headlessly to a Skia surface (ImageComposeScene) and + // asserted on. No new third-party dependency: this is the same + // artifact jvmMain already uses, and it rasterises in software, + // so it needs no display. + implementation(compose.desktop.currentOs) + } + } + // Shared JVM code for both Android and Desktop val jvmAndroid = create("jvmAndroid") { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index b0fc2cb0e0..a0b587b986 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2911,6 +2911,7 @@ You don't pass this community's web-of-trust requirements. The latest community rules document was rejected as stale. Marmot Groups + Cordn Groups Create Group Create Marmot Group A new MLS group will be created. You can add members after. @@ -5320,6 +5321,136 @@ %1$d new message %1$d new messages + + + Coordinator group + Delivered by a coordinator, not by relays + + What this coordinator can see + A coordinator is one server that carries every message in this group, in order. That is a different trade from relays — worth knowing before you treat this like any other chat. + Message contents + Who is in the group + Who sends what + Unreadable + Under a throwaway key + Tied to your real account + Details + Coordinator %1$s + This is not the same exposure as a Marmot group, where no single operator holds the whole conversation. + + Joining names real accounts at both ends, so the coordinator knows who is in this group. + One throwaway key sends and fetches for every group you have here, so the coordinator can tell they are all you. + One operator holds the complete, ordered history of every group it carries. + The key package you published is a signed record that this account uses cordn, and the coordinator can hand it to anyone. + Messages are not padded, so the coordinator sees how long each one is. + This client is not encrypting to the coordinator. That is a bug — please report it. + + Not contacted yet + Responding + Not responding + %1$d failed attempts in a row + + Paste a cordn1… link someone shared with you to see which coordinator carries that group, and what that operator would learn about you if you joined. + cordn1… + Inspect + Paste + Clear + That is not a cordn group link: %1$s + Group id + Coordinator + Reachable through + This link names no coordinator, so it cannot be followed on its own. Ask whoever sent it which coordinator carries the group. + Inspecting a link does not join anything, and holding one does not make you a member. + + + Cordn group link + cordn coordinator group link invite mls chat metadata privacy exposure + Cordn coordinators + cordn coordinator add remove purge health server mls chat ordering + Cordn key packages + cordn key package publish invite last resort coordinator mls + Cordn backup + cordn backup restore export import passphrase groups recovery + Cordn groups + End-to-end encrypted groups ordered by a coordinator you choose, instead of by relays. + + + Group %1$s + No messages yet + Cordn group, carried by %1$s + Message deleted + Voice note + Photo + Video + File + Cordn group + Encrypted group chat ordered by a coordinator you pick. + Coordinated + Best for teams that want one reliable ordering of the conversation. + Create Cordn group + End-to-end encrypted with MLS — the coordinator can never read a message. + One agreed order for everyone, so history cannot be reshuffled by a bad clock. + The coordinator learns who is in the group and when you talk, even though it cannot read what you say. + Invited to a cordn group? + Review invitations + + + Cordn + cordn coordinator group mls chat migrate backup key package link move device + Groups delivered by a coordinator you choose, instead of by relays. + Inspect a group link + See what a cordn1… link points at before you act on it + Move off this phone + Take over from another phone + Published key packages + The link + Coordinators you use + Announcing themselves + Add one by hand + Passphrase + Make a backup + Restore from a backup + The coordinator + This device + Coordinators + The servers that carry your groups + Key packages + What lets people add you to a group + Backup and restore + A passphrase-encrypted file you keep + Move to a new phone + Hand your groups to a device you are switching to + + Move to a new phone + cordn migrate move new phone device transfer handoff switch qr + Your cordn groups live on this device. Moving them takes one code: this phone publishes them, the new phone reads them, and this phone stops. + Sign in with the same account on the new phone first. This moves your groups, never your key. + Start the handoff + Publishing… + Scan this on the new phone + The code is only a pointer. Anyone who photographs it learns nothing — what it points at is encrypted to you. + This device has handed its groups over + It has stopped sending and receiving, because two phones sending from one group would break it for everyone in it. Your groups are still on this device and nothing was deleted. + Take this device back + Use this if the move did not finish. Only do it if the new phone has not started sending. + Receive from another phone + Scan the code + or paste it + Bring the groups here + Fetching… + This replaces any cordn groups already on this phone. They cannot be merged. + %1$d groups moved + There are no cordn groups on this device to move. + Set a media server first — the encrypted documents need somewhere to sit while the other phone fetches them. + What leaves this device + Your group state is encrypted and uploaded to your media server so the other phone can fetch it. It is unreadable to that server, which sees only a size and a time. Delete the blobs afterwards if you want nothing left behind. 3D object %1$d vertices · %2$d faces This 3D object could not be read (rule %1$s) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt index d6061a875f..a9988fb4cd 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.commons.chats.ui import androidx.compose.foundation.layout.Row +import androidx.compose.material3.DividerDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP @@ -33,21 +36,32 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding @Composable -fun ChatDivisor(info: String) { +fun ChatDivisor( + info: String, + /** + * Tints both rules and the label. Unspecified keeps the default, which is every + * date divisor; an unread marker passes the accent so the line it draws reads as a + * status rather than another date. + */ + color: Color = Color.Unspecified, +) { Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) { HorizontalDivider( modifier = Modifier.weight(1f), thickness = DividerThickness, + color = if (color.isSpecified) color else DividerDefaults.color, ) Text( text = info, fontWeight = FontWeight.Bold, fontSize = Font14SP, + color = color, modifier = HalfPadding, ) HorizontalDivider( modifier = Modifier.weight(1f), thickness = DividerThickness, + color = if (color.isSpecified) color else DividerDefaults.color, ) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt index 013d758a4a..96af13638e 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.chats.ui import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -63,6 +64,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry_action import com.vitorpamplona.amethyst.commons.resources.new_conversation_best_for import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_best import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_chip @@ -73,6 +76,14 @@ import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_pro import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_tagline import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_title import com.vitorpamplona.amethyst.commons.resources.new_conversation_cons +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_best +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_chip +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_con_1 +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_cta +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_pro_1 +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_pro_2 +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_tagline +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_title import com.vitorpamplona.amethyst.commons.resources.new_conversation_dm_best import com.vitorpamplona.amethyst.commons.resources.new_conversation_dm_chip import com.vitorpamplona.amethyst.commons.resources.new_conversation_dm_con_1 @@ -143,6 +154,7 @@ import org.jetbrains.compose.resources.StringResource private val ColorPrivate = Color(0xFF7C3AED) private val ColorMarmot = Color(0xFF4F46E5) private val ColorConcord = Color(0xFF0F766E) +private val ColorCordn = Color(0xFF14B8A6) private val ColorPublic = Color(0xFFB45309) private val ColorRelay = Color(0xFF2563EB) private val ColorEphemeral = Color(0xFFC2410C) @@ -224,6 +236,24 @@ private val conversationSections = cons = listOf(Res.string.new_conversation_concord_con_1), route = Route.ConcordCreate, ), + ConversationType( + icon = MaterialSymbols.Dns, + color = ColorCordn, + title = Res.string.new_conversation_cordn_title, + tagline = Res.string.new_conversation_cordn_tagline, + chip = Res.string.new_conversation_cordn_chip, + bestFor = Res.string.new_conversation_cordn_best, + cta = Res.string.new_conversation_cordn_cta, + pros = listOf(Res.string.new_conversation_cordn_pro_1, Res.string.new_conversation_cordn_pro_2), + // The §8 exposure, stated where the choice is made + // rather than discovered later: a coordinator learns + // who is in which group and when they talk, even though + // it can never read a word. The whole point of the + // exposure work was to say this before someone commits + // to it, and this is the first place it can be said. + cons = listOf(Res.string.new_conversation_cordn_con_1), + route = Route.CordnCreateGroup, + ), ), ), ConversationSection( @@ -325,6 +355,46 @@ fun NewConversationScreen(nav: INav) { } } } + + // Being invited is the other half of "start a conversation", so it + // belongs on the screen people reach for when they want one -- + // not buried in settings. The count is deliberately absent: it + // would take a call to every coordinator, and every call to a + // coordinator is metadata (spec/00.md §8). + item(key = "cordn-invitations") { + CordnInvitationsEntry(onClick = { nav.nav(Route.CordnInvitations) }) + } + } + } +} + +@Composable +private fun CordnInvitationsEntry(onClick: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 6.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + tint = ColorCordn, + modifier = Modifier.size(20.dp), + ) + Column(Modifier.weight(1f)) { + Text( + text = stringRes(Res.string.cordn_invitations_entry), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringRes(Res.string.cordn_invitations_entry_action), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) } } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt new file mode 100644 index 0000000000..a6770124d3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_health_down +import com.vitorpamplona.amethyst.commons.resources.cordn_health_failures +import com.vitorpamplona.amethyst.commons.resources.cordn_health_ok +import com.vitorpamplona.amethyst.commons.resources.cordn_health_unknown +import org.jetbrains.compose.resources.stringResource + +/** + * Whether a coordinator is answering. + * + * Losing a coordinator is not like losing a relay: relays are redundant and the + * next one has the same events, while a coordinator is the single authority for + * the groups it carries. So "not responding" means those conversations have + * stopped, not that they are slower — which is why it gets a row of its own + * rather than a dot. + * + * Three states, not two. [CoordinatorHealth.State.isUnknown] (nothing tried + * yet) reads as neutral, because showing a fresh session a red marker for a + * coordinator that is probably fine trains people to ignore the marker that + * matters. + */ +@Composable +fun CoordinatorHealthRow( + state: CoordinatorHealth.State, + modifier: Modifier = Modifier, +) { + val tint = + when { + state.isUnknown -> MaterialTheme.colorScheme.onSurfaceVariant + state.isDown -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = + when { + state.isUnknown -> MaterialSymbols.HourglassEmpty + state.isDown -> MaterialSymbols.SyncProblem + else -> MaterialSymbols.CheckCircle + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = tint, + ) + Text( + text = + stringResource( + when { + state.isUnknown -> Res.string.cordn_health_unknown + state.isDown -> Res.string.cordn_health_down + else -> Res.string.cordn_health_ok + }, + ), + style = MaterialTheme.typography.bodySmall, + color = tint, + ) + // Only once it is actually down: one failed call is a network blip and + // deserves no words at all. + if (state.isDown) { + Text( + text = stringResource(Res.string.cordn_health_failures, state.consecutiveFailures), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt new file mode 100644 index 0000000000..558e7de2dd --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.ExposureLevel +import com.vitorpamplona.amethyst.commons.cordn.ExposureNote +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_details +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_differs +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_content +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_membership +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_messaging +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_identified +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_none +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_pseudonymous +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_subtitle +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_title +import com.vitorpamplona.amethyst.commons.resources.cordn_note_encryption_bug +import com.vitorpamplona.amethyst.commons.resources.cordn_note_history +import com.vitorpamplona.amethyst.commons.resources.cordn_note_linked +import com.vitorpamplona.amethyst.commons.resources.cordn_note_membership +import com.vitorpamplona.amethyst.commons.resources.cordn_note_publication +import com.vitorpamplona.amethyst.commons.resources.cordn_note_sizes +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.Hex +import org.jetbrains.compose.resources.stringResource + +/** + * What the coordinator behind a cordn group learns, as a panel. + * + * §8 of `quartz/plans/2026-09-17-cordn-interop.md` ends with a requirement: + * the exposure analysis "should be surfaced in the UI if we ship this, not + * buried. A Marmot group and a cordn group have materially different metadata + * exposure and users cannot infer that from either one looking like a group + * chat." This is that surface. + * + * Two deliberate choices about how it reads: + * + * - **It does not rank the two.** cordn is weaker against the operator and + * stronger against the network — the coordinator never sees an IP (§8.5). + * Which trade is right depends on who runs the coordinator, which is the + * user's call. So the card states facts and stops. + * - **The notes come from [GroupExposure.notes], not from this file.** They are + * computed from the group's real state, so a group linked to five others says + * so and a lone group does not. A hand-written paragraph would drift from the + * truth the moment either changed. + */ +@Composable +fun CordnExposureCard( + exposure: GroupExposure, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.PrivacyTip, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.cordn_exposure_title), + style = MaterialTheme.typography.titleMedium, + ) + } + + Text( + text = stringResource(Res.string.cordn_exposure_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_content), exposure.content) + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_membership), exposure.membership) + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_messaging), exposure.messaging) + + HorizontalDivider() + + Text( + text = stringResource(Res.string.cordn_exposure_details), + style = MaterialTheme.typography.labelLarge, + ) + exposure.notes().forEach { NoteRow(it) } + + if (exposure.differsFromMarmot()) { + Text( + text = stringResource(Res.string.cordn_exposure_differs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + // An npub, not a hex head. What somebody checks this against is + // the npub they were sent, and a hex prefix cannot be compared + // with one at all -- different encoding, different alphabet. + // + // Name and face are deliberately absent: this card is shared + // code with no AccountViewModel to read a profile through, and + // it is a footnote under a disclosure rather than an identity + // surface. Every screen that embeds it names the coordinator + // properly nearby. + text = stringResource(Res.string.cordn_exposure_coordinator, remember(exposure.coordinator) { shortNpub(exposure.coordinator) }), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The coordinator key as a short npub. + * + * Shortened with the same prefix `User.pubkeyDisplayHex` uses, so a coordinator + * key reads identically here and on every other screen that prints one. + * + * Falls back to the hex if the key is not decodable, which no stored exposure + * should carry -- a label that is wrong is still better than a card that + * crashes on one malformed group. Decoded with the total `decode64OrNull` + * rather than `Hex.decode`, which validates no characters at all: it indexes a + * 256-entry table by char code, so anything above U+00FF throws an + * ArrayIndexOutOfBounds that no IllegalArgumentException guard would catch. + */ +private const val NPUB_PREFIX = 5 + +private fun shortNpub(pubKeyHex: String): String = Hex.decode64OrNull(pubKeyHex)?.toNpub()?.toShortDisplay(NPUB_PREFIX) ?: pubKeyHex.take(16) + +@Composable +private fun ExposureRow( + dimension: String, + level: ExposureLevel, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = level.symbol(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = level.tint(), + ) + Text( + text = dimension, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = level.label(), + style = MaterialTheme.typography.labelMedium, + fontWeight = level.weight(), + color = level.tint(), + ) + } +} + +@Composable +private fun NoteRow(note: ExposureNote) { + val isBug = note == ExposureNote.ENCRYPTION_NOT_PINNED + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = if (isBug) MaterialSymbols.Warning else MaterialSymbols.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isBug) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = note.label(), + style = MaterialTheme.typography.bodySmall, + color = if (isBug) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The colour of a level, chosen so the scale reads without the words. + * + * It has to be **monotonic**, and getting that wrong is easy: an earlier + * version used `tertiary` for the middle level, which rendered pink against a + * dark `onSurface` for the worst one — so the row a user should worry about + * least of the two looked like the alarming one. Severity now runs + * affirmative → muted → emphatic, and emphasis for the worst case comes from + * [weight] rather than another hue. + * + * Nothing uses `error`. Everything on this card is how cordn works, not a + * fault, and painting normal operation red teaches people to ignore red. + */ +@Composable +private fun ExposureLevel.tint(): Color = + when (this) { + ExposureLevel.NONE -> MaterialTheme.colorScheme.primary + ExposureLevel.PSEUDONYMOUS -> MaterialTheme.colorScheme.onSurfaceVariant + ExposureLevel.IDENTIFIED -> MaterialTheme.colorScheme.onSurface + } + +/** The other half of the scale: only the worst level is emphasised. */ +private fun ExposureLevel.weight(): FontWeight = + when (this) { + ExposureLevel.IDENTIFIED -> FontWeight.SemiBold + else -> FontWeight.Normal + } + +private fun ExposureLevel.symbol(): MaterialSymbol = + when (this) { + ExposureLevel.NONE -> MaterialSymbols.Lock + ExposureLevel.PSEUDONYMOUS -> MaterialSymbols.NoAccounts + ExposureLevel.IDENTIFIED -> MaterialSymbols.Person + } + +@Composable +private fun ExposureLevel.label(): String = + stringResource( + when (this) { + ExposureLevel.NONE -> Res.string.cordn_exposure_level_none + ExposureLevel.PSEUDONYMOUS -> Res.string.cordn_exposure_level_pseudonymous + ExposureLevel.IDENTIFIED -> Res.string.cordn_exposure_level_identified + }, + ) + +@Composable +private fun ExposureNote.label(): String = + stringResource( + when (this) { + ExposureNote.MEMBERSHIP_IS_IDENTIFIED -> Res.string.cordn_note_membership + ExposureNote.GROUPS_LINKED_BY_SESSION -> Res.string.cordn_note_linked + ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY -> Res.string.cordn_note_history + ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD -> Res.string.cordn_note_publication + ExposureNote.MESSAGE_SIZES_UNPADDED -> Res.string.cordn_note_sizes + ExposureNote.ENCRYPTION_NOT_PINNED -> Res.string.cordn_note_encryption_bug + }, + ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt new file mode 100644 index 0000000000..7ed9488337 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_badge_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_badge_coordinator_desc +import org.jetbrains.compose.resources.stringResource + +/** + * Marks a group as coordinator-delivered, wherever groups are listed together. + * + * The whole §8 problem in one line: a cordn group and a Marmot group look + * identical in a list, and their metadata exposure is not. Somebody scanning a + * list of conversations will not open a disclosure panel — so the list itself + * has to say which ones are which, and the panel is where they go to find out + * what it means. + * + * Carries its own content description rather than leaning on the label, because + * "Coordinator group" read aloud with no context is a noise, not a warning. + */ +@Composable +fun CordnGroupBadge(modifier: Modifier = Modifier) { + val description = stringResource(Res.string.cordn_badge_coordinator_desc) + + Surface( + modifier = modifier.semantics { contentDescription = description }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + Text( + text = stringResource(Res.string.cordn_badge_coordinator), + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt new file mode 100644 index 0000000000..af4de6b3cf --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import org.jetbrains.skia.EncodedImageFormat +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders the cordn disclosure surface headlessly and looks at the pixels. + * + * Compiling a composable proves almost nothing about it. Two failure modes it + * cannot catch, and this can: + * + * - **A missing string resource throws at render, not at build.** The generated + * `Res.string.*` accessors compile whether or not `strings.xml` has the entry, + * so a typo ships and crashes the screen the first time someone opens it. + * This suite opens it. + * - **A composable that draws nothing** — a zero-height container, a colour that + * equals its background — still builds and still "renders". + * + * It asserts structure rather than exact pixels, so it does not become a + * screenshot test that has to be re-blessed on every font or Material bump. It + * does not write files either; what it checks is what it can check honestly + * without a human looking. `ImageComposeScene` rasterises in software, so this + * needs no display and runs in CI. + */ +class CordnExposureRenderTest { + private val width = 900 + private val height = 1500 + + // Inside the card and clear of text: the card starts at the Column's 8dp + // padding and this sits in the gutter to the right of the title row. + private val cardInteriorX = width - 40 + private val cardInteriorY = 40 + + private fun exposure(linked: Int = 3) = + GroupExposure( + coordinator = "cc".repeat(32), + linkedGroupCount = linked, + joinedFromShareLink = true, + publishedKeyPackage = true, + encryptionPinned = true, + ) + + /** Renders [content] under a light or dark Material theme and decodes it. */ + private fun render( + dark: Boolean, + content: @Composable () -> Unit, + ): BufferedImage { + val scene = + ImageComposeScene(width = width, height = height, density = Density(2f)) { + MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + } + } + } + return try { + val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes + ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } + } + + /** Every composable on the surface, in one pass. */ + private val wholeSurface: @Composable () -> Unit = { + CordnExposureCard(exposure()) + CordnGroupBadge() + CoordinatorHealthRow(CoordinatorHealth.State()) + CoordinatorHealthRow(CoordinatorHealth.State(lastSuccessAt = 1L)) + CoordinatorHealthRow( + CoordinatorHealth.State(lastFailureAt = 1L, consecutiveFailures = 4, lastFailure = "timeout"), + ) + } + + private fun BufferedImage.distinctColours(): Int { + val seen = mutableSetOf() + for (x in 0 until width step 3) { + for (y in 0 until height step 3) { + seen += getRGB(x, y) + } + } + return seen.size + } + + @Test + fun `the whole surface renders in both themes`() { + // The string-resource check: any missing entry throws here. + listOf(false, true).forEach { dark -> + val image = render(dark, wholeSurface) + assertTrue( + image.distinctColours() > 20, + "the ${if (dark) "dark" else "light"} surface drew ${image.distinctColours()} colours, which is not text on a card", + ) + } + } + + @Test + fun `the surface follows the theme rather than hardcoding colours`() { + // A composable that paints its own background survives a theme switch + // looking identical, and is unreadable in one of the two. + val light = render(false, wholeSurface) + val dark = render(true, wholeSurface) + + val brightness = { rgb: Int -> ((rgb shr 16 and 0xFF) + (rgb shr 8 and 0xFF) + (rgb and 0xFF)) / 3 } + + // Two samples, because they catch different mistakes. The page + // background catches a screen that ignores the theme; a point inside + // the card, clear of any glyph, catches a *component* that paints its + // own colour — which the outer sample cannot see at all. + listOf( + "page background" to (2 to 2), + "card surface" to (cardInteriorX to cardInteriorY), + ).forEach { (what, point) -> + val (x, y) = point + val lightPixel = light.getRGB(x, y) + val darkPixel = dark.getRGB(x, y) + assertTrue(lightPixel != darkPixel, "the $what ignored the theme") + assertTrue(brightness(lightPixel) > brightness(darkPixel), "light and dark are swapped on the $what") + } + } + + @Test + fun `an unlinked group draws less than a linked one`() { + // The §8.2 note is conditional, and a conditional that never fires is + // indistinguishable from one that is broken. Fewer notes means a + // shorter card, so the ink below the fold differs. + val linked = render(false) { CordnExposureCard(exposure(linked = 3)) } + val alone = render(false) { CordnExposureCard(exposure(linked = 1)) } + + val inkBelow = { image: BufferedImage -> + var count = 0 + for (x in 0 until width step 3) { + for (y in height / 2 until height step 3) { + if (image.getRGB(x, y) != image.getRGB(2, 2)) count++ + } + } + count + } + + assertTrue( + inkBelow(linked) > inkBelow(alone), + "a group linked to others must show the extra disclosure: ${inkBelow(linked)} vs ${inkBelow(alone)}", + ) + } +} diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt index 47a25cc4c1..50b256ffaa 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.marmotbench import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stores, matching the commons test doubles byte for byte. diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt index 4754138eaf..8e997fe0eb 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.marmotbench -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.X25519 // The elliptic-curve primitives on their own. // diff --git a/quartz/interop/verify-with-ts-mls.sh b/quartz/interop/verify-with-ts-mls.sh new file mode 100755 index 0000000000..a3fde7a9c9 --- /dev/null +++ b/quartz/interop/verify-with-ts-mls.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# The reverse interop direction: ts-mls checks OUR output. +# +# `quartz/cordn`'s own tests prove we can read what ts-mls writes. That is half of +# interoperating -- a client can parse everything correctly and still emit +# something nobody accepts, and that failure keeps our tests green while every +# peer silently drops us. This script closes the loop by handing our artifacts +# to the implementation cordn's client actually runs. +# +# It is opt-in because it needs two checkouts and a Node toolchain, which CI +# for this repo does not carry. `KotlinArtifactProducerTest` writes the +# artifacts unconditionally; only the verification below needs the extras. +# +# Usage: +# quartz/interop/verify-with-ts-mls.sh +# +# Environment: +# CORDN_DIR cordn checkout with `pnpm install` run (default ../cordn) +# STAIRCASE_DIR staircase checkout, for its verify.ts (default ../staircase) +# +# git clone https://github.com/Cordn-msg/cordn ../cordn && (cd ../cordn && pnpm install) +# git clone https://code.relay.tools/opensauce/staircase ../staircase +# +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +CORDN="${CORDN_DIR:-$REPO/../cordn}" +STAIRCASE="${STAIRCASE_DIR:-$REPO/../staircase}" +OUT="$REPO/quartz/build/interop" +FIXTURES="$REPO/quartz/src/commonTest/resources/tsmls" +VERIFY="$STAIRCASE/conformance/fixtures-gen/verify.ts" + +die() { echo "error: $*" >&2; exit 2; } + +[ -d "$CORDN/packages/cli" ] || die "no cordn checkout at $CORDN (set CORDN_DIR)" +[ -d "$CORDN/node_modules" ] || die "run 'pnpm install' in $CORDN first" +[ -f "$VERIFY" ] || die "no staircase checkout at $STAIRCASE (set STAIRCASE_DIR)" + +# Node, not bun. staircase's own run.sh uses bun, and bun's WebCrypto has no +# X25519 DHKEM, so ts-mls there cannot open a Welcome at all -- not even one it +# produced itself. The failure surfaces as `DecapError: The algorithm is not +# supported` deep inside HPKE and looks exactly like a wire-format mismatch, +# which is worth knowing before spending an afternoon on it. +command -v node >/dev/null || die "node is required" + +echo "==> producing artifacts from quartz" +"$REPO/gradlew" -p "$REPO" :quartz:jvmTest --tests '*KotlinArtifactProducerTest*' --rerun-tasks -q + +# verify.ts reads /gen for the ts-mls KeyPackage it joins with, and +# /kotlin for everything we produced. +echo "==> assembling fixture root at $OUT" +mkdir -p "$OUT/gen" +cp "$FIXTURES/bob2-kp.bin" "$FIXTURES/bob2-privkp.bin" "$OUT/gen/" + +STAGE="$CORDN/packages/cli/.staircase" +mkdir -p "$STAGE" +cp "$VERIFY" "$STAGE/" + +echo "==> running ts-mls against them" +cd "$CORDN/packages/cli" +node --experimental-strip-types "$STAGE/verify.ts" "$OUT" diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md new file mode 100644 index 0000000000..b410415140 --- /dev/null +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -0,0 +1,1351 @@ +# Cordn interop: extract the MLS core, then add a second binding + +Status: Stages 1, 2, the core of 3 and the wire half of Stage 0 landed. The RFC 9420 engine is `quartz/…/mls/` and imports +nothing from `marmot/` — a binding supplies its rules through `MlsGroupPolicy`. `quartz/…/contextvm/` +implements the core spec plus all 12 CEPs on the client side, with the Tier C fixture server; +`quartz/…/cordn/` implements the MLS profile, the eleven coordinator tools, the seal, envelopes, +group refs and the sync rules, verified against ts-mls in both directions **and against cordn's +own wire contracts** (`quartz/tools/cordn-vector-gen` → `resources/cordn/`). Stage 4 (app +integration) is open. **Tier B has now been run** — on an explicit decision to proceed despite +the licensing problem in §7 — and found two bugs no fixture could (§7.1). §4.1 turned out not to gate the binding — see Stage 3 — and is now settled on our +side by implementing both encodings rather than waiting for an agreement (§4.1). + +Correction to an earlier gate in this plan: §4.1 does **not** block Stage 2. ContextVM is +credential-agnostic and has no MLS dependency at all, so the transport was safe to build first; +only Stage 3's KeyPackage work depends on that decision. + +Sources checked on 2026-09-17: + +- `Cordn-msg/cordn` @ `b465df0` (2026-08-27), package version `0.5.1` — specs + reference + coordinator + `@cordn/cli` +- `Cordn-msg/cordn-web` @ `c38e307` (2026-09-16), version `0.4.0` — the client at + +- `Cordn-msg/cordn-rs` @ `9aff928` (2026-08-29) — Rust coordinator, wire- and SQLite-compatible + with the TS one; **not** an MLS implementation +- `ContextVM/contextvm-docs` @ `e63bce6` — the ContextVM specification and all 12 CEPs; the + clean-room source for §6. **No LICENSE file in the repo** +- `ContextVM/sdk` @ `b5d1e4e` (2026-09-17), version `0.13.17` — **LGPL-3.0**, see §7. Read only to + confirm deployed defaults, never as an implementation source + +Verification status: `:quartz:jvmTest` passes in a container, covering the MLS engine, ContextVM +and cordn, so the interop claims in §3 are execution-verified rather than read-verified. +`:quartz:testAndroidHostTest` passes as well — the four failures this plan previously recorded as +pre-existing (`NostrServerTest`, `LiveNegentropyIndexStoreTest`) were fixed on 2026-09-18: the +event store classified insert failures by parsing the SQLite driver's exception message, and +Android's carries none. Note the source-set shape when reading test counts: `jvmTest` dependsOn +`jvmAndroidTest`, but `androidHostTest` does **not**, so the cordn and ContextVM suites under +`jvmAndroidTest/` run on the JVM target only. + +## 1. Executive summary + +Cordn is **an alternative to Marmot, not an alternative to MLS**. Both are bindings of RFC 9420 +onto Nostr; they agree almost exactly on the crypto layer and disagree on everything above it. + +- **Same:** ciphersuite `0x0001`, the ChaCha20-Poly1305 outer seal (byte-identical framing), the + MLS-exporter-derived seal key, the pre-commit epoch rule for Commits, and the unsigned + NIP-01-shaped application envelope with `kind: 9` chat. +- **Different:** the delivery service. Marmot publishes kinds 443/444/445 to relays. Cordn calls + an **MCP server over ContextVM** — 11 tools, per-group monotonic cursors, history in the + coordinator's SQLite, and *no key-package event kind at all*. + +So the only code both bindings can share is the RFC 9420 engine. Everything named `Marmot*`, +`Mip*` or `mip0*` is the wrong layer and must not be reused. + +The engine is reusable but **not yet binding-agnostic**: it lives inside +`quartz/…/marmot/mls/`, and three files plus three hardcoded policies leak Marmot into it (§5.1). +Extracting it is the prerequisite for this work and is worth doing on its own merits. + +The transport has to be written from scratch: **ContextVM is MCP-over-Nostr and nothing in Quartz +speaks it.** Scope is **the core spec plus all 12 CEPs** (§6.2), so `:contextvm` is a complete +MCP-over-Nostr implementation rather than a cordn adapter — cordn only exercises 7 of the 13 +documents, but the rest are cheap next to the two large transfer profiles and several ride on NIPs +we already have. Only **CEP-4, CEP-6 and CEP-16** are Final; the core spec and the other nine CEPs +are Draft, including CEP-22 and CEP-41 (§6.7). + +Because the CEPs are symmetric, compliance is not demonstrable against cordn alone. §6.4 defines +five test tiers. **Tier C — a Kotlin fixture server that misbehaves on demand — is built** +(`contextvm/…/fixture/`) and is what makes the negative half testable: no real server sends a +non-monotonic `progress`, a stale `pong` nonce or a mismatched digest, yet those are MUST-fail +requirements. **RFC 8785 JCS** is built too, in `quartz/…/utils/jcs/`, shared by CEP-8 and CEP-15. +**Tier B (live coordinator) has been run** — see §7.1 for the two bugs it found and §7 for the +terms it was run on. Tiers D (cross-implementation vectors) and E (a real wallet) remain open. + +Sequencing, as revised in practice: **Stage 2 (the transport) was built first**, because +ContextVM has no MLS dependency and so no dependency on the §4.1 decision. What that decision +gates is **Stage 3**, the cordn binding: if it goes the wrong way every KeyPackage is permanently +ecosystem-bound and "interop" degrades to Amethyst speaking two unrelated protocols. Remaining +order: Stage 0 (cordn-side vectors) → Stage 1 (extract the MLS engine) → decide §4.1 → Stage 3-4. + +## 2. The coordinator protocol surface + +Eleven MCP tools (`cordn/packages/core/src/contracts.ts`): + +``` +kp_publish kp_list kp_take kp_remove +welcome_store welcome_take +join_request_store join_request_take_many +msg_post msg_fetch_many msg_sub_many +``` + +Transport is ContextVM: kind **25910** ephemeral events whose `content` is a stringified MCP +JSON-RPC message, `p` = peer, `e` = request correlation, optionally gift-wrapped in 1059/21059 +(`contextvm-sdk/src/core/constants.ts`). `msg_sub_many` needs **CEP-41** open streams and the +coordinator also enables **CEP-22** oversized transfer, both framed over +`notifications/progress`. + +Delivery model (`cordn/spec/00.md`, `spec/03.md`): the coordinator stores opaque bytes keyed by an +outer delivery id `gid`, assigns a monotonic per-group cursor, and must not parse payloads. It +cannot distinguish a Commit from a chat message — epoch, wireformat and content-type were +deliberately removed from its view (`cordn/design/private-coordinator-refactor.md`, which is an +explicit study of Marmot's kind-445 and the reason the two seals match). + +`gid` is spec'd as decoupled from the MLS `group_id`, but the reference client sets both to +`utf8(crypto.randomUUID())` (`cordn-web/src/lib/services/chatGroupLifecycle.svelte.ts:76`, +and `getProtocolGroupId` at `:83` reads the `gid` straight back out of the group context). + +Group sharing is a bech32 `cordn1…` string with NIP-19-shaped TLV (type 0 = `gid` as UTF-8, +1 = coordinator pubkey as raw 32 bytes, 2 = relay URL), bech32 not bech32m +(`cordn/spec/applications/group-ref.md`). Our NIP-19 codec covers it with a prefix change. + +## 3. Verified compatible (the crypto layer) + +| Layer | Marmot (ours) | cordn | Verdict | +| ----- | ------------- | ----- | ------- | +| Ciphersuite | `MlsCiphersuite.DEFAULT` = `0x0001` (`mip01Groups/MlsCiphersuite.kt`) | `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (`chatMlsUtils.ts:38`) | identical | +| Outer seal | ChaCha20-Poly1305, 12-byte random nonce, **empty AAD**, `base64(nonce‖ct‖tag)` (`mip03GroupMessages/GroupEventEncryption.kt`) | same, same, same, same (`spec/03.md` §4) | byte-identical framing | +| Seal key | `MLS-Exporter("marmot","group-event",32)` | `MLS-Exporter("cordn","group-payload",32)` | label/context only | +| Commit epoch | pre-commit epoch | pre-commit epoch (`spec/03.md` §5) | identical | +| App payload | unsigned NIP-01 event, no `sig`, `id` = NIP-01 hash, `kind: 9` default (`foundation/appEvents/MarmotAppEvent.kt:78`) | same, and rejects any payload carrying `sig` (`spec/02.md`) | same wire shape | +| Threading / reactions | NIP-22 `1111`, NIP-25 `7` | same (`spec/02.md` §6) | identical | +| Sender privacy intent | fresh ephemeral key per kind-445 | ephemeral ContextVM identity on the message path | same idea, different grain (§8.2) | + +cordn-web's MLS engine is **ts-mls 2.0.0-rc.13**, the same library our existing +`TsMlsWelcomeInteropTest` has vectors for +(`quartz/src/commonTest/resources/mls/tsmls-welcome.json` — key packages, Welcome, exporter and +app messages at ciphersuite 1). The RFC 9420 core is therefore a known quantity, subject to the +caveat in the header that this container could not re-run it. + +Note what this table is *not*: none of these are reuse candidates. `GroupEventEncryption`, +`MarmotAppEvent` and MIP-04 media are Marmot-layer code. The table says a cordn-side +implementation will look structurally familiar and can be written with confidence — it will be +parallel code under `quartz/…/cordn/`, not a shared path. + +## 4. Verified divergences + +### 4.1 Credential identity encoding — hard incompatibility, decide first + +- Ours: raw **32 bytes**, x-only pubkey. `KeyPackageUtils.kt:253` — + `if (credential.identity.size != 32) return false`. +- Theirs: **64 ASCII bytes** of lowercase hex. `chatMlsUtils.ts:251` — + `identity: new TextEncoder().encode(stablePubkey)`; the coordinator reads it back with + `TextDecoder` and string-compares to `event.pubkey` + (`coordinatorMethods.ts:118-151`). + +One KeyPackage cannot satisfy both. This is exactly what `spec/00.md` §13 requires +implementations to agree on, and the two ecosystems picked differently. It is a one-line change +on either side today and unfixable once either has deployed users at scale. + +**Decision (2026-09-18): support both encodings; do not wait for an agreement.** Neither +ecosystem is expected to move, and neither needs to. The credential encoding is a *binding* +choice, and since Stage 1 the engine no longer has an opinion about it — `MlsGroupPolicy` picks +the profile, `MarmotCapabilities`/`CordnCredential` supply the encoding. Marmot KeyPackages carry +the raw 32 bytes; cordn KeyPackages carry the 64 ASCII hex bytes; both are produced and read by +the same engine, and neither can be mistaken for the other (32 raw bytes is not valid 64-char +ASCII hex, so `CordnCredential.identityOrNull` and `KeyPackageUtils`'s 32-byte check are +mutually exclusive by construction). + +What this costs is §4.4: a member advertising only one profile's capabilities cannot be added to +the other's groups, so "one MLS group, both clients" stays out of reach. That was already a +deliberate profile decision rather than a consequence of this one. What it buys is that Amethyst +speaks both today rather than blocking on a conversation neither side has an incentive to finish. + +Still worth raising with gzuuus as a fact rather than a request — an implementation that reads +both is useful evidence for whichever encoding a future joint profile picks. + +### 4.2 No key-package event kind + +There is no cordn equivalent of Marmot's kind 443. `kp_publish` takes `{ kp_ref, kp_64 }` — plain +base64 in the JSON-RPC arguments. The "signed publication payload" of `spec/00.md` §7 is the +**ContextVM request event itself**: the coordinator reaches back into the transport for it +(`coordinatorServer.ts:92` → `transport.getNostrRequestEvent(requestEventId)`), stores it +verbatim, and returns it on `kp_take`. A consumer then does +(`chatMlsUtils.ts:579-613`): + +```ts +if (!verifyEvent(publicationEvent)) throw … +const kp64 = JSON.parse(publicationEvent.content).params?.arguments?.kp_64 + ?? JSON.parse(publicationEvent.content).params?.arguments?.keyPackageBase64; +``` + +i.e. the KeyPackage is recovered by parsing JSON-RPC out of a kind-25910 event's `content`, and +identity binding is `decodeKeyPackageIdentity(kp) === publicationEvent.pubkey`. + +Consequences: + +- A Marmot kind-443 event cannot serve as a cordn publication payload or vice versa. There is no + "publish to relays, interop on KeyPackages only" shortcut — KeyPackage publication is + inseparable from having a working ContextVM client. +- The invariant rides JSON-RPC envelope shape rather than a stable event schema, and the client + already carries a fallback from an earlier field rename (the `?? keyPackageBase64` above). + +**Worth proposing upstream** alongside §4.1: give KeyPackage publication its own signed payload +(or its own kind). That is mutual-benefit — it would let a cordn KeyPackage be published to relays +and consumed without a coordinator at all. + +### 4.3 Last-resort marker + +Ours: MLS extension `0x000A`. Theirs: mls-extensions `app_data_dictionary` (`0x0006`) with +component id `0x0004` and empty component data +(`cordn/packages/core/src/lastResortKeyPackage.ts`). We already have `AppDataDictionary.kt`, +`ComponentsList.kt` and `AppDataDictionaryInteropTest` — this is a codec selection, not new work. + +### 4.4 Capability gates keep the two group types disjoint + +Marmot groups carry `required_capabilities = { extensions: [0xF2EE], proposals: [0x000A +self_remove] }` (`MlsGroup.kt:3255`, built by `buildMarmotRequiredCapabilitiesExtension()` at +`:3259`). Cordn groups use GroupContext extension `0xC04D cordn_group_metadata` and require +members to advertise it (`spec/01.md` §7). Neither client can be *added* to the other's groups. +Cheap to fix by advertising both in `Capabilities`, but "one group, both clients" is a deliberate +profile decision, not a free consequence of sharing an engine. + +### 4.5 Encrypted media diverges + +Exporter *context* matches (`"encrypted-media"`), but cordn uses the exporter output directly as +the file key with `aad = mime‖0x00‖filename‖0x00‖sha256(plaintext)`, where our MIP-04 v2 does +`HKDF-Expand(exporter, context)` with `aad = "mip04-v2"‖0x00‖hash‖0x00‖mime‖0x00‖filename`. +Separate codec; shared primitives (ChaCha20-Poly1305, NIP-92 `imeta`, Blossom) all already exist. + +### 4.6 Multi-device — cross-client interop is impossible; the feature is not + +`spec/applications/multi-device.md` gives a user's devices **one shared MLS leaf** per group and +carries that group's `ClientState` inside sealed Blossom documents advertised by an opaque tip. + +**What is not implementable: interop with their devices.** The document's `clientState` is +`base64(serialized MLS ClientState)`, and §4.2 says outright that it is *"library-serialized and +intentionally not pinned to a wire format"* — the only pinned MLS serialization in the document is +TLS, used for `lastResortKeyPackage`. So the field is ts-mls's private encoding by design, not by +omission. An Amethyst device and a cordn-web device can never share a leaf, and no work on our side +changes that. + +**What IS implementable: our own fleet.** Everything else §14 lists as a MUST is pinned and +vendor-neutral — the two document JSON shapes, the NIP-44 v2 seal to a per-identity DEK, `sha256` +content addressing, the highest-epoch-wins reconciliation rule, the `{gid, epoch}` tombstone shape, +the sibling-skip rule, and the tip format. Amethyst-device ↔ Amethyst-device sync would work with +`MlsGroupState` in that one field. + +**Status: the fleet stays a non-goal; migration LANDED.** A handoff — one phone +publishing a snapshot and standing down, another seeding from it — has one writer, so +§10's race is out of reach by construction rather than by mitigation. That shipped; see +`amethyst/plans/2026-09-19-cordn-ui.md` §5.3 for what was built and what was deliberately +left out. The reasoning below is why a *live fleet* is still not worth it, and it is +unchanged: + +- It buys Amethyst-only device sync. A user who mixes clients gets nothing. +- §10's **symmetric commit race is unresolved in the spec**. Two devices committing inside one + delivery round-trip both reach epoch N+1 with different states; the forward-only epoch check + cannot break the tie, and §15 concedes that *"equal-epoch MLS states have no merge function."* + The spec offers only refuse-to-commit-while-behind and a manual re-sync prompt; automatic + resolution is *"possible but unspecified."* We would be shipping that race. +- It is a heavy, always-on discipline for a Draft spec: a document republish plus a full tip + rewrite after **every** epoch-advancing Commit (§10.5), and a full reconcile before opening any + delivery stream on startup (§10.6), because a backlog fetched while behind arrives sealed under + epochs the device has not adopted. +- It needs an `MlsGroupState` ⇄ document codec and a `prev`-chain walk (§8.5) that nothing else + in the codebase wants. + +So: do not attempt it — but record it as a cost/benefit call on a Draft spec with a known race, +not as "there is nothing to implement." + +## 5. What we already have + +### 5.1 The engine, and how Marmot-clean it is + +**Superseded by Stage 1, which landed.** The measurements below are what the engine looked like +before the extraction; they are kept because they are what the stage was scoped against. The +engine is now `quartz/…/mls/` with zero `marmot/` imports. + +`quartz/…/marmot/mls/` was **11,964 LOC**. Measured coupling to the Marmot layer: + +- **3 files**, **10 imports** total: + - `group/MlsGroup.kt` (7): `MarmotGroupData`, `MarmotGroupState`, `AdminPolicyV1`, + `AppComponentIds`, `AgentTextStreamCrypto`, `AgentTextStreamQuicPolicyV1`, + `AgentTextStreamRoles` + - `group/MlsGroupManager.kt` (2): `AdminPolicyV1`, `GroupLifecycleV1` + - `messages/MlsKeyPackage.kt` (1): `AppComponentIds` +- **Zero** Marmot imports in `codec/`, `crypto/`, `tree/`, `schedule/`, `framing/`, + `components/`, `messages/{Commit,Proposal,Welcome}`. + +Plus three Marmot policies hardcoded *inside* the engine that a cordn group must not inherit: + +| Location | Hardcoded | +| -------- | --------- | +| `MlsGroup.kt:3259` | `required_capabilities = {ext:[0xF2EE], props:[0x000A]}` | +| `MlsGroup.kt:650`, `:4276` | `exporterSecret("marmot", "group-event", 32)` — label baked in | +| `KeyPackageRotationManager.kt:645-670` | leaf `capabilities.extensions = [0x000A, 0xF2EE]` | + +`MlsGroup.kt:2072` already exposes `exporterSecret(label, context, length)`, so the last of those +is a call-site fix, not a redesign. + +### 5.2 Reusable as-is + +NIP-44 (and NIP-59) for the ContextVM gift wrap, NIP-19 bech32/TLV for `cordn1…`, +`ChaCha20Poly1305`, the TLS presentation-language codec (`mls/codec/`) for the `0xC04D` extension, +Blossom (`nipB7Blossom`), `INostrClient` and the `accessories/` one-shot helpers for relay I/O, +and `NostrSigner` for both identities. + +## 6. ContextVM: full surface review and compliance matrix + +We have to write this from scratch — nothing in Quartz speaks it, and the only SDKs are TypeScript +and Rust. **Scope decision: implement the whole CEP list**, not just the subset cordn exercises. +That makes `:contextvm` a complete MCP-over-Nostr implementation rather than a cordn adapter, and +it means compliance has to be demonstrable per CEP rather than "cordn works". + +**Clean-room sourcing.** Everything below is derived from the specification documents in +`ContextVM/contextvm-docs` (the `docs/contextvm-docs` submodule of the SDK, at +`src/content/docs/reference/`), **not** from the LGPL SDK source (§7). Implementers should work +from those documents. The docs repo carries **no LICENSE file** — the protocol is free to +implement, but do not paste spec prose into our repo; paraphrase. + +### 6.1 The core spec is small + +`spec/ctxvm-draft-spec.md` (352 lines, Draft) is nearly all of the base protocol: + +- **One event kind, 25910**, ephemeral (NIP-01 range 20000–30000). `content` is the stringified + MCP JSON-RPC message, preserved exactly. Nostr metadata lives only in tags: `p` addresses the + peer, `e` references the request event for correlation. +- **Standard MCP lifecycle** — `initialize` → result → `notifications/initialized` — and the spec + explicitly says it is **not required**, because servers may operate statelessly. +- Everything else (`tools/list`, `tools/call`, notifications) is unmodified MCP in `content`. + +The consequence worth flagging up front: **25910 is ephemeral, so relays do not store it.** A +client must already be subscribed when the response is published or the response is simply gone — +there is no REQ-after-the-fact recovery. That shapes our subscription lifecycle more than anything +else in the spec. + +### 6.2 Compliance matrix + +Thirteen documents: the core spec plus 12 CEPs. `Rules` is the rule-id prefix this plan assigns +for citing individual requirements, following the `STORE-Fxx` convention the `event-store-semantics` +skill established — so a future divergence can be named precisely instead of described. `Gate` is +what has to pass before we claim compliance; the test catalog is §6.5. + +| Spec | Status | Surface | Rules | Gate | +| ---- | ------ | ------- | ----- | ---- | +| **Core** draft spec | Draft | Kind 25910, `content` = stringified JSON-RPC, `p`/`e` tags, optional MCP lifecycle | `CVM-CORE-*` | A + B | +| **CEP-4** Encryption | **Final** | NIP-44 encrypt the *signed* inner 25910 event into a NIP-59 wrap (kind 1059), **no rumor layer**; `support_encryption` | `CVM-4-*` | A + B + **D** | +| **CEP-19** Ephemeral Gift Wraps | Draft | Kind **21059**, identical semantics to 1059 but ephemeral; `support_encryption_ephemeral`; MUST fall back to 1059 | `CVM-19-*` | A + B | +| **CEP-6** Public Announcements | **Final** | Addressable **11316** server, **11317** tools, **11318** resources, **11319** resource templates, **11320** prompts; discovery tags `name`/`about`/`picture`/`website`/`support_*` | `CVM-6-*` | A + B | +| **CEP-17** Relay List Metadata | Draft | NIP-65 **kind 10002**, unmarked `r` tags in the ContextVM profile; bootstrap vs advertised relays are distinct | `CVM-17-*` | A + B | +| **CEP-35** Stateless Discovery | Draft, Info | Discovery tags on the **first direct message each side sends**; unknown tags MUST be preserved; `p`/`e` excluded from the learned surface | `CVM-35-*` | A + C | +| **CEP-22** Oversized Transfer | Draft | Bounded reassembly over `notifications/progress`; `progressToken` = transfer id; `start`/`accept`/`chunk`/`end`/`abort`; `completionMode: "render"`; SHA-256 digest + `totalBytes` + `totalChunks` | `CVM-22-*` | A + B + C | +| **CEP-41** Open Streams | Draft | Long-lived streams, same envelope; `start`/`accept`/`chunk`/`ping`/`pong`/`close`/`abort`; per-sender `progress`; contiguous `chunkIndex` | `CVM-41-*` | A + B + C | +| **CEP-16** Client Pubkey Injection | **Final**, Info | Server injects `_meta.clientPubkey` into inbound requests; opt-in, default off | `CVM-16-*` | A + C (server role) | +| **CEP-8** Pricing and Payment | Draft | `cap`/`pmi`/`payment_interaction`/`direct_payment`/`change` tags; transparent notification lifecycle vs `explicit_gating` JSON-RPC errors (`-32042`, `-32043`, `-32602`); canonical invocation identity | `CVM-8-*` | A + C + **E** | +| **CEP-15** Common Tool Schemas | Draft | RFC 8785 JCS hash of `{name, normalized inputSchema, normalized outputSchema?}`; `io.contextvm/common-schema` `_meta`; NIP-73 `i`/`k` tags | `CVM-15-*` | A + B | +| **CEP-21** PMI Recommendations | Draft, Info | PMI naming conventions, `-direct` suffix for bearer settlement | `CVM-21-*` | A | +| **CEP-23** Server Profile Metadata | Draft | Server-published **kind 0** and optional **kind 1** | `CVM-23-*` | A + B | +| **CEP-24** Server Reviews | Draft | NIP-22 **kind 1111** anchored to the `11316::` `a` coordinate | `CVM-24-*` | A + B | + +Gate legend (method in §6.4): **A** rule-derived unit tests · **B** live integration against a +real counterparty · **C** adversarial tests against our own fixture server · **D** cross- +implementation vector exchange · **E** wallet integration. + +Status reality check: only **CEP-4, CEP-6 and CEP-16** are Final. The core spec and the nine other +CEPs are Draft, including both large transfer profiles. Per the CEP guidelines a CEP reaches Final +only once its reference implementation lands, so "Draft" here means the spec text may still move, +not that it is unimplemented. Budget for churn (§6.7). + +### 6.3 The high-risk rules + +These are where an implementation passes its own unit tests and then diverges against a real peer. +Each is a spec MUST and each maps to a named test in §6.5. + +1. **CEP-41 has two ordering fields, and they are not interchangeable.** `progress` orders *all* + frames (control frames included) and is explicitly **not** a chunk counter. `chunkIndex` starts + at `0`, increases contiguously by `1`, and is what receivers MUST use to validate contiguity + and completeness. Conflating them is the obvious first bug. +2. **Progress sequences are per-sender**, each starting at `1`. A frame's `progress` may only be + compared against frames from the same peer. A `pong`'s `progress` comes from the responder's own + sequence and has **no** required ordering relationship to the triggering `ping`; pongs match by + `nonce` only. Do not build one shared counter per stream. +3. **`close` does not complete the JSON-RPC request.** After `close` the sender MUST still send the + final JSON-RPC success response, and a client MUST NOT synthesize success from `close` alone. + So `msg_sub_many` has two independent completion signals — cordn-web's API shape + (`{ stream, result, abort }`) is a direct consequence, not a style choice. +4. **The CEP-22 digest is over the exact serialized string**, UTF-8-encoded — not over a + re-serialized JSON object. Any reparse-and-reserialize (key reordering, whitespace) breaks it. + The reassembly path must carry bytes end to end and only parse after the digest verifies. +5. **`accept` is conditional bootstrap, not a handshake.** Skip it when peer support is already + known; it is **mandatory before the first `chunk` in stateless flows**. cordn is stateless, so + our client→server oversized path must wait for `accept`. +6. **CEP-41 keepalive is mandatory.** Any valid frame resets the idle timer; on expiry the peer + MUST send `ping`; no matching `pong` before the probe timeout MUST fail the stream. cordn-web + runs 30s/30s explicitly because the SDK's 20s default turned a single lost relay round-trip into + a stream abort. Do not ship a 20s window. +7. **Relay size limits apply to the whole serialized event**, ~64 KiB in practice, not just + `content`. Chunk sizing must budget for base64 expansion plus JSON plus event overhead, with + conservative margin, and MUST NOT assume a uniform threshold across relays. +8. **Encryption leaks the recipient.** CEP-4 says so outright: the gift wrap carries a `p` tag for + the recipient. Sender, inner kind and real timestamp are hidden; the recipient pubkey is not. +9. **Gift wrap timestamps are randomized** per NIP-59 — never use them for ordering. +10. **The inner event is fully signed, not a rumor.** CEP-4's flow signs the 25910 event *first*, + then NIP-44-encrypts the whole thing into the wrap. Receivers verify the inner signature, and + response correlation uses the **inner** `id`, not the gift wrap's. +11. **Four separate correlation identifiers coexist**: the nostr `e` tag (event level), the + JSON-RPC `id` (MCP level), `progressToken` (CEP-22/41 transfer level), and CEP-8's canonical + invocation identity (payment level). Plus `_meta.clientPubkey` (CEP-16) as the authenticated + caller identity — the mechanism §4.2's KeyPackage binding ultimately rests on. +12. **Ephemeral delivery has no replay.** Per §6.1, subscribe before you publish. Combined with + per-identity subscriptions (§8.2), each identity needs its own live `#p` subscription on 25910 + plus both gift wrap kinds. +13. **A zero-chunk stream is valid** — `close` immediately after `start`, with `lastChunkIndex` + omitted. An empty `msg_sub_many` backlog will exercise this on day one. +14. **`close.lastChunkIndex` is optional and meaningful.** Present, it is a completeness bound and + every index `0..lastChunkIndex` must have arrived; omitted, the stream was open-ended and no + bound is asserted. Senders omit it for live feeds. +15. **CEP-8 excludes `params._meta` from the canonical invocation identity, but forwards it at + execution.** The exclusion exists because MCP clients regenerate `progressToken` per call, so + without it two semantically identical invocations never match one paid authorization. Getting + this backwards either breaks retry matching or strips transport metadata from the handler. +16. **CEP-8 forbids silent fallback.** A server that will not accept `explicit_gating` MUST NOT + quietly use the transparent lifecycle; and a client that required `explicit_gating` SHOULD NOT + auto-satisfy transparent `payment_required` notifications. A naive payment handler that pays + whatever it is asked to pay violates the client half of this. +17. **CEP-15's hash is a verification target, not a label.** The whole point is that two servers + documenting a tool differently produce the same hash. A client that trusts the advertised + `schemaHash` without recomputing it from the tool definition gains nothing from the CEP. + +### 6.4 Conformance method + +Five tiers, because the CEPs are symmetric and no single counterparty exercises all of them. + +**Tier A — rule-derived unit tests.** Every MUST/MUST NOT in a CEP becomes a named test carrying +its rule id, asserted against pure codecs and frame state machines with no network. This is where +the negative cases live and it is the bulk of the value: the CEP-22/41 validation sections are +written almost entirely as failure conditions. Offline, fast, runs in `commonTest`. + +**Tier B — live integration against a real counterparty.** `ghcr.io/cordn-msg/cordn:latest` with +`CORDN_STORAGE_BACKEND=memory` and `CORDN_ANNOUNCED=false` boots in one command and enables +CEP-22 and CEP-41. It covers the core spec, CEP-4/19, CEP-6/17 and the happy paths of 22/41. +`cordn/packages/test-utils/src/mockRelay.ts` exists if we want a relay stub instead of a public +relay. Tagged as an integration suite, not run on every build. + +**Tier C — adversarial tests against our own fixture server.** This is the tier that does not +exist yet and has to be built: **a Kotlin `:contextvm` test fixture that can play the server role +and misbehave on demand.** No real server will send a non-monotonic `progress`, a second `start` +on a live token, a `pong` with a stale nonce, a digest that does not match, or a +`payment_required` in a session where `explicit_gating` was accepted — but our client must handle +all of them correctly, and several are outright MUST-fail requirements. The fixture is also the +only practical way to test CEP-16 (a server-side obligation) and the server half of CEP-8. +Building it is a first-class Stage 2 deliverable, not test scaffolding. + +**Tier D — cross-implementation vector exchange.** For the crypto surface, agreeing with ourselves +is not evidence. Generate CEP-4 wrap/unwrap vectors and CEP-22/41 frame sequences, check them in +under `quartz/src/commonTest/resources/contextvm/`, and verify both directions against the +reference implementation the way `TsMlsWelcomeInteropTest` does for MLS. Ask upstream to adopt them +(§10) — a shared vector set helps every non-TypeScript implementation and is a cheap contribution. + +**Tier E — wallet integration.** CEP-8's client role is a payment *handler*, so compliance is only +demonstrable end to end against a real rail. `bitcoin-lightning-bolt11` is the one recommended PMI +(CEP-21) and we already have NIP-47 NWC and NIP-57 zaps in Quartz, so this is integration, not new +payment code. Regtest or a small-amount live wallet; gated behind a manual test tag. + +**Shared prerequisite: RFC 8785 (JCS).** Both CEP-8 (canonical invocation identity) and CEP-15 +(schema hash) require it, and **Quartz has no JCS implementation** — I checked; the +`canonicalize` hits in the tree are IPv6, media types and relay URLs, all unrelated. So JCS is its +own build item with its own vector suite (the RFC's test vectors, plus the number-formatting edge +cases that make JCS genuinely tricky: `1E30`, `-0`, very small and very large doubles). Put it in +`quartz/…/utils/` rather than in `:contextvm` — it is a generic primitive and NIP work may want it. + +### 6.5 Per-CEP test catalog + +Tier A cases, grouped by rule prefix. Negative cases are marked ✗ — they are the majority by +design, and a suite without them proves nothing. + +**`CVM-CORE`** — round-trip every message class (request, response, error, notification); +`content` is a *string*, not an embedded object (a plausible early bug); reject a non-25910 kind; +`e`-tag correlation maps a response to its request; ✗ a response published before we subscribed is +unrecoverable (asserts the §6.1 lifecycle rule rather than pretending it works); the +`initialize` → `notifications/initialized` sequence; and the stateless path succeeding with no +initialize at all. + +**`CVM-4`** — inner event is signed and verifies; wrap `p` tag names the recipient; two wraps of +the same payload have **different** outer pubkeys (fresh key per wrap); decrypt recovers the inner +event byte-for-byte; correlation uses the inner `id`; ✗ inner signature invalid → reject; ✗ +unsupported wrap kind → reject; conversation-key symmetry both directions. Tier D vectors here. + +**`CVM-19`** — prefer 21059 when both peers advertise `support_encryption_ephemeral`; MUST fall +back to 1059 when the peer does not; 21059 and 1059 decode identically; subscription filters +include both kinds. + +**`CVM-6`** — parse each of 11316–11320 (`content` is a stringified initialize/list result); +replaceable semantics keep the newest `created_at` per `(kind, pubkey)`; all discovery tags parsed; +optional tags absent → no failure; discovery tags seen on a first direct message are treated as +equivalent to announcement tags (the CEP-6/CEP-35 overlap). + +**`CVM-17`** — unmarked `r` tag means read **and** write; `read`/`write` markers honored when +present; latest-wins replacement; bootstrap relays are publication targets and MUST NOT be assumed +operational; absent 10002 → fall back to configured relays. + +**`CVM-35`** — client sends capability/negotiation tags on its first direct message and omits them +after; server tags are learned from the first direct server→client message even when it is not an +initialize result; **unknown tags preserved** and reachable via a raw accessor; `p` and `e` excluded +from the learned surface; a feature tag on a later message is message-local and does not mutate the +session baseline. + +**`CVM-22`** — happy path reassembles and validates; out-of-order chunks inside the buffer window +reassemble correctly by `progress`; nothing is surfaced upward before validation succeeds; ✗ digest +mismatch; ✗ `totalBytes` mismatch; ✗ `totalChunks` mismatch; ✗ `chunk` before `accept` in a +stateless flow; ✗ non-monotonic `progress`; ✗ `end` with unresolved gaps; ✗ unknown +`completionMode`; ✗ declared totals over local policy rejected at `start`; ✗ transfer started for a +request with no `progressToken`; `abort` is terminal. + +**`CVM-41`** — happy path streams incrementally; zero-chunk stream (`close` straight after +`start`) succeeds; `close` with `lastChunkIndex` and every index present succeeds; `close` without +`lastChunkIndex` on an open-ended feed succeeds; the final JSON-RPC response is still required and +delivered after `close`; idle → `ping` → `pong` keeps the stream alive; ✗ no `pong` before probe +timeout fails the stream; ✗ `pong` with unknown, duplicate or expired nonce is not liveness +evidence; ✗ nonce over 64 bytes rejected; ✗ second `start` on a live `progressToken`; ✗ +non-contiguous `chunkIndex` at `close`; ✗ `close` with `lastChunkIndex` and a missing index; ✗ +frames after `close` or `abort` ignored; `pong.progress` unrelated to `ping.progress` (asserts +§6.3.2 explicitly). + +**`CVM-16`** — the fixture server injects `_meta.clientPubkey` derived from the event pubkey; +injection is off by default; our client never sends `clientPubkey` itself (a client-supplied value +would be a spoof, and the coordinator's §4.2 binding depends on it being server-derived). + +**`CVM-8`** — `cap` tag parses fixed (`"100"`) and range (`"100-1000"`) prices with the +`tool:`/`prompt:`/`resource:` prefixes; PMI intersection selection picks a mutually supported +method; absent `payment_interaction` means `transparent`; a requested `explicit_gating` accepted by +the server is disclosed on the first direct response; ✗ requested `explicit_gating` not accepted +MUST NOT silently become transparent, and our handler MUST NOT auto-pay transparent +`payment_required` in that session (§6.3.16); `-32602` shape on an unsupported mode; `-32042` +`Payment Required` carries one or more `payment_options`; `-32043` `Payment Pending` with +`retry_after`; mid-session mode upsert re-discloses on transition to `explicit_gating`; canonical +identity is stable across a changed JSON-RPC `id`, a changed outer event id, and a regenerated +`progressToken` (the `_meta` exclusion); transparent idempotency — the same outer event id is not +charged twice; `ttl` expiry; at most one `direct_payment` tag, first supported PMI wins; `change` +tag parsed on `payment_accepted`. + +**`CVM-15`** — normalization strips `title`/`description`/`examples`/`default`/`deprecated`/ +`readOnly`/`writeOnly` and `x-*` keys **at every nesting level**; the same tool documented +differently yields the same hash (the CEP's whole purpose); adding an `outputSchema` changes the +hash; `$ref` bundled into a self-contained representation, with ✗ no network resolution attempted; +`i`/`k` NIP-73 tags emitted and parsed; and the key client-side rule — **recompute the hash from +the tool definition and reject a mismatched advertised `schemaHash`** rather than trusting it. + +**`CVM-21`** — PMI format matches `[a-z0-9-]+`; `-direct` suffix detected as bearer-settlement +capable; unknown PMI degrades gracefully rather than failing the session. + +**`CVM-23`** — parse a server `kind 0` as NIP-01 metadata (reuses existing Quartz code); `kind 1` +notes from a server pubkey carry no special semantics. + +**`CVM-24`** — top-level review builds both uppercase `A`/`K`/`P` and lowercase `a`/`k`/`p` with +`k = 11316`; a reply keeps uppercase `A`/`K`/`P` at the root announcement while using lowercase +`e` for the parent comment and `k = 1111`; the discovery filter returns reviews for a given server. + +### 6.6 Build list for `:contextvm` + +Ordered so each item is testable before the next depends on it: + +| # | Component | Gate | Notes | +| - | --------- | ---- | ----- | +| 1 | Kinds, tags, frame types, JSON-RPC 2.0 codec | A | Pure data | +| 2 | Minimal MCP client | A + B | `initialize`, `notifications/initialized`, `tools/call`, `tools/list`, typed errors, `_meta`/`progressToken` plumbing | +| 3 | CEP-4/19 gift wrap | A + B + D | On existing `nip44Encryption` + `nip59Giftwrap`. Pin `REQUIRED` (§8.6) | +| 4 | Correlation + subscription lifecycle | A + B | `#p` subscriptions on 25910 + both wrap kinds, `e`-tag routing, subscribe-before-publish, per-identity scoping | +| 5 | CEP-35 discovery-tag learning | A | First-message exchange, unknown-tag preservation, raw accessor | +| 6 | CEP-6/17/23 server discovery | A + B | 11316–11320 + 10002 + kind 0. Reuses `INostrClient` `accessories/` one-shots | +| 7 | **Fixture server (Tier C)** | — | Plays the server role, misbehaves on demand. Unblocks every adversarial test below | +| 8 | CEP-22 receiver | A + B + C | Frame machine, bounded reassembly, admission control, digest verify | +| 9 | CEP-41 receiver + writer | A + B + C | Two-counter machine, keepalive, `Flow` delivery, dual completion | +| 10 | CEP-22 sender | A + C | Proactive fragmentation with relay-size margin | +| 11 | RFC 8785 JCS | A | In `quartz/…/utils/`, not here — shared by 8 and 15 (§6.4) | +| 12 | CEP-15 common tool schemas | A + B | Normalization, hash, `i`/`k` tags, recompute-and-verify | +| 13 | CEP-8 + CEP-21 payments | A + C + E | Both lifecycles, canonical identity, PMI registry; handler on NIP-47 NWC | +| 14 | CEP-16 injection (server role) | A + C | Only meaningful in the fixture server and any server we later expose | +| 15 | CEP-24 reviews | A + B | Thin layer on existing NIP-22 | +| 16 | Dual-signer plumbing | A + B | Account `NostrSigner` for stable, local keypair for ephemeral | + +Sizing reference: the SDK's client-side surface (`src/core` + `src/transport/nostr-client` + +oversized-transfer + open-stream, tests excluded) is ~5.4k lines of TypeScript, and that excludes +payments, the server role and the announcement manager. Items 8, 9 and 13 are the bulk of the work +and the bulk of the risk. + +Reusable beyond cordn: this is a general MCP-over-Nostr client *and* the beginnings of a server. +Any future Amethyst work that wants to call a remote MCP server — or expose one — lands here +rather than in a feature module. + +### 6.7 Spec stability risk + +Only CEP-4, CEP-6 and CEP-16 are **Final**. Everything else, including the core spec and both +transfer profiles, is **Draft** — and CEP-8 (722 lines), CEP-41 (534) and CEP-15 (502) are the +three largest documents. Per the CEP guidelines, Final requires a completed reference +implementation, so Draft here means the text can still move. + +Mitigations: keep each CEP's rules behind a narrow interface so a revision is a localized change; +record the implemented revision (commit hash of `contextvm-docs`) in the module README and in each +rule-id group; and make the Tier A suite the tripwire — when a CEP revises, the diff against our +named rules says exactly what to change. + +## 7. Licensing + +🔴 **`ContextVM/sdk` is LGPL-3.0** (`COPYING.LESSER` over the GPL-3 base text; `package.json` +declares `LGPL-3.0-1`). We would not link a TypeScript library into Quartz regardless, but per the +dependency rule in `.claude/CLAUDE.md` this means: + +- A Kotlin ContextVM implementation **must be clean-room from the spec and CEP documents**, not a + translation of that source. A derivative translation would carry LGPL terms into MIT Quartz. +- `cordn-rs` links the Rust `contextvm-sdk` as a dependency; that is their artifact, not ours. +- If a Kotlin/JVM ContextVM library ever appears under LGPL, linking it is a WARN-and-call-out + (call it out in the PR description), not an automatic stop. + +🔴 **Correction (2026-09-18, verified): "everything under `Cordn-msg` is MIT" was wrong.** Only +two of the five packages are licensed at all. Checked against the actual files at `b465df0` and +against the published npm tarballs: + +| Package | `LICENSE` file | `license` field | Published to npm | +| ------- | -------------- | --------------- | ---------------- | +| `packages/core` | yes | MIT | `@cordn/core` | +| `packages/cli` | yes | MIT | `@cordn/cli` | +| `packages/coordinator` | **no** | **none** | no | +| `packages/server` | **no** | **none** | no | +| `packages/test-utils` | **no** | **none** | no | + +The repository root has no LICENSE either. So the **reference coordinator is unlicensed** — +default copyright, all rights reserved — and so is the `ghcr.io/cordn-msg/cordn` image built from +it. Almost certainly an oversight rather than intent, given the two licensed siblings, but the +rule in `.claude/CLAUDE.md` does not have an "obviously meant to be MIT" branch. + +Consequences, and they are the reason Stage 0 landed the way it did: + +- **Tier B rests on unlicensed code, and was run anyway on an explicit decision.** The plan's + Tier B (run their coordinator locally with `CORDN_STORAGE_BACKEND=memory`) rests entirely on + unlicensed code. Nothing about that has changed and nothing here is a shipping dependency: the + image is pulled by hand, on a developer machine, and no build file, test task or CI job + references it. What changed is that a maintainer decided the verification was worth doing on + those terms — see §7.1 for what it found, which is the argument for the decision. The line the + dependency rule draws still holds: **do not commit it as a dependency, and do not wire it into + a build or CI.** If Tier B becomes a routine part of the test process, it needs the LICENSE + below first. +- **`@cordn/core` is fine and is enough for the valuable half.** It is MIT, it ships a LICENSE, + and it holds the authoritative zod contracts for all eleven tools plus the group-ref bech32 + codec — i.e. the wire surface. That is what `quartz/tools/cordn-vector-gen` uses. +- **Ask upstream for a LICENSE on the remaining packages.** Cheap for them, and it is what + unblocks Tier B. Worth raising alongside §4.2. + +The specs themselves (`spec/`, `design/`) are also unlicensed, so the existing rule stands: we +implement from them, we do not paste their prose into KDoc. + +## 7.1 Tier B, run: what a live counterparty found that five tiers of our own tests could not + +Run on 2026-09-22 against `ghcr.io/cordn-msg/cordn:latest` (`v0.5.7`, digest +`sha256:3e20391…`, `CORDN_STORAGE_BACKEND=memory`, `CORDN_ANNOUNCED=false`) over **geode** as the +relay, driven by the new `amy cordn …` verbs. Two accounts, one process per command. + +**It works, end to end.** `kp_publish` → `group create` → `invite` (take KeyPackage, verify the +publication payload, commit, store Welcome) → `welcome_take` → accept → messages in both +directions → `group info` agreeing on both sides at epoch 1 with the same two members. The +coordinator reports `name: cordn-server`, `version: 0.1.0`, `protocolVersion: 2025-11-25`, +`capabilities: [tools]`. + +But it did not work at first, and neither reason was findable without a real peer. + +### Finding 1 — our CEP-4 gift wraps were invisible to any real ContextVM server + +`CvmGiftWrap.wrap` set `created_at` to NIP-59's randomized timestamp: the real time minus a +random offset of up to two days. The reference server subscribes with **`since = now`** when it +connects, which is the obvious filter for a live request stream, and a relay honours `since`. So +every request we have ever sent to a real coordinator was dropped **by the relay**, before the +coordinator saw it. The symptom is a 20-second timeout with nothing in any log, because from the +server's side nothing happened. + +Why no test caught it: our fixture server (Tier C) reads whatever is addressed to it with no +`since` filter, and so does every test double. A backdated wrap is indistinguishable from a +fresh one unless something on the other end filters on time — and only a real server does. +There was even a test, `CVM-4-14`, asserting the *broken* behaviour ("the wrap timestamp is +shifted and must not be used for ordering"); it passed for the whole life of the transport. A +test that pins what the code does is not evidence about what the protocol needs. + +The fix is to send the real time, and the reasoning is worth keeping because the NIP-59 rule is +right in its own context: NIP-59 shifts the timestamp because a gift-wrapped DM is **stored** and +fetched later, so its timestamp would otherwise reveal when a conversation happened. A ContextVM +wrap carries an RPC request to a peer listening right now. The shift also protects nothing here — +CEP-4 puts the recipient in a visible `p` tag, delivery is real time, and kind 21059 is ephemeral +so no relay retains it — so it hid from an observer a fact that same observer reads off the +socket, at the cost of the request never arriving. `CVM-4-14` now asserts the send time. + +### Finding 2 — self-echo bookkeeping does not survive a process boundary + +With delivery working, a client's own traffic came back to it as `undecryptable`: alice's +`fetch` after her own invite and message reported `cursor 1: Authentication failed` (her Commit, +sealed under the pre-commit epoch key she has since left) and `cursor 2: Generation 0 already +consumed` (her own message, whose sender ratchet has moved on). + +The cause is that `GroupInbox` holds `pendingOperations` and `ownMessageCursors` in memory while +the cursor beside them is persisted. Post advances neither cursor nor disk — correctly, since a +lower cursor may still hold somebody else's unprocessed message — so a client that exits between +posting and ingesting starts the next run with a cursor that will re-read its own traffic and no +record that it is its own. + +`amy` exposes this on every run because each verb is a process. It is **not** an `amy` bug: the +Android app hits it whenever the OS kills it between sending and syncing, which is ordinary. The +visible damage is a gap in the sender's own conversation. The latent damage is worse and narrower: +`Ingestion.SelfEchoUnapplied` is how a client that died mid-post applies its own Commit, and that +recovery path depends on exactly the record that dying destroys. + +Fixed by persisting the bookkeeping next to the cursor: `EchoState` beside `GroupCursor`, saved on +the same beat, deleted when there is nothing pending. Own-message cursors at or below the fetch +cursor are pruned — the stream has delivered them and they can never come round again — while +pending Commits are kept until their echo matches, because that echo is the only copy that will +ever be offered. + +There was a test for this too, and it also passed: `a group survives a restart` asserted +`delivered.none { it is Delivery.Message }`. An `Undecryptable` is not a `Message`, so a gap in +the sender's own conversation satisfied it. Both restart tests now assert what the delivery **is** +— `Echo` — and both fail if either half of the persistence is removed. + +With both fixed, `cli/tests/cordn/tier-b.sh` passes end to end: handshake, `kp_publish`, group +create, invite, Welcome opened without joining, join, messages both ways with the sender's own +traffic reported as echoes rather than gaps, and both sides agreeing on epoch 1 and the same two +members. + +### 7.2 The other half: our MLS against theirs + +Tier B above runs amy against amy through their coordinator. Both MLS +endpoints are ours, so the ratchet tree, the Welcome and the Commit only ever +agree with themselves — it proves the transport and the coordinator client, +and nothing about RFC 9420 interop. + +`cli/tests/cordn/interop-client.sh` closes that. It puts **`@cordn/cli` +(ts-mls)** on one end and **amy (quartz)** on the other, in one group, over the +live wire. That half carries no licensing problem — `@cordn/cli` is MIT and +comes from npm — though the coordinator underneath it still does. + +Three directions, and they are not redundant: + +1. **Their group, our joiner.** Our engine opens a ts-mls Welcome and reads + their GroupContext extensions, their metadata and their credentials out of + it, then decrypts their application messages. +2. **Our group, their joiner.** Their engine opens **our** Welcome. This is + the direction no fixture can test: a fixture we wrote accepts what we emit + by construction, so only a foreign implementation can say our Welcome is + well formed. +3. **Our later Commit.** The sharpest, and the one worth having built the + harness for. Until direction 3 their epoch came from a Welcome, which + carries the group state ready-made; this is the first time they must apply + one of our handshake messages. Ours are **public-framed** + (`MlsMessage(PublicMessage)`, wireformat 2) where theirs are private-framed, + and `CordnGroupManager.invite` has always asserted in its KDoc that their + `processMessageBase64` admits both — a claim read off their source and never + executed. It holds: they apply our Commit, advance to epoch 2, and seal a + message we then open. + +All of it passes. Mutation-checked rather than trusted: sealing +`result.commitBytes` (the bare RFC 9420 struct) instead of +`result.framedCommitBytes` fails directions 3 and the third-member join and +**leaves direction 2 green**, because a peer that joined by Welcome never +parses that Commit and only stalls once it has to. That is the whole reason +direction 3 exists as its own case, and it is now demonstrated rather than +argued. + +One asymmetry this surfaced and did not resolve: **the reference client sends +kind 25910 in the clear**, unwrapped, where we pin `EncryptionMode.REQUIRED` +and always gift-wrap (§8.6). Both work against the coordinator, so nothing is +broken — but the two clients exercise different halves of CEP-4 against the +same server, and our encrypted path is the one with no second implementation +behind it. Worth a Tier D vector exchange. + +### What Tier B is, and is not + +These are both **transport and bookkeeping** bugs. Not one byte of the crypto surface moved: the +MLS engine, the seal, the envelopes and the group refs were already verified against ts-mls and +against cordn's own wire contracts, and Tier B found nothing wrong with any of them. That is the +shape to expect from a live tier — it tests the things a fixture cannot model, which are the +things a fixture was written by the same person who wrote the client. §7.2 then covers the +crypto surface against a foreign implementation, and finds it sound. + +## 8. What the coordinator can see + +Content: **nothing.** Double-sealed (MLS ciphertext, then ChaCha20-Poly1305 under the epoch +exporter), and the coordinator is forbidden from parsing. It cannot even tell handshake from +application traffic. This is genuinely stronger than a conventional MLS delivery service. + +Metadata is where the cost sits. Every call carries an authenticated caller pubkey +(`extra._meta.clientPubkey`, derived from the signed inner 25910 event), and cordn-web splits +identities deliberately: + +| identity | calls | +| -------- | ----- | +| **stable** (real npub) | `kp_publish`, `kp_remove`, `welcome_take`, `join_request_store` | +| **ephemeral** | `kp_take`, `kp_list`, `welcome_store`, `join_request_take_many`, `msg_post`, `msg_fetch_many`, `msg_sub_many` | + +### 8.1 Admission is in the clear on both ends + +`join_request_store` carries the **stable** identity and the `gid` → "npub X wants into group G". +`welcome_store` names the **target's stable pubkey**; `welcome_take` is called by that target's +**stable** identity. For any group joined via a share link the coordinator observes real-identity +membership directly. Structural, not a bug. + +### 8.2 The ephemeral identity is per-session, not per-message + +`coordinatorClient.ts:169` → `new PrivateKeySigner()` with no argument, constructed once per +`cordnClient`, and `chatRuntime.ts:62` caches one client per coordinator pubkey. So **one +pseudonym posts, fetches and subscribes across every group you have on that coordinator, for the +whole session.** That `gid` set is a stable fingerprint: it links all your groups together and +leaks how many you are in. Marmot rotates per kind-445 event and has no equivalent linkage +(relays still see `h` tags and fetch patterns — different exposure, not obviously better). + +### 8.3 Complete ordered history in one place + +Per-group cursors, `at` timestamps, sealed payload sizes (no padding specified anywhere I could +find), per-`gid` rates, live subscription membership. Cross-`gid` timing correlation inside one +session is trivial. Unlike relays there is no redundancy or partitioning: one operator sees the +whole graph of every group it serves, retained in SQLite. + +### 8.4 A signed, verifiable record that you use cordn + +`kp_publish` rides the stable identity and the coordinator retains the signed event — it is +*designed* to be re-servable as proof, rotation cadence included. `kp_take`/`kp_list` also reveal +who is being looked up, i.e. "someone is about to add X to a group". + +### 8.5 IPs go to relays, not the coordinator + +A real structural win over an HTTP delivery service. The relay then sees the 21059 traffic pattern +and the `p` tag naming the coordinator: who talks to which coordinator, how often, how big. + +### 8.6 Encryption can silently downgrade — pin it + +cordn-web pins `giftWrapMode: GiftWrapMode.EPHEMERAL` (`coordinatorClient.ts:186`) but leaves +`encryptionMode` at the SDK default **`OPTIONAL`** +(`contextvm-sdk/src/transport/base-nostr-transport.ts:102`), which resolves as +`isEncrypted ?? true` from negotiated session state (`:544`). The reference coordinator does +announce `support_encryption`, so live deployments are encrypted — but the policy permits a +coordinator that does not announce it to receive **plaintext JSON-RPC on public relays**, +exposing `gid`, `target_pk`, `kp_64` and cursors to any relay. + +**Our client must pin `REQUIRED` and fail closed.** Non-negotiable. + +### 8.7 Net + +Content privacy equals Marmot's. Metadata privacy is **weaker than Marmot's against the delivery +operator** and **stronger against the network**. Whether that trade is acceptable depends on +whether coordinators are self-hosted per community or a handful of public ones; the spec permits +either, and the shipped default is one coordinator pubkey over three public relays +(`cordn/README.md`). + +This section should be surfaced in the UI if we ship this, not buried. A Marmot group and a cordn +group have materially different metadata exposure and users cannot infer that from either one +looking like a group chat. + +## 9. Plan + +### Stage 0 — Ground truth and the upstream conversation — PARTLY LANDED + +The wire half landed on 2026-09-18; the live-coordinator half ran on 2026-09-22 (§7.1). + +**Landed: contract vectors from cordn's own code.** `quartz/tools/cordn-vector-gen` generates +`resources/cordn/coordinator-contracts.json` from **`@cordn/core`** (MIT) — the package their +reference coordinator and client both import. It hands each payload *we* send to *their* zod +schema, so a field we named wrong fails at generation time, and carries a `rejects` set per +method so a passing positive means something. `CoordinatorContractVectorTest` (15 tests) drives +`CoordinatorClient` through the real ContextVM transport and asserts the arguments the +coordinator sees equal those vectors, then replays their result shapes back through our parser; +`CordnGroupRefVectorTest` (5 tests) checks `cordn1…` refs in both directions against their bech32 +codec. + +Two findings from doing it: + +1. **Mutation-checked, because 20 tests passing on the first run means nothing on its own.** + Making a first-ever fetch send `after = 0` instead of omitting it, and flipping the group-ref + TLV emission to ascending order, each killed exactly one test. The TLV mutation was caught + *only* by the encode-direction test — decoders accept any order by spec, so a decode-only + suite would have shipped that divergence. +2. **The `kp_publish` legacy field is read-only.** Their current schema rejects + `{kp_ref, keyPackageBase64}`, so our `?? keyPackageBase64` fallback is correct for parsing old + publication events and must never be used when sending. + +**Landed: Tier B (live coordinator).** Run on 2026-09-22 against the reference coordinator in +Docker, over geode as the relay, driven by `amy cordn …`; the harness is +`cli/tests/cordn/tier-b.sh`. It found two bugs — backdated CEP-4 gift wraps that no real +ContextVM server could see, and self-echo bookkeeping that did not survive a process boundary — +both fixed. §7.1 has the detail; §7 has the licensing terms it was run on, which have not +changed: not a dependency, not in CI. + +**Not done: the upstream conversation**, which is the maintainer's to have. §4.1 no longer waits +on it (we implement both encodings); §4.2 and the missing LICENSE files are the asks worth making. + +The original plan for this stage, for reference: + +1. **Restore executable verification.** Get `:quartz:jvmTest` green in a clean container + (the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and + `AppDataDictionaryInteropTest` still pass. Everything downstream assumes the engine is sound. +2. **cordn-side vectors.** `@cordn/cli` is published and has a filesystem-queue mode built for + scripting; the coordinator ships as `ghcr.io/cordn-msg/cordn:latest` with + `CORDN_STORAGE_BACKEND=memory`. Drive both to emit a vector file (KeyPackage with hex-ASCII + credential, Welcome, a sealed application payload, a sealed Commit, a `cordn1…` group ref, a + `0xC04D` extension blob) into `quartz/src/commonTest/resources/cordn/`, with a generator under + `quartz/tools/cordn-vector-gen/` mirroring `tools/tsmls-vector-gen/`. Assert them in + `quartz/…/cordn/interop/`. + + These tests will fail on the credential encoding, by design — that is the point. They convert + §4.1 from an argument into a reproducible artifact we can hand upstream. +3. **Raise §4.1 and §4.2 with gzuuus.** Credential encoding is the blocking one. The + key-package-payload shape is the higher-value ask. + +**Gate:** do not start Stage 2 until §4.1 has an answer. Stage 1 is safe to do regardless. + +### Stage 1 — Extract a binding-agnostic RFC 9420 engine — LANDED + +Done in four commits. The engine is `quartz/…/mls/` and imports nothing from `quartz/…/marmot/`. + +| What | Where it went | +| ---- | ------------- | +| The engine (28 files, 11,964 LOC) | `quartz/…/marmot/mls/` → `quartz/…/mls/` | +| `MlsGroupManager`, `MlsGroupStateStore`, `MarmotMessageStore` | → `marmot/groups/` (all keyed on `nostrGroupId`) | +| MIP-03 authorization, depletion guard, join role check, self-remove gate | → `marmot/groups/MarmotGroupPolicy` | +| Leaf + `required_capabilities` profiles | → `marmot/groups/MarmotCapabilities` | +| `currentMarmotData/GroupState/NostrGroupId`, `agentTextStreamSecret` | → `marmot/groups/MarmotGroupViews` (extension functions) | +| `last_resort_key_package` (0x0004) | → `mls/components/ComponentsList` — it is the extensions draft's, not Marmot's | + +**The seam is `MlsGroupPolicy`**: three hooks the engine calls where RFC 9420 defers to the +application (`authorizeCommit`, `authorizeSelfRemove`, `validateJoin`) and four values it reads +(leaf capabilities, `required_capabilities`, extra known extension types, the commit exporter +label). One argument selects a whole profile — `MlsGroup.create(id, policy = MarmotGroupPolicy)` +brings the rules, the capabilities and `MLS-Exporter("marmot", "group-event", 32)` together — so +adopting it cost one added argument per call site rather than five. + +Policies receive a read-only `GroupView`, not the `MlsGroup`: a policy holding the group could +commit or rotate keys from inside the check meant to gate those things. + +**The default is permissive**, which is a trade worth naming. Closed would make the engine +unusable without a policy and would push callers into writing an allow-everything one anyway. +The cost is that a group restored without its policy silently drops the binding's rules — a +policy is behaviour, not state, so it is deliberately not in `MlsGroupState`. All ten production +construction sites are in `marmot/` and all ten name it. + +Two findings from doing it: + +1. **`:quic` was already a second consumer, reaching through the wrong package.** + `quic/tls/TlsClient.kt` imported `quartz.marmot.mls.crypto.X25519` for its TLS 1.3 handshake — + neither Marmot nor MLS. That is the argument for this stage independent of cordn. +2. **The test suite found every site that had been relying on Marmot defaults.** The first run + after `MlsGroup.create` stopped defaulting to Marmot's profile failed 13 tests, each one a + Marmot test that had been getting a Marmot-shaped group for free. The other ~180 construction + sites kept passing on the permissive default — they are engine tests, and they now prove the + engine runs without Marmot at all. + +8 tests were added for the seam itself (`MlsGroupPolicySeamTest`, `MarmotPolicySeamTest`), +including the half no existing test covered: the same group at the same state accepts the same +commit once the policy is gone. Verified by mutation — ignoring the policy in `commit()` and +re-hardcoding the exporter label each kill exactly their guarding tests. + +Suite: `:quartz:jvmTest` 5074 → 5082, `:commons:jvmTest` 2202, both green. + +What Stage 3 still owes: `MlsGroupManager` is keyed on `nostrGroupId` 147 times, so cordn cannot +reuse it and needs its own manager over the same `MlsGroup`. Class names were left alone in the +move — `MlsGroupManager` under `marmot/groups/` reads correctly and renaming four classes would +have churned 22 files across five modules for clarity the package path already gives. + +### Stage 2 — ContextVM (clean-room) — LANDED + +Shipped as `quartz/…/contextvm/`, implemented from the specification documents rather than the +LGPL SDK. 172 tests, +green on jvm. What is in: + +| Build item | Where | +| ---------- | ----- | +| 1 constants, tags, JSON-RPC codec | `core/CvmKinds`, `core/CvmTags`, `jsonrpc/` | +| 2 minimal MCP client | `mcp/CvmMcpClient`, `mcp/McpMethods` | +| 3 CEP-4/19 gift wrap | `cep04Encryption/CvmGiftWrap` (pins `REQUIRED`) | +| 4 correlation + subscription lifecycle | `transport/CvmTransport` | +| 5 CEP-35 discovery learning | `cep35Discovery/SessionDiscovery` | +| 6 CEP-6/17/23 discovery | `cep06Announcements/`, `cep17RelayList/ServerRelay` | +| 7 **fixture server (Tier C)** | `fixture/CvmFixtureServer`, `fixture/InMemoryRelayPool` | +| 8 CEP-22 receiver | `cep22OversizedTransfer/OversizedTransferReceiver` | +| 9 CEP-41 receiver | `cep41OpenStreams/OpenStreamReceiver` | +| 10 CEP-22 sender | `cep22OversizedTransfer/OversizedTransferSender` | +| 11 RFC 8785 JCS | `quartz/…/utils/jcs/JsonCanonicalization` | +| 12 CEP-15 schemas | `cep15CommonSchemas/CommonToolSchema` | +| 13 CEP-8 + CEP-21 | `cep08Payments/` | +| 14 CEP-16 injection | in the fixture's server role | +| 15 CEP-24 reviews | `cep24Reviews/ServerReview` | +| 16 dual-signer | `transport/DualSigner` | + +Source is organized one package per CEP (`cep04Encryption/`, `cep08Payments/`, …), matching the +`nipXX` convention in `quartz` and `mipXX` in `marmot`; only what the core draft spec defines +(`core/`, `jsonrpc/`, `transport/`, `mcp/`) and the CEP-22/41 shared framing (`transfer/`) keep +names, having no CEP number to carry. `contextvm/README.md` records the four placements that are +judgment calls. + +Four findings worth carrying forward, all caught by tests rather than review: + +1. **CEP-22/41 ordering.** The first receiver rejected frames whose `progress` did not increase on + arrival, conflating "the sender emits monotonic progress" with "frames arrive in order". Both + CEPs say the opposite: `progress` is the assembly index and explicitly not an arrival-order + guarantee, and receivers may buffer out-of-order chunks. Validation is positional now. +2. **JCS and `Double.MIN_VALUE`.** JVM prints `4.9E-324` where ECMAScript requires `5e-324`, so + "trust the platform to already be shortest" would have hashed differently from every other + implementation. Digits are shortened explicitly until the shortest round-tripping form is + found, which removes the platform assumption entirely. +3. **Event subclassing.** `CvmMessageEvent` began as an `Event` subclass whose `create()` claimed + to return that subclass, but quartz mints subclasses through its own kind-to-class factory, + which knows nothing about 25910. It is a wrapper over `Event` now. +4. **Subscribe-before-publish is an API-shape problem, not a discipline problem.** `CvmTransport` + exposes `request()` with no public publish/subscribe pair, and `InMemoryRelayPool` drops an + event nobody is subscribed to so the property is actually tested rather than assumed. + +Remaining gaps in this stage: Tier B (live integration against +`ghcr.io/cordn-msg/cordn:latest`), Tier D (cross-implementation vectors) and Tier E (a real +wallet for CEP-8) are all unstarted — see §6.4. `testAndroidHostTest` has not been run in a +container yet; Maven Central rate-limits the Android secp256k1 artifact. + +Original scope notes follow. + + +New Gradle module, peer of `:quic`. Depends on `:quartz` only; no Android framework deps. **Scope, +CEP inventory, the fourteen subtle rules and the ordered build list are §6** — this stage is that +section turned into code, so read it before starting rather than working from this summary. + +Sourcing discipline, restated because it constrains the whole stage: implement from the +specification documents in `ContextVM/contextvm-docs`, **not** from the LGPL SDK (§7). Whoever +takes this should avoid reading the SDK source at all; §6 was written so they do not have to. + +Substages, matching §6.6's build list. Each ships when its rule-id group in §6.5 is green at the +tier §6.2 assigns it: + +- **2a — wire layer.** Items 1–4: constants and codecs, the minimal MCP client, CEP-4/19 gift wrap + pinned to `REQUIRED` (§8.6), and the correlation + subscription lifecycle. Ships when a + `tools/list` round-trips against the reference coordinator in Docker and `CVM-CORE`/`CVM-4`/ + `CVM-19` pass, with CEP-4 vectors exchanged both ways (Tier D). +- **2b — discovery.** Items 5–6: CEP-35 first-message tag learning, CEP-6/17/23 announcement and + relay resolution. Ships when a coordinator pubkey alone is enough to connect. +- **2c — fixture server.** Item 7, and the gate for everything after it. A Kotlin `:contextvm` + test double that plays the server role and can be told to violate any rule in §6.5. Without it + the adversarial half of CEP-22/41 and all of CEP-16 are untestable. +- **2d — transfer profiles.** Items 8–10: CEP-22 receiver, CEP-41 receiver/writer, CEP-22 sender. + The bulk of the work and the risk; §6.3 items 1–7, 13 and 14 all live here. Ships when + `msg_sub_many` streams a live backlog, a >64 KiB `msg_fetch_many` reassembles with a verified + digest, and every ✗ case in `CVM-22`/`CVM-41` fails the way the spec requires. +- **2e — JCS and schemas.** Items 11–12: RFC 8785 in `quartz/…/utils/` against the RFC's own + vectors plus number-formatting edge cases, then CEP-15 on top of it. +- **2f — payments and the rest.** Items 13–16: CEP-8 both lifecycles with the NIP-47 NWC handler + (Tier E), CEP-16 injection in the fixture, CEP-24 reviews on existing NIP-22, dual-signer + plumbing. Lowest priority — cordn needs none of it — but it is what makes the module complete. + +Two constraints that shape the API and are easy to discover too late: + +- **Ephemeral events have no replay** (§6.1). The subscription must be live before the request is + published. This is a lifecycle requirement on the public API, not an implementation detail — + a `suspend fun call()` that subscribes after publishing will lose responses nondeterministically + and look like flaky relays. +- **The stable transport needs `sign()` *and* NIP-44 encrypt/decrypt per MCP message.** On a + NIP-55/NIP-46 external signer that is a signer round-trip per request. cordn-web's + stable/ephemeral split (§8) already keeps stable traffic rare; preserve that property rather + than fighting it, and make the identity split explicit in the API so it cannot be bypassed. + +Conformance approach — rule-derived unit tests plus live integration against +`ghcr.io/cordn-msg/cordn:latest` — is §6.5. Write the negative tests; the MUST-fail cases in +CEP-22/41 are where an implementation that "works" quietly diverges. + +### Stage 3 — the cordn binding — LANDED (core), app work outstanding + +Shipped as `quartz/…/cordn/`, as this plan originally said. Packages follow the spec documents +(`spec00Coordinator`, `spec01GroupMetadata`, `spec02Envelopes`, `spec03Payloads`, `appGroupRef`) +plus `groups/` and `sync/`. + +Both ContextVM and cordn spent a while as separate Gradle modules and were folded back in. The +stated reason for `:cordn` — "the coordinator client needs `:contextvm`, and quartz cannot depend +on it without a cycle" — was circular: it only held because `:contextvm` had been put outside +quartz first, on the grounds that it was "a peer of `:quic`". That analogy does not survive +contact: `:quic` is a transport library with no Nostr in it at all, while ContextVM is nothing +but Nostr — kind 25910 events, NIP-59 gift wraps, relay subscriptions. Meanwhile `marmot/` +(18,876 LOC) is a complete non-NIP protocol family living inside quartz, `concord/` is another, +and neither new module had a single platform-specific file. Both now compile for every quartz +target, including iOS and linuxX64, which they never did as jvm+android modules. + +| Item | Where | +| ---- | ----- | +| 11-tool coordinator client, identity split in the API | `spec00Coordinator/CoordinatorClient`, `CoordinatorMethod` | +| `cordn1…` group ref | `appGroupRef/CordnGroupRef` | +| `CordnGroupMetadata` (`0xC04D`) | `spec01GroupMetadata/` | +| last-resort `app_data_dictionary` variant | `groups/CordnGroupPolicy.lastResortExtension` | +| cordn-profile KeyPackage | `groups/CordnCredential` + `CordnGroupPolicy` | +| the seal | `spec03Payloads/SealedPayload` | +| the application envelope | `spec02Envelopes/CordnEnvelope` | +| cursors, self-echo, fetch-then-subscribe | `sync/GroupCursor`, `sync/CordnGroupSync` | +| KeyPackage publication + §9 verification | `spec00Coordinator/KeyPackagePublication` | + +`CordnGroupPolicy` is what Stage 1 was for: one argument gives an `MlsGroup` cordn's +capabilities, extension registry and payload exporter. It deliberately leaves `authorizeCommit` +at the default — **cordn has no MIP-03**. `spec/01.md` §5.3 makes `admin_pubkeys` presentation +metadata and nothing restricts who may commit, so any member can commit anything MLS permits. +Empty means egalitarian *permanently*, where Marmot reads the same empty set as a bootstrap +window. Same bytes, opposite meaning; no authorization code is shared. + +Five findings, all caught by tests or by reading the reference rather than the prose: + +1. **§4.1 does not block the binding.** The 32-byte credential check is in Marmot's own + `KeyPackageUtils`, not the engine, so our binding just implements cordn's encoding + (`groups/CordnCredential`). The incompatibility between the two ecosystems is unchanged and + still worth raising. +2. **`spec/01.md` §3 contradicts itself** — "MLS variable-length vector encoding conventions" + and then `opaque Name<0..2^16-1>`. The reference emits a plain uint16, so that is what + interoperates. Our test derives the bytes by hand from the spec, because a round trip agrees + with itself whichever encoding we had picked. +3. **Group refs match the reference byte for byte** on the first run. The three golden strings + in `packages/core/src/groupRef.test.ts` are cross-checked there against an independent + TLV+bech32 assembly, so pinning them is a real Tier D vector. +4. **quartz's NIP-19 `Tlv.parse` is too lenient for a group ref.** It stops silently at a + malformed tuple — right for an `nprofile`, wrong here, where dropping the tail turns a ref + naming a coordinator into one that reaches for a default. `appGroupRef` parses strictly and + still ignores unknown types. +5. **ContextVM's fixture could not answer a second call.** It echoed a constant JSON-RPC id, + which passed every contextvm test because each made exactly one call, and hung the first + cordn test that made two. The handler now takes the request id, and two contextvm tests pin + the behaviour: a second call correlates, and a stale id is ignored rather than resolving the + wrong call. + +Verified by mutation: ignoring a pending self-echo, and advancing the cursor only for processed +messages, each kill their guarding tests at both the unit and end-to-end level. The second of +those needed a new end-to-end test — a single catch-up pass looks correct either way, and only a +second pass reveals the stall. + +**Cross-implementation verification — added after reading +[Staircase](https://code.relay.tools/opensauce/staircase) (`d9dd1a0`, MIT), an +independent Kotlin cordn client that vendors this project's own MLS engine.** + +`quartz/…/cordn/interop/CordnLifecycleInteropTest` walks the whole lifecycle against +fixtures **ts-mls** generated (Staircase's `conformance/fixtures/gen`, vendored +under `quartz/src/commonTest/resources/tsmls/`): read their KeyPackage and agree on +its `kp_ref`, unseal their commit under the published epoch exporter, join from +their Welcome, derive the same epoch exporter byte for byte, apply their +`0xC04D` metadata commit, and read their application message end to end. 15 +tests, all green. + +Reading Staircase found three things no amount of self-consistent testing would +have: + +1. **`authenticated_data` is a wire requirement the cordn spec never mentions.** + The reference client puts the sender's account pubkey in MLS + `authenticated_data` and **rejects** any application message that arrives + with it empty (`packages/cli/src/groupSync.ts:247`). Our engine AEAD-bound + the field correctly on receive but hardcoded `ByteArray(0)` on send and never + exposed it — so we would have shipped a client every cordn peer silently + discarded. `MlsGroup.encrypt` now takes it and `DecryptedMessage` carries it; + `spec02Envelopes/CordnApplicationMessage` owns the binding. Worth raising + upstream: it belongs in `spec/02.md` §5. +2. **Our GroupContextExtensions check implemented a rule RFC 9420 does not + have, and omitted the one it does.** §12.1.7 says nothing about recognising + extension types; its only validity rule is that the resulting group must not + require capabilities some member lacks. We rejected any type outside a + hardcoded list — which would have refused cordn's `0xC04D` metadata commit + outright — while never checking the real rule, so a commit could install a + `required_capabilities` a sitting member could not meet and split the group. + Both fixed; `MlsGroupPolicy.knownExtensionTypes` is gone, because it encoded + the invented rule. +3. **`Ed25519` could not rebuild a key pair from a known seed.** `keyPairFromSeed` + is now in the expect/actual set — any interop fixture needs it, and ts-mls + stores only the seed (inside a PKCS#8 blob). + +Staircase also independently confirms Stage 1's design. Their `VENDORED.md` +lists the same decoupling we did — remove `currentMarmotData`/`currentGroupState`/ +`currentNostrGroupId`/`agentTextStreamSecret`, drop the `AdminPolicyV1` branch, +inject the admin resolver, do not vendor `MlsGroupManager` or +`MarmotMessageStore` — arrived at independently, as patches against a fork. +**Now that the seam is upstream they could stop forking**, and their remaining +patches are a ready-made list of what a cordn binding still wants from the +engine: caller-chosen `group_id`, explicit leaf lifetimes (cordn uses ~100 +years), retained per-epoch receiver data, and skipped-generation keys. + +One trap worth recording: `MlsGroup.memberIdentityHex` hex-encodes the +credential bytes, which is right only for a binding that stores a raw key. +cordn's identity is already hex, so it returns 128 characters of hex-of-hex; +`CordnCredential.memberIdentities` is the cordn-side accessor. + +**Both directions now verified.** Reading their output was half of it; a client +can parse everything correctly and still emit something nobody accepts, and that +failure keeps our own tests green while every peer silently drops us. So +`KotlinArtifactProducerTest` builds a group under `CordnGroupPolicy`, adds a real +ts-mls KeyPackage, sends an application message and commits a metadata change, +and `quartz/interop/verify-with-ts-mls.sh` hands the result to Staircase's +`verify.ts`. ts-mls joins from our Welcome, reads our metadata, derives the same +epoch-1 and epoch-2 exporters, decrypts our message, checks the AAD sender and +envelope id, and applies our commit — **ten checks, all passing on the first +run**. Confirmed non-vacuous: flipping one nibble of `k-exporter-e1.hex` fails +exactly that check and exits 1. + +That gate lives in a script rather than the test suite because it needs a cordn +checkout with `pnpm install` and a staircase checkout. The producer half runs +unconditionally in `:quartz:jvmTest`. + +**Run it under Node, not bun.** Staircase's own `run.sh` uses bun, and bun's +WebCrypto has no X25519 DHKEM, so ts-mls there cannot open a Welcome at all — +not even one it generated itself. It surfaces as `DecapError: The algorithm is +not supported` inside HPKE and reads exactly like a wire-format mismatch. Worth +telling them; a one-line change to their runner. + +Still open in Stage 3: + +- **A ts-mls `ClientState` export.** `verify.ts` carries an optional gate that + decodes a Kotlin-exported ts-mls state and sends from it. We write no such + file, so it is skipped. Producing one means re-encoding `MlsGroupState` into + ts-mls's layout — real work, and the only thing it would buy is cross-client + multi-device, which §4.6 shows the spec rules out by design (`clientState` is + deliberately library-private). So this gate has no reachable purpose, not + merely a deferred one. +- **Tier B**, live against `ghcr.io/cordn-msg/cordn:latest`. + +### Stage 4 — App integration — LANDED (disclosure UI + headless layer); group UI open + +Landed in `commons/…/cordn/` (2026-09-19): + +| What | Where | +| ---- | ----- | +| Coordinator identity, relays, provenance | `CoordinatorConfig` (+ `from(CordnGroupRef)`) | +| Per-coordinator health, recorded not polled | `CoordinatorHealth` | +| §8 as a model a UI renders | `CordnExposure` (`GroupExposure`, `ExposureNote`) | +| Group lifecycle keyed on `gid` | `CordnGroupManager` | +| Encrypted-at-rest state + cursors | `CordnGroupStore` (+ an in-memory one for tests) | +| The 11 tools as a contract | `ICoordinator` in `quartz`, implemented by `CoordinatorClient` | +| `amy cordn ref encode/decode`, `amy cordn exposure` | `cli/…/CordnCommands` | +| §8 rendered: the disclosure card, health row, group badge | `commonsUI/…/cordn/ui/` | +| Parse a pasted `cordn1…` and compute its disclosure | `commons/…/cordn/CordnLinkInspection` | +| The screen that shows it, Settings → Cordn group link | `amethyst/…/settings/cordn/CordnLinkScreen` | + +`CordnGroupManager` is the cordn counterpart of `MarmotManager` and shares no code with it, as +Stage 1 predicted: Marmot's is keyed on the Nostr group id 147 times and its delivery model — +kinds 443/444/445, `h`-tag subscriptions, publish obligations — has no cordn analogue. What the +two share is the engine underneath. Its store is separate from `MlsGroupStateStore` for the same +reason: the keys look alike (`nostrGroupId` vs `gid`) and mean different things, so one interface +serving both would invite handing the wrong id to the wrong store. + +Four findings, each of which was a bug until the test that found it: + +1. **`CommitResult.commitBytes` is the bare RFC 9420 `Commit` struct**, not an `MLSMessage`. + Posting it produces a payload no receiver can parse. `framedCommitBytes` is the wire form. +2. **The pre-commit epoch rule (`spec/03.md` §5) is invisible to a two-party test.** Sealing a + Commit under the epoch it *creates* still passes create→invite→join→read, because the + joiner's Welcome cursor skips the only Commit in the stream. It takes a third member — one + already in the group when a later Commit lands — to catch it. `CommitResult` already hands + back `preCommitExporterSecret` precisely because the key is unobtainable afterwards. +3. **A Welcome does not carry the `gid`.** `spec/03.md` §2 decouples the delivery id from the MLS + `group_id`, so in principle a joiner cannot know where to fetch from. The reference client + closes the gap by convention — `group_id = utf8(gid)` — which staircase's fixtures confirm + (their `group_id` decodes to exactly their published `gid`). We follow the convention and + treat it as an observation, not a guarantee: a non-UTF-8 group id yields a named skip rather + than a mojibake key that fetches nothing forever. `MlsGroup.create` gained an optional + `groupId` so our groups carry it too. +4. **Public-framed handshake messages interoperate.** Our engine frames Commits as + `MLSMessage(PublicMessage)`; cordn's client emits private framing. Checked rather than + assumed: `packages/cli/src/utils/mlsMessages.ts:68` accepts wireformat 1 *and* 2 and hands + both to ts-mls's `processMessage`. Nothing is weakened — the coordinator sees only the outer + seal either way — so the manager emits public framing and reads both. + +**The §8 disclosure now has a screen** (2026-09-19). The requirement was that exposure be +"surfaced in the UI if we ship this, not buried" — so it sits where it can still change a +decision: on a pasted `cordn1…` link, before joining. The screen reads the link, names the +coordinator and its relays, and renders `GroupExposure`; it deliberately does not join, because +joining needs a live coordinator and Tier B is blocked (§7). + +The card states facts and does not rank the two bindings — cordn is weaker against the operator +and stronger against the network (§8.5), and which trade is right depends on who runs the +coordinator. Its notes come from `GroupExposure.notes()` rather than from prose, so a group +linked to four others says so and a lone group does not. + +Rendering it found two things compiling could not: + +1. **The severity colours were not monotonic.** `tertiary` for the middle level rendered pink + against a dark `onSurface` for the worst one, so "under a throwaway key" looked more alarming + than "tied to your real account". Emphasis for the worst case is now weight, not another hue; + nothing uses `error`, because everything on the card is how cordn works, not a fault. +2. **Note order is part of the disclosure.** Cross-group linkage (§8.2) — the item nobody + predicts — was below message padding, the least consequential one. `notes()` now returns + most-surprising-first. + +`CordnExposureRenderTest` keeps both honest by rasterising the surface headlessly +(`ImageComposeScene`, software Skia, no display) and looking at the pixels. It catches the two +things a compile cannot: a missing string resource, which throws at render because the generated +`Res.string.*` accessors compile regardless, and a composable that draws nothing. Mutation-checked +— hardcoding the card's background and disabling the §8.2 conditional each kill exactly one test. +The first attempt at the theme test sampled only the page background and let the hardcoded card +through, which is why it now samples inside the card too. + +Still open: + +- **Group UI.** Chatroom/feed models beside `model/marmotGroups/`, a ViewModel, and the badge + wired into a real group list — all of which need groups to exist on a device first. +- **Coordinator-driving `amy` verbs** (`publish`, `invite`, `send`, `sync`). Deliberately not + shipped: with Tier B blocked (§7) there is nothing to exercise them against, and unexercised + coordinator verbs are a guess with a command-line interface. The logic they would call is in + `CordnGroupManager` and is covered against an in-memory coordinator. +- Key-package rotation and a published-KeyPackage lifecycle, which cordn has no event kind for + (§4.2) and which therefore lives entirely in coordinator calls. + +The original scope, for reference: + +- `commons/` state holders and ViewModels for cordn groups, alongside the Marmot ones. +- Coordinator configuration and health surface (their client tracks per-coordinator health). +- **Surface the metadata model (§8) in the UI.** A cordn group and a Marmot group are not + equivalent privacy-wise and must not look identical. +- `amy` verbs for scripted interop testing, per the thin-assembly-layer rule. + +### Non-goals + +- A live multi-device **fleet** (§4.6) — it would ship §10's unresolved equal-epoch commit + race. Device **migration** is a different shape and landed: a handoff has one writer, so + that race is out of reach by construction. See `amethyst/plans/2026-09-19-cordn-ui.md` §5.3. +- Cross-client migration, which stays impossible by design: `clientState` is deliberately + library-private (§4.2), so an Amethyst device and a cordn-web device can never share a leaf. +- Reusing any `Marmot*`/`Mip*` type for cordn. +- A production Kotlin ContextVM *server*. The Tier C fixture (§6.4) plays the server role for + tests only. For a real coordinator, `cordn-rs` exists, is faster, and shares the SQLite schema — + run theirs. The fixture is deliberately not hardened for deployment. +- A full MCP SDK. `:contextvm` implements the client surface the CEPs define plus the fixture's + server role, not MCP's sampling/roots/elicitation breadth. +- Bridging a Marmot group and a cordn group into one MLS group. Possible in principle once §4.1 + and §4.4 are resolved, but it is a separate design with its own trust questions. + +## 10. Open questions + +1. ~~**§4.1 credential encoding** — who moves?~~ **Answered 2026-09-18: nobody has to.** We + implement both. The engine has had no opinion on credential encoding since Stage 1, and the + two encodings are 32 raw bytes vs 64 ASCII bytes — mutually exclusive by length, so no leaf + can be misread as the other profile's. `BothCredentialProfilesTest` pins that, including the + `memberIdentityHex` hex-of-hex trap. The cost is §4.4 (no single group holding both clients), + which was already a deliberate profile decision. +2. **§4.2 publication payload** — will upstream give KeyPackage publication its own signed payload + or kind? Changes whether cordn KeyPackages can exist outside a coordinator. Still open, and now + the highest-value ask, since §4.1 no longer needs one. +2b. **Will upstream license the coordinator?** `packages/coordinator`, `packages/server` and + `packages/test-utils` carry no LICENSE (§7). Until they do, Tier B cannot be built on them. + Cheap for upstream to fix and it unblocks the only remaining verification tier that needs + their code. +3. **Is the goal interop or a second transport?** "Amethyst can talk to cordn users" and "Amethyst + supports coordinator-backed groups" are different products. The second is strictly less work + (no §4.1 dependency for group creation among Amethyst users) and strictly less valuable. +4. **Padding.** Nothing in `spec/03.md` pads the sealed payload, so message sizes leak. Worth + proposing; cheap to add on both sides while the deployed base is small. +5. **Whose coordinator?** The privacy analysis reads very differently for a self-hosted + per-community coordinator versus the shipped public default. +6. **Are there ContextVM conformance vectors?** None found in `contextvm-docs` or the SDK. Ask + upstream, and offer ours — a shared CEP-4 wrap and CEP-22/41 framing vector set helps every + non-TypeScript implementation and is a cheap contribution (Tier D, §6.4). +7. **How stable are CEP-22 and CEP-41?** Both are Draft, both are the largest CEPs, and both are + mandatory for cordn. A breaking revision mid-implementation is the main schedule risk in + Stage 2c. Worth asking whether either is close to Final. +8. **Should ContextVM be published separately?** It is a general MCP-over-Nostr client with no + Amethyst or cordn dependency. If the Nostr ecosystem wants a JVM/KMP ContextVM implementation, + this is it — but that is a maintenance commitment, and the answer changes how carefully the + public API needs designing in Stage 2a. +9. **The ContextVM spec repo has no LICENSE.** Implementing a protocol from a published spec is + normal and fine, but if we want to quote rule text into KDoc or the module README, ask upstream + to add one (CC-BY or similar) rather than assuming. +10. **Should the Tier C fixture server become a shared conformance harness?** It is the piece the + ecosystem is missing — a counterparty that can violate any rule on demand. Offering it + upstream would make it the de facto ContextVM test suite, which is influence worth having but + also a maintenance commitment beyond our own needs. +11. **Is CEP-8 worth implementing at all, or just not precluding?** It is 722 lines, needs Tier E + wallet integration, and no coordinator we know of prices anything. The full-list decision says + build it; if that is really "build it when someone charges", say so now and 2f drops to a + stub that surfaces `-32042` to the user instead of paying. diff --git a/quartz/plans/README.md b/quartz/plans/README.md index e56dc4d372..f1c06931f1 100644 --- a/quartz/plans/README.md +++ b/quartz/plans/README.md @@ -1,6 +1,6 @@ # quartz plans -_Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 closed (negative result)._ +_Audited 2026-09-17. 13 plans: 7 shipped (archived), 0 in-progress, 5 queued, 1 closed (negative result)._ ## Queued | Plan | Summary | @@ -10,6 +10,7 @@ _Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 | [2026-07-03-incremental-negentropy-storage.md](2026-07-03-incremental-negentropy-storage.md) | Always-current (created_at, id) index so cold NEG-OPENs stop paying a full scan + seal (~340 ms at 50k vs strfry's ~21 ms). | | [2026-07-04-small-req-floor.md](2026-07-04-small-req-floor.md) | Small-REQ dispatch floor: decomposed, inline fast path tried and reverted (no wire-level win); floor is transport-side. | | [2026-08-13-gpu-pow-mining.md](2026-08-13-gpu-pow-mining.md) | GPU NIP-13 mining declined (ARMv8 has SHA-256 in silicon, mobile GPUs do not). Midstate is ~3x on JVM targets; Android hinges on Conscrypt per-digest JNI cost, still unmeasured. created_at refresh while mining shipped. | +| [2026-09-17-cordn-interop.md](2026-09-17-cordn-interop.md) | Cordn (cordn.net) is an alternative binding of MLS onto Nostr, not an alternative to MLS: same ciphersuite `0x0001`, byte-identical ChaCha20-Poly1305 seal and NIP-01 envelope, but the delivery service is an MCP server over ContextVM with no key-package event kind. Only the RFC 9420 engine is shareable, and it is not yet Marmot-clean (3 files, 10 imports, 3 hardcoded policies). ContextVM must be written from scratch: full review of the spec + all 12 CEPs, with a per-CEP compliance matrix, `CVM-*` rule ids and a 5-tier test method (including a fixture server that misbehaves on demand, and RFC 8785 JCS which Quartz lacks). Blocked on the credential-identity encoding (raw 32 bytes vs 64-byte hex ASCII). Includes a coordinator metadata-exposure analysis. | | [2026-09-08-marmot-spec-resync.md](2026-09-08-marmot-spec-resync.md) | Marmot moved off the MIP-era spec (2026-07-02): group state split into `app_data_dictionary` components, account identity proof v2, and a convergence engine. Current MDK rejects our groups outright. Gap analysis + 8-stage plan; Stages 0-4 done (mdk interop reference, app_data_dictionary, identity proof v2, the six group components, transport corrections); lifecycle + branch selection landed. | | [2026-09-22-cyberspace-region-bags.md](2026-09-22-cyberspace-region-bags.md) | Open `kind 33330` region bags: §2 coordinates, §4 Cantor roots, §7.2 region keys, §7.7 hint sweeps, §7.6 item verification. Reverses SNO's D2 — §7.7 says a seeker's position never enters the cost, and a key is 1.2 ms at h8 against the spec's own 1.3 ms. Three layers: quartz, `amy`, and a tap-to-search card. | diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt similarity index 99% rename from quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt index e311cd8aa1..5f6f1c6e31 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance import io.github.andreypfau.kotlinx.crypto.Sha512 @@ -129,7 +129,7 @@ actual object Ed25519 { } actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { - require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } val publicKey = derivePublicKey(seed) return Ed25519KeyPair(seed + publicKey, publicKey) } diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.apple.kt similarity index 99% rename from quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.apple.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.apple.kt index 6577f257ed..e7b3959ec3 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.apple.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/README.md new file mode 100644 index 0000000000..59a82c90b3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/README.md @@ -0,0 +1,127 @@ +# `contextvm/` + +**ContextVM** — the Model Context Protocol (MCP) carried over Nostr. Kind 25910 +ephemeral events whose `content` is a JSON-RPC message, gift-wrapped per CEP-4. + +A general MCP-over-Nostr implementation, not a client for any one server. It +exists because Amethyst needs to talk to [cordn](https://cordn.net) +coordinators (see `quartz/plans/2026-09-17-cordn-interop.md`), but nothing in it +is cordn-specific. + +It lives in quartz for the same reason `marmot/` and `concord/` do: it is a +Nostr specification family, and quartz is where those go. The CEPs are to +ContextVM what NIPs are to Nostr core — the package layout mirrors them the way +`nipXX` packages mirror NIPs. + +## Implemented specification revision + +Built from the specification documents in +[`ContextVM/contextvm-docs`](https://github.com/ContextVM/contextvm-docs) at +commit **`e63bce6`**, read on 2026-09-17. + +Most of these are **Draft**, including both transfer profiles, so the text can +still move. When bumping the revision, diff against the `CVM-*` rule ids in the +plan's §6.5 catalog — the test names carry them, so a spec change shows up as +named failures rather than as silent divergence. + +| Spec | Status | Implemented in | +| ---- | ------ | -------------- | +| Core draft spec | Draft | `core/`, `jsonrpc/`, `transport/`, `mcp/` | +| CEP-4 Encryption | Final | `cep04Encryption/CvmGiftWrap` | +| CEP-6 Public Announcements | Final | `cep06Announcements/` | +| CEP-8 Pricing and Payment | Draft | `cep08Payments/` | +| CEP-15 Common Tool Schemas | Draft | `cep15CommonSchemas/CommonToolSchema` | +| CEP-16 Client Pubkey Injection | Final | `mcp/McpMethods` (the `_meta` key), `fixture/` (server role) | +| CEP-17 Relay List Metadata | Draft | `cep17RelayList/ServerRelay` | +| CEP-19 Ephemeral Gift Wraps | Draft | `cep04Encryption/CvmGiftWrap` | +| CEP-21 PMI Recommendations | Draft | `cep08Payments/PaymentSession` | +| CEP-22 Oversized Transfer | Draft | `cep22OversizedTransfer/` | +| CEP-23 Server Profile Metadata | Draft | `cep06Announcements/DiscoverySurface` (kind 0 via quartz) | +| CEP-24 Server Reviews | Draft | `cep24Reviews/ServerReview` | +| CEP-35 Stateless Discovery | Draft | `cep35Discovery/SessionDiscovery` | +| CEP-41 Open Streams | Draft | `cep41OpenStreams/` | + +### Why the packages are named this way + +Each CEP gets its own `cepNNName/` package, the way `quartz` uses `nipXX` and +`marmot` uses `mipXX`: a spec number in the path is what makes "is this rule +implemented, and where" answerable without grep. Four placements are judgment +calls rather than mechanics: + +- **CEP-19 lives in `cep04Encryption/`.** Choosing the wrap kind (21059 with the + 1059 fallback) and building the wrap are one negotiation inside + `CvmGiftWrap`; a separate package would split a single method from its caller. +- **CEP-16 has no package.** It is a `_meta` key name plus the server-side + obligation to inject it — a key constant and fixture behaviour, not a + subsystem. +- **`transfer/ProgressEnvelope`** stays cross-cutting because CEP-22 and CEP-41 + share that framing; it is the `marmot/foundation/` analogue. +- **`core/`, `jsonrpc/`, `transport/`, `mcp/`** keep names because the core draft + spec is not a CEP and has no number to carry. + +RFC 8785 (JCS), required by CEP-8 and CEP-15, lives in +`quartz/…/utils/jcs/JsonCanonicalization.kt` — it is a generic primitive, not a +ContextVM concern. + +## Licensing + +Implemented **clean-room from the specification**. The reference +[`ContextVM/sdk`](https://github.com/ContextVM/sdk) is **LGPL-3.0**; Amethyst +ships under MIT, so translating that source would carry copyleft terms into +Quartz. Do not read it while working here — the plan's §6 was written so you do +not have to. + +The spec repository carries no LICENSE file. Implementing a published protocol +is fine; do not paste spec prose into this repo. + +## Three things that are easy to get wrong + +1. **Kind 25910 is ephemeral, so relays do not store it.** A subscription + opened after the peer published has missed the response permanently. This is + why `CvmTransport` exposes `request()` and no public publish/subscribe pair: + the ordering is the transport's job, not the caller's. `InMemoryRelayPool` + drops an event nobody is listening for, so the property is tested rather + than assumed. + +2. **CEP-41 has two independent ordering fields.** `progress` orders every + frame, control frames included, and is explicitly *not* a chunk counter; + `chunkIndex` (contiguous from 0) is what validates completeness. Progress + sequences are also per-sender, so a `pong`'s progress bears no relation to + the `ping` it answers — they match by nonce alone. + +3. **`close` does not complete the request.** After a CEP-41 stream closes, the + originating JSON-RPC request still needs its own response, and a client must + never synthesize success from `close`. `ToolCallResult` carries `streamed` + fragments and the `result` separately for exactly this reason. + +## Testing + +```bash +./gradlew :quartz:jvmTest --tests '*.contextvm.*' +./gradlew :quartz:testAndroidHostTest --tests '*.contextvm.*' +``` + +Protocol-level tests live in `commonTest` and run on every target; the ones +needing real secp256k1 and NIP-44 — the gift wrap round trip, the transport and +the MCP client — live in `jvmAndroidTest`, which is where quartz keeps +crypto-dependent tests and gives them the JVM *and* Android host runs. + +The suite is **Tier A and Tier C** from the plan's §6.4: rule-derived unit +tests, plus adversarial tests against `fixture/CvmFixtureServer`, which plays +the server role and can be told to violate any rule on demand (`FixtureFaults`). +Most tests are negative, because the CEPs are written as failure conditions. + +Still open: + +- **Tier B** — live integration against `ghcr.io/cordn-msg/cordn:latest`. +- **Tier D** — cross-implementation vector exchange for CEP-4 wraps and + CEP-22/41 framing. Worth offering upstream; no official vectors exist. +- **Tier E** — a real Lightning wallet behind CEP-8 (NIP-47 NWC is already in + Quartz). + +## Not in scope + +- A production server. `fixture/` plays the server role for tests only and is + deliberately not hardened; for a real coordinator, `cordn-rs` exists. +- Full MCP. Lifecycle, tool listing and tool calling are implemented because + that is what the CEPs define; sampling, roots and elicitation are not. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrap.kt new file mode 100644 index 0000000000..a758ad2a8c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrap.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep04Encryption + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Whether ContextVM messages must be encrypted. + * + * Defaults to [REQUIRED] rather than mirroring the reference SDK's permissive + * default. Under `OPTIONAL` a coordinator that simply does not advertise + * encryption receives plaintext JSON-RPC on public relays, exposing every tool + * argument to any relay operator. Failing closed is the only safe default for a + * messaging client. + */ +enum class EncryptionMode { + /** Encrypt always; refuse to talk to a peer that cannot. */ + REQUIRED, + + /** Encrypt when the peer advertises support. Opt in deliberately. */ + OPTIONAL, + + /** Never encrypt. For test fixtures and diagnostics only. */ + DISABLED, +} + +/** Which gift wrap kind to use (CEP-19). */ +enum class GiftWrapMode { + /** Kind 21059 — ephemeral, so relays do not retain the envelope. */ + EPHEMERAL, + + /** Kind 1059 — persistent, for deliberately retained delivery. */ + PERSISTENT, +} + +/** Thrown when a gift wrap cannot be produced or trusted. */ +class CvmEncryptionException( + message: String, +) : IllegalStateException(message) + +/** + * CEP-4 and CEP-19 message encryption. + * + * The scheme is a simplified NIP-59: the inner kind-25910 event is **signed + * first**, then the whole signed event JSON is NIP-44 encrypted to the + * recipient and placed in a gift wrap. There is no separate seal and no unsigned + * rumor, so a receiver verifies the inner signature and correlates on the + * **inner** event id. + * + * What this hides and what it does not: the sender, the inner kind and the real + * timestamp are concealed, but the recipient's pubkey is a `p` tag on the wrap + * and is therefore visible to relays. CEP-4 states that limitation outright; it + * is inherent to the addressing model, not a defect here. + */ +class CvmGiftWrap( + private val encryptionMode: EncryptionMode = EncryptionMode.REQUIRED, + private val giftWrapMode: GiftWrapMode = GiftWrapMode.EPHEMERAL, +) { + /** + * What this side can *receive*, as CEP-35 tag names. + * + * Receive, not prefer: the transport subscribes to both wrap kinds, so a + * client that emits 1059 still accepts 21059 and says so. Declaring only + * what we emit would tell a peer to withhold something we can read. + * + * Empty under [EncryptionMode.DISABLED] - there, a wrap is not something + * this client will open at all. + */ + fun receivableWrapTags(): List = + if (encryptionMode == EncryptionMode.DISABLED) { + emptyList() + } else { + listOf(CvmTags.SUPPORT_ENCRYPTION, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL) + } + + /** The wrap kind this client emits. */ + fun wrapKind(): Kind = + when (giftWrapMode) { + GiftWrapMode.EPHEMERAL -> CvmKinds.EPHEMERAL_GIFT_WRAP + GiftWrapMode.PERSISTENT -> CvmKinds.GIFT_WRAP + } + + /** + * The kind to actually use against a peer. + * + * CEP-19 requires falling back to the persistent wrap for a peer that does + * not advertise ephemeral support, so preferring 21059 must never mean + * refusing to talk to a 1059-only server. + */ + fun negotiatedWrapKind(peerSupportsEphemeral: Boolean): Kind = + if (giftWrapMode == GiftWrapMode.EPHEMERAL && peerSupportsEphemeral) { + CvmKinds.EPHEMERAL_GIFT_WRAP + } else { + CvmKinds.GIFT_WRAP + } + + /** + * Whether a message to this peer must be encrypted. + * + * @throws CvmEncryptionException under [EncryptionMode.REQUIRED] when the + * peer cannot encrypt — failing loudly rather than downgrading. + */ + fun shouldEncrypt(peerSupportsEncryption: Boolean): Boolean = + when (encryptionMode) { + EncryptionMode.DISABLED -> false + EncryptionMode.OPTIONAL -> peerSupportsEncryption + EncryptionMode.REQUIRED -> + if (peerSupportsEncryption) { + true + } else { + throw CvmEncryptionException( + "peer does not advertise encryption and this client requires it", + ) + } + } + + /** + * Wraps an already-signed inner event for [recipient]. + * + * The wrap is signed by a fresh throwaway key, so nothing links two wraps + * from the same sender. + * + * ## `created_at` is the real send time, NOT NIP-59's shifted timestamp + * + * This is the one place a ContextVM wrap deliberately parts from NIP-59, + * and it is not a shortcut. NIP-59 shifts a wrap's timestamp into the past + * because a gift-wrapped DM is **stored** and fetched later, so its + * timestamp would otherwise reveal when a conversation happened. A + * ContextVM wrap carries an RPC request that has to reach a peer + * **listening right now**: the reference server subscribes with + * `since = now` when it connects, which is the obvious filter for a live + * request stream, and a relay honours `since`. A wrap dated an hour ago is + * therefore dropped by the relay and the request is never delivered — it + * does not fail, it simply times out. + * + * The shift also protects nothing here. CEP-4 puts the recipient in a + * visible `p` tag, the request is delivered in real time, and kind 21059 + * is ephemeral so no relay retains it to be read later. A shifted + * timestamp would hide from an observer a fact that same observer reads + * off the socket. + */ + suspend fun wrap( + inner: Event, + recipient: HexKey, + kind: Kind = wrapKind(), + ): Event { + if (!CvmKinds.isGiftWrap(kind)) { + throw CvmEncryptionException("$kind is not a gift wrap kind") + } + + val wrapSigner = NostrSignerInternal(KeyPair()) + val ciphertext = wrapSigner.nip44Encrypt(OptimizedJsonMapper.toJson(inner), recipient) + + return wrapSigner.sign( + createdAt = TimeUtils.now(), + kind = kind, + tags = arrayOf(PTag.assemble(recipient, relayHint = null)), + content = ciphertext, + ) + } + + /** + * Unwraps [giftWrap] and returns the signed inner event. + * + * The inner signature is verified here: without that check anyone able to + * encrypt to us could impersonate any pubkey, since the wrap's own signature + * only proves the throwaway key signed it. + */ + suspend fun unwrap( + giftWrap: Event, + signer: NostrSigner, + ): Event { + if (!CvmKinds.isGiftWrap(giftWrap.kind)) { + throw CvmEncryptionException("${giftWrap.kind} is not a gift wrap kind") + } + + val json = + try { + signer.nip44Decrypt(giftWrap.content, giftWrap.pubKey) + } catch (e: Exception) { + throw CvmEncryptionException("could not decrypt gift wrap: ${e.message}") + } + + val inner = + try { + OptimizedJsonMapper.fromJson(json) + } catch (e: Exception) { + throw CvmEncryptionException("gift wrap did not contain an event: ${e.message}") + } + + if (!inner.verify()) { + throw CvmEncryptionException("inner event signature is invalid") + } + + return inner + } + + /** Reads whether a peer advertised encryption support (CEP-4). */ + fun peerSupportsEncryption(tags: Array>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION) + + /** Reads whether a peer advertised ephemeral wrap support (CEP-19). */ + fun peerSupportsEphemeral(tags: Array>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL) + + /** The capability flags this client advertises to peers. */ + fun capabilityTags(): List> = + buildList { + if (encryptionMode != EncryptionMode.DISABLED) { + add(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)) + if (giftWrapMode == GiftWrapMode.EPHEMERAL) { + add(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/AnnouncedTools.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/AnnouncedTools.kt new file mode 100644 index 0000000000..22ff81a402 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/AnnouncedTools.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The tool list a CEP-6 kind-11317 announcement carries. + * + * [ServerAnnouncement] deliberately leaves `content` as text, because the + * announcement kinds each carry a different MCP result. This decodes the one + * shape that matters for finding a server worth talking to: the `tools/list` + * result, `{"tools":[{"name":…,"inputSchema":…}, …]}`. + * + * ## Why the names are the useful part + * + * An announcement says nothing about *what protocol* a server speaks — there + * is no marker tag and no registry. What a server is, from the outside, is the + * set of tools it serves. So a client looking for one specific kind of server + * matches on the names, and [names] is what it matches against. + */ +class AnnouncedTools( + /** Each tool definition verbatim, for a caller that wants the schemas. */ + val tools: List, +) { + /** Every advertised tool name, in announcement order, deduplicated. */ + val names: Set = + tools.mapNotNullTo(LinkedHashSet()) { + (it[NAME] as? JsonPrimitive)?.takeIf { p -> p.isString }?.content + } + + /** Whether every one of [required] is advertised. Extra tools are fine. */ + fun serves(required: Collection): Boolean = names.containsAll(required) + + companion object { + private const val NAME = "name" + private const val TOOLS = "tools" + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Reads the content of a kind-11317 announcement. + * + * Null when the text is not JSON or carries no `tools` array — an + * announcement we cannot read is not an error to propagate, it is one + * server in a relay's worth of them that this client skips. + */ + fun parseOrNull(content: String): AnnouncedTools? { + val root = + try { + json.parseToJsonElement(content) as? JsonObject + } catch (e: IllegalArgumentException) { + null + } ?: return null + + val tools = root[TOOLS] as? JsonArray ?: return null + return AnnouncedTools(tools.filterIsInstance()) + } + + /** Reads [announcement] when it is a tool list; null for any other kind. */ + fun from(announcement: ServerAnnouncement): AnnouncedTools? = + if (announcement.kind == CvmKinds.TOOLS_LIST) { + parseOrNull(announcement.content) + } else { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt new file mode 100644 index 0000000000..b3fa0a6b74 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A server's CEP-6 announcement of itself: kind [CvmKinds.SERVER_ANNOUNCEMENT]. + * + * A typed class so the announcement can live in the event cache like anything + * else. Without one the kind is unlisted, and an unlisted kind is rejected as + * unsupported on the way in -- which is why a coordinator's announced name had + * to be fetched and parsed by hand everywhere it was wanted, with the + * newest-wins rule, the author check and the signature check all re-derived per + * caller. Being replaceable (10000..19999), the cache now keeps the newest per + * (kind, pubkey) and verifies before trusting it. + * + * [discovery] is the surface the server advertises -- its name, its blurb, and + * what it says it supports. Every field of it is the server's own claim; + * [pubKey] is the only thing here that is not. + */ +@Immutable +class CvmServerAnnouncementEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * What the server says about itself, parsed from the tags. + * + * Not cached on the instance: the cache keeps one event per coordinator and + * the screens read a name off it, so the parse is rare and cheap next to + * holding a second copy of every surface in memory. + */ + fun discovery(): DiscoverySurface = DiscoverySurface.parse(tags) + + /** The server's own name for itself, or null when it publishes none. */ + fun serverName(): String? = discovery().name?.takeIf { it.isNotBlank() } + + companion object { + const val KIND = CvmKinds.SERVER_ANNOUNCEMENT + } +} + +/** + * A server's CEP-6 `tools/list` announcement: kind [CvmKinds.TOOLS_LIST]. + * + * Stored for the same reason as [CvmServerAnnouncementEvent], and needed + * alongside it because what makes a ContextVM server a *cordn coordinator* is + * the eleven tools it advertises here, not anything it says about itself. + */ +@Immutable +class CvmToolsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The advertised tools, or null when the content does not parse as a list. */ + fun tools(): AnnouncedTools? = AnnouncedTools.parseOrNull(content) + + companion object { + const val KIND = CvmKinds.TOOLS_LIST + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt new file mode 100644 index 0000000000..0d158f6a2a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * What a peer told us about itself. + * + * CEP-35 makes this a *session* concept rather than an announcement one: the + * same tag vocabulary arrives either on a public announcement (CEP-6) or on the + * first direct message of a session, and both are treated as equivalent. + */ +data class DiscoverySurface( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val website: String? = null, + val supportsEncryption: Boolean = false, + val supportsEphemeralEncryption: Boolean = false, + val supportsOversizedTransfer: Boolean = false, + val supportsOpenStream: Boolean = false, + /** + * Everything else, routing excluded. + * + * CEP-35 requires unknown discovery tags to be preserved rather than + * discarded, so custom protocols can build on the same exchange. Keeping + * them reachable is what makes that work. + */ + val unknownTags: List = emptyList(), +) { + /** + * Whether the peer said anything about itself at all. + * + * The distinction this exists for: CEP-35 reads an absent flag as "not + * supported", which is right for a peer that sent a discovery surface and + * left a flag out of it. It is wrong for a peer that sent no discovery tags + * whatsoever — that peer has made no claim, and treating its silence as a + * denial would have us conclude it cannot do anything. + * + * This is not hypothetical. A live cordn coordinator's kind-25910 responses + * carry only the routing tags `p` and `e` (observed on the public relays, + * 2026-09-23), so reading them as a full surface would say "supports + * nothing" about a server that in fact accepts every wrap we send. + */ + val declaresNothing: Boolean + get() = + name == null && + about == null && + picture == null && + website == null && + !supportsEncryption && + !supportsEphemeralEncryption && + !supportsOversizedTransfer && + !supportsOpenStream && + unknownTags.isEmpty() + + /** Raw access for a caller that understands a tag this version does not. */ + fun rawTag(name: String): Tag? = unknownTags.firstOrNull { it.isNotEmpty() && it[0] == name } + + companion object { + private val KNOWN = + setOf( + CvmTags.NAME, + CvmTags.ABOUT, + CvmTags.PICTURE, + CvmTags.WEBSITE, + CvmTags.SUPPORT_ENCRYPTION, + CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL, + CvmTags.SUPPORT_OVERSIZED_TRANSFER, + CvmTags.SUPPORT_OPEN_STREAM, + ) + + fun parse(tags: Array): DiscoverySurface { + fun value(name: String) = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1) + + return DiscoverySurface( + name = value(CvmTags.NAME), + about = value(CvmTags.ABOUT), + picture = value(CvmTags.PICTURE), + website = value(CvmTags.WEBSITE), + supportsEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION), + supportsEphemeralEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL), + supportsOversizedTransfer = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OVERSIZED_TRANSFER), + supportsOpenStream = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OPEN_STREAM), + unknownTags = + tags.filter { + it.isNotEmpty() && it[0] !in KNOWN && !CvmTags.isRouting(it[0]) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncement.kt new file mode 100644 index 0000000000..3dc665ddb8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncement.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** + * A CEP-6 announcement. + * + * `content` is the stringified result of the matching MCP call — the initialize + * result for a server announcement, a list result for the others — so it is left + * as text for the caller to decode with the same codec it uses on the wire. + */ +data class ServerAnnouncement( + val kind: Kind, + val pubKey: HexKey, + val createdAt: Long, + val content: String, + val discovery: DiscoverySurface, +) { + companion object { + fun parseOrNull(event: Event): ServerAnnouncement? { + if (event.kind !in CvmKinds.ANNOUNCEMENTS) return null + return ServerAnnouncement( + kind = event.kind, + pubKey = event.pubKey, + createdAt = event.createdAt, + content = event.content, + discovery = DiscoverySurface.parse(event.tags), + ) + } + + /** + * Keeps the newest announcement per `(kind, pubkey)`. + * + * These kinds are replaceable, so an older event arriving late from a + * lagging relay must not overwrite a newer one already held. + */ + fun latestPerKind(events: List): Map = latestOf(events.mapNotNull { parseOrNull(it) }) + + /** + * As [latestPerKind], for announcements already parsed. + * + * A caller that grouped announcements itself — by pubkey, say — would + * otherwise have to re-derive the newest-per-kind rule, which is the + * part that is easy to get wrong. + */ + fun latestOf(announcements: List): Map = + announcements + .groupBy { it.kind } + .mapValues { (_, list) -> list.maxBy { it.createdAt } } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/CanonicalInvocation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/CanonicalInvocation.kt new file mode 100644 index 0000000000..161a89764d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/CanonicalInvocation.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.json.toPlainJson +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.jcs.JsonCanonicalization +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject + +/** + * CEP-8's canonical invocation identity for the `explicit_gating` lifecycle. + * + * A successful payment authorizes a *future* execution, and the client is told + * to retry "the same request". That has to be defined on something stable, so + * the identity is the client pubkey plus + * `sha256(JCS({method, params}))` — with `params._meta` removed. + * + * The `_meta` exclusion is the load-bearing part. MCP regenerates + * `progressToken` on every `callTool`, so without it two semantically identical + * invocations hash differently and a paid authorization could never be matched. + * The JSON-RPC id, the outer event id, timestamps, signatures and tags are all + * excluded for the same reason: a retry may legitimately change any of them. + * + * The exclusion applies **only** to identity derivation. When an authorization + * is consumed, the full original `params` — `_meta` included — must still reach + * the handler, so progress and streaming keep working at execution time. + */ +object CanonicalInvocation { + /** Derives the invocation identity hash from an MCP request. */ + fun identityOf(request: JsonRpcRequest): HexKey = identityOf(request.method, request.params) + + fun identityOf( + method: String, + params: JsonObject?, + ): HexKey { + val payload = + buildMap { + put(METHOD, method) + params?.let { put(PARAMS, semanticParams(it).toPlainJson()) } + } + + return sha256(JsonCanonicalization.canonicalize(payload).encodeToByteArray()).toHexKey() + } + + /** + * The full authorization key: the requesting client plus the invocation. + * + * A payment authorizes one client's future execution, not anyone's, so the + * pubkey is part of the identity rather than context around it. + */ + fun authorizationKey( + clientPubKey: HexKey, + request: JsonRpcRequest, + ) = clientPubKey.lowercase() + ":" + identityOf(request) + + /** [params] with `_meta` removed; everything else untouched. */ + fun semanticParams(params: JsonObject): JsonObject = + if (!params.containsKey(McpParams.META)) { + params + } else { + buildJsonObject { + params.forEach { (key, value) -> if (key != McpParams.META) put(key, value) } + } + } + + private const val METHOD = "method" + private const val PARAMS = "params" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentMessages.kt new file mode 100644 index 0000000000..11e5880c06 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentMessages.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.mcp.McpMethods +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * A payment the server is asking for. + * + * Carried by `notifications/payment_required` in the transparent lifecycle and + * as one entry of `error.data.payment_options` under explicit gating — the + * fields are identical, which is why one type covers both. + */ +data class PaymentRequest( + val amount: Double, + val pmi: Pmi, + /** + * The settlement payload, opaque here. + * + * Its format is PMI-defined, so a handler must match [pmi] before trying to + * interpret it. Treating it as a BOLT11 invoice because it starts with "ln" + * is exactly the guess the PMI exists to prevent. + */ + val payRequest: String, + val description: String? = null, + /** Seconds. Absent means the PMI or the implementation defines expiry. */ + val ttl: Long? = null, + val meta: JsonObject? = null, +) { + companion object { + const val AMOUNT = "amount" + const val PAY_REQ = "pay_req" + const val PMI = "pmi" + const val DESCRIPTION = "description" + const val TTL = "ttl" + const val META = "_meta" + + fun parseOrNull(params: JsonObject): PaymentRequest? { + val amount = (params[AMOUNT] as? JsonPrimitive)?.doubleOrNull ?: return null + val pmi = (params[PMI] as? JsonPrimitive)?.contentOrNullIfNotString()?.let { Pmi.parseOrNull(it) } ?: return null + val payRequest = (params[PAY_REQ] as? JsonPrimitive)?.contentOrNullIfNotString() ?: return null + + return PaymentRequest( + amount = amount, + pmi = pmi, + payRequest = payRequest, + description = (params[DESCRIPTION] as? JsonPrimitive)?.contentOrNullIfNotString(), + ttl = (params[TTL] as? JsonPrimitive)?.longOrNull, + meta = params[META] as? JsonObject, + ) + } + } +} + +/** Server confirmation that payment was accepted (transparent lifecycle). */ +data class PaymentAccepted( + val amount: Double, + val pmi: Pmi, + val meta: JsonObject? = null, +) + +/** Server rejection of an attempted payment (transparent lifecycle). */ +data class PaymentRejected( + val pmi: Pmi, + /** For a bearer asset, the amount actually required. */ + val amount: Double? = null, + val message: String? = null, +) + +/** Parsers for the CEP-8 notifications and errors. */ +object PaymentMessages { + /** Reads a `notifications/payment_required`, or null if this is another notification. */ + fun paymentRequired(notification: JsonRpcNotification): PaymentRequest? { + if (notification.method != McpMethods.PAYMENT_REQUIRED) return null + return notification.params?.let { PaymentRequest.parseOrNull(it) } + } + + fun paymentAccepted(notification: JsonRpcNotification): PaymentAccepted? { + if (notification.method != McpMethods.PAYMENT_ACCEPTED) return null + val params = notification.params ?: return null + val amount = (params[PaymentRequest.AMOUNT] as? JsonPrimitive)?.doubleOrNull ?: return null + val pmi = + (params[PaymentRequest.PMI] as? JsonPrimitive) + ?.contentOrNullIfNotString() + ?.let { Pmi.parseOrNull(it) } ?: return null + return PaymentAccepted(amount, pmi, params[PaymentRequest.META] as? JsonObject) + } + + fun paymentRejected(notification: JsonRpcNotification): PaymentRejected? { + if (notification.method != McpMethods.PAYMENT_REJECTED) return null + val params = notification.params ?: return null + val pmi = + (params[PaymentRequest.PMI] as? JsonPrimitive) + ?.contentOrNullIfNotString() + ?.let { Pmi.parseOrNull(it) } ?: return null + return PaymentRejected( + pmi = pmi, + amount = (params[PaymentRequest.AMOUNT] as? JsonPrimitive)?.doubleOrNull, + message = (params["message"] as? JsonPrimitive)?.contentOrNullIfNotString(), + ) + } + + /** + * The payment options carried by a `-32042 Payment Required` error. + * + * Returns null when [error] is a different failure, and an empty list when + * the error claims payment is required but offers no way to make it — which + * the caller should treat as malformed rather than as "nothing to pay". + */ + fun paymentOptions(error: JsonRpcError): List? { + if (error.code != JsonRpcError.PAYMENT_REQUIRED) return null + val data = error.data as? JsonObject ?: return emptyList() + val options = data[PAYMENT_OPTIONS] as? JsonArray ?: return emptyList() + return options.mapNotNull { (it as? JsonObject)?.let(PaymentRequest::parseOrNull) } + } + + /** Seconds the server suggests waiting before retrying a `-32043 Payment Pending`. */ + fun retryAfter(error: JsonRpcError): Long? { + if (error.code != JsonRpcError.PAYMENT_PENDING) return null + val data = error.data as? JsonObject ?: return null + return (data[RETRY_AFTER] as? JsonPrimitive)?.longOrNull + } + + /** Human-readable guidance a payment error carries. */ + fun instructions(error: JsonRpcError): String? = + (error.data as? JsonObject) + ?.get(INSTRUCTIONS) + ?.let { (it as? JsonPrimitive)?.contentOrNullIfNotString() } + + const val PAYMENT_OPTIONS = "payment_options" + const val RETRY_AFTER = "retry_after" + const val INSTRUCTIONS = "instructions" +} + +private fun JsonPrimitive.contentOrNullIfNotString() = if (isString) content else null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentSession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentSession.kt new file mode 100644 index 0000000000..9c68e2cdad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentSession.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * Client-side CEP-8 negotiation state for one session. + * + * The rule this exists to enforce: a server that will not honour + * `explicit_gating` MUST NOT silently fall back to the transparent lifecycle, + * and a client that *required* explicit gating MUST NOT auto-satisfy transparent + * `payment_required` notifications it receives anyway. A payment handler that + * simply pays whatever it is asked to pay violates the client half of that, so + * [mayAutoPay] is the gate every handler has to pass through. + */ +class PaymentSession( + /** The lifecycle this client wants. */ + private val requested: PaymentInteraction = PaymentInteraction.DEFAULT, + /** + * True when the application needs payment decisions to be visible — an agent + * or a user must approve them. + * + * With this set, failing to negotiate [PaymentInteraction.EXPLICIT_GATING] + * disables automatic payment entirely rather than degrading to silent + * spending. + */ + private val requiresVisiblePayments: Boolean = false, + /** PMIs this client can actually settle. */ + private val supportedPmis: List = emptyList(), +) { + private var negotiated = false + private var effective = PaymentInteraction.DEFAULT + + /** The lifecycle in force. Before the first response this is the default. */ + val effectiveMode get() = effective + + /** True once the server has disclosed (or implied) the effective mode. */ + val isNegotiated get() = negotiated + + /** + * True when negotiation did not deliver the mode this client asked for. + * + * Only meaningful after [observeServerTags]. + */ + val negotiationFailed get() = negotiated && effective != requested + + /** Tags to attach to the first direct message of the session. */ + fun negotiationTags(): List = + buildList { + // transparent is the default, so advertising it is noise; only a + // non-default request needs to be stated. + if (requested != PaymentInteraction.DEFAULT) add(PaymentTags.paymentInteraction(requested)) + supportedPmis.forEach { add(PaymentTags.pmi(it)) } + } + + /** + * Applies the server's first direct response. + * + * Absence of the tag means transparent, per CEP-8's first-message semantics. + */ + fun observeServerTags(tags: Array) { + effective = PaymentTags.parsePaymentInteraction(tags) ?: PaymentInteraction.DEFAULT + negotiated = true + } + + /** + * Applies a mid-session upsert. + * + * CEP-8 treats a repeated `payment_interaction` as an upsert rather than a + * one-shot handshake, because ContextVM messaging is connectionless: a + * server cannot observe a client reconnecting, so first-message-only + * negotiation could never be renegotiated after a transport reset. An absent + * tag on a later message inherits the current mode. + */ + fun observeLaterServerTags(tags: Array) { + PaymentTags.parsePaymentInteraction(tags)?.let { + effective = it + negotiated = true + } + } + + /** + * Whether a handler may settle [request] without asking anyone. + * + * False when the client required visible payments but did not get explicit + * gating, and false for a PMI this client cannot settle anyway. + */ + fun mayAutoPay(request: PaymentRequest): Boolean { + if (requiresVisiblePayments && effective != PaymentInteraction.EXPLICIT_GATING) return false + return request.pmi in supportedPmis + } + + /** + * The first offered option this client can settle, or null. + * + * Order is the server's preference; a client picks the first it supports + * rather than the cheapest, since price comparison across payment rails is + * not something this layer can do. + */ + fun selectPayable(options: List): PaymentRequest? = options.firstOrNull { it.pmi in supportedPmis } + + /** PMIs both sides support, preserving the server's ordering. */ + fun intersectPmis(serverPmis: List): List = serverPmis.filter { it in supportedPmis } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTags.kt new file mode 100644 index 0000000000..959c1bc1c2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTags.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * How payment is surfaced for a session (CEP-8). + * + * The distinction is not cosmetic: under [TRANSPARENT] payment is handled by + * transport middleware and the application may never see it, while under + * [EXPLICIT_GATING] payment becomes the invocation's own error result so an + * agent or user can decide. A client that needs the decision visible must not + * silently accept the other mode — see [PaymentSession]. + */ +enum class PaymentInteraction( + val wire: String, +) { + TRANSPARENT("transparent"), + EXPLICIT_GATING("explicit_gating"), + ; + + companion object { + /** The compatibility baseline when no tag is present. */ + val DEFAULT = TRANSPARENT + + fun fromWire(value: String?) = entries.firstOrNull { it.wire == value } + } +} + +/** + * A Payment Method Identifier. + * + * Follows the W3C format (`[a-z0-9-]+`). A PMI is not just a discovery label: it + * is the type tag for the opaque `pay_req` string, so a handler that does not + * recognise the PMI cannot interpret the payment request at all. + */ +data class Pmi( + val value: String, +) { + init { + require(PATTERN.matches(value)) { "PMI must match [a-z0-9-]+ but was '$value'" } + } + + /** + * True for bearer-asset methods that allow settlement directly on the + * request via a `direct_payment` tag (CEP-21's `-direct` suffix convention). + */ + val supportsDirectPayment get() = value.endsWith(DIRECT_SUFFIX) + + override fun toString() = value + + companion object { + private val PATTERN = Regex("[a-z0-9-]+") + + const val DIRECT_SUFFIX = "-direct" + + /** The one PMI CEP-21 currently recommends: `pay_req` is a BOLT11 invoice. */ + val LIGHTNING_BOLT11 = Pmi("bitcoin-lightning-bolt11") + + fun parseOrNull(value: String) = if (PATTERN.matches(value)) Pmi(value) else null + } +} + +/** A capability's advertised reference price. */ +sealed interface Price { + data class Fixed( + val amount: Long, + ) : Price + + /** + * An inclusive range. The server may request any amount within it, so this + * is a discovery hint rather than a commitment. + */ + data class Range( + val min: Long, + val max: Long, + ) : Price + + fun includes(amount: Long) = + when (this) { + is Fixed -> amount == this.amount + is Range -> amount in min..max + } +} + +/** What a [CapTag] prices. */ +enum class CapabilityKind( + val prefix: String, +) { + TOOL("tool:"), + PROMPT("prompt:"), + RESOURCE("resource:"), + ; + + companion object { + fun of(identifier: String) = entries.firstOrNull { identifier.startsWith(it.prefix) } + } +} + +/** `["cap", "", "", ""]` — a reference price for discovery and UX. */ +data class CapTag( + val kind: CapabilityKind, + val name: String, + val price: Price, + val unit: String, +) { + fun toTag(): Tag = arrayOf(CvmTags.CAPABILITY_PRICE, kind.prefix + name, priceWire(), unit) + + private fun priceWire() = + when (price) { + is Price.Fixed -> price.amount.toString() + is Price.Range -> "${price.min}-${price.max}" + } + + companion object { + fun parse(tag: Tag): CapTag? { + if (tag.size < 4 || tag[0] != CvmTags.CAPABILITY_PRICE) return null + val kind = CapabilityKind.of(tag[1]) ?: return null + val price = parsePrice(tag[2]) ?: return null + return CapTag(kind, tag[1].removePrefix(kind.prefix), price, tag[3]) + } + + private fun parsePrice(raw: String): Price? { + val separator = raw.indexOf('-') + if (separator <= 0) return raw.toLongOrNull()?.let { Price.Fixed(it) } + + val min = raw.substring(0, separator).toLongOrNull() ?: return null + val max = raw.substring(separator + 1).toLongOrNull() ?: return null + return if (min <= max) Price.Range(min, max) else null + } + } +} + +/** Assembling and reading the CEP-8 tag family. */ +object PaymentTags { + fun pmi(pmi: Pmi): Tag = arrayOf(CvmTags.PAYMENT_METHOD, pmi.value) + + fun paymentInteraction(mode: PaymentInteraction): Tag = arrayOf(CvmTags.PAYMENT_INTERACTION, mode.wire) + + fun directPayment( + pmi: Pmi, + payload: String, + ): Tag = arrayOf(CvmTags.DIRECT_PAYMENT, pmi.value, payload) + + fun change( + pmi: Pmi, + payload: String, + ): Tag = arrayOf(CvmTags.CHANGE, pmi.value, payload) + + fun parsePmis(tags: Array): List = + tags + .filter { it.size >= 2 && it[0] == CvmTags.PAYMENT_METHOD } + .mapNotNull { Pmi.parseOrNull(it[1]) } + + fun parseCaps(tags: Array): List = tags.mapNotNull(CapTag::parse) + + /** + * The interaction mode a peer is asking for, or null when the tag is absent. + * + * Absence is meaningful: on a session's first direct message it means + * [PaymentInteraction.TRANSPARENT], while on a later message it means the + * current effective mode is inherited unchanged. + */ + fun parsePaymentInteraction(tags: Array): PaymentInteraction? = + tags + .firstOrNull { it.size >= 2 && it[0] == CvmTags.PAYMENT_INTERACTION } + ?.let { PaymentInteraction.fromWire(it[1]) } + + /** + * Direct-payment offers in request order. + * + * A client SHOULD send at most one, but a server evaluating several takes + * the first whose PMI it supports, so order is preserved here. + */ + fun parseDirectPayments(tags: Array): List> = + tags + .filter { it.size >= 3 && it[0] == CvmTags.DIRECT_PAYMENT } + .mapNotNull { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } } + + fun parseChange(tags: Array): Pair? = + tags + .firstOrNull { it.size >= 3 && it[0] == CvmTags.CHANGE } + ?.let { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchema.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchema.kt new file mode 100644 index 0000000000..640293fcd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchema.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep15CommonSchemas + +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.contextvm.json.toPlainJson +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.jcs.JsonCanonicalization +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +/** + * CEP-15 common tool schemas. + * + * Two servers implementing the same tool interface produce the same + * `schemaHash`, so a client can recognise an equivalent service across + * providers and switch between them without code changes. That only works if + * documentation differences are stripped before hashing, which is what + * [normalize] does. + * + * The rule that matters most on the client side: the advertised hash is a + * **verification target, not a label**. [verify] recomputes it from the tool + * definition; trusting the advertised value would give up everything the CEP + * provides. + */ +object CommonToolSchema { + const val META_NAMESPACE = CvmTags.COMMON_SCHEMA_NAMESPACE + const val SCHEMA_HASH = "schemaHash" + + const val NAME = "name" + const val INPUT_SCHEMA = "inputSchema" + const val OUTPUT_SCHEMA = "outputSchema" + const val META = "_meta" + + /** + * Annotation and documentation keywords removed at every nesting level. + * + * These carry no structural meaning, so two providers describing the same + * interface differently must still agree on the hash. + */ + val ANNOTATION_KEYWORDS = + setOf( + "title", + "description", + "examples", + "default", + "deprecated", + "readOnly", + "writeOnly", + ) + + /** Vendor extensions are stripped too, by prefix. */ + const val VENDOR_PREFIX = "x-" + + /** + * Strips annotation and vendor keywords recursively. + * + * This applies only to the hashed representation — the tool definition a + * server actually returns from `tools/list` is untouched. + */ + fun normalize(schema: JsonElement): JsonElement = + when (schema) { + is JsonObject -> + buildJsonObject { + schema.forEach { (key, value) -> + if (key !in ANNOTATION_KEYWORDS && !key.startsWith(VENDOR_PREFIX)) { + put(key, normalize(value)) + } + } + } + + is JsonArray -> buildJsonArray { schema.forEach { add(normalize(it)) } } + + else -> schema + } + + /** + * The schema hash: `sha256(JCS({name, inputSchema, outputSchema?}))`, hex. + * + * The tool name is part of the payload on purpose — MCP invokes tools by + * name, so a shared hash is only useful if the name is shared too. + */ + fun hash( + name: String, + inputSchema: JsonElement, + outputSchema: JsonElement? = null, + ): String { + val payload = + buildMap { + put(NAME, name) + put(INPUT_SCHEMA, normalize(inputSchema).toPlainJson()) + // Presence changes the hash, so an omitted output schema is not + // the same as an empty one. + if (outputSchema != null) put(OUTPUT_SCHEMA, normalize(outputSchema).toPlainJson()) + } + + return sha256(JsonCanonicalization.canonicalize(payload).encodeToByteArray()).toHexKey() + } + + /** Computes the hash from a `tools/list` tool definition. */ + fun hashOf(tool: JsonObject): String { + val name = + (tool[NAME] as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: throw IllegalArgumentException("tool definition requires a name") + val input = tool[INPUT_SCHEMA] ?: throw IllegalArgumentException("tool definition requires an inputSchema") + return hash(name, input, tool[OUTPUT_SCHEMA]) + } + + /** The hash a server claims in `_meta`, or null when the tool is bespoke. */ + fun advertisedHash(tool: JsonObject): String? { + val meta = tool[META] as? JsonObject ?: return null + val namespace = meta[META_NAMESPACE] as? JsonObject ?: return null + return (namespace[SCHEMA_HASH] as? JsonPrimitive)?.takeIf { it.isString }?.content + } + + /** + * True when the tool advertises a common schema whose hash matches what its + * own definition produces. + * + * Returns false for a mismatch rather than throwing: a server advertising a + * wrong hash is a tool to ignore, not a session to fail. + */ + fun verify(tool: JsonObject): Boolean { + val advertised = advertisedHash(tool) ?: return false + return advertised.equals(hashOf(tool), ignoreCase = true) + } + + /** Builds the `_meta` block a server attaches to a common-schema tool. */ + fun metaFor(schemaHash: String): JsonObject = + buildJsonObject { + put( + META_NAMESPACE, + buildJsonObject { put(SCHEMA_HASH, JsonPrimitive(schemaHash)) }, + ) + } + + /** NIP-73 `["i", "", ""]` marker for an implemented schema. */ + fun externalIdTag( + schemaHash: String, + toolName: String, + ): Tag = arrayOf(CvmTags.EXTERNAL_ID, schemaHash, toolName) + + /** NIP-73 `["k", "io.contextvm/common-schema"]`; one per announcement event. */ + fun externalKindTag(): Tag = arrayOf(CvmTags.EXTERNAL_KIND, META_NAMESPACE) + + /** Reads `(schemaHash, toolName)` pairs off an announcement's `i` tags. */ + fun parseExternalIds(tags: Array): List> = + tags + .filter { it.size >= 2 && it[0] == CvmTags.EXTERNAL_ID } + .map { it[1] to it.getOrNull(2) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep17RelayList/ServerRelay.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep17RelayList/ServerRelay.kt new file mode 100644 index 0000000000..2eb5131b57 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep17RelayList/ServerRelay.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep17RelayList + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * A relay a server is reachable on (CEP-17, NIP-65 kind 10002). + * + * The ContextVM profile publishes unmarked tags, meaning the relay serves both + * directions. Markers are honoured when present but are not the norm here. + */ +data class ServerRelay( + val url: String, + val read: Boolean = true, + val write: Boolean = true, +) { + companion object { + const val READ = "read" + const val WRITE = "write" + + fun parseAll(event: Event): List { + if (event.kind != CvmKinds.RELAY_LIST) return emptyList() + return event.tags + .filter { it.size >= 2 && it[0] == CvmTags.RELAY && it[1].isNotBlank() } + .map { tag -> + when (tag.getOrNull(2)) { + READ -> ServerRelay(tag[1], read = true, write = false) + WRITE -> ServerRelay(tag[1], read = false, write = true) + // Unmarked is the recommended ContextVM profile: the + // relay is usable for both publishing and subscribing. + else -> ServerRelay(tag[1]) + } + } + } + + /** + * Relays usable for a full request/response exchange. + * + * A read-only or write-only relay cannot carry both halves, and since + * kind 25910 is ephemeral there is no fetching a response later from + * somewhere else. + */ + fun operational(relays: List) = relays.filter { it.read && it.write } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedFrame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedFrame.kt new file mode 100644 index 0000000000..a40cbe679b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedFrame.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asLongOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asStringOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * A CEP-22 bounded oversized-transfer frame. + * + * The payload is reassembled from the `data` fragments of [Chunk] frames, which + * are **raw substrings of the serialized JSON-RPC message**, not base64. That is + * what makes the digest rule work: concatenate the fragments, encode the result + * as UTF-8, and hash those bytes. + */ +sealed interface OversizedFrame { + val envelope: ProgressEnvelope + val token: ProgressToken get() = envelope.token + val progress: Double get() = envelope.progress + + data class Start( + override val envelope: ProgressEnvelope, + val completionMode: String, + val digest: String, + val totalBytes: Long, + val totalChunks: Long, + ) : OversizedFrame + + data class Accept( + override val envelope: ProgressEnvelope, + ) : OversizedFrame + + data class Chunk( + override val envelope: ProgressEnvelope, + val data: String, + ) : OversizedFrame + + data class End( + override val envelope: ProgressEnvelope, + ) : OversizedFrame + + data class Abort( + override val envelope: ProgressEnvelope, + val reason: String? = null, + ) : OversizedFrame + + companion object { + const val START = "start" + const val ACCEPT = "accept" + const val CHUNK = "chunk" + const val END = "end" + const val ABORT = "abort" + + /** The only completion mode this CEP version defines. */ + const val COMPLETION_MODE_RENDER = "render" + + /** Digest values are algorithm-prefixed on the wire, e.g. `sha256:ab12…`. */ + const val DIGEST_PREFIX_SHA256 = "sha256:" + + const val COMPLETION_MODE = "completionMode" + const val DIGEST = "digest" + const val TOTAL_BYTES = "totalBytes" + const val TOTAL_CHUNKS = "totalChunks" + const val DATA = "data" + const val REASON = "reason" + + /** + * Parses a CEP-22 frame, or returns null when the envelope belongs to a + * different transfer profile (CEP-41 shares this envelope). + */ + fun parseOrNull(envelope: ProgressEnvelope): OversizedFrame? { + if (envelope.type != ProgressEnvelope.TYPE_OVERSIZED) return null + val cvm = envelope.cvm + + return when (val frameType = envelope.frameType) { + START -> { + val completionMode = + cvm[COMPLETION_MODE]?.asStringOrNull() + ?: throw TransferFrameException("start requires completionMode") + // Receivers MUST reject unknown or unsupported completion + // modes; the field exists as an extension point for future + // CEPs, so silently treating anything else as render would + // be the wrong kind of tolerance. + if (completionMode != COMPLETION_MODE_RENDER) { + throw TransferFrameException("unsupported completionMode: $completionMode") + } + OversizedFrame.Start( + envelope = envelope, + completionMode = completionMode, + digest = + cvm[DIGEST]?.asStringOrNull() + ?: throw TransferFrameException("start requires digest"), + totalBytes = + cvm[TOTAL_BYTES]?.asLongOrNull() + ?: throw TransferFrameException("start requires totalBytes"), + totalChunks = + cvm[TOTAL_CHUNKS]?.asLongOrNull() + ?: throw TransferFrameException("start requires totalChunks"), + ) + } + + ACCEPT -> OversizedFrame.Accept(envelope) + + CHUNK -> + OversizedFrame.Chunk( + envelope = envelope, + data = + cvm[DATA]?.asStringOrNull() + ?: throw TransferFrameException("chunk requires string data"), + ) + + END -> OversizedFrame.End(envelope) + + ABORT -> OversizedFrame.Abort(envelope, cvm[REASON]?.asStringOrNull()) + + else -> throw TransferFrameException("unknown oversized frameType: $frameType") + } + } + + fun start( + token: ProgressToken, + progress: Double, + digest: String, + totalBytes: Long, + totalChunks: Long, + message: String? = null, + ) = OversizedFrame.Start( + envelope = + envelopeOf(START, token, progress, message) { + put(COMPLETION_MODE, JsonPrimitive(COMPLETION_MODE_RENDER)) + put(DIGEST, JsonPrimitive(digest)) + put(TOTAL_BYTES, JsonPrimitive(totalBytes)) + put(TOTAL_CHUNKS, JsonPrimitive(totalChunks)) + }, + completionMode = COMPLETION_MODE_RENDER, + digest = digest, + totalBytes = totalBytes, + totalChunks = totalChunks, + ) + + fun accept( + token: ProgressToken, + progress: Double, + ) = OversizedFrame.Accept(envelopeOf(ACCEPT, token, progress)) + + fun chunk( + token: ProgressToken, + progress: Double, + data: String, + ) = OversizedFrame.Chunk( + envelope = envelopeOf(CHUNK, token, progress) { put(DATA, JsonPrimitive(data)) }, + data = data, + ) + + fun end( + token: ProgressToken, + progress: Double, + message: String? = null, + ) = OversizedFrame.End(envelopeOf(END, token, progress, message)) + + fun abort( + token: ProgressToken, + progress: Double, + reason: String? = null, + ) = OversizedFrame.Abort( + envelope = + envelopeOf(ABORT, token, progress) { + reason?.let { put(REASON, JsonPrimitive(it)) } + }, + reason = reason, + ) + + private fun envelopeOf( + frameType: String, + token: ProgressToken, + progress: Double, + message: String? = null, + body: JsonObjectBuilder.() -> Unit = {}, + ) = ProgressEnvelope( + token = token, + progress = progress, + message = message, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OVERSIZED)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(frameType)) + body() + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferReceiver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferReceiver.kt new file mode 100644 index 0000000000..bd7c4b5598 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferReceiver.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Admission-control limits for one receiver. + * + * CEP-22 requires evaluating the declared `totalBytes` and `totalChunks` against + * local policy **before** committing reassembly state, so a peer cannot make us + * allocate by announcing a huge transfer. + */ +data class OversizedLimits( + val maxTotalBytes: Long = 8L * 1024 * 1024, + val maxTotalChunks: Long = 4_096, + /** + * The most chunks one transfer may consist of. + * + * It bounds memory, not a reordering window. Relay delivery can reorder + * and assembly is by `progress`, so every chunk has to be held until + * `end` — which means nothing is ever released early and this is the + * effective ceiling on a whole transfer. Bounded because otherwise a peer + * parks unbounded memory by withholding one low-`progress` chunk. + * + * Enforced at `start` as well as per chunk, so an over-large transfer is + * refused before any bytes move. Keep it at or below [maxTotalChunks], + * which bounds what a peer may *declare*; the larger of the two is + * otherwise decoration. + */ + val maxPendingChunks: Int = 256, +) + +/** Outcome of feeding one frame to [OversizedTransferReceiver]. */ +sealed interface OversizedProgressResult { + /** Frame accepted; the transfer continues. Nothing is surfaced upward yet. */ + data object Continue : OversizedProgressResult + + /** The receiver should send an `accept` frame before chunks may flow. */ + data object SendAccept : OversizedProgressResult + + /** The transfer completed and validated. [message] is the reassembled payload. */ + data class Completed( + val message: JsonRpcMessage, + val raw: String, + ) : OversizedProgressResult + + /** The transfer ended without producing a payload. */ + data class Aborted( + val reason: String?, + ) : OversizedProgressResult +} + +/** Thrown when a transfer violates CEP-22. The transfer is terminal once this is raised. */ +class OversizedTransferException( + message: String, +) : IllegalStateException(message) + +/** + * Receives one CEP-22 bounded transfer, keyed by its `progressToken`. + * + * The rules this enforces are §6.5 `CVM-22-*` in + * `quartz/plans/2026-09-17-cordn-interop.md`. Two are worth stating here because + * they shape the design: + * + * - **Nothing is surfaced upward before validation succeeds.** The reassembled + * string only becomes a message after the chunk count, byte length and digest + * all agree, so a partially delivered payload can never be mistaken for a + * complete one. + * - **The digest covers the exact serialized string.** Fragments are + * concatenated and encoded to UTF-8 once; the payload is never parsed and + * re-serialized on the way, because that would reorder keys or change + * whitespace and break the hash. + * + * @param requireAccept true in stateless bootstrap, where the sender must wait + * for `accept` before the first chunk. When peer support is already known the + * sender may go straight from `start` to `chunk`, and this is false. + */ +class OversizedTransferReceiver( + val token: ProgressToken, + private val limits: OversizedLimits = OversizedLimits(), + private val requireAccept: Boolean = true, +) { + private var started: OversizedFrame.Start? = null + private var accepted = false + private var terminal = false + + /** Fragments keyed by `progress`, so out-of-order arrivals assemble correctly. */ + private val chunks = mutableMapOf() + + val isTerminal get() = terminal + + fun accept(frame: OversizedFrame): OversizedProgressResult { + if (terminal) throw OversizedTransferException("frame received after the transfer ended") + if (frame.token != token) throw OversizedTransferException("frame belongs to another transfer") + + if (frame is OversizedFrame.Abort) { + terminal = true + return OversizedProgressResult.Aborted(frame.reason) + } + + return when (frame) { + is OversizedFrame.Start -> onStart(frame) + is OversizedFrame.Accept -> OversizedProgressResult.Continue + is OversizedFrame.Chunk -> onChunk(frame) + is OversizedFrame.End -> onEnd(frame) + is OversizedFrame.Abort -> error("handled above") + } + } + + private fun onStart(frame: OversizedFrame.Start): OversizedProgressResult { + if (started != null) fail("a second start frame arrived for this transfer") + + // Admission control runs before any state is committed. + if (frame.totalBytes < 0) fail("totalBytes must not be negative") + if (frame.totalChunks < 0) fail("totalChunks must not be negative") + if (frame.totalBytes > limits.maxTotalBytes) { + fail("declared totalBytes ${frame.totalBytes} exceeds the limit ${limits.maxTotalBytes}") + } + if (frame.totalChunks > limits.maxTotalChunks) { + fail("declared totalChunks ${frame.totalChunks} exceeds the limit ${limits.maxTotalChunks}") + } + // Checked here, not only per chunk. Every chunk stays buffered until + // `end` — assembly is by `progress`, and relays reorder, so nothing can + // be released early — which makes [maxPendingChunks] the real ceiling + // on a transfer rather than a window within one. Leaving it to the + // per-chunk check would admit a transfer at `start`, let the sender + // push a few megabytes, and then fail it at chunk 257 every single + // time: the same refusal, paid for. + if (frame.totalChunks > limits.maxPendingChunks) { + fail("declared totalChunks ${frame.totalChunks} exceeds the buffer limit ${limits.maxPendingChunks}") + } + if (!frame.digest.startsWith(OversizedFrame.DIGEST_PREFIX_SHA256)) { + fail("unsupported digest algorithm: ${frame.digest.substringBefore(':')}") + } + + started = frame + return if (requireAccept) OversizedProgressResult.SendAccept else OversizedProgressResult.Continue + } + + private fun onChunk(frame: OversizedFrame.Chunk): OversizedProgressResult { + val start = started ?: fail("chunk arrived before start") + + if (requireAccept && !accepted) { + // In stateless bootstrap the sender must not transmit before the + // receiver confirms. Seeing a chunk first means the peer skipped a + // step the profile requires, so the transfer cannot be trusted. + fail("chunk arrived before accept in a bootstrap transfer") + } + if (chunks.size >= limits.maxPendingChunks) { + fail("buffered chunk count exceeds the limit ${limits.maxPendingChunks}") + } + if (chunks.size.toLong() >= start.totalChunks) { + fail("more chunks arrived than the declared totalChunks ${start.totalChunks}") + } + // `progress` is the assembly index, so a chunk must sort after `start`. + // Arrival order is NOT checked: relays reorder, and the CEP explicitly + // says progress is not a guarantee of arrival order. Out-of-order + // frames are buffered and assembled by progress at `end`. + if (frame.progress <= start.progress) { + fail("chunk progress ${frame.progress} does not follow start at ${start.progress}") + } + if (chunks.put(frame.progress, frame.data) != null) { + fail("duplicate chunk at progress ${frame.progress}") + } + return OversizedProgressResult.Continue + } + + private fun onEnd(frame: OversizedFrame.End): OversizedProgressResult { + val start = started ?: fail("end arrived before start") + + val highestChunk = chunks.keys.maxOrNull() + if (highestChunk != null && frame.progress <= highestChunk) { + fail("end progress ${frame.progress} does not follow the last chunk at $highestChunk") + } + if (frame.progress <= start.progress) { + fail("end progress ${frame.progress} does not follow start at ${start.progress}") + } + + if (chunks.size.toLong() != start.totalChunks) { + fail("expected ${start.totalChunks} chunks but assembled ${chunks.size}") + } + + // `progress` is the canonical assembly index, not arrival order. + val raw = chunks.entries.sortedBy { it.key }.joinToString("") { it.value } + val bytes = raw.encodeToByteArray() + + if (bytes.size.toLong() != start.totalBytes) { + fail("reassembled ${bytes.size} bytes but start declared ${start.totalBytes}") + } + + val actual = OversizedFrame.DIGEST_PREFIX_SHA256 + sha256(bytes).toHexKey() + if (!actual.equals(start.digest, ignoreCase = true)) { + fail("digest mismatch: expected ${start.digest} but reassembled $actual") + } + + terminal = true + return OversizedProgressResult.Completed(JsonRpcCodec.decode(raw), raw) + } + + /** Records that we sent `accept`, unblocking chunk frames. */ + fun markAccepted() { + accepted = true + } + + private fun fail(reason: String): Nothing { + terminal = true + throw OversizedTransferException(reason) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferSender.kt new file mode 100644 index 0000000000..46dd94c964 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferSender.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Splits an oversized serialized JSON-RPC message into CEP-22 frames. + * + * Two subtleties the chunking has to respect: + * + * - **Relay limits apply to the whole serialized event**, not just `content`, + * and roughly 64 KiB is the practical ceiling. [DEFAULT_CHUNK_CHARS] leaves + * generous room for the JSON-RPC envelope, the event's tags and signature, + * and any gift wrap around it. + * - **A chunk boundary must not split a surrogate pair.** Fragments are + * concatenated as text and only then encoded to UTF-8 for the digest, so + * cutting a pair in half would corrupt the reassembled bytes even though each + * fragment still looks like a valid JSON string. + */ +class OversizedTransferSender( + private val chunkChars: Int = DEFAULT_CHUNK_CHARS, +) { + init { + require(chunkChars > 1) { "chunkChars must leave room for a surrogate pair" } + } + + /** + * Frames [serialized] for [token], starting at `progress` 1. + * + * The returned list is `start`, then the chunks, then `end`. When the peer's + * support is not yet known the caller must withhold everything after `start` + * until the peer's `accept` arrives; the frames themselves are unchanged. + */ + fun frame( + token: ProgressToken, + serialized: String, + ): List { + val pieces = split(serialized) + val bytes = serialized.encodeToByteArray() + val digest = OversizedFrame.DIGEST_PREFIX_SHA256 + sha256(bytes).toHexKey() + + var progress = 1.0 + val frames = mutableListOf() + frames += + OversizedFrame.start( + token = token, + progress = progress, + digest = digest, + totalBytes = bytes.size.toLong(), + totalChunks = pieces.size.toLong(), + ) + + pieces.forEach { piece -> + progress += 1.0 + frames += OversizedFrame.chunk(token, progress, piece) + } + + frames += OversizedFrame.end(token, progress + 1.0) + return frames + } + + /** True when [serialized] is large enough to be worth fragmenting. */ + fun shouldFragment(serialized: String) = serialized.length > chunkChars + + private fun split(text: String): List { + if (text.isEmpty()) return emptyList() + + val pieces = mutableListOf() + var index = 0 + while (index < text.length) { + var end = minOf(index + chunkChars, text.length) + // Never cut between a high and low surrogate: the two halves would + // each survive JSON encoding but rejoin into a different codepoint. + if (end < text.length && text[end - 1].isHighSurrogate()) end -= 1 + pieces += text.substring(index, end) + index = end + } + return pieces + } + + companion object { + /** + * Conservative fragment size in UTF-16 characters. + * + * Well below the ~64 KiB practical relay ceiling because the fragment is + * only part of what ships: it is wrapped in a progress notification, then + * an event with tags and a signature, then possibly a gift wrap. + */ + const val DEFAULT_CHUNK_CHARS = 16 * 1024 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep24Reviews/ServerReview.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep24Reviews/ServerReview.kt new file mode 100644 index 0000000000..239b4ce5e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep24Reviews/ServerReview.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep24Reviews + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** CEP-24 server reviews: NIP-22 comments anchored to a kind-11316 announcement. */ +object ServerReview { + /** The addressable coordinate a review targets. */ + fun coordinate(serverPubKey: HexKey) = "${CvmKinds.SERVER_ANNOUNCEMENT}:$serverPubKey:" + + /** + * Tags for a top-level review. + * + * NIP-22 uses uppercase tags for the root and lowercase for the immediate + * parent; for a top-level comment both are the announcement, hence the + * apparent duplication. + */ + fun topLevelTags( + serverPubKey: HexKey, + relayHint: String? = null, + announcementEventId: HexKey? = null, + ): List { + val coordinate = coordinate(serverPubKey) + val kind = CvmKinds.SERVER_ANNOUNCEMENT.toString() + return buildList { + add(tagOf("A", coordinate, relayHint)) + add(arrayOf("K", kind)) + add(tagOf("P", serverPubKey, relayHint)) + add(tagOf("a", coordinate, relayHint)) + announcementEventId?.let { add(arrayOf("e", it, relayHint ?: "", serverPubKey)) } + add(arrayOf("k", kind)) + add(tagOf("p", serverPubKey, relayHint)) + } + } + + /** + * Tags for a reply to an existing review. + * + * The uppercase root stays on the announcement while the lowercase parent + * moves to the comment being answered — that asymmetry is the whole point of + * NIP-22's dual tagging and is easy to get wrong. + */ + fun replyTags( + serverPubKey: HexKey, + parentCommentId: HexKey, + parentAuthor: HexKey, + relayHint: String? = null, + ): List = + buildList { + add(tagOf("A", coordinate(serverPubKey), relayHint)) + add(arrayOf("K", CvmKinds.SERVER_ANNOUNCEMENT.toString())) + add(tagOf("P", serverPubKey, relayHint)) + add(arrayOf("e", parentCommentId, relayHint ?: "", parentAuthor)) + add(arrayOf("k", CvmKinds.REVIEW.toString())) + add(tagOf("p", parentAuthor, relayHint)) + } + + private fun tagOf( + name: String, + value: String, + relayHint: String?, + ): Tag = if (relayHint != null) arrayOf(name, value, relayHint) else arrayOf(name, value) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt new file mode 100644 index 0000000000..65157e496c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep35Discovery + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * Per-session capability learning (CEP-35). + * + * Discovery is a first-message exchange in each direction, not an + * initialize-only step: a server-to-client message that is not an initialize + * response may still carry the server's baseline. After that exchange, later + * feature tags are message-local and do not mutate the baseline unless a + * feature-specific CEP says they do — CEP-8's `payment_interaction` upsert being + * the one that does. + */ +class SessionDiscovery { + private var baseline: DiscoverySurface? = null + + /** The peer's learned baseline, or null before their first message. */ + val peer get() = baseline + + val hasLearned get() = baseline != null + + /** + * The baseline, but only when the peer actually declared something. + * + * Null both before the peer's first message and when that message carried + * no discovery tags — two different facts a caller cannot act on + * differently, because both mean "this peer has told us nothing". Negotiate + * off this, not off [peer]: see [DiscoverySurface.declaresNothing] for the + * live case that makes the difference. + */ + val declaredPeer get() = baseline?.takeIf { !it.declaresNothing } + + /** + * Applies a peer message's tags. + * + * The first one establishes the baseline; later ones are returned for + * message-local interpretation but leave the baseline alone. + */ + fun observe(tags: Array): DiscoverySurface { + val surface = DiscoverySurface.parse(tags) + if (baseline == null) baseline = surface + return surface + } + + /** Replaces the baseline outright. For a feature CEP that defines an update. */ + fun replaceBaseline(tags: Array) { + baseline = DiscoverySurface.parse(tags) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamFrame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamFrame.kt new file mode 100644 index 0000000000..ebfcdf552b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamFrame.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep41OpenStreams + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asLongOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asStringOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * A CEP-41 open-ended stream frame. + * + * The profile carries **two** independent ordering fields and conflating them is + * the classic implementation bug: + * + * - `progress` (on the envelope) orders *all* frames, control frames included, + * and is explicitly not a chunk counter. Each peer numbers its own outbound + * frames from 1, so a value may only be compared against frames from the same + * sender. + * - [Chunk.chunkIndex] starts at 0, increases contiguously, and is what + * validates payload completeness. + */ +sealed interface OpenStreamFrame { + val envelope: ProgressEnvelope + val token: ProgressToken get() = envelope.token + val progress: Double get() = envelope.progress + + data class Start( + override val envelope: ProgressEnvelope, + ) : OpenStreamFrame + + data class Accept( + override val envelope: ProgressEnvelope, + ) : OpenStreamFrame + + data class Chunk( + override val envelope: ProgressEnvelope, + val data: String, + val chunkIndex: Long, + ) : OpenStreamFrame + + data class Ping( + override val envelope: ProgressEnvelope, + val nonce: String, + ) : OpenStreamFrame + + data class Pong( + override val envelope: ProgressEnvelope, + val nonce: String, + ) : OpenStreamFrame + + /** + * Successful closure. + * + * [lastChunkIndex] is present only when the sender is declaring a + * completeness bound; a live, open-ended feed omits it, and a stream that + * carried no chunks MUST omit it. + */ + data class Close( + override val envelope: ProgressEnvelope, + val lastChunkIndex: Long? = null, + ) : OpenStreamFrame + + data class Abort( + override val envelope: ProgressEnvelope, + val reason: String? = null, + ) : OpenStreamFrame + + companion object { + const val START = "start" + const val ACCEPT = "accept" + const val CHUNK = "chunk" + const val PING = "ping" + const val PONG = "pong" + const val CLOSE = "close" + const val ABORT = "abort" + + const val DATA = "data" + const val CHUNK_INDEX = "chunkIndex" + const val NONCE = "nonce" + const val LAST_CHUNK_INDEX = "lastChunkIndex" + const val REASON = "reason" + + /** Receivers SHOULD enforce this ceiling and MAY reject oversized nonces. */ + const val MAX_NONCE_BYTES = 64 + + /** Parses a CEP-41 frame, or null when the envelope is a different profile. */ + fun parseOrNull(envelope: ProgressEnvelope): OpenStreamFrame? { + if (envelope.type != ProgressEnvelope.TYPE_OPEN_STREAM) return null + val cvm = envelope.cvm + + return when (val frameType = envelope.frameType) { + START -> Start(envelope) + ACCEPT -> Accept(envelope) + + CHUNK -> + Chunk( + envelope = envelope, + data = + cvm[DATA]?.asStringOrNull() + ?: throw TransferFrameException("chunk requires string data"), + chunkIndex = + cvm[CHUNK_INDEX]?.asLongOrNull() + ?: throw TransferFrameException("chunk requires chunkIndex"), + ) + + PING -> Ping(envelope, requireNonce(cvm[NONCE]?.asStringOrNull(), PING)) + PONG -> Pong(envelope, requireNonce(cvm[NONCE]?.asStringOrNull(), PONG)) + + CLOSE -> Close(envelope, cvm[LAST_CHUNK_INDEX]?.asLongOrNull()) + + ABORT -> Abort(envelope, cvm[REASON]?.asStringOrNull()) + + else -> throw TransferFrameException("unknown open-stream frameType: $frameType") + } + } + + private fun requireNonce( + nonce: String?, + frameType: String, + ): String { + if (nonce == null) throw TransferFrameException("$frameType requires a nonce") + if (nonce.encodeToByteArray().size > MAX_NONCE_BYTES) { + throw TransferFrameException("$frameType nonce exceeds $MAX_NONCE_BYTES bytes") + } + return nonce + } + + fun start( + token: ProgressToken, + progress: Double, + ) = Start(envelopeOf(START, token, progress)) + + fun accept( + token: ProgressToken, + progress: Double, + ) = Accept(envelopeOf(ACCEPT, token, progress)) + + fun chunk( + token: ProgressToken, + progress: Double, + chunkIndex: Long, + data: String, + ) = Chunk( + envelope = + envelopeOf(CHUNK, token, progress) { + put(CHUNK_INDEX, JsonPrimitive(chunkIndex)) + put(DATA, JsonPrimitive(data)) + }, + data = data, + chunkIndex = chunkIndex, + ) + + fun ping( + token: ProgressToken, + progress: Double, + nonce: String, + ) = Ping( + envelope = envelopeOf(PING, token, progress) { put(NONCE, JsonPrimitive(nonce)) }, + nonce = nonce, + ) + + fun pong( + token: ProgressToken, + progress: Double, + nonce: String, + ) = Pong( + envelope = envelopeOf(PONG, token, progress) { put(NONCE, JsonPrimitive(nonce)) }, + nonce = nonce, + ) + + fun close( + token: ProgressToken, + progress: Double, + lastChunkIndex: Long? = null, + ) = Close( + envelope = + envelopeOf(CLOSE, token, progress) { + lastChunkIndex?.let { put(LAST_CHUNK_INDEX, JsonPrimitive(it)) } + }, + lastChunkIndex = lastChunkIndex, + ) + + fun abort( + token: ProgressToken, + progress: Double, + reason: String? = null, + ) = Abort( + envelope = + envelopeOf(ABORT, token, progress) { + reason?.let { put(REASON, JsonPrimitive(it)) } + }, + reason = reason, + ) + + private fun envelopeOf( + frameType: String, + token: ProgressToken, + progress: Double, + body: JsonObjectBuilder.() -> Unit = {}, + ) = ProgressEnvelope( + token = token, + progress = progress, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(frameType)) + body() + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamReceiver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamReceiver.kt new file mode 100644 index 0000000000..251a11ce30 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamReceiver.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep41OpenStreams + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken + +/** Local resource and keepalive policy for one stream. */ +data class OpenStreamPolicy( + /** + * Idle time before the peer must be probed with a `ping`. + * + * 30s matches what deployed clients use. A shorter window (the 20s some + * SDKs default to) turns a single lost relay round-trip into a stream abort. + */ + val idleTimeoutMs: Long = 30_000, + /** How long a `ping` may go unanswered before the stream is failed. */ + val probeTimeoutMs: Long = 30_000, + /** Bounded buffering for chunks that arrive before their predecessors. */ + val maxPendingChunks: Int = 256, +) + +/** Outcome of feeding one inbound frame to [OpenStreamReceiver]. */ +sealed interface OpenStreamEvent { + /** Frame accepted; nothing to deliver or answer. */ + data object Continue : OpenStreamEvent + + /** The receiver must send an `accept` before the peer may send chunks. */ + data object SendAccept : OpenStreamEvent + + /** Payload fragments that became contiguous, in `chunkIndex` order. */ + data class Delivered( + val fragments: List, + ) : OpenStreamEvent + + /** + * The peer probed us and MUST receive a `pong` carrying the same nonce. + * + * The response rides our own outbound progress sequence, which is why the + * caller supplies the progress value rather than this class. + */ + data class Pong( + val nonce: String, + ) : OpenStreamEvent + + /** + * The peer closed the stream. + * + * This does **not** complete the originating JSON-RPC request: CEP-41 + * requires a final response as well, and a client must never synthesize + * success from `close` alone. + */ + data class Closed( + val lastChunkIndex: Long?, + ) : OpenStreamEvent + + data class Aborted( + val reason: String?, + ) : OpenStreamEvent +} + +/** Thrown when a stream violates CEP-41. The stream is terminal once this is raised. */ +class OpenStreamException( + message: String, +) : IllegalStateException(message) + +/** + * Receives one CEP-41 open-ended stream, keyed by its `progressToken`. + * + * Rules are §6.5 `CVM-41-*` in `quartz/plans/2026-09-17-cordn-interop.md`. + * + * @param requireAccept true in stateless bootstrap, where the peer must wait for + * our `accept` before its first chunk. + * @param now injectable clock (epoch millis) so keepalive is testable without + * real time passing. + */ +class OpenStreamReceiver( + val token: ProgressToken, + private val policy: OpenStreamPolicy = OpenStreamPolicy(), + private val requireAccept: Boolean = true, + private val now: () -> Long = { 0L }, +) { + private var started = false + private var startProgress: Double? = null + private var accepted = false + private var terminal = false + + /** Every inbound `progress` seen, to reject replays without assuming arrival order. */ + private val seenProgress = mutableSetOf() + + private val pending = mutableMapOf() + private var nextIndex = 0L + private var highestIndex: Long? = null + + /** Nonces of pings we sent that are still awaiting a pong, with their send time. */ + private val outstandingPings = mutableMapOf() + + // Starts now, not at zero. Against a real clock `now() - 0` is thirty-odd + // years, so a receiver that had not yet seen a single frame reported + // itself idle and due a probe the instant it was built. + private var lastActivityMs = now() + + val isTerminal get() = terminal + + fun accept(frame: OpenStreamFrame): OpenStreamEvent { + if (terminal) throw OpenStreamException("frame received after the stream terminated") + if (frame.token != token) throw OpenStreamException("frame belongs to another stream") + + lastActivityMs = now() + + if (frame is OpenStreamFrame.Abort) { + terminal = true + return OpenStreamEvent.Aborted(frame.reason) + } + + // A repeated progress value from the same peer is a replay or a bug. + // Arrival *order* is deliberately not checked -- relays reorder, and the + // CEP makes chunkIndex, not progress, the completeness test. + if (!seenProgress.add(frame.progress)) { + fail("duplicate progress ${frame.progress} from the peer") + } + + return when (frame) { + is OpenStreamFrame.Start -> onStart(frame) + is OpenStreamFrame.Accept -> OpenStreamEvent.Continue + is OpenStreamFrame.Chunk -> onChunk(frame) + is OpenStreamFrame.Ping -> OpenStreamEvent.Pong(frame.nonce) + is OpenStreamFrame.Pong -> onPong(frame) + is OpenStreamFrame.Close -> onClose(frame) + is OpenStreamFrame.Abort -> error("handled above") + } + } + + private fun onStart(frame: OpenStreamFrame.Start): OpenStreamEvent { + // A second start for a live token MUST fail the stream. + if (started) fail("a second start arrived for a live stream") + started = true + startProgress = frame.progress + return if (requireAccept) OpenStreamEvent.SendAccept else OpenStreamEvent.Continue + } + + private fun onChunk(frame: OpenStreamFrame.Chunk): OpenStreamEvent { + if (!started) fail("chunk arrived before start") + if (requireAccept && !accepted) fail("chunk arrived before accept in a bootstrap stream") + + val start = startProgress + if (start != null && frame.progress <= start) { + fail("chunk progress ${frame.progress} does not follow start at $start") + } + if (frame.chunkIndex < nextIndex) { + fail("chunkIndex ${frame.chunkIndex} was already delivered") + } + if (pending.containsKey(frame.chunkIndex)) { + fail("duplicate chunkIndex ${frame.chunkIndex}") + } + if (pending.size >= policy.maxPendingChunks) { + fail("buffered chunk count exceeds the limit ${policy.maxPendingChunks}") + } + + pending[frame.chunkIndex] = frame.data + highestIndex = maxOf(highestIndex ?: frame.chunkIndex, frame.chunkIndex) + + // Release the contiguous run. A gap is not an error while the stream is + // live -- it is a provisional gap the peer may still fill. + val ready = mutableListOf() + while (true) { + val next = pending.remove(nextIndex) ?: break + ready += next + nextIndex += 1 + } + + return if (ready.isEmpty()) OpenStreamEvent.Continue else OpenStreamEvent.Delivered(ready) + } + + private fun onPong(frame: OpenStreamFrame.Pong): OpenStreamEvent { + // A pong for a nonce we never sent, or already retired, is not evidence + // of liveness. It is ignored rather than fatal: the CEP allows local + // anti-abuse policy, and failing the stream would let a third party + // disrupt it by replaying a stale pong. + outstandingPings.remove(frame.nonce) + return OpenStreamEvent.Continue + } + + private fun onClose(frame: OpenStreamFrame.Close): OpenStreamEvent { + if (!started) fail("close arrived before start") + + val bound = frame.lastChunkIndex + if (bound != null) { + if (highestIndex == null) { + // "If the stream included no chunk frames, close.lastChunkIndex + // MUST be omitted." + fail("close declared lastChunkIndex $bound but the stream carried no chunks") + } + if (bound != (nextIndex - 1)) { + fail("close declared lastChunkIndex $bound but ${nextIndex - 1} is the last contiguous index") + } + if (pending.isNotEmpty()) { + fail("close declared a completeness bound while ${pending.size} chunks remain unresolved") + } + } + + terminal = true + return OpenStreamEvent.Closed(bound) + } + + // --- keepalive --- + + /** True when the idle timeout has elapsed and the peer must be probed. */ + fun needsProbe(atMs: Long = now()) = !terminal && outstandingPings.isEmpty() && atMs - lastActivityMs >= policy.idleTimeoutMs + + /** Records that we sent a `ping` with [nonce], starting its probe window. */ + fun markProbeSent( + nonce: String, + atMs: Long = now(), + ) { + require(nonce.encodeToByteArray().size <= OpenStreamFrame.MAX_NONCE_BYTES) { + "nonce exceeds ${OpenStreamFrame.MAX_NONCE_BYTES} bytes" + } + outstandingPings[nonce] = atMs + } + + /** + * True when a probe went unanswered past the probe timeout. + * + * The caller MUST then fail the stream, and SHOULD send `abort` if it still + * can. + */ + fun probeExpired(atMs: Long = now()) = outstandingPings.values.any { atMs - it >= policy.probeTimeoutMs } + + /** Records that we sent `accept`, unblocking the peer's chunk frames. */ + fun markAccepted() { + accepted = true + } + + /** Marks the stream terminal after a local policy failure. */ + fun failLocally(reason: String): Nothing = fail(reason) + + private fun fail(reason: String): Nothing { + terminal = true + throw OpenStreamException(reason) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmKinds.kt new file mode 100644 index 0000000000..d8a7f0764e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmKinds.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral + +/** + * Nostr event kinds used by ContextVM. + * + * Implemented from the ContextVM specification and its CEPs, not from any + * reference SDK source. See `quartz/plans/2026-09-17-cordn-interop.md` §6-§7 + * for the sourcing rule and the revision this targets. + */ +object CvmKinds { + /** + * The single kind carrying every ContextVM message. `content` is the + * stringified MCP JSON-RPC message; addressing and correlation live in tags. + * + * This kind is **ephemeral**, so relays are not expected to retain it. A + * client must already be subscribed when the peer publishes, because there + * is no fetch-after-the-fact recovery (rule `CVM-CORE-06`). + */ + const val MESSAGE: Kind = 25910 + + /** + * NIP-59 gift wrap carrying an encrypted [MESSAGE] (CEP-4). + * + * Not ephemeral: relays may retain the encrypted envelope. [EPHEMERAL_GIFT_WRAP] + * exists to avoid that. + */ + const val GIFT_WRAP: Kind = 1059 + + /** + * Ephemeral gift wrap (CEP-19) — identical structure and semantics to + * [GIFT_WRAP], but in NIP-01's ephemeral range so relays do not store the + * envelope either. + */ + const val EPHEMERAL_GIFT_WRAP: Kind = 21059 + + /** Addressable server announcement (CEP-6). `content` is the initialize result. */ + const val SERVER_ANNOUNCEMENT: Kind = 11316 + + /** Addressable `tools/list` announcement (CEP-6). */ + const val TOOLS_LIST: Kind = 11317 + + /** Addressable `resources/list` announcement (CEP-6). */ + const val RESOURCES_LIST: Kind = 11318 + + /** Addressable `resources/templates/list` announcement (CEP-6). */ + const val RESOURCE_TEMPLATES_LIST: Kind = 11319 + + /** Addressable `prompts/list` announcement (CEP-6). */ + const val PROMPTS_LIST: Kind = 11320 + + /** NIP-65 relay list metadata, reused by CEP-17 for server reachability. */ + const val RELAY_LIST: Kind = 10002 + + /** NIP-01 profile metadata, optionally published by servers (CEP-23). */ + const val PROFILE_METADATA: Kind = 0 + + /** NIP-22 comment, reused by CEP-24 for server reviews. */ + const val REVIEW: Kind = 1111 + + /** The announcement kinds a client subscribes to when discovering a server (CEP-6). */ + val ANNOUNCEMENTS = + intArrayOf( + SERVER_ANNOUNCEMENT, + TOOLS_LIST, + RESOURCES_LIST, + RESOURCE_TEMPLATES_LIST, + PROMPTS_LIST, + ) + + /** + * Both gift wrap kinds. A client subscribes to both regardless of which it + * sends: CEP-19 requires falling back to [GIFT_WRAP] for peers that do not + * advertise ephemeral support, so either may arrive. + */ + val GIFT_WRAPS = intArrayOf(GIFT_WRAP, EPHEMERAL_GIFT_WRAP) + + fun isGiftWrap(kind: Kind) = kind == GIFT_WRAP || kind == EPHEMERAL_GIFT_WRAP + + /** + * True when relays are not expected to retain this kind. + * + * [MESSAGE] and [EPHEMERAL_GIFT_WRAP] are ephemeral; [GIFT_WRAP] is not, + * which is the whole reason CEP-19 exists. + */ + fun isTransient(kind: Kind) = kind.isEphemeral() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEvent.kt new file mode 100644 index 0000000000..181bc55cac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEvent.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A typed view over a ContextVM message event: kind 25910, carrying a + * stringified MCP JSON-RPC message in `content`. + * + * This wraps an [Event] rather than extending it. Quartz mints event subclasses + * through its own kind-to-class factory, which knows nothing about 25910, so an + * event signed or parsed anywhere is always a plain [Event] — a subclass would + * only ever exist where we constructed one by hand, and claiming a signer could + * return one is simply false. + * + * The ContextVM layering is deliberately thin: the MCP message is preserved + * byte-for-byte and only addressing and correlation move into tags, `p` for the + * peer and `e` for the request a response answers. + * + * `content` is a **JSON string**, not an embedded JSON object. The spec's + * examples show it unstringified for readability, which is an easy trap; rule + * `CVM-CORE-02` asserts the stringified form. + * + * This kind is ephemeral, so a response can only be received by a subscription + * that was already live when the peer published it (`CVM-CORE-06`). + */ +class CvmMessageEvent( + val event: Event, +) { + val id: HexKey get() = event.id + val pubKey: HexKey get() = event.pubKey + val createdAt: Long get() = event.createdAt + val tags: TagArray get() = event.tags + val content: String get() = event.content + + /** The peer this message is addressed to, or null when untagged. */ + fun recipient(): HexKey? = event.tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The request event id this message answers, or null when it is not a response. */ + fun inReplyTo(): HexKey? = event.tags.firstNotNullOfOrNull(ETag::parseId) + + /** + * The JSON-RPC message in `content`. + * + * @throws com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFormatException when + * `content` is not a well-formed JSON-RPC 2.0 message. + */ + fun message(): JsonRpcMessage = JsonRpcCodec.decode(event.content) + + /** + * The discovery tags this message carries, per CEP-35. + * + * Routing tags are excluded; everything else is preserved, including tags we + * do not understand, because CEP-35 makes forward compatibility the default. + */ + fun discoveryTags(): List = event.tags.filter { it.isNotEmpty() && !CvmTags.isRouting(it[0]) } + + companion object { + const val KIND = CvmKinds.MESSAGE + + /** Wraps [event] when it is a ContextVM message, or returns null. */ + fun fromOrNull(event: Event) = if (event.kind == KIND) CvmMessageEvent(event) else null + + /** + * Template for a message addressed to [recipient], optionally answering + * [inReplyTo]. + * + * [extraTags] carries this side's CEP-35 discovery tags on the first + * direct message of a session; omit them afterwards. + */ + fun build( + message: JsonRpcMessage, + recipient: HexKey, + inReplyTo: HexKey? = null, + extraTags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, JsonRpcCodec.encode(message), createdAt) { + addAll(assembleTags(recipient, inReplyTo, extraTags)) + } + + /** + * Signs a message for [recipient]. + * + * Returns a plain [Event] because that is what the signer produces; wrap + * it with [fromOrNull] when a typed view is wanted. + */ + suspend fun create( + message: JsonRpcMessage, + recipient: HexKey, + signer: NostrSigner, + inReplyTo: HexKey? = null, + extraTags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ): Event = + signer.sign( + createdAt, + KIND, + assembleTags(recipient, inReplyTo, extraTags), + JsonRpcCodec.encode(message), + ) + + private fun assembleTags( + recipient: HexKey, + inReplyTo: HexKey?, + extraTags: List, + ): TagArray = + buildList { + add(PTag.assemble(recipient, relayHint = null)) + inReplyTo?.let { add(ETag.assemble(it, relay = null, author = null)) } + addAll(extraTags) + }.toTypedArray() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmTags.kt new file mode 100644 index 0000000000..1d95e36d82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmTags.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag + +/** + * ContextVM tag vocabulary. + * + * Addressing (`p`) and correlation (`e`) reuse the NIP-01 tags quartz already + * models — [PTag] and [ETag] — because ContextVM assigns them their ordinary + * NIP-01 meanings. Only the ContextVM-specific names are defined here. + */ +object CvmTags { + /** Recipient public key. Same meaning as NIP-01; parse with [PTag]. */ + const val PUBKEY = PTag.TAG_NAME + + /** Correlates a response to its request event. Same meaning as NIP-01; parse with [ETag]. */ + const val EVENT_ID = ETag.TAG_NAME + + /** Relay URL hint (CEP-17 reuses the NIP-65 `r` tag). */ + const val RELAY = "r" + + // --- CEP-6 server identity metadata --- + + const val NAME = "name" + const val ABOUT = "about" + const val PICTURE = "picture" + const val WEBSITE = "website" + + // --- Transport capability advertisement --- + + /** CEP-4: presence alone indicates the peer supports encrypted messages. */ + const val SUPPORT_ENCRYPTION = "support_encryption" + + /** CEP-19: presence indicates the peer accepts kind 21059 gift wraps. */ + const val SUPPORT_ENCRYPTION_EPHEMERAL = "support_encryption_ephemeral" + + /** CEP-22: presence indicates support for bounded oversized payload transfer. */ + const val SUPPORT_OVERSIZED_TRANSFER = "support_oversized_transfer" + + /** CEP-41: presence indicates support for open-ended streams. */ + const val SUPPORT_OPEN_STREAM = "support_open_stream" + + // --- CEP-8 pricing and payment --- + + /** `["cap", "", "", ""]`. */ + const val CAPABILITY_PRICE = "cap" + + /** `["pmi", ""]`. */ + const val PAYMENT_METHOD = "pmi" + + /** `["payment_interaction", "transparent"|"explicit_gating"]`. */ + const val PAYMENT_INTERACTION = "payment_interaction" + + /** `["direct_payment", "", ""]` — bearer-asset optimization. */ + const val DIRECT_PAYMENT = "direct_payment" + + /** `["change", "", ""]` — overpayment remainder. */ + const val CHANGE = "change" + + // --- CEP-15 common tool schemas (NIP-73 external identity tags) --- + + /** `["i", "", ""]`. */ + const val EXTERNAL_ID = "i" + + /** `["k", "io.contextvm/common-schema"]`. */ + const val EXTERNAL_KIND = "k" + + /** The NIP-73 kind value CEP-15 uses in its [EXTERNAL_KIND] tag. */ + const val COMMON_SCHEMA_NAMESPACE = "io.contextvm/common-schema" + + /** + * Tags that carry routing rather than discovery information. + * + * CEP-35 requires unknown discovery tags to be preserved, but says routing + * tags SHOULD be excluded from the learned discovery surface. Keeping the + * exclusion set explicit (rather than an allow-list of known discovery tags) + * is what makes forward compatibility work: a tag we have never heard of is + * preserved by default instead of dropped. + */ + val ROUTING = setOf(PUBKEY, EVENT_ID) + + fun isRouting(tagName: String) = tagName in ROUTING + + /** A valueless capability tag, e.g. `["support_encryption"]`. */ + fun flag(name: String): Tag = arrayOf(name) + + /** + * True when [tags] contains the capability flag [name]. + * + * A flag is signalled by presence, so a tag carrying extra elements still + * counts — the spec defines meaning by the tag name, not by arity. + */ + fun hasFlag( + tags: Array, + name: String, + ) = tags.any { it.isNotEmpty() && it[0] == name } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/fixture/CvmFixtureServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/fixture/CvmFixtureServer.kt new file mode 100644 index 0000000000..742181de5b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/fixture/CvmFixtureServer.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.fixture + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import com.vitorpamplona.quartz.contextvm.transport.CvmRelayPool +import com.vitorpamplona.quartz.contextvm.transport.CvmSubscription +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * An in-memory relay that delivers to whoever is subscribed at publish time. + * + * Its most important property is a faithful one: an event published while + * nobody is subscribed is **dropped**, exactly as a real relay treats an + * ephemeral kind. A test double that queued it would hide the subscribe-before- + * publish bug this whole layer is designed to prevent. + */ +class InMemoryRelayPool : CvmRelayPool { + private data class Listener( + val pubKey: HexKey, + val kinds: Set, + val onEvent: (Event) -> Unit, + ) + + private val listeners = mutableListOf() + + /** Every event published, in order. For assertions about what went on the wire. */ + val published = mutableListOf() + + /** Events dropped because nothing was listening for them. */ + val dropped = mutableListOf() + + override fun subscribe( + pubKey: HexKey, + kinds: IntArray, + onEvent: (Event) -> Unit, + ): CvmSubscription { + val listener = Listener(pubKey, kinds.toSet(), onEvent) + listeners += listener + return object : CvmSubscription { + override fun close() { + listeners -= listener + } + } + } + + override suspend fun publish(event: Event) { + published += event + + val recipients = event.tags.filter { it.size >= 2 && it[0] == "p" }.map { it[1] } + val matched = + listeners.filter { listener -> + listener.kinds.contains(event.kind) && recipients.contains(listener.pubKey) + } + + if (matched.isEmpty()) dropped += event + matched.forEach { it.onEvent(event) } + } +} + +/** + * How the fixture should misbehave. + * + * This is the reason the fixture exists. No real server sends a duplicate + * `progress`, a stale `pong` nonce or a digest that does not match, yet a client + * MUST handle all of them correctly — several are outright MUST-fail rules. The + * only way to test that is a counterparty that can be told to break them. + */ +data class FixtureFaults( + /** Answer with a JSON-RPC id that does not match the request. */ + val mismatchedResponseId: Boolean = false, + /** Omit the `e` tag that correlates the response to the request event. */ + val omitCorrelationTag: Boolean = false, + /** Answer a different request event id entirely. */ + val wrongCorrelationTag: Boolean = false, + /** Send a response body that is not valid JSON-RPC. */ + val malformedResponse: Boolean = false, + /** Never answer at all. */ + val silent: Boolean = false, + /** Send this many junk notifications before the real response. */ + val noisePrefix: Int = 0, +) + +/** + * One call as a [CvmFixtureServer] handler sees it. + * + * [id] is here because a handler MUST echo it: the client correlates on it, so + * a fixture that answered every call with a constant works for the first call + * of a session and silently hangs on the second. That is a bug the fixture + * should surface in the code under test, not create — it created one once. + * + * [event] is the signed kind-25910 event that carried the call, after any gift + * wrap was removed. A real coordinator has this too, and cordn's depends on it: + * `spec/00.md` §7 has no KeyPackage event kind, so the "signed publication + * payload" a `kp_take` must serve back **is this event**, retained verbatim. + * A fixture that could not see it could not model `kp_publish` at all. + */ +data class CvmRequest( + val method: String, + val params: JsonObject?, + val id: JsonRpcId, + val event: Event, +) + +/** + * A ContextVM server that plays the peer role in tests (Tier C). + * + * Not hardened for deployment and deliberately so: for a real coordinator, + * `cordn-rs` already exists. This exists to be wrong on demand. + */ +class CvmFixtureServer( + private val relays: InMemoryRelayPool, + private val signer: NostrSigner, + private val crypto: CvmGiftWrap = CvmGiftWrap(), + private val faults: FixtureFaults = FixtureFaults(), + /** CEP-16: inject the caller's pubkey into `_meta` before handling. */ + private val injectClientPubkey: Boolean = false, + /** Discovery tags sent on the first direct message back, per CEP-35. */ + private val discoveryTags: List = emptyList(), + /** Answers a request. See [CvmRequest] for what a handler is given. */ + private val handler: suspend (CvmRequest) -> JsonRpcMessage, +) { + private var subscription: CvmSubscription? = null + private var sentFirstMessage = false + + /** Params as the handler saw them, for asserting CEP-16 injection. */ + val handledParams = mutableListOf() + + /** Starts listening. Call before the client publishes anything. */ + fun start(): CvmSubscription { + val sub = + relays.subscribe( + pubKey = signer.pubKey, + kinds = intArrayOf(CvmKinds.MESSAGE) + CvmKinds.GIFT_WRAPS, + onEvent = { event -> pending += event }, + ) + subscription = sub + return sub + } + + /** Events received but not yet answered. Drained by [pump]. */ + private val pending = mutableListOf() + + /** + * Handles everything received so far. + * + * Explicit rather than automatic because the relay callback cannot suspend, + * and because a test usually wants to control when the answer appears. + */ + suspend fun pump() { + val batch = pending.toList() + pending.clear() + batch.forEach { handle(it) } + } + + private suspend fun handle(event: Event) { + val plain = + if (CvmKinds.isGiftWrap(event.kind)) { + try { + crypto.unwrap(event, signer) + } catch (e: IllegalStateException) { + return + } + } else { + event + } + + val message = CvmMessageEvent.fromOrNull(plain) ?: return + val request = message.message() as? JsonRpcRequest ?: return + + if (faults.silent) return + + val clientPubKey = plain.pubKey + val params = if (injectClientPubkey) inject(request.params, clientPubKey) else request.params + handledParams += params + + repeat(faults.noisePrefix) { index -> + reply( + JsonRpcNotification("notifications/message", buildJsonObject { put("seq", JsonPrimitive(index)) }), + clientPubKey, + plain.id, + ) + } + + val response = handler(CvmRequest(request.method, params, request.id, plain)) + reply(response, clientPubKey, plain.id) + } + + /** Sends [message] back to [clientPubKey], answering [requestEventId]. */ + suspend fun reply( + message: JsonRpcMessage, + clientPubKey: HexKey, + requestEventId: HexKey, + ) { + val correlation = + when { + faults.omitCorrelationTag -> null + faults.wrongCorrelationTag -> "f".repeat(64) + else -> requestEventId + } + + val tags = if (sentFirstMessage) emptyList() else discoveryTags + sentFirstMessage = true + + val content = + if (faults.malformedResponse) { + MALFORMED + } else { + com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec + .encode(faultInjected(message)) + } + + val inner = + signer.sign( + createdAt = + com.vitorpamplona.quartz.utils.TimeUtils + .now(), + kind = CvmKinds.MESSAGE, + tags = + buildList { + add(arrayOf("p", clientPubKey)) + correlation?.let { add(arrayOf("e", it)) } + addAll(tags) + }.toTypedArray(), + content = content, + ) + + relays.publish( + if (crypto.shouldEncrypt(peerSupportsEncryption = true)) { + crypto.wrap(inner, clientPubKey) + } else { + inner + }, + ) + } + + private fun faultInjected(message: JsonRpcMessage): JsonRpcMessage = + if (faults.mismatchedResponseId && message is com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess) { + message.copy( + id = + com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId + .Num(999_999), + ) + } else { + message + } + + private fun inject( + params: JsonObject?, + clientPubKey: HexKey, + ): JsonObject = + buildJsonObject { + params?.forEach { (key, value) -> if (key != McpParams.META) put(key, value) } + put( + McpParams.META, + buildJsonObject { + (params?.get(McpParams.META) as? JsonObject)?.forEach { (key, value) -> put(key, value) } + // The client never supplies this: a client-supplied value + // would be a spoof, which is exactly why CEP-16 has the + // server derive it from the event signature. + put(McpParams.CLIENT_PUBKEY, JsonPrimitive(clientPubKey)) + }, + ) + } + + fun stop() { + subscription?.close() + subscription = null + } + + private companion object { + const val MALFORMED = """{"not":"json-rpc"}""" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/json/PlainJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/json/PlainJson.kt new file mode 100644 index 0000000000..459b9fd4d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/json/PlainJson.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.json + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Converts a kotlinx JSON tree into the plain Kotlin types + * `JsonCanonicalization` works on. + * + * The canonicalizer deliberately takes plain types so it stays usable from any + * module regardless of serializer, and this is the bridge for our side. + * + * Numbers: an integral value becomes a [Long] and anything else a [Double]. + * Either way the canonicalizer renders it through the same ECMAScript path, so + * the distinction does not change the output — it just avoids widening large + * integers through [Double] any earlier than JCS already does. + */ +fun JsonElement.toPlainJson(): Any? = + when (this) { + is JsonNull -> null + is JsonObject -> mapValues { (_, value) -> value.toPlainJson() } + is JsonArray -> map { it.toPlainJson() } + is JsonPrimitive -> { + if (isString) { + content + } else { + booleanOrNull + ?: longOrNull + ?: doubleOrNull + ?: throw IllegalArgumentException("unsupported JSON primitive: $content") + } + } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodec.kt new file mode 100644 index 0000000000..a8ae3282a1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodec.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.jsonrpc + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * Encodes and decodes the JSON-RPC 2.0 messages carried in a ContextVM event's + * `content`. + * + * Decoding is deliberately strict. ContextVM's only framing is "the content is a + * JSON-RPC message", so a malformed payload has to be rejected here or it becomes + * a confusing failure several layers up. Every rejection below is a rule in + * `quartz/plans/2026-09-17-cordn-interop.md` §6.5 (`CVM-CORE-*`) and has a test. + */ +object JsonRpcCodec { + private const val JSONRPC = "jsonrpc" + private const val ID = "id" + private const val METHOD = "method" + private const val PARAMS = "params" + private const val RESULT = "result" + private const val ERROR = "error" + private const val CODE = "code" + private const val MESSAGE = "message" + private const val DATA = "data" + + /** + * Lenient only about *unknown* members: MCP grows fields, and CEP-35 tells us + * to preserve what we do not understand rather than fail. The structural + * checks in [decode] are not relaxed. + */ + private val json = Json { ignoreUnknownKeys = true } + + fun encode(message: JsonRpcMessage): String = json.encodeToString(JsonObject.serializer(), toJsonObject(message)) + + fun toJsonObject(message: JsonRpcMessage): JsonObject = + buildJsonObject { + put(JSONRPC, JsonPrimitive(JsonRpcMessage.VERSION)) + when (message) { + is JsonRpcRequest -> { + put(ID, message.id.toPrimitive()) + put(METHOD, JsonPrimitive(message.method)) + message.params?.let { put(PARAMS, it) } + } + + is JsonRpcNotification -> { + put(METHOD, JsonPrimitive(message.method)) + message.params?.let { put(PARAMS, it) } + } + + is JsonRpcSuccess -> { + put(ID, message.id.toPrimitive()) + put(RESULT, message.result) + } + + is JsonRpcFailure -> { + put(ID, message.id?.toPrimitive() ?: JsonPrimitive(null as String?)) + put( + ERROR, + buildJsonObject { + put(CODE, JsonPrimitive(message.error.code)) + put(MESSAGE, JsonPrimitive(message.error.message)) + message.error.data?.let { put(DATA, it) } + }, + ) + } + } + } + + fun decode(text: String): JsonRpcMessage { + val root = + try { + json.parseToJsonElement(text) + } catch (e: IllegalArgumentException) { + throw JsonRpcFormatException("content is not valid JSON: ${e.message}") + } + + if (root !is JsonObject) throw JsonRpcFormatException("JSON-RPC message must be an object") + return decode(root) + } + + fun decode(root: JsonObject): JsonRpcMessage { + val version = root[JSONRPC]?.asStringOrNull() + if (version != JsonRpcMessage.VERSION) { + throw JsonRpcFormatException("unsupported jsonrpc version: $version") + } + + val hasMethod = root.containsKey(METHOD) + val hasResult = root.containsKey(RESULT) + val hasError = root.containsKey(ERROR) + + // A response is exactly one of result or error. Carrying both is + // ambiguous about whether the call succeeded, so it cannot be repaired + // by preferring one -- reject it. + if (hasResult && hasError) { + throw JsonRpcFormatException("response carries both result and error") + } + if (hasMethod && (hasResult || hasError)) { + throw JsonRpcFormatException("message carries both a method and a response body") + } + + // `id` may legitimately be JSON null on a failure, so distinguish + // "absent" (a notification) from "present but null". + val idElement = root[ID] + val id = if (idElement == null || idElement.isJsonNull()) null else parseId(idElement) + + return when { + hasMethod -> { + val method = + root[METHOD]?.asStringOrNull() + ?: throw JsonRpcFormatException("method must be a string") + val params = root[PARAMS]?.let { requireObject(it, PARAMS) } + if (id == null) { + JsonRpcNotification(method, params) + } else { + JsonRpcRequest(id, method, params) + } + } + + hasResult -> { + if (id == null) throw JsonRpcFormatException("success response requires an id") + JsonRpcSuccess(id, root.getValue(RESULT)) + } + + hasError -> JsonRpcFailure(id, parseError(root.getValue(ERROR))) + + else -> throw JsonRpcFormatException("message has no method, result or error") + } + } + + private fun parseError(element: JsonElement): JsonRpcError { + val obj = requireObject(element, ERROR) + val code = + obj[CODE]?.jsonPrimitive?.longOrNull + ?: throw JsonRpcFormatException("error.code must be a number") + val message = + obj[MESSAGE]?.asStringOrNull() + ?: throw JsonRpcFormatException("error.message must be a string") + return JsonRpcError(code.toInt(), message, obj[DATA]) + } + + private fun parseId(element: JsonElement): JsonRpcId { + val primitive = + (element as? JsonPrimitive) + ?: throw JsonRpcFormatException("id must be a string or a number") + + if (primitive.isString) return JsonRpcId.Text(primitive.content) + + return primitive.longOrNull?.let { JsonRpcId.Num(it) } + ?: throw JsonRpcFormatException("id must be a string or an integral number") + } + + private fun requireObject( + element: JsonElement, + field: String, + ): JsonObject = + element as? JsonObject + ?: throw JsonRpcFormatException("$field must be an object") + + private fun JsonRpcId.toPrimitive() = + when (this) { + is JsonRpcId.Num -> JsonPrimitive(value) + is JsonRpcId.Text -> JsonPrimitive(value) + } + + private fun JsonElement.isJsonNull() = this is JsonPrimitive && !isString && content == "null" + + private fun JsonElement.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcMessage.kt new file mode 100644 index 0000000000..56031e7b2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcMessage.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.jsonrpc + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * A JSON-RPC 2.0 message, as carried in a ContextVM event's `content`. + * + * ContextVM transports MCP messages unmodified, so this models JSON-RPC itself + * rather than any MCP-specific shape: `params` and `result` stay as a JSON tree + * and pass through untouched. MCP semantics live a layer up. + */ +sealed interface JsonRpcMessage { + companion object { + /** The only version ContextVM carries. Decoding rejects anything else. */ + const val VERSION = "2.0" + } +} + +/** + * A JSON-RPC message id. + * + * JSON-RPC allows a string or a number, and MCP implementations use both — the + * TypeScript SDK numbers its requests while others use strings. Modelling the + * distinction (rather than normalising to String) keeps decode/encode a faithful + * round-trip, which rule `CVM-CORE-01` asserts. + * + * It also matters for CEP-8: the canonical invocation identity deliberately + * excludes the id, so a retry may legitimately change both its type and value + * and still match a paid authorization. + */ +sealed interface JsonRpcId { + data class Num( + val value: Long, + ) : JsonRpcId + + data class Text( + val value: String, + ) : JsonRpcId +} + +/** A call expecting exactly one matching [JsonRpcSuccess] or [JsonRpcFailure]. */ +data class JsonRpcRequest( + val id: JsonRpcId, + val method: String, + val params: JsonObject? = null, +) : JsonRpcMessage + +/** + * A one-way message with no id and no response. + * + * CEP-22 and CEP-41 both ride `notifications/progress` notifications, so this is + * the carrier for every transfer frame as well as for ordinary MCP notifications. + */ +data class JsonRpcNotification( + val method: String, + val params: JsonObject? = null, +) : JsonRpcMessage + +/** A successful response. `result` is opaque to this layer. */ +data class JsonRpcSuccess( + val id: JsonRpcId, + val result: JsonElement, +) : JsonRpcMessage + +/** + * An error response. + * + * [id] is nullable because JSON-RPC permits a null id when the request could not + * be parsed well enough to recover one. + */ +data class JsonRpcFailure( + val id: JsonRpcId?, + val error: JsonRpcError, +) : JsonRpcMessage + +data class JsonRpcError( + val code: Int, + val message: String, + val data: JsonElement? = null, +) { + companion object { + // JSON-RPC 2.0 reserved codes. + const val PARSE_ERROR = -32700 + const val INVALID_REQUEST = -32600 + const val METHOD_NOT_FOUND = -32601 + const val INVALID_PARAMS = -32602 + const val INTERNAL_ERROR = -32603 + + /** CEP-8 `explicit_gating`: payment is required before the call will run. */ + const val PAYMENT_REQUIRED = -32042 + + /** CEP-8 `explicit_gating`: payment is in flight but not yet verified. */ + const val PAYMENT_PENDING = -32043 + } +} + +/** Thrown when a payload is not a well-formed JSON-RPC 2.0 message. */ +class JsonRpcFormatException( + message: String, +) : IllegalArgumentException(message) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClient.kt new file mode 100644 index 0000000000..7f21cd7f32 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClient.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.mcp + +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedFrame +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedLimits +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedProgressResult +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedTransferReceiver +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamEvent +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamFrame +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamPolicy +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamReceiver +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.contextvm.transport.TimeoutMode +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** A tool call's outcome, with anything a transfer profile delivered alongside it. */ +data class ToolCallResult( + val result: JsonElement?, + val error: com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError? = null, + /** Fragments a CEP-41 stream delivered while the call was in flight. */ + val streamed: List = emptyList(), + /** + * Whether the response arrived reassembled over CEP-22 rather than in the + * single event that carried the rest. + * + * A caller does not need this to read the result - that is the point of the + * profile. It is here so a live interop run can assert the profile actually + * fired, instead of passing whether or not the server ever chunked. + */ + val viaOversizedTransfer: Boolean = false, +) { + val isError get() = error != null +} + +/** + * A minimal MCP client over ContextVM. + * + * Implements the surface the CEPs define — lifecycle, tool listing and calling — + * rather than all of MCP. Sampling, roots and elicitation are deliberately out + * of scope; nothing in ContextVM or its CEPs needs them, and a partial version + * would be worse than none. + * + * Transfer profiles are handled here because they are request-scoped: a call + * carries a `progressToken`, and CEP-22 or CEP-41 frames for that token arrive + * as notifications while the call is open. + */ +class CvmMcpClient( + private val transport: CvmTransport, + private val clientName: String = "amethyst-contextvm", + private val clientVersion: String = "0.1.0", + private val oversizedLimits: OversizedLimits = OversizedLimits(), + private val streamPolicy: OpenStreamPolicy = OpenStreamPolicy(), +) { + private var nextId = 0L + + /** + * Performs the MCP handshake. + * + * Optional per the spec — servers may operate statelessly — but it is the + * natural place to exchange CEP-35 discovery tags, so a client that can + * afford the round trip should do it. + */ + suspend fun initialize( + capabilityTags: List = transport.selfDiscoveryTags(), + protocolVersion: String = PROTOCOL_VERSION, + ): JsonRpcMessage { + val response = + transport.request( + message = + JsonRpcRequest( + id = nextId(), + method = McpMethods.INITIALIZE, + params = + buildJsonObject { + put("protocolVersion", JsonPrimitive(protocolVersion)) + put("capabilities", buildJsonObject {}) + put( + "clientInfo", + buildJsonObject { + put("name", JsonPrimitive(clientName)) + put("version", JsonPrimitive(clientVersion)) + }, + ) + }, + ), + discoveryTags = capabilityTags, + ) + + // The server is only allowed to assume readiness after this. + transport.notify(JsonRpcNotification(McpMethods.INITIALIZED)) + return response + } + + suspend fun listTools(cursor: String? = null): JsonRpcMessage = + transport.request( + JsonRpcRequest( + id = nextId(), + method = McpMethods.TOOLS_LIST, + params = cursor?.let { buildJsonObject { put("cursor", JsonPrimitive(it)) } }, + ), + ) + + /** + * Calls a tool. + * + * A `progressToken` is always attached: without it a server MUST NOT start + * either transfer profile, so omitting it would silently cap every response + * at one relay event. + */ + suspend fun callTool( + name: String, + arguments: JsonObject = buildJsonObject {}, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + timeoutMs: Long = CvmTransport.DEFAULT_TIMEOUT_MS, + timeoutMode: TimeoutMode = TimeoutMode.IDLE, + onStreamFragment: (String) -> Unit = {}, + ): ToolCallResult { + val id = nextId() + val token = ProgressToken.Text("call-${id.value}") + + var oversized: OversizedTransferReceiver? = null + var stream: OpenStreamReceiver? = null + var reassembled: JsonRpcMessage? = null + val streamed = mutableListOf() + + val response = + try { + transport.request( + message = + JsonRpcRequest( + id = id, + method = McpMethods.TOOLS_CALL, + params = + buildJsonObject { + put(McpParams.NAME, JsonPrimitive(name)) + put(McpParams.ARGUMENTS, arguments) + put( + McpParams.META, + buildJsonObject { + put(McpParams.PROGRESS_TOKEN, JsonPrimitive("call-${id.value}")) + }, + ) + }, + ), + identity = identity, + timeoutMs = timeoutMs, + timeoutMode = timeoutMode, + ) { notification -> + val envelope = ProgressEnvelope.parseOrNull(notification) ?: return@request null + if (envelope.token != token) return@request null + + when (envelope.type) { + ProgressEnvelope.TYPE_OVERSIZED -> { + val receiver = + oversized ?: OversizedTransferReceiver(token, oversizedLimits, requireAccept = false) + .also { oversized = it } + OversizedFrame.parseOrNull(envelope)?.let { frame -> + val result = receiver.accept(frame) + if (result is OversizedProgressResult.Completed) { + reassembled = result.message + oversizedTransfersCompleted++ + } + } + } + + ProgressEnvelope.TYPE_OPEN_STREAM -> { + val receiver = + stream ?: OpenStreamReceiver(token, streamPolicy, requireAccept = false) + .also { stream = it } + OpenStreamFrame.parseOrNull(envelope)?.let { frame -> + val event = receiver.accept(frame) + if (event is OpenStreamEvent.Delivered) { + streamed += event.fragments + event.fragments.forEach(onStreamFragment) + } + } + } + + else -> Unit + } + + // A completed CEP-22 transfer IS the response, so handing it + // back ends the call. A CEP-41 stream never does: `close` says + // no more frames, not that the request is answered. + reassembled + } + } catch (e: TimeoutCancellationException) { + // Under TOTAL the timeout IS how the call ends. CEP-41 says a + // stream's `close` does not complete the JSON-RPC request, so + // an open-ended subscription has no other exit - treating the + // budget running out as a failure meant every subscription, + // however healthy, ended in an exception. + // + // Fragments were handed to onStreamFragment as they arrived, + // so nothing is lost by returning here. + if (timeoutMode == TimeoutMode.TOTAL) null else throw e + } + + // A CEP-22 transfer replaces the response that could not be published + // directly; a CEP-41 stream does not, since `close` never completes the + // JSON-RPC request. + val chunked = reassembled != null + // No response and no transfer: a subscription that ran its budget. + val effective = reassembled ?: response ?: return ToolCallResult(null, streamed = streamed) + return when (effective) { + is JsonRpcSuccess -> ToolCallResult(effective.result, streamed = streamed, viaOversizedTransfer = chunked) + is JsonRpcFailure -> ToolCallResult(null, effective.error, streamed, chunked) + else -> ToolCallResult(null, streamed = streamed, viaOversizedTransfer = chunked) + } + } + + private fun nextId(): JsonRpcId.Num = JsonRpcId.Num(nextId++) + + /** + * How many responses this client has reassembled over CEP-22. + * + * Diagnostics, not control flow. Nothing decides anything on it; it exists + * so a live run can tell a server that chunked from one that never had to. + */ + var oversizedTransfersCompleted: Int = 0 + private set + + companion object { + /** The MCP revision the ContextVM spec's examples use. */ + const val PROTOCOL_VERSION = "2025-07-02" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/McpMethods.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/McpMethods.kt new file mode 100644 index 0000000000..ead0f8ab2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/McpMethods.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.mcp + +/** + * The MCP methods ContextVM carries. + * + * ContextVM is a transport: these names are MCP's, not ContextVM's, and travel + * unmodified inside the event `content`. + */ +object McpMethods { + const val INITIALIZE = "initialize" + const val INITIALIZED = "notifications/initialized" + const val PING = "ping" + + const val TOOLS_LIST = "tools/list" + const val TOOLS_CALL = "tools/call" + const val RESOURCES_LIST = "resources/list" + const val RESOURCE_TEMPLATES_LIST = "resources/templates/list" + const val PROMPTS_LIST = "prompts/list" + + /** + * The envelope CEP-22 and CEP-41 both ride. + * + * Their frames are ordinary MCP progress notifications with an extra `cvm` + * object in `params`; a peer that does not understand ContextVM transfer + * profiles still sees valid MCP. + */ + const val PROGRESS = "notifications/progress" + + /** CEP-8 transparent lifecycle notifications. */ + const val PAYMENT_REQUIRED = "notifications/payment_required" + const val PAYMENT_ACCEPTED = "notifications/payment_accepted" + const val PAYMENT_REJECTED = "notifications/payment_rejected" +} + +/** + * Well-known member names inside MCP `params`. + * + * `_meta` matters beyond convenience: CEP-8 excludes it from the canonical + * invocation identity (because MCP regenerates `progressToken` on every call) + * while still requiring it to reach the handler at execution time. + */ +object McpParams { + const val META = "_meta" + const val PROGRESS_TOKEN = "progressToken" + const val NAME = "name" + const val ARGUMENTS = "arguments" + + /** CEP-16: the caller identity a server transport injects into `_meta`. */ + const val CLIENT_PUBKEY = "clientPubkey" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transfer/ProgressEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transfer/ProgressEnvelope.kt new file mode 100644 index 0000000000..e4866a12f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transfer/ProgressEnvelope.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transfer + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.mcp.McpMethods +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * An MCP progress token: the transfer/stream identifier for CEP-22 and CEP-41. + * + * MCP allows a string or a number. The distinction is kept rather than + * normalised for the same reason as `JsonRpcId`: a faithful round trip. + */ +sealed interface ProgressToken { + data class Num( + val value: Long, + ) : ProgressToken + + data class Text( + val value: String, + ) : ProgressToken +} + +/** Thrown when a `notifications/progress` payload is not a usable transfer frame. */ +class TransferFrameException( + message: String, +) : IllegalArgumentException(message) + +/** + * The `notifications/progress` envelope shared by CEP-22 and CEP-41. + * + * MCP owns `progressToken`, `progress`, `total` and `message`; ContextVM adds + * the `cvm` object carrying the frame. `total` and `message` are UX hints and + * explicitly do not define transfer correctness, so nothing below reads them + * for control flow. + */ +data class ProgressEnvelope( + val token: ProgressToken, + val progress: Double, + val total: Double? = null, + val message: String? = null, + val cvm: JsonObject, +) { + val type: String? get() = cvm[TYPE]?.asStringOrNull() + val frameType: String? get() = cvm[FRAME_TYPE]?.asStringOrNull() + + fun toNotification(): JsonRpcNotification = + JsonRpcNotification( + McpMethods.PROGRESS, + buildJsonObject { + put(McpParams.PROGRESS_TOKEN, token.toPrimitive()) + put(PROGRESS, JsonPrimitive(progress)) + total?.let { put(TOTAL, JsonPrimitive(it)) } + message?.let { put(MESSAGE, JsonPrimitive(it)) } + put(CVM, cvm) + }, + ) + + companion object { + const val PROGRESS = "progress" + const val TOTAL = "total" + const val MESSAGE = "message" + const val CVM = "cvm" + const val TYPE = "type" + const val FRAME_TYPE = "frameType" + + /** CEP-22's `cvm.type`. */ + const val TYPE_OVERSIZED = "oversized-transfer" + + /** CEP-41's `cvm.type`. */ + const val TYPE_OPEN_STREAM = "open-stream" + + /** + * Reads the envelope out of a notification, or returns null when this is + * an ordinary MCP progress notification rather than a ContextVM frame. + * + * Returning null rather than throwing is deliberate: a peer may send + * plain MCP progress for the same request, and that is not an error. + */ + fun parseOrNull(notification: JsonRpcNotification): ProgressEnvelope? { + if (notification.method != McpMethods.PROGRESS) return null + val params = notification.params ?: return null + val cvm = params[CVM] as? JsonObject ?: return null + + val token = params[McpParams.PROGRESS_TOKEN]?.let { parseToken(it) } ?: return null + val progress = + (params[PROGRESS] as? JsonPrimitive)?.doubleOrNull + ?: throw TransferFrameException("progress must be a number") + + return ProgressEnvelope( + token = token, + progress = progress, + total = (params[TOTAL] as? JsonPrimitive)?.doubleOrNull, + message = params[MESSAGE]?.asStringOrNull(), + cvm = cvm, + ) + } + + private fun parseToken(element: kotlinx.serialization.json.JsonElement): ProgressToken? { + val primitive = element as? JsonPrimitive ?: return null + if (primitive.isString) return ProgressToken.Text(primitive.content) + return primitive.longOrNull?.let { ProgressToken.Num(it) } + } + + internal fun ProgressToken.toPrimitive() = + when (this) { + is ProgressToken.Num -> JsonPrimitive(value) + is ProgressToken.Text -> JsonPrimitive(value) + } + + internal fun kotlinx.serialization.json.JsonElement.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } + + internal fun kotlinx.serialization.json.JsonElement.asLongOrNull(): Long? = (this as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmRelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmRelayPool.kt new file mode 100644 index 0000000000..afd2a809b3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmRelayPool.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transport + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** A live subscription. Closing it stops delivery. */ +interface CvmSubscription { + fun close() +} + +/** + * The relay surface ContextVM needs. + * + * Deliberately tiny and transport-agnostic: everything above it is pure + * protocol, and the Tier C fixture implements this same interface to play a + * misbehaving peer without any network. The production binding wraps quartz's + * relay client. + */ +interface CvmRelayPool { + /** + * Subscribes to events addressed to [pubKey] (`#p`) of the given [kinds]. + * + * Delivery starts when this returns. Because kind 25910 is ephemeral, a + * subscription opened after a peer published has missed the event + * permanently — [CvmTransport] is built so callers cannot make that mistake. + */ + fun subscribe( + pubKey: HexKey, + kinds: IntArray, + onEvent: (Event) -> Unit, + ): CvmSubscription + + /** Publishes [event] to the configured relays. */ + suspend fun publish(event: Event) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt new file mode 100644 index 0000000000..cfda426607 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transport + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep35Discovery.SessionDiscovery +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout + +/** + * The two identities a ContextVM client uses. + * + * Splitting them is a privacy measure, not plumbing: the stable identity signs + * only what must be attributable, while everything else rides a throwaway key so + * a server cannot link a session's activity to an account. Making the choice an + * explicit parameter means a caller cannot leak the stable one by omission. + */ +class DualSigner( + /** The account identity. Used only where a call must be attributable. */ + val stable: NostrSigner, + /** A per-session throwaway. Used for everything else. */ + val ephemeral: NostrSigner, +) { + enum class Identity { + STABLE, + EPHEMERAL, + } + + fun signerFor(identity: Identity) = + when (identity) { + Identity.STABLE -> stable + Identity.EPHEMERAL -> ephemeral + } +} + +/** What a call's `timeoutMs` bounds. */ +enum class TimeoutMode { + /** + * Silence. The clock restarts on every message the peer sends, so a + * response still being delivered is never cut off. + * + * The right default for a request/response call: both transfer profiles + * deliver one logical response as a run of notifications, each its own + * signed, wrapped, published relay event, so how long a response takes is + * a function of its size and not something a caller can predict. + */ + IDLE, + + /** + * The whole call, from publish to return. + * + * For an open-ended subscription, where the timeout is not a failure but + * the budget after which the caller re-opens - a busy stream under [IDLE] + * would simply never come back. + */ + TOTAL, +} + +/** Thrown when a request cannot be completed at the transport layer. */ +class CvmTransportException( + message: String, +) : IllegalStateException(message) + +/** + * Correlates ContextVM requests with their responses over a [CvmRelayPool]. + * + * [request] subscribes before it publishes, always. Kind 25910 is ephemeral, so + * a subscription opened afterwards has missed the response permanently and the + * failure looks exactly like a flaky relay. Making the ordering the transport's + * job rather than the caller's removes the whole class of bug, which is why + * there is no public publish/subscribe pair to get wrong. + * + * Inbound notifications that are not responses — CEP-22/41 frames, CEP-8 payment + * notifications — are handed to `onNotification` while the request is still in + * flight. A notification never resolves a request: CEP-41 is explicit that a + * stream's `close` does not complete it. + */ +class CvmTransport( + private val relays: CvmRelayPool, + private val signers: DualSigner, + private val serverPubKey: HexKey, + private val crypto: CvmGiftWrap = CvmGiftWrap(), + private val discovery: SessionDiscovery = SessionDiscovery(), + /** + * What to assume about the peer **until it declares otherwise**. + * + * Not a fixed answer: once the peer sends a CEP-35 discovery surface, that + * surface wins (see [peerEncrypts]). These are only what to believe before + * the first message arrives, and for a peer that never declares anything. + * + * The default is optimistic on purpose. Assuming a peer cannot encrypt + * would have us send the first request of every session in the clear, which + * is the one message whose exposure we can still avoid. + */ + private val assumePeerSupportsEncryption: Boolean = true, + private val assumePeerSupportsEphemeralWrap: Boolean = true, +) { + /** + * This side's CEP-35 surface: what a peer may use against us. + * + * CEP-35 is symmetric, and the half we were not doing is the expensive one + * to skip. A spec-correct server only chunks a CEP-22 response, or opens a + * CEP-41 stream, for a client that declared it can take one — so declaring + * nothing does not make us conservative, it makes those profiles dead on + * every session and caps every response at one relay event. + * + * Both transfer profiles are unconditional: [CvmMcpClient] reassembles + * CEP-22 and collects CEP-41 fragments on every call, with no flag to turn + * either off. + */ + fun selfDiscoveryTags(): List = + (crypto.receivableWrapTags() + CvmTags.SUPPORT_OVERSIZED_TRANSFER + CvmTags.SUPPORT_OPEN_STREAM) + .map { arrayOf(it) } + + /** The peer's learned discovery baseline, once its first message has arrived. */ + val peer get() = discovery.peer + + /** + * Whether to encrypt to this peer, by what it has actually told us. + * + * A declared surface wins; silence leaves the assumption in place. That + * split is what makes [com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode.REQUIRED] + * mean something: before this, its input was a constant, so its promise to + * fail loudly rather than downgrade could never fire. Now a peer that + * declares a surface without `support_encryption` gets a stated refusal + * instead of a wrap it cannot open and a request that times out with no + * reason. + */ + private fun peerEncrypts() = discovery.declaredPeer?.supportsEncryption ?: assumePeerSupportsEncryption + + /** As [peerEncrypts], for CEP-19's ephemeral wrap kind. */ + private fun peerTakesEphemeralWrap() = discovery.declaredPeer?.supportsEphemeralEncryption ?: assumePeerSupportsEphemeralWrap + + private var sentFirstMessage = false + + /** + * Sends [message] and waits for the correlated response. + * + * @param identity which key signs the request. Anything not required to be + * attributable should stay [DualSigner.Identity.EPHEMERAL]. + * @param onNotification every notification that arrives while the call is + * open. Returning a message **completes the call with it**, which is how + * CEP-22 finishes: a chunked response replaces the direct one rather than + * preceding it, so nothing else would ever arrive to end the wait. + * Returning null keeps waiting - CEP-41 is explicit that a stream's + * `close` does not complete the request. + * @param discoveryTags this side's CEP-35 baseline, sent on the session's + * first direct message only. Defaults to [selfDiscoveryTags] so the + * surface rides whatever that first message turns out to be - a session + * that opens with a tool call rather than a handshake still declares. + * Pass `emptyList()` to declare nothing. + */ + suspend fun request( + message: JsonRpcRequest, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + timeoutMode: TimeoutMode = TimeoutMode.IDLE, + discoveryTags: List = selfDiscoveryTags(), + onNotification: (JsonRpcNotification) -> JsonRpcMessage? = { null }, + ): JsonRpcMessage { + val signer = signers.signerFor(identity) + val inbound = Channel(Channel.UNLIMITED) + + // Subscribe first, before anything is published. + val subscription = + relays.subscribe( + pubKey = signer.pubKey, + // Both wrap kinds plus the bare message: CEP-19's fallback means + // either wrap may arrive, and an unencrypted peer sends 25910. + kinds = intArrayOf(CvmKinds.MESSAGE) + CvmKinds.GIFT_WRAPS, + onEvent = { inbound.trySend(it) }, + ) + + try { + // CEP-35: the baseline rides the first direct message only; after + // that both sides omit repeated common discovery tags. + val tags = if (sentFirstMessage) emptyList() else discoveryTags + val request = CvmMessageEvent.create(message, serverPubKey, signer, extraTags = tags) + sentFirstMessage = true + + relays.publish(outbound(request)) + + return when (timeoutMode) { + TimeoutMode.IDLE -> awaitResponse(inbound, signer, request.id, message.id, timeoutMs, onNotification) + TimeoutMode.TOTAL -> + withTimeout(timeoutMs) { + awaitResponse(inbound, signer, request.id, message.id, Long.MAX_VALUE, onNotification) + } + } + } finally { + subscription.close() + inbound.close() + } + } + + /** Sends a notification. Nothing is awaited, so no subscription is opened. */ + suspend fun notify( + message: JsonRpcNotification, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + ) { + val signer = signers.signerFor(identity) + relays.publish(outbound(CvmMessageEvent.create(message, serverPubKey, signer))) + } + + /** + * Waits for the answer, allowing [idleMs] of **silence** between the peer's + * messages. [TimeoutMode.TOTAL] passes `Long.MAX_VALUE` and wraps the whole + * thing instead. + * + * A flat deadline cannot express what a CEP-22 or CEP-41 response is. Both + * arrive as a run of notifications, each its own signed, wrapped, published + * relay event, so a large response takes as long as it takes: a 100 KB + * oversized transfer from the reference coordinator ran well past the 20 s + * default and was killed mid-run, with the frames arriving and parsing + * correctly right up to the cancellation. + * + * So the clock measures a stalled peer, which is what a caller actually + * wants bounded, and only the peer can reset it - events from anyone else + * are dropped before this point, or a stranger could hold a call open + * indefinitely by publishing noise addressed to us. + */ + private suspend fun awaitResponse( + inbound: Channel, + signer: NostrSigner, + requestEventId: HexKey, + requestId: JsonRpcId, + idleMs: Long, + onNotification: (JsonRpcNotification) -> JsonRpcMessage?, + ): JsonRpcMessage { + while (true) { + // withTimeout, not a null-returning variant, so silence still + // surfaces as the TimeoutCancellationException callers already + // handle - only when the clock starts has changed. + // receiveCatching tells a closed subscription from a silent one. + val event = + withTimeout(idleMs) { inbound.receiveCatching() } + .getOrNull() + ?: throw CvmTransportException("subscription closed before a response arrived") + val plain = decryptOrNull(event, signer) ?: continue + val wrapped = CvmMessageEvent.fromOrNull(plain) ?: continue + if (wrapped.pubKey != serverPubKey) continue + + discovery.observe(wrapped.discoveryTags().toTypedArray()) + + val decoded = + try { + wrapped.message() + } catch (e: IllegalArgumentException) { + // A malformed payload from the peer is not our request's + // answer; keep waiting rather than failing the call on it. + continue + } + + when (decoded) { + is JsonRpcNotification -> onNotification(decoded)?.let { return it } + + // Correlate on both layers: the `e` tag ties the response to our + // request event, and the JSON-RPC id ties it to our call. Either + // alone is weaker -- a peer may omit the tag on a wrap, and ids + // are only unique within a session. + is JsonRpcSuccess -> + if (matches(wrapped, requestEventId, decoded.id, requestId)) return decoded + + is JsonRpcFailure -> + if (decoded.id == null || matches(wrapped, requestEventId, decoded.id, requestId)) return decoded + + else -> Unit + } + } + } + + /** + * Whether [message] is the answer to our call — from the peer we called. + * + * The sender check is the load-bearing one and it is checked first. This + * client subscribes to everything `p`-tagged to its own key, so **anyone** + * on the relay can gift-wrap a well-formed JSON-RPC response to us; the + * wrap's own signature proves only that its throwaway key signed it, and + * `CvmGiftWrap.unwrap` verifies the INNER signature without knowing who + * the inner signer ought to be. Without this, correlation rests on an `e` + * tag a forger simply omits (the check below skips a missing one) and a + * JSON-RPC id that is small and guessable — so a stranger could answer + * `kp_take` with their own KeyPackage, or `msg_fetch_many` with a stream + * of their choosing. + * + * `serverPubKey` is the coordinator's identity and the only thing that + * identifies it (`spec/00.md` §8.5), so it is exactly the right thing to + * compare against. + */ + private fun matches( + message: CvmMessageEvent, + requestEventId: HexKey, + responseId: JsonRpcId, + requestId: JsonRpcId, + ): Boolean { + if (message.pubKey != serverPubKey) return false + val inReplyTo = message.inReplyTo() + if (inReplyTo != null && inReplyTo != requestEventId) return false + return responseId == requestId + } + + private suspend fun decryptOrNull( + event: Event, + signer: NostrSigner, + ): Event? = + if (CvmKinds.isGiftWrap(event.kind)) { + try { + crypto.unwrap(event, signer) + } catch (e: IllegalStateException) { + // Not addressed to us, or forged. Ignoring is correct: a relay + // may deliver wraps we cannot open, and failing the request on + // one would let anyone disrupt a call. + null + } + } else { + event + } + + private suspend fun outbound(inner: Event): Event = + if (crypto.shouldEncrypt(peerEncrypts())) { + crypto.wrap(inner, serverPubKey, crypto.negotiatedWrapKind(peerTakesEphemeralWrap())) + } else { + inner + } + + companion object { + /** Covers signing, the relay round trip and the peer's own work. */ + const val DEFAULT_TIMEOUT_MS = 20_000L + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/README.md new file mode 100644 index 0000000000..03de70fe16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/README.md @@ -0,0 +1,207 @@ +# `cordn/` + +**cordn** — MLS group messaging whose delivery service is an MCP server reached +over Nostr, rather than relays. + +Built from `Cordn-msg/cordn` at commit **`b465df0`**, read on 2026-09-18. +Where the prose and the reference implementation disagree, the implementation +wins, because that is what is on the wire — each such case is noted below and +in the KDoc of the code that implements it. + +## Where it sits + +Beside `marmot/` — the other MLS-over-Nostr binding — and on top of `mls/` (the +RFC 9420 engine) and `contextvm/` (MCP over Nostr). All four are quartz +packages, which is what lets cordn use the engine and the transport directly. + +It briefly lived in a `:cordn` Gradle module. The stated reason was that it +needed `:contextvm` and quartz could not depend on that without a cycle — true, +but only because `:contextvm` had been put outside quartz first, for reasons +that did not survive examination either. Marmot is the same shape and three +times the size and has always been a quartz package. + +## Layout + +Packages follow the spec documents, the way quartz follows NIPs. + +| Package | Spec | What | +| ------- | ---- | ---- | +| `spec00Coordinator/` | `spec/00.md` | The eleven coordinator tools, the identity model, KeyPackage publication and its verification | +| `spec01GroupMetadata/` | `spec/01.md` | `cordn_group_metadata` (`0xC04D`) | +| `spec02Envelopes/` | `spec/02.md` | The unsigned Nostr-shaped application envelope | +| `spec03Payloads/` | `spec/03.md` | The ChaCha20-Poly1305 outer seal | +| `appGroupRef/` | `applications/group-ref.md` | `cordn1…` bech32 TLV references | +| `groups/` | — | The MLS binding: credential encoding, capabilities, `CordnGroupPolicy` | +| `sync/` | `spec/00.md` §4-5 | Cursors, self-echo reconciliation, fetch-then-subscribe | + +## Making a cordn group + +One argument turns an `MlsGroup` into a cordn group — capabilities, extension +registry and payload exporter all come from the policy: + +```kotlin +val group = MlsGroup.create( + identity = CordnCredential.of(myPubKeyHex).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "Design").toExtension()), +) +``` + +## Interoperability + +Tested against **ts-mls**, the implementation cordn's own client runs, using +fixtures generated by it. `interop/CordnLifecycleInteropTest` walks a whole +group lifecycle from the other side of the wire: read their KeyPackage, unseal +their commit, join from their Welcome, agree on the epoch exporter byte for +byte, apply their metadata commit, and read their application message. + +Fixtures come from **Staircase** (, +MIT), an independent Kotlin cordn client that vendors this project's own MLS +engine. See `quartz/src/commonTest/resources/tsmls/README.md`. + +Agreeing on the epoch exporter is the assertion that carries the most: it sits +at the end of the entire key schedule, so a one-bit divergence in the tree, the +transcript hash or any epoch secret produces 32 completely different bytes. + +### The other direction + +Reading their output correctly is only half of it. A client can parse +everything and still emit something nobody accepts — and that failure keeps our +own tests green while every peer silently drops us. So ts-mls also checks +**our** output: + +```bash +quartz/interop/verify-with-ts-mls.sh # needs ../cordn and ../staircase checkouts +``` + +`KotlinArtifactProducerTest` builds a group under `CordnGroupPolicy`, adds a +real ts-mls KeyPackage, sends an application message and commits a metadata +change. Staircase's `verify.ts` then has ts-mls join from our Welcome, read our +metadata, derive the same epoch-1 and epoch-2 exporters, decrypt our message, +check the AAD sender and the envelope id, and apply our commit. Ten checks, +all passing. + +The producer test runs in the ordinary suite; only the ts-mls half needs the +extra checkouts, which is why it is a script rather than a test. + +**Use Node, not bun.** Staircase's own `run.sh` uses bun, and bun's WebCrypto +has no X25519 DHKEM, so ts-mls there cannot open a Welcome at all — not even +one it produced itself. It surfaces as `DecapError: The algorithm is not +supported` deep inside HPKE and looks exactly like a wire-format mismatch. The +script pins `node --experimental-strip-types`. + +## Six things that are easy to get wrong + +1. **A cordn group has no admins in the enforcement sense.** `spec/01.md` §5.3 + makes `admin_pubkeys` *presentation* metadata, and neither the spec nor the + reference coordinator restricts who may commit. Any member can commit + anything MLS permits, including removing others. An empty list means + **egalitarian**, permanently — Marmot reads the same empty set as "bootstrap, + gate still open", which is why no authorization code is shared between them. + A UI presenting a cordn admin list as an access-control boundary would be + lying. + +2. **The credential is 64 ASCII bytes of hex, not 32 raw bytes.** `spec/00.md` + §6 requires "the canonical encoded Nostr public key" and explicitly declines + to say which encoding. The reference fixed it as hex-ASCII; Marmot picked + raw. One KeyPackage cannot satisfy both, and it is a one-line change on + either side today. Still open upstream — plan §4.1. + +3. **A self-echo is confirmation, not work.** A Commit we posted comes back + through the same stream as everyone else's traffic. Feeding it through MLS + again advances the epoch twice, and nothing complains until messages stop + decrypting several epochs later. Matching is on the sealed ciphertext, which + is unique per posting because the nonce is fresh (`spec/03.md` §4). + +4. **The cursor advances past messages you skip.** Including undecryptable + ones. A message sealed under an epoch we never had is unreadable forever, so + a cursor that refused to move past it would stall that group permanently + with no error anywhere. Only a *second* catch-up reveals the bug. + +5. **An application message MUST carry `authenticated_data`.** cordn puts the + sender's account pubkey in MLS `authenticated_data` and **rejects** any + application message that arrives with it empty + (`packages/cli/src/groupSync.ts:247`). The spec never mentions this; it is + only in the reference implementation. Use `CordnApplicationMessage`, not + `MlsGroup.encrypt` directly. It also means the binding costs metadata: the + field is authenticated but not encrypted, so the coordinator — which holds + every ciphertext — can read who sent what. + +6. **Encryption must be pinned.** The ContextVM SDK defaults `encryptionMode` + to `OPTIONAL`, which resolves from negotiated session state, so a coordinator + that simply does not announce `support_encryption` gets plaintext JSON-RPC on + public relays — `gid`s, target pubkeys, KeyPackages and cursors, readable by + any relay operator. `contextvm/`'s `CvmGiftWrap` defaults to `REQUIRED` and + fails closed. Do not undo that. + +## What the coordinator learns + +Content: nothing. Double-sealed, and it cannot tell a Commit from a chat line. + +Metadata is the cost, and the stable/ephemeral split in `CoordinatorMethod` is +how it is managed — the identity is fixed per method, not a parameter, so +`msg_post` cannot accidentally ride your real npub. The split is real but +partial: + +- **Admission is in the clear on both ends.** `join_request_store` names your + real key and the `gid`; `welcome_store` names the target's and `welcome_take` + is called by it. For any group joined through a share link the coordinator + observes real-identity membership directly. Structural, not a bug. +- **The ephemeral key is per session, not per message.** One pseudonym touches + every group you hold on that coordinator, so that `gid` set is a stable + fingerprint linking them. `CoordinatorClient` takes its signers from the + caller precisely so the caller decides how often to rotate. + +Plan §8 has the full analysis. + +## Three spec/implementation divergences found + +1. **`spec/01.md` §3 contradicts itself**: "MLS variable-length vector encoding + conventions" and then `opaque Name<0..2^16-1>`, which are different + encodings. The reference emits a plain uint16 length. We match the + reference, and the test derives the expected bytes by hand from the spec + rather than round-tripping — a round trip agrees with itself whichever one + you picked. +2. **`authenticated_data` is a wire requirement the spec omits.** See item 5 + above. A client built from `spec/02.md` alone produces messages every cordn + peer discards. +3. **KeyPackage publication rides JSON-RPC envelope shape**, not a stable + schema: `spec/00.md` §7's "signed publication payload" is the `kp_publish` + request event, and the KeyPackage is recovered by parsing JSON-RPC out of its + `content`. The reference client already carries a fallback from one rename + (`kp_64 ?? keyPackageBase64`); so do we. Worth proposing upstream that + publication gets its own payload or kind — it would also let a cordn + KeyPackage be published to relays and consumed with no coordinator at all. + +## Testing + +```bash +./gradlew :quartz:jvmTest --tests '*.cordn.*' +./gradlew :quartz:testAndroidHostTest --tests '*.cordn.*' +``` + +Protocol-level tests are in `commonTest`; the ones needing real secp256k1 — +anything driving a coordinator over a live transport, and the ts-mls interop +suite — are in `jvmAndroidTest`, so they run on the JVM and the Android host. + +`fixture/CordnFixtureCoordinator` implements the eleven tools in memory and +**records which identity made each call**, which is the only way to test the +privacy claim: it is about what the coordinator learns, so you have to stand on +its side of the wire and look. + +The group-ref tests pin the three golden strings from the reference's own suite +(`packages/core/src/groupRef.test.ts`), where they are cross-checked against an +independent TLV+bech32 assembly. That makes them a genuine cross-implementation +vector — the plan's Tier D — rather than a record of our own output. + +Still open: + +- **Live integration** against `ghcr.io/cordn-msg/cordn:latest` (Tier B). +- **A ts-mls `ClientState` export.** `verify.ts` has an optional gate that + decodes a Kotlin-exported ts-mls state and sends from it; we write no such + file, so it is skipped. It would need our `MlsGroupState` to re-encode into + ts-mls's layout, which is a real piece of work and only matters for + multi-device — an explicit non-goal. +- **Multi-device** is an explicit non-goal — `spec/applications/multi-device.md` + ships a ts-mls-internal serialization, not an MLS wire format. There is + nothing to implement against. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUpload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUpload.kt new file mode 100644 index 0000000000..bf4db8fb7f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUpload.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Everything a blob host is told about a cordn attachment — which is as close + * to nothing as an upload allows. + * + * A value rather than a call so it can be asserted. Each field below is a + * privacy decision, and every one of them fails *silently* if it regresses: + * the upload still succeeds, the group still sees the file, and the only + * difference is what a server learned. Nothing downstream would notice, so the + * only thing that can notice is a test — hence a descriptor that is computed + * once and forwarded verbatim, rather than seven literal arguments at a call + * site. + * + * | told | withheld | + * | --- | --- | + * | the ciphertext and its length | the plaintext | + * | `sha256(ciphertext)` | `sha256(plaintext)`, which rides in the `imeta` | + * | `application/octet-stream` | the real MIME type | + * | a hex filename | the real filename | + * | who uploaded it | which group it is for, and what it is | + * + * Blossom addresses a blob by the hash of the bytes it stores, so [hash] must + * be the **ciphertext** hash. [CordnMediaTag] carries the **plaintext** hash. + * Two hashes on purpose: the host needs one to name the blob, the group needs + * the other to know it received the file that was sent, and neither can be + * derived from the other. + */ +data class CordnBlobUpload( + /** What the host stores. */ + val bytes: ByteArray, + /** `sha256(bytes)`, hex — how Blossom names the blob. */ + val hash: String, + /** + * The name to upload under. The hex hash, never the real filename: a name + * like `bank-statement.pdf` describes the file to a server that is + * supposed to see opaque bytes. + */ + val baseFileName: String, + /** + * Always [OPAQUE]. The real type is in the sealed `imeta` descriptor; + * declaring `image/jpeg` here would tell the host what kind of file this + * is for no benefit to anybody. + */ + val contentType: String, + /** + * Always null. Alt text is plaintext on a Blossom upload, and an alt + * string describing a private photo *is* the photo's caption, handed to + * the one party that was supposed to see nothing. + */ + val alt: String?, + /** Always null, for the same reason as [alt]. */ + val sensitiveContent: String?, + /** + * Always false — `/upload`, never `/media`. The `/media` endpoint asks the + * server to re-encode, and re-encoding ciphertext destroys it. An account + * with "optimize uploads" turned on would otherwise break every + * attachment, and only the recipient would find out. + */ + val useMediaEndpoint: Boolean, +) { + val length: Long get() = bytes.size.toLong() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CordnBlobUpload) return false + return bytes.contentEquals(other.bytes) && + hash == other.hash && + baseFileName == other.baseFileName && + contentType == other.contentType && + alt == other.alt && + sensitiveContent == other.sensitiveContent && + useMediaEndpoint == other.useMediaEndpoint + } + + override fun hashCode(): Int = 31 * bytes.contentHashCode() + hash.hashCode() + + companion object { + /** What the blob host is told the type is. */ + const val OPAQUE = "application/octet-stream" + + fun of(media: CordnEncryptedMedia): CordnBlobUpload { + val hash = sha256(media.ciphertext).toHexKey() + return CordnBlobUpload( + bytes = media.ciphertext, + hash = hash, + baseFileName = hash, + contentType = OPAQUE, + alt = null, + sensitiveContent = null, + useMediaEndpoint = false, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaCipher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaCipher.kt new file mode 100644 index 0000000000..b73b85ccd4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaCipher.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.utils.ciphers.NostrCipher + +/** + * cordn's encrypted media as a [NostrCipher], so the ordinary media pipeline + * can open it. + * + * Registering one of these against a blob URL in the encryption key cache is + * what lets `EncryptedBlobInterceptor` decrypt the download in flight, which in + * turn lets a cordn attachment go through `ZoomableContentView` like every + * other image, video and voice note in the app. Before this existed the cordn + * chat fetched and decrypted blobs by hand and drew its own `Image`, an "Open + * " button and its own audio player — so the one chat with encrypted + * media was the one chat whose media did not look like the rest of the app. + * + * The same shape as `Mip04Cipher` and `EncryptedMediaV2Cipher`: a sending + * constructor that produces the nonce, and a receiving one that is handed the + * nonce and hash off the `imeta` tag. + */ +class CordnMediaCipher( + /** The group's epoch media key — see [CordnMediaEncryption.mediaKey]. */ + private val mediaKey: ByteArray, + /** Canonical media type: the `m` field, byte-for-byte, because it is in the AAD. */ + val mimeType: String, + /** The `filename` field, byte-for-byte, for the same reason. */ + val filename: String, +) : NostrCipher { + var nonce: ByteArray = ByteArray(0) + private set + + var plaintextHash: ByteArray = ByteArray(0) + private set + + /** + * The RECEIVING side, where the nonce and the plaintext hash come off the + * tag rather than out of [encrypt]. + * + * Without it the object could only decrypt what the same instance had just + * encrypted, which is the sender's case and nobody else's. + */ + constructor( + mediaKey: ByteArray, + attachment: CordnMediaAttachment, + ) : this(mediaKey, attachment.mimeType, attachment.filename) { + nonce = attachment.nonceBytes + plaintextHash = attachment.hashBytes + } + + override fun name(): String = CordnMediaTag.VERSION_V1 + + override fun encrypt(bytesToEncrypt: ByteArray): ByteArray { + val sealed = CordnMediaEncryption.encrypt(bytesToEncrypt, mediaKey, mimeType, filename) + nonce = sealed.nonce + plaintextHash = sealed.plaintextHash + return sealed.ciphertext + } + + override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + CordnMediaEncryption.decrypt( + ciphertext = bytesToDecrypt, + fileKey = mediaKey, + nonce = nonce, + plaintextHash = plaintextHash, + mimeType = mimeType, + filename = filename, + ) + + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryption.kt new file mode 100644 index 0000000000..c3d65ce272 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryption.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * cordn's encrypted-media codec. + * + * ## Not MIP-04, and not a refactor of it + * + * §4.5 of `quartz/plans/2026-09-17-cordn-interop.md` records the divergence. + * The primitives are shared — ChaCha20-Poly1305, NIP-92 `imeta`, Blossom — and + * the codecs are not: + * + * | | Marmot MIP-04 v2 | cordn | + * | --- | --- | --- | + * | file key | `HKDF-Expand(exporter, context)`, per file | `MLS-Exporter("cordn", "encrypted-media", 32)`, per epoch | + * | AAD | `"mip04-v2"‖0‖hash‖0‖mime‖0‖filename` | `mime‖0‖filename‖0‖hash` | + * + * Feeding one's blob to the other produces an authentication failure, which is + * the correct outcome and the reason this is a separate file rather than a + * flag on [com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption]. + * + * ## The key is the epoch exporter, not a per-file random + * + * This file used to mint a random key per file and ship it in the `imeta` tag, + * so that an attachment outlived the epoch it was sent in. It was a reasonable + * trade to want and the wrong one to make unilaterally: it is not what + * `spec/applications/encrypted-media.md` §3.1 specifies, the key it put on the + * wire is one §4 says is "never transmitted", and it omitted the required + * `v cordn-em-v1`. Measured against the live cordn.net client, every Amethyst + * attachment arrived as an empty bubble. + * + * The durability cost is real and is the spec's intent: the key "rotates + * automatically with each MLS epoch advance, inheriting the forward-secrecy + * and post-compromise-security properties of the group". Since + * `exporterSecret` answers only for the epoch the group is on now, media from + * an earlier epoch cannot currently be re-opened. Keeping old media readable + * is a matter of caching the derived key per epoch locally, which changes + * nothing on the wire — unlike the previous approach, which changed the wire + * and broke every other client. + */ +object CordnMediaEncryption { + const val KEY_LENGTH = 32 + const val NONCE_LENGTH = 12 + + private val NULL = byteArrayOf(0x00) + + /** + * The group's media key for its current epoch. + * + * `spec/applications/encrypted-media.md` §3.1 makes this a derivation, not + * a choice: `MLS-Exporter("cordn", "encrypted-media", 32)`. The key + * therefore rotates with every epoch and is never transmitted, which is + * what lets a cordn client that has only the group state open the blob. + * + * Losing older media at an epoch advance is the intended consequence, not + * a defect to design around: the spec says the key "rotates automatically + * with each MLS epoch advance, inheriting the forward-secrecy and + * post-compromise-security properties of the group". An earlier version of + * this file used a fresh random key per file and shipped it in the `imeta` + * tag to avoid that loss. It bought re-readable history at the price of + * both the forward secrecy and interoperability: cordn.net could not open + * a single Amethyst attachment, and rendered each as an empty bubble. + */ + fun mediaKey(group: MlsGroup): ByteArray = CordnGroupPolicy.MEDIA_EXPORTER.let { group.exporterSecret(it.label, it.context, it.length) } + + /** + * Encrypts [plaintext] for a group, binding it to its type and name. + * + * [mimeType] and [filename] are authenticated, not encrypted: a recipient + * who is handed the same bytes under a different name or type gets an + * authentication failure rather than a file that opens as something else. + */ + fun encrypt( + plaintext: ByteArray, + fileKey: ByteArray, + mimeType: String, + filename: String, + ): CordnEncryptedMedia { + require(fileKey.size == KEY_LENGTH) { "a cordn media key is $KEY_LENGTH bytes" } + + val hash = sha256(plaintext) + val nonce = RandomInstance.bytes(NONCE_LENGTH) + val ciphertext = ChaCha20Poly1305.encrypt(plaintext, aad(mimeType, filename, hash), nonce, fileKey) + + return CordnEncryptedMedia( + ciphertext = ciphertext, + nonce = nonce, + plaintextHash = hash, + mimeType = mimeType, + filename = filename, + ) + } + + /** + * Decrypts and then checks the plaintext against [plaintextHash]. + * + * The AEAD tag already proves the ciphertext was not altered, so the hash + * check catches the other thing: a sender whose declared hash does not + * describe what they actually encrypted. That matters because the hash is + * what a recipient would use to recognise or deduplicate a file, and + * because it is the one field the AAD binds that the blob store also sees. + */ + fun decrypt( + ciphertext: ByteArray, + fileKey: ByteArray, + nonce: ByteArray, + plaintextHash: ByteArray, + mimeType: String, + filename: String, + ): ByteArray { + require(fileKey.size == KEY_LENGTH) { "a cordn media key is $KEY_LENGTH bytes" } + require(nonce.size == NONCE_LENGTH) { "a cordn media nonce is $NONCE_LENGTH bytes" } + require(plaintextHash.size == 32) { "a sha256 hash is 32 bytes" } + + val plaintext = ChaCha20Poly1305.decrypt(ciphertext, aad(mimeType, filename, plaintextHash), nonce, fileKey) + check(sha256(plaintext).contentEquals(plaintextHash)) { + "the decrypted file does not match the hash it was sent with" + } + return plaintext + } + + /** + * `mime ‖ 0x00 ‖ filename ‖ 0x00 ‖ sha256(plaintext)`. + * + * The hash is last here and first in MIP-04. There is no reason to prefer + * either; matching cordn is the whole requirement, and a fixed-length + * field at the end means the two variable-length ones are still + * unambiguously separated by their NULs. + */ + private fun aad( + mimeType: String, + filename: String, + plaintextHash: ByteArray, + ): ByteArray { + val mime = mimeType.encodeToByteArray() + val name = filename.encodeToByteArray() + + val out = ByteArray(mime.size + 1 + name.size + 1 + plaintextHash.size) + var at = 0 + mime.copyInto(out, at) + at += mime.size + NULL.copyInto(out, at) + at += 1 + name.copyInto(out, at) + at += name.size + NULL.copyInto(out, at) + at += 1 + plaintextHash.copyInto(out, at) + return out + } +} + +/** One encrypted file, plus everything a recipient needs to open it. */ +data class CordnEncryptedMedia( + val ciphertext: ByteArray, + val nonce: ByteArray, + val plaintextHash: ByteArray, + val mimeType: String, + val filename: String, +) { + // Arrays, so the generated equals/hashCode would compare identity. Written + // out because a data class that silently means the wrong thing is worse + // than one without them. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CordnEncryptedMedia) return false + return ciphertext.contentEquals(other.ciphertext) && + nonce.contentEquals(other.nonce) && + plaintextHash.contentEquals(other.plaintextHash) && + mimeType == other.mimeType && + filename == other.filename + } + + override fun hashCode(): Int { + var result = ciphertext.contentHashCode() + result = 31 * result + nonce.contentHashCode() + result = 31 * result + plaintextHash.contentHashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + filename.hashCode() + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaTag.kt new file mode 100644 index 0000000000..705dfc159d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaTag.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * How a cordn message points at an encrypted file. + * + * A NIP-92 `imeta` tag, which is what every Nostr client already parses, with + * the fields a recipient needs to fetch and open the blob. It rides **inside** + * the MLS-encrypted envelope, so the coordinator and the blob host see none of + * it — the host sees an opaque upload, the coordinator sees a sealed payload. + * + * The hash is of the **plaintext**, not of the blob. That is deliberate: it is + * what the AAD binds ([CordnMediaEncryption]), so a recipient can tell an + * altered file from a re-encoded one, and it is useless to the blob host, + * which only ever holds the ciphertext. + * + * cordn has a `v` field too, and it is required: + * `spec/applications/encrypted-media.md` §4 fixes it at `cordn-em-v1` and says + * a client "MUST reject tags whose `v` field is absent or names an unknown + * version". An earlier version of this file asserted the opposite — that there + * was no cordn equivalent of Marmot's — and omitted it. cordn.net duly + * rejected every Amethyst attachment and drew an empty bubble. + */ +object CordnMediaTag { + const val TAG_NAME = "imeta" + + const val URL = "url" + const val MIME_TYPE = "m" + const val FILENAME = "filename" + const val HASH = "x" + const val NONCE = "n" + + /** + * The encryption version. Required, and only ever [VERSION_V1]. + * + * No key field accompanies it: §3.1 derives the key from the group's + * epoch exporter and §4 states it is "never transmitted, never stored in + * `imeta`". See [CordnMediaEncryption.mediaKey]. + */ + const val VERSION = "v" + + const val VERSION_V1 = "cordn-em-v1" + const val DIMENSIONS = "dim" + const val BLURHASH = "blurhash" + const val THUMBHASH = "thumbhash" + const val ALT = "alt" + + /** + * Amplitudes for the voice-note bars, space-separated floats. + * + * On `MediaRecorder.maxAmplitude`'s own scale (0..32767), which is what the + * rest of the app already puts on the wire for kind-1222 voice notes — see + * `VoiceAnonymizer`, which multiplies back up by 32768 to match. The + * renderer normalises whatever range it is given, so the absolute scale + * only has to be consistent with the app's other waveforms, and is. + * + * NOT in `spec/applications/encrypted-media.md` §5, which lists no such + * field. It is written as one more of the display hints the spec does + * define (`dim`, `blurhash`, `thumbhash`, `alt`) and describes as "passed + * through unchanged" — so a client that does not know it ignores it, and a + * client that does gets the bars the recorder already measured. The + * recorder produces them either way; without somewhere to put them they + * were thrown away at upload and the sender's own message came back + * bar-less. + * + * Kept out of the AAD deliberately: it is a hint about the file, not a + * claim the ciphertext is bound to, and putting it in the AAD would make + * the blob undecryptable to anyone who wrote the floats differently. + */ + const val WAVEFORM = "waveform" + + /** Builds the `imeta` tag for an uploaded [media] at [url]. */ + fun build( + media: CordnEncryptedMedia, + url: String, + dimensions: String? = null, + blurhash: String? = null, + thumbhash: String? = null, + alt: String? = null, + waveform: List? = null, + ): Array = + buildList { + add(TAG_NAME) + add("$URL $url") + add("$MIME_TYPE ${media.mimeType}") + add("$FILENAME ${media.filename}") + add("$HASH ${media.plaintextHash.toHexKey()}") + add("$NONCE ${media.nonce.toHexKey()}") + add("$VERSION $VERSION_V1") + dimensions?.let { add("$DIMENSIONS $it") } + blurhash?.let { add("$BLURHASH $it") } + thumbhash?.let { add("$THUMBHASH $it") } + // A newline in alt would split the tag field, so it is flattened. + alt?.takeIf { it.isNotBlank() }?.let { add("$ALT ${it.replace('\n', ' ')}") } + waveform?.takeIf { it.isNotEmpty() }?.let { add("$WAVEFORM ${it.joinToString(" ")}") } + }.toTypedArray() + + /** + * Reads every attachment in [tags], skipping any that is not usable. + * + * A tag missing a field, or carrying a nonce or hash of the wrong length, + * is dropped rather than returned half-formed: the only thing a caller + * could do with a partial descriptor is attempt a decrypt that must fail, + * and a message with one broken attachment should still show its other + * attachments and its text. + */ + fun parseAll(tags: TagArray): List = + tags.mapNotNull { tag -> + if (tag.getOrNull(0) != TAG_NAME) return@mapNotNull null + + val fields = + tag + .drop(1) + .mapNotNull { field -> + val at = field.indexOf(' ') + if (at <= 0) null else field.substring(0, at) to field.substring(at + 1) + }.toMap() + + val url = fields[URL] ?: return@mapNotNull null + val mime = fields[MIME_TYPE] ?: return@mapNotNull null + val filename = fields[FILENAME] ?: return@mapNotNull null + val hash = fields[HASH]?.takeIf { it.length == HASH_HEX_LENGTH } ?: return@mapNotNull null + val nonce = fields[NONCE]?.takeIf { it.length == NONCE_HEX_LENGTH } ?: return@mapNotNull null + // §4: an absent or unknown version is a rejection, not something to + // guess at — the bytes under it are a format we have not agreed on. + if (fields[VERSION] != VERSION_V1) return@mapNotNull null + + CordnMediaAttachment( + url = url, + mimeType = mime, + filename = filename, + plaintextHash = hash, + nonce = nonce, + dimensions = fields[DIMENSIONS], + blurhash = fields[BLURHASH], + thumbhash = fields[THUMBHASH], + alt = fields[ALT], + // A malformed number makes the whole hint useless rather than + // the message: drop the bars, keep the attachment. + waveform = + fields[WAVEFORM] + ?.split(' ') + ?.mapNotNull { it.toFloatOrNull() } + ?.takeIf { it.isNotEmpty() }, + ) + } + + private const val HASH_HEX_LENGTH = 64 + private const val NONCE_HEX_LENGTH = 24 +} + +/** One encrypted attachment, as a message advertises it. */ +data class CordnMediaAttachment( + val url: String, + val mimeType: String, + val filename: String, + val plaintextHash: String, + val nonce: String, + val dimensions: String? = null, + val blurhash: String? = null, + val thumbhash: String? = null, + val alt: String? = null, + val waveform: List? = null, +) { + val hashBytes: ByteArray get() = plaintextHash.hexToByteArray() + val nonceBytes: ByteArray get() = nonce.hexToByteArray() + + val isImage: Boolean get() = mimeType.startsWith("image/") + val isAudio: Boolean get() = mimeType.startsWith("audio/") +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRef.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRef.kt new file mode 100644 index 0000000000..e5a6978156 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRef.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appGroupRef + +import com.vitorpamplona.quartz.cordn.tlv.CordnStrictTlv +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * A `cordn1…` group reference — `spec/applications/group-ref.md`. + * + * One checksummed string carrying the three coordinates needed to reach a + * group: the delivery `gid`, optionally which coordinator serves it, and + * optionally where that coordinator is reachable. Bech32 with NIP-19's TLV + * layout, so existing Nostr tooling reads it. + * + * It is a locator and authorizes nothing. Holding one lets you ask to join + * (see `spec/applications/join-requests.md`); it does not make you a member. + */ +data class CordnGroupRef( + /** + * The delivery group identifier, exactly as the producing client uses it. + * + * Opaque: not a UUID, not a hash, and specifically **not** the MLS + * `group_id`. §4.1 requires it to round-trip byte for byte with no + * trimming or re-encoding, because the coordinator keys its cursor space + * on these bytes. + */ + val gid: String, + /** The coordinator serving this group, as lowercase hex. */ + val coordinatorPubKey: HexKey? = null, + /** Where to reach that coordinator. Meaningless, and invalid, without one. */ + val relays: List = emptyList(), +) { + init { + require(gid.isNotEmpty()) { "cordn group ref gid must not be empty" } + require(gid.encodeToByteArray().size <= MAX_TLV_VALUE) { + "cordn group ref gid must be at most $MAX_TLV_VALUE bytes (the TLV length field is one byte)" + } + require(coordinatorPubKey == null || coordinatorPubKey.length == PUBKEY_HEX_LENGTH) { + "cordn group ref coordinator pubkey must be 32 bytes" + } + require(relays.isEmpty() || coordinatorPubKey != null) { + "cordn group ref carries relays with no coordinator pubkey: a relay names where to reach a coordinator" + } + } + + /** + * Encodes as `cordn1…`. + * + * TLV elements go out in descending type order to match NIP-19's reference + * encoder, though §3 requires decoders to accept any order. + */ + fun encode(): String { + val builder = TlvBuilder() + relays.forEach { builder.addString(TLV_RELAY, it) } + coordinatorPubKey?.let { builder.addHex(TLV_COORDINATOR, it) } + builder.addString(TLV_GID, gid) + return Bech32.encodeBytes(HRP, builder.build(), Bech32.Encoding.Bech32) + } + + companion object { + /** Encoded refs begin `cordn1`. */ + const val HRP = "cordn" + + /** UTF-8 `gid`. Exactly one. */ + const val TLV_GID: Byte = 0 + + /** Raw 32-byte coordinator pubkey. At most one. */ + const val TLV_COORDINATOR: Byte = 1 + + /** UTF-8 relay URL. Any number, but only alongside a coordinator. */ + const val TLV_RELAY: Byte = 2 + + /** §2: the NIP-19 bound, present only to cap decoding work. */ + const val MAX_LENGTH = 5000 + + private const val MAX_TLV_VALUE = 255 + private const val PUBKEY_HEX_LENGTH = 64 + + /** Names this type in decode errors. */ + private const val SUBJECT = "cordn group ref" + private const val PUBKEY_SIZE = 32 + + /** Decodes a `cordn1…` reference, or throws with the rule it broke. */ + fun decode(encoded: String): CordnGroupRef { + require(encoded.length <= MAX_LENGTH) { + "cordn group ref is longer than $MAX_LENGTH characters" + } + // Bech32 forbids mixed case, and Bech32.decode lowercases rather + // than rejecting, so the check has to happen before the call. + require(encoded == encoded.lowercase() || encoded == encoded.uppercase()) { + "cordn group ref must be all lowercase or all uppercase" + } + + val (hrp, bytes, encoding) = Bech32.decodeBytes(encoded.lowercase(), false) + require(hrp == HRP) { "not a cordn group ref: prefix is '$hrp', expected '$HRP'" } + // §2 pins bech32, not bech32m. Accepting either would let two + // encodings of the same ref exist, and NIP-19 made the same choice. + require(encoding == Bech32.Encoding.Bech32) { + "cordn group ref must use bech32, not bech32m" + } + + val tlv = CordnStrictTlv.parse(bytes, SUBJECT) + + val gids = tlv[TLV_GID].orEmpty() + require(gids.size == 1) { "cordn group ref must carry exactly one gid, found ${gids.size}" } + require(gids[0].isNotEmpty()) { "cordn group ref gid must not be empty" } + + val coordinators = tlv[TLV_COORDINATOR].orEmpty() + require(coordinators.size <= 1) { "cordn group ref must carry at most one coordinator pubkey" } + coordinators.forEach { + require(it.size == PUBKEY_SIZE) { + "cordn group ref coordinator pubkey must be $PUBKEY_SIZE bytes, was ${it.size}" + } + } + + // §5 lets a consumer discard an empty relay rather than reject the + // whole reference, which is the kinder reading of a producer bug. + val relays = tlv[TLV_RELAY].orEmpty().map { CordnStrictTlv.utf8(it, SUBJECT, "relay") }.filter { it.isNotEmpty() } + require(relays.isEmpty() || coordinators.isNotEmpty()) { + "cordn group ref carries a relay with no coordinator pubkey" + } + + return CordnGroupRef( + gid = CordnStrictTlv.utf8(gids[0], SUBJECT, "gid"), + coordinatorPubKey = coordinators.firstOrNull()?.toHexKey(), + relays = relays, + ) + } + + /** Decodes, or null when the string is not a valid reference. */ + fun decodeOrNull(encoded: String): CordnGroupRef? = + try { + decode(encoded) + } catch (e: IllegalArgumentException) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt new file mode 100644 index 0000000000..184dfa77ed --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt @@ -0,0 +1,409 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The two sealed documents of `spec/applications/multi-device.md` §4. + * + * ## What this is for here + * + * The spec's subject is a fleet of devices sharing one MLS leaf and converging + * continuously. Amethyst uses the same documents for the narrow case that has + * no convergence problem: **moving an account to a new phone**. One device + * writes a snapshot, one device reads it, and the writer then stops (see the + * handoff lock). Nothing here implements §8.5 `prev` chains, §10 sibling-Commit + * convergence or §10.5's publish-on-every-Commit — those exist to keep two + * *live* devices in step, which migration does not ask for. + * + * ## The one field that is not interoperable + * + * `clientState` is `base64(serialized MLS ClientState)`, and §4.2 says outright + * that it is *library-serialized and intentionally not pinned to a wire format* + * — TLS is pinned only for the meta document's key package. So the field holds + * whatever the writing implementation's engine produces: ts-mls for the + * reference client, [CLIENT_STATE_FORMAT] for us. The two cannot read each + * other, by the spec's design rather than by omission. + * + * Because the spec gives no place to say which one a document carries, a reader + * would otherwise discover the mismatch as a deserialization crash deep inside + * its MLS engine. [CordnGroupDocument.clientStateFormat] is an additive field + * that lets a reader check first and refuse with a reason. Unknown fields are + * ignored by both sides, so it costs a foreign reader nothing. + */ +object CordnDeviceDocument { + const val SCHEMA_VERSION = 1 + + /** §4.1 `type`. */ + const val TYPE_GROUP = "group" + + /** §4.2 `type`. */ + const val TYPE_META = "meta" + + /** + * What Amethyst puts in `clientState`: our own `MlsGroupState` encoding. + * + * Not a spec field. See the class KDoc for why it exists. + */ + const val CLIENT_STATE_FORMAT = "amethyst.MlsGroupState.v1" + + private const val SCHEMA_VERSION_FIELD = "schemaVersion" + private const val TYPE = "type" + private const val ISSUED_AT = "issuedAt" + private const val PREV = "prev" + private const val GID = "gid" + private const val COORDINATOR = "coordinator" + private const val CLIENT_STATE = "clientState" + private const val CLIENT_STATE_FORMAT_FIELD = "clientStateFormat" + private const val CURSOR = "cursor" + private const val ROOM_STATE = "amethystRoomState" + private const val ECHO_STATE = "amethystEchoState" + private const val JOINED_VIA_REQUEST = "amethystJoinedViaRequest" + private const val MESSAGES = "amethystMessages" + private const val COORDINATOR_RELAYS = "amethystCoordinatorRelays" + private const val KEY_PACKAGES = "amethystKeyPackages" + private const val COORDINATOR_PUBKEY = "coordinator" + private const val REF = "ref" + private const val BUNDLE = "bundle" + private const val REMOVED = "removed" + private const val EPOCH = "epoch" + private const val LAST_RESORT_KEY_PACKAGE = "lastResortKeyPackage" + private const val KEY_PACKAGE = "keyPackage" + private const val PRIVATE_KEY_PACKAGE = "privateKeyPackage" + + private val json = Json { ignoreUnknownKeys = true } + + /** §5: the plaintext is UTF-8 JSON, with no canonical form required. */ + fun encode(document: CordnDeviceDoc): String = + when (document) { + is CordnGroupDocument -> encodeGroup(document) + is CordnMetaDocument -> encodeMeta(document) + } + + /** + * Reads a decrypted document. + * + * Throws [CordnDocumentException] rather than returning null: by the time a + * caller has a plaintext it has already fetched a blob whose hash matched + * the tip's advertised address, so a document that does not parse is a real + * fault worth a reason, not one candidate among many to skip. + */ + fun decode(plaintext: String): CordnDeviceDoc { + val root = + try { + json.parseToJsonElement(plaintext) as? JsonObject + } catch (e: IllegalArgumentException) { + throw CordnDocumentException("document is not valid JSON: ${e.message}") + } ?: throw CordnDocumentException("document is not a JSON object") + + val version = root.intOrNull(SCHEMA_VERSION_FIELD) + // §4.1/§4.2: "Clients MUST reject documents with an unknown schema version." + if (version != SCHEMA_VERSION) { + throw CordnDocumentException("unsupported document schemaVersion: $version") + } + + return when (val type = root.stringOrNull(TYPE)) { + TYPE_GROUP -> decodeGroup(root) + TYPE_META -> decodeMeta(root) + else -> throw CordnDocumentException("unknown document type: $type") + } + } + + private fun encodeGroup(document: CordnGroupDocument) = + buildJsonObject { + put(SCHEMA_VERSION_FIELD, SCHEMA_VERSION) + put(TYPE, TYPE_GROUP) + put(ISSUED_AT, document.issuedAt) + document.prev?.let { put(PREV, it) } + put(GID, document.gid) + put(COORDINATOR, document.coordinator) + put(CLIENT_STATE, document.clientState) + put(CLIENT_STATE_FORMAT_FIELD, document.clientStateFormat) + put(CURSOR, document.cursor) + document.roomState?.let { put(ROOM_STATE, it) } + document.messages + ?.takeIf { it.isNotEmpty() } + ?.let { entries -> put(MESSAGES, buildJsonArray { entries.forEach { add(JsonPrimitive(it)) } }) } + document.echoState?.let { put(ECHO_STATE, it) } + if (document.joinedViaRequest) put(JOINED_VIA_REQUEST, true) + if (document.coordinatorRelays.isNotEmpty()) { + put(COORDINATOR_RELAYS, buildJsonArray { document.coordinatorRelays.forEach { add(JsonPrimitive(it)) } }) + } + }.toString() + + private fun decodeGroup(root: JsonObject): CordnGroupDocument = + CordnGroupDocument( + gid = root.stringOrNull(GID) ?: throw CordnDocumentException("group document requires a gid"), + coordinator = + root.stringOrNull(COORDINATOR) + ?: throw CordnDocumentException("group document requires a coordinator"), + clientState = + root.stringOrNull(CLIENT_STATE) + ?: throw CordnDocumentException("group document requires a clientState"), + cursor = root.longOrNull(CURSOR) ?: throw CordnDocumentException("group document requires a cursor"), + issuedAt = root.longOrNull(ISSUED_AT) ?: 0L, + prev = root.stringOrNull(PREV), + // Absent means a writer that predates the marker, or the reference + // client. Either way it is not ours; say so rather than guess. + clientStateFormat = root.stringOrNull(CLIENT_STATE_FORMAT_FIELD), + roomState = root.stringOrNull(ROOM_STATE), + messages = (root[MESSAGES] as? JsonArray)?.map { (it as JsonPrimitive).content }, + echoState = root.stringOrNull(ECHO_STATE), + joinedViaRequest = root.boolOrNull(JOINED_VIA_REQUEST) ?: false, + coordinatorRelays = + (root[COORDINATOR_RELAYS] as? JsonArray) + ?.filterIsInstance() + ?.filter { it.isString } + ?.map { it.content } + .orEmpty(), + ) + + private fun encodeMeta(document: CordnMetaDocument) = + buildJsonObject { + put(SCHEMA_VERSION_FIELD, SCHEMA_VERSION) + put(TYPE, TYPE_META) + put(ISSUED_AT, document.issuedAt) + if (document.removed.isNotEmpty()) { + put( + REMOVED, + buildJsonArray { + document.removed.forEach { + add( + buildJsonObject { + put(GID, it.gid) + put(EPOCH, it.epoch) + }, + ) + } + }, + ) + } + if (document.keyPackages.isNotEmpty()) { + put( + KEY_PACKAGES, + buildJsonArray { + document.keyPackages.forEach { + add( + buildJsonObject { + put(COORDINATOR_PUBKEY, it.coordinatorPubKey) + put(REF, it.keyPackageRef) + put(BUNDLE, it.bundle) + }, + ) + } + }, + ) + } + document.lastResortKeyPackage?.let { + put( + LAST_RESORT_KEY_PACKAGE, + buildJsonObject { + put(KEY_PACKAGE, it.keyPackage) + put(PRIVATE_KEY_PACKAGE, it.privateKeyPackage) + }, + ) + } + }.toString() + + private fun decodeMeta(root: JsonObject): CordnMetaDocument { + val removed = + (root[REMOVED] as? JsonArray) + ?.filterIsInstance() + ?.mapNotNull { + val gid = it.stringOrNull(GID) ?: return@mapNotNull null + val epoch = it.longOrNull(EPOCH) ?: return@mapNotNull null + CordnTombstone(gid, epoch) + }.orEmpty() + + val keyPackage = + (root[LAST_RESORT_KEY_PACKAGE] as? JsonObject)?.let { + val public = it.stringOrNull(KEY_PACKAGE) + val private = it.stringOrNull(PRIVATE_KEY_PACKAGE) + if (public == null || private == null) { + // §4.2 needs both halves to be usable at join time; half an + // entry would resolve a Welcome and then fail to open it. + throw CordnDocumentException("lastResortKeyPackage requires both halves") + } + CordnLastResortKeyPackage(public, private) + } + + val keyPackages = + (root[KEY_PACKAGES] as? JsonArray) + ?.filterIsInstance() + ?.mapNotNull { + val coordinator = it.stringOrNull(COORDINATOR_PUBKEY) ?: return@mapNotNull null + val ref = it.stringOrNull(REF) ?: return@mapNotNull null + val bundle = it.stringOrNull(BUNDLE) ?: return@mapNotNull null + CordnCarriedKeyPackage(coordinator, ref, bundle) + }.orEmpty() + + return CordnMetaDocument( + removed = removed, + lastResortKeyPackage = keyPackage, + keyPackages = keyPackages, + issuedAt = root.longOrNull(ISSUED_AT) ?: 0L, + ) + } + + private fun JsonObject.stringOrNull(name: String) = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.longOrNull(name: String) = + (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.let { + try { + it.long + } catch (e: NumberFormatException) { + null + } + } + + private fun JsonObject.intOrNull(name: String) = longOrNull(name)?.toInt() + + private fun JsonObject.boolOrNull(name: String) = (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toBooleanStrictOrNull() +} + +/** A document that is not readable. See [CordnDeviceDocument.decode]. */ +class CordnDocumentException( + message: String, +) : Exception(message) + +/** One of the two §4 document types. */ +sealed interface CordnDeviceDoc + +/** + * §4.1 — one group's MLS state and delivery cursor. + * + * [cursor] and [clientState] must be a snapshot taken at the same instant: + * §4.1 requires that ingesting the stream up to and including [cursor] leaves + * the writer at the epoch encoded in [clientState]. A reader trusts that, + * because it has no way to check it. + */ +data class CordnGroupDocument( + val gid: String, + /** The coordinator serving [gid], so a seeded device knows where to fetch. */ + val coordinator: String, + /** `base64(serialized MLS ClientState)`, in [clientStateFormat]. */ + val clientState: String, + val cursor: Long, + val issuedAt: Long = 0L, + /** + * §4.1's per-`gid` chain link. + * + * Always null on a migration snapshot: the chain exists for a live fleet + * replaying skipped epochs (§8.5), and a handoff publishes one generation. + */ + val prev: String? = null, + /** See [CordnDeviceDocument.CLIENT_STATE_FORMAT]. Null when the writer said nothing. */ + val clientStateFormat: String? = CordnDeviceDocument.CLIENT_STATE_FORMAT, + /** + * `base64(CordnRoomStateCodec)` — the draft and the read position. + * + * Additive, like [clientStateFormat], and for the same reason: the spec's + * document carries group *state*, and these are the per-device reading + * position on top of it. A fleet syncing continuously can regard them as + * device-local; a phone being replaced cannot, because losing them is + * visible to the user as every conversation coming back unread with the + * half-typed message gone. + */ + val roomState: String? = null, + /** + * The conversation, as `CordnDeliveredMessageCodec` entries, oldest first. + * + * Additive like [roomState], and the most load-bearing of the additions. A + * cordn message is readable exactly once — at ingest — because both of its + * seal keys are epoch-derived and the [cursor] this document carries has + * already advanced past everything behind it. A device seeded without this + * cannot fetch the history back from anywhere: it would arrive holding + * every group and no conversation, permanently. + */ + val messages: List? = null, + /** + * `base64(EchoStateCodec)` — pending commits and own-message cursors. + * + * Without it the new device re-reports its predecessor's own traffic as a + * gap on first sync. Same additive justification as [roomState]. + */ + val echoState: String? = null, + /** Whether this group was entered by join request rather than invitation. */ + val joinedViaRequest: Boolean = false, + /** + * Where the coordinator is reachable. + * + * §4.1's `coordinator` field is an identity, and `spec/00.md` §8.5 says a + * coordinator has no address beyond its pubkey — so the relays its traffic + * runs on have to come from somewhere, and for a device that has never + * talked to it there is nowhere else. A seeded group whose coordinator has + * no relays is a group that cannot sync. + */ + val coordinatorRelays: List = emptyList(), +) : CordnDeviceDoc { + /** Whether this device's MLS engine can read [clientState] at all. */ + val isReadableHere: Boolean get() = clientStateFormat == CordnDeviceDocument.CLIENT_STATE_FORMAT +} + +/** §4.2 — identity-level state: tombstones and the account's last-resort key package. */ +data class CordnMetaDocument( + val removed: List = emptyList(), + val lastResortKeyPackage: CordnLastResortKeyPackage? = null, + /** + * Every key package bundle this account holds, one-use ones included. + * + * §11.5 carries only the last-resort package, because a *fleet* keeps + * one-use packages device-local: a sibling that cannot open one Welcome is + * an inconvenience while the publishing device is still around. A device + * being replaced is not still around, so a Welcome in flight against one of + * its one-use packages would be lost outright. Additive for that reason. + */ + val keyPackages: List = emptyList(), + val issuedAt: Long = 0L, +) : CordnDeviceDoc + +/** One key package bundle travelling with a migration. `bundle` is base64. */ +data class CordnCarriedKeyPackage( + val coordinatorPubKey: String, + val keyPackageRef: String, + val bundle: String, +) + +/** §4.2 `removed` — "stopped tracking [gid] while it was at [epoch]". */ +data class CordnTombstone( + val gid: String, + val epoch: Long, +) + +/** + * §4.2 / §11.5 — the account's one reusable key package, both halves. + * + * Unlike `clientState` this **is** pinned: §4.2 says TLS is the only MLS wire + * serialization, so both fields are base64 of the RFC 9420 TLS encoding and + * carry across implementations. + */ +data class CordnLastResortKeyPackage( + val keyPackage: String, + val privateKeyPackage: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTip.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTip.kt new file mode 100644 index 0000000000..4fb2efcc15 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTip.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * §6 — the inventory a device publishes so another device can find its documents. + * + * ## Two events, two jobs + * + * The **outer** event is an addressable kind-[OUTER_KIND] event signed by an + * ephemeral key that has nothing to do with the owner. It exists to be + * replaceable on a relay and to leak nothing: an observer sees an unknown + * account updating an opaque, randomly-`d`-tagged event. + * + * The **inner** event — this file's subject — is signed by the owner and sealed + * inside the outer one's content. It is the authenticity guarantee: the outer + * signature only says "whoever holds the ephemeral key put this here", which a + * leaked connection string also lets someone do. What makes a document + * inventory trustworthy is the owner's signature on the inner event, so a + * reader MUST verify it and MUST NOT act on the outer event alone. + * + * ## Why the ephemeral key is not derived from the owner + * + * §6 forbids deriving it. A public derivation would let anyone compute the + * signing pubkey from an `npub` and query for that person's tip, which is + * precisely the linkage the design exists to prevent. [CordnConnectionString] + * mints it randomly for the same reason. + */ +object CordnDeviceTip { + /** The outer, relayed, addressable event. A coordination detail (§14). */ + const val OUTER_KIND: Kind = 30078 + + /** The inner, sealed, never-relayed event. A coordination detail (§14). */ + const val INNER_KIND: Kind = 178 + + const val TAG_X = "x" + const val TAG_DEK = "dek" + const val TAG_SERVER = "server" + const val TAG_D = "d" + + /** The `x` tag's 3rd element for a group document. */ + const val KIND_GROUP = "group" + + /** The `x` tag's 3rd element for the meta document. */ + const val KIND_META = "meta" + + private const val DEK_HEX_LENGTH = 64 + + /** The tags of the inner event for [inventory]. */ + fun tags(inventory: CordnTipInventory): Array = + buildList { + inventory.groups.forEach { add(arrayOf(TAG_X, it.address, KIND_GROUP, it.gid)) } + inventory.meta?.let { add(arrayOf(TAG_X, it, KIND_META)) } + add(arrayOf(TAG_DEK, inventory.dekPrivateKey)) + // Ordered: §6 says a reader tries them in listed order, so the most + // reliable host goes first. + inventory.servers.forEach { add(arrayOf(TAG_SERVER, it)) } + }.toTypedArray() + + /** + * Reads a verified inner event. + * + * The caller is responsible for having decrypted the outer content and + * checked the inner signature first — this only reads tags, and a tag set + * says nothing about who wrote it. + * + * @throws CordnDocumentException when the inventory is unusable: no DEK, a + * malformed DEK, or a `group` entry with no `gid`. A tip that cannot name + * its documents is not a tip a device can act on partially. + */ + fun parse(inner: Event): CordnTipInventory { + if (inner.kind != INNER_KIND) { + throw CordnDocumentException("tip inner event has kind ${inner.kind}, expected $INNER_KIND") + } + + val groups = mutableListOf() + var meta: String? = null + var dek: String? = null + val servers = mutableListOf() + + inner.tags.forEach { tag -> + if (tag.size < 2) return@forEach + when (tag[0]) { + TAG_X -> + when (tag.getOrNull(2)) { + KIND_GROUP -> { + val gid = + tag.getOrNull(3) + ?: throw CordnDocumentException("a group x tag carries no gid") + groups += CordnTipEntry(address = tag[1], gid = gid) + } + // §4.3 allows exactly one meta entry. A second is a + // malformed tip; take the first and ignore the rest + // rather than fail, since either could be the real one + // and the address check still gates what we accept. + KIND_META -> if (meta == null) meta = tag[1] + else -> Unit + } + TAG_DEK -> if (dek == null) dek = tag[1] + TAG_SERVER -> servers += tag[1] + else -> Unit + } + } + + val dekKey = dek ?: throw CordnDocumentException("tip carries no dek tag") + if (dekKey.length != DEK_HEX_LENGTH || !dekKey.all { it.isHexDigit() }) { + throw CordnDocumentException("tip dek is not 64 hex chars") + } + + return CordnTipInventory( + groups = groups, + meta = meta, + dekPrivateKey = dekKey.lowercase(), + servers = servers, + ) + } + + private fun Char.isHexDigit() = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' +} + +/** One `x`-tagged group document in a tip. */ +data class CordnTipEntry( + /** `sha256` of the sealed blob; also its key on the content store. */ + val address: String, + val gid: String, +) + +/** + * What a tip advertises. + * + * [dekPrivateKey] is the whole reason the inner event is sealed to the owner: + * it is a private key in the clear inside that seal, and it opens every + * document listed here. + */ +data class CordnTipInventory( + val groups: List, + val meta: String?, + val dekPrivateKey: HexKey, + /** Content-store hosts, in the order a reader should try them (§6). */ + val servers: List, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDocumentSeal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDocumentSeal.kt new file mode 100644 index 0000000000..3ebae9e6bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDocumentSeal.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * §7 — sealing a document to the per-identity document encryption key. + * + * ## Why a DEK rather than sealing to the owner + * + * Sealing each document straight to the owner `npub` would bind every document + * decrypt to the owner `nsec` — one signer round-trip per document, which on a + * remote NIP-46 signer over a bad connection is the difference between a + * migration that finishes and one that does not. The DEK is a throwaway Nostr + * keypair; its private half rides inside the tip's owner-sealed inner event, so + * one NIP-44 decrypt of the tip yields a key every later document decrypt uses + * locally. + * + * ## What the seal does and does not give you + * + * Confidentiality only. It is a self-seal — sender and recipient are both the + * DEK — so anyone holding the DEK can encrypt as easily as decrypt. Documents + * carry no signature. **Authenticity comes from the tip**, whose inner event is + * signed by the owner, and integrity of a particular blob comes from the + * address: [address] is `sha256` of the sealed bytes, and §6 requires a reader + * to check it against what the tip advertised before trusting the content. + * + * Nothing here is a substitute for that check. A blob that decrypts is not a + * blob that anybody authorised. + */ +object CordnDocumentSeal { + private val nip44 = Nip44v2() + + /** A fresh DEK. One per identity, reused across every publish (§7). */ + fun newKey(): KeyPair = KeyPair() + + /** + * Seals [document] to [dek]. + * + * NIP-44 v2 uses a random nonce, so the same document seals to different + * bytes — and therefore a different [address] — every time. §5 says that + * explicitly: no canonical form is required, because the address is over + * ciphertext and could not enable dedup anyway. + */ + fun seal( + document: CordnDeviceDoc, + dek: KeyPair, + ): ByteArray = nip44.encrypt(CordnDeviceDocument.encode(document), dek.privKey!!, dek.pubKey).encodePayload().encodeToByteArray() + + /** + * Opens a sealed blob. + * + * @throws CordnDocumentException when the blob does not decrypt, or decrypts + * to something that is not a readable document. Both are the same class of + * fault to a caller that has already matched the address. + */ + fun open( + blob: ByteArray, + dek: KeyPair, + ): CordnDeviceDoc { + val plaintext = + try { + nip44.decrypt(blob.decodeToString(), dek.privKey!!, dek.pubKey) + } catch (e: Exception) { + throw CordnDocumentException("sealed document did not decrypt: ${e.message}") + } + return CordnDeviceDocument.decode(plaintext) + } + + /** + * §6 — a document's address is `sha256` of its **sealed** bytes, lowercase + * hex, and doubles as the content-addressed store key. + */ + fun address(blob: ByteArray): String = sha256(blob).toHexKey() + + /** + * The §6 check a reader MUST perform before trusting a fetched blob. + * + * Separate from [open] on purpose: the order matters. Verifying the address + * first means a blob that fails was never decrypted, so a store that serves + * the wrong bytes cannot get its plaintext in front of the parser at all. + */ + fun verifyAddress( + blob: ByteArray, + expected: String, + ): Boolean = address(blob).equals(expected, ignoreCase = true) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCode.kt new file mode 100644 index 0000000000..8eb19fa587 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCode.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.cordn.tlv.CordnStrictTlv +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * Everything a new phone needs to find the old phone's tip — the §6/§11 + * connection string, as one scannable `cordndev1…` code. + * + * ## What it carries, and what it deliberately does not + * + * The tip lives at `(ephemeral pubkey, d)` on some relays, so the code carries + * those. It carries **no owner key material**: §4.3 is explicit that the + * documents do not provision identity, and neither does this. The user signs + * in on the new phone first, by whatever means they already use — nsec, Amber, + * a bunker — and only then migrates cordn state onto it. + * + * ## Why the write key is usually absent + * + * §11's connection string bundles the ephemeral `nsec` so a newly added device + * can publish its own tip moves. A device being *migrated to* has no such need: + * there is no fleet to stay in step with, and when that phone is itself + * replaced one day it mints a fresh ephemeral keypair for its own handoff. + * + * Leaving the key out shrinks what a photographed QR is worth. Without it the + * code is a locator for an event anyone could already fetch and nobody but the + * owner can decrypt — the tip's content is NIP-44-sealed to the owner `npub` + * — so a leak reveals nothing. With it, a leak also buys the ability to repoint + * the tip at a stale-but-valid inventory: denial of service, per §13, but a + * real one. [writeKey] therefore defaults to null and is populated only when a + * caller genuinely wants the §11 semantics. + */ +data class CordnHandoffCode( + /** The tip's author. Independent of the owner identity, per §6. */ + val ephemeralPubKey: HexKey, + /** The tip's `d` value: random, opaque, stable across republishes. */ + val dTag: String, + /** Where to look for the tip. */ + val relays: List, + /** The outer event kind, carried so a later change cannot strand old codes. */ + val kind: Int = CordnDeviceTip.OUTER_KIND, + /** The ephemeral private key, when this code grants tip writes. See the class doc. */ + val writeKey: HexKey? = null, +) { + init { + require(ephemeralPubKey.length == PUBKEY_HEX_LENGTH) { + "a handoff code's ephemeral pubkey must be 32 bytes of hex" + } + require(writeKey == null || writeKey.length == PUBKEY_HEX_LENGTH) { + "a handoff code's write key must be 32 bytes of hex" + } + require(dTag.isNotEmpty()) { "a handoff code must carry the tip's d tag" } + require(relays.isNotEmpty()) { "a handoff code with no relays cannot find the tip" } + } + + /** Whether this code lets its holder move the tip, not merely read it. */ + val grantsWrite: Boolean get() = writeKey != null + + /** + * The same code with the write key removed. + * + * What a device shares when it wants the other side to read and nothing + * more — which for a migration is every time. + */ + fun readOnly(): CordnHandoffCode = if (writeKey == null) this else copy(writeKey = null) + + /** Encodes as `cordndev1…`. TLV order mirrors NIP-19's reference encoder. */ + fun encode(): String { + val builder = TlvBuilder() + builder.addHexIfNotNull(TLV_WRITE_KEY, writeKey) + relays.forEach { builder.addString(TLV_RELAY, it) } + builder.addInt(TLV_KIND, kind) + builder.addString(TLV_D, dTag) + builder.addHex(TLV_PUBKEY, ephemeralPubKey) + return Bech32.encodeBytes(HRP, builder.build(), Bech32.Encoding.Bech32) + } + + companion object { + /** Encoded codes begin `cordndev1`. */ + const val HRP = "cordndev" + + /** Raw 32-byte ephemeral pubkey. Exactly one. */ + const val TLV_PUBKEY: Byte = 0 + + /** UTF-8 `d` value. Exactly one. */ + const val TLV_D: Byte = 1 + + /** UTF-8 relay URL. At least one. */ + const val TLV_RELAY: Byte = 2 + + /** Big-endian outer event kind. At most one; defaults when absent. */ + const val TLV_KIND: Byte = 3 + + /** Raw 32-byte ephemeral private key. At most one; usually absent. */ + const val TLV_WRITE_KEY: Byte = 4 + + const val MAX_LENGTH = 5000 + + private const val PUBKEY_HEX_LENGTH = 64 + private const val PUBKEY_SIZE = 32 + + /** Names this type in decode errors. */ + private const val SUBJECT = "handoff code" + + private fun ByteArray.toInt32(): Int? = + if (size != Int.SIZE_BYTES) { + null + } else { + (this[0].toInt() and 0xFF shl 24) or + (this[1].toInt() and 0xFF shl 16) or + (this[2].toInt() and 0xFF shl 8) or + (this[3].toInt() and 0xFF) + } + + /** Decodes a `cordndev1…` code, or throws with the rule it broke. */ + fun decode(encoded: String): CordnHandoffCode { + require(encoded.length <= MAX_LENGTH) { + "a handoff code is longer than $MAX_LENGTH characters" + } + // Bech32.decode lowercases rather than rejecting mixed case, so the + // check has to happen before the call. + require(encoded == encoded.lowercase() || encoded == encoded.uppercase()) { + "a handoff code must be all lowercase or all uppercase" + } + + val (hrp, bytes, encoding) = Bech32.decodeBytes(encoded.lowercase(), false) + require(hrp == HRP) { "not a $SUBJECT: prefix is '$hrp', expected '$HRP'" } + // bech32, never bech32m — the same choice CordnGroupRef makes, so + // one code never has two valid spellings. + require(encoding == Bech32.Encoding.Bech32) { "a $SUBJECT must use bech32, not bech32m" } + + val tlv = CordnStrictTlv.parse(bytes, SUBJECT) + + val pubKeys = tlv[TLV_PUBKEY].orEmpty() + require(pubKeys.size == 1) { "a $SUBJECT must carry exactly one ephemeral pubkey, found ${pubKeys.size}" } + require(pubKeys[0].size == PUBKEY_SIZE) { "a $SUBJECT ephemeral pubkey must be $PUBKEY_SIZE bytes" } + + val dTags = tlv[TLV_D].orEmpty() + require(dTags.size == 1) { "a $SUBJECT must carry exactly one d tag, found ${dTags.size}" } + + val writeKeys = tlv[TLV_WRITE_KEY].orEmpty() + require(writeKeys.size <= 1) { "a $SUBJECT must carry at most one write key" } + writeKeys.forEach { require(it.size == PUBKEY_SIZE) { "a $SUBJECT write key must be $PUBKEY_SIZE bytes" } } + + val kinds = tlv[TLV_KIND].orEmpty() + require(kinds.size <= 1) { "a $SUBJECT must carry at most one kind" } + + return CordnHandoffCode( + ephemeralPubKey = pubKeys[0].toHexKey(), + dTag = CordnStrictTlv.utf8(dTags[0], SUBJECT, "d tag"), + // An empty relay entry is a producer bug, not a reason to + // refuse the whole code — the same reading CordnGroupRef takes. + relays = tlv[TLV_RELAY].orEmpty().map { CordnStrictTlv.utf8(it, SUBJECT, "relay") }.filter { it.isNotEmpty() }, + kind = kinds.firstOrNull()?.toInt32() ?: CordnDeviceTip.OUTER_KIND, + writeKey = writeKeys.firstOrNull()?.toHexKey(), + ) + } + + /** Decodes, or null when [encoded] is not a well-formed handoff code. */ + fun decodeOrNull(encoded: String): CordnHandoffCode? = + try { + decode(encoded) + } catch (e: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt new file mode 100644 index 0000000000..58fadf944a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt @@ -0,0 +1,443 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.fixture + +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamFrame +import com.vitorpamplona.quartz.contextvm.fixture.CvmRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorFields +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorMethod +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * An in-memory cordn coordinator for tests. + * + * It implements the eleven tools against maps, and — more usefully — it + * RECORDS which identity made each call. The privacy claim in `spec/00.md` §8 + * is not about what the coordinator stores, it is about what it learns, so the + * only way to test it is to be the coordinator and look. + * + * Deliberately not hardened; `cordn-rs` exists for real deployments. Like + * `:contextvm`'s fixture, this one can also be told to misbehave — see + * [rejectPublication] and [rejectRemoval]. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnFixtureCoordinator( + /** Serve `kp_publish` back as if it had been stored, keyed by ref. */ + private val publications: MutableMap = mutableMapOf(), + /** Reject every publication, as a coordinator enforcing §8 would. */ + var rejectPublication: Boolean = false, + /** Reject every withdrawal, as a coordinator that is simply down would. */ + var rejectRemoval: Boolean = false, +) { + data class StoredKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + val publicationEvent: Event?, + ) + + data class Call( + val method: String, + /** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */ + val callerPubKey: HexKey?, + /** Exactly the arguments object that arrived, for wire-shape assertions. */ + val arguments: JsonObject = JsonObject(emptyMap()), + ) + + /** Every call seen, with who made it. The privacy surface, made observable. */ + val calls = mutableListOf() + + /** + * Answers keyed by tool name that replace this fixture's own bookkeeping, + * served verbatim as `structuredContent`. + * + * For replaying a result recorded from another implementation: the fixture + * models the coordinator well enough to drive a client, but a result it + * composed itself only proves we agree with ourselves. + */ + val scriptedResults = mutableMapOf() + + private val welcomes = mutableListOf() + private val joinRequests = mutableListOf() + private val messages = mutableMapOf>() + private var nextCursor = 1L + private var clock = 1_700_000_000L + + /** Messages posted to [gid], oldest first. */ + fun posted(gid: String): List = messages[gid].orEmpty().map { it[CoordinatorFields.MSG_64]!!.jsonPrimitive.content } + + /** Pre-seeds a group's stream, as history a client will catch up on. */ + fun seed( + gid: String, + sealedBase64: String, + ): Long { + val cursor = nextCursor++ + messages.getOrPut(gid) { mutableListOf() } += + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.CURSOR, cursor) + put(CoordinatorFields.MSG_64, sealedBase64) + put(CoordinatorFields.AT, clock++) + } + return cursor + } + + /** Pre-seeds a Welcome in an account's inbox. */ + fun seedWelcome( + keyPackageRef: String, + welcomeBase64: String, + after: Long? = null, + /** Who this Welcome is addressed to. A fetch by anyone else will not see it. */ + targetPubKey: HexKey, + ) { + welcomes += + buildJsonObject { + put(CoordinatorFields.TARGET_PK, targetPubKey) + put(CoordinatorFields.KP_REF, keyPackageRef) + put(CoordinatorFields.WELCOME_64, welcomeBase64) + put(CoordinatorFields.AT, clock++) + after?.let { put(CoordinatorFields.AFTER, it) } + } + } + + /** How many single-use KeyPackages this coordinator still holds for [pubKey]. */ + fun availableCount(pubKey: HexKey): Int = keyPackagesOf(pubKey).count { !it.lastResort } + + /** Every KeyPackage this coordinator holds for [pubKey], reusable ones included. */ + fun keyPackagesOf(pubKey: HexKey): List = publications.values.filter { it.pubKey == pubKey } + + /** Reads the last-resort marker out of a base64 KeyPackage, as a real one would. */ + private fun isLastResort(keyPackageBase64: String): Boolean = + try { + MlsKeyPackage.decodeTls(TlsReader(Base64.decode(keyPackageBase64))).isLastResort() + } catch (e: Exception) { + // Unparseable bytes are not our problem here: the client verifies + // the publication payload (§9), and a fixture that threw would hide + // that check behind a transport error. + false + } + + /** Stores a publication event so `kp_take` can serve it back verbatim (§7). */ + fun seedPublication( + keyPackageRef: String, + event: Event, + ) { + publications[keyPackageRef] = + StoredKeyPackage(event.pubKey, keyPackageRef, false, clock++, event) + } + + /** + * Sends CEP-41 stream frames back to the caller, for `msg_sub_many`. + * + * Null by default, and a subscription then returns an empty result rather + * than failing: a fixture that needs the streaming half wires this to the + * server's `reply`, and one that only drives catch-up should not have to + * know the difference. + */ + var emitStream: (suspend (frames: List, clientPubKey: HexKey, requestEventId: HexKey) -> Unit)? = null + + /** The `progressToken` CEP-41 frames must carry to reach the caller's receiver. */ + private fun progressToken(params: JsonObject?): ProgressToken? = + params + ?.get("_meta") + ?.jsonObject + ?.get("progressToken") + ?.jsonPrimitive + ?.content + ?.let { ProgressToken.Text(it) } + + /** The handler to hand to `CvmFixtureServer`. */ + suspend fun handle(request: CvmRequest): JsonRpcMessage { + val params = request.params + val id = request.id + val name = params?.get("name")?.jsonPrimitive?.content ?: request.method + val args = params?.get("arguments")?.jsonObject ?: buildJsonObject {} + val caller = + params + ?.get("_meta") + ?.jsonObject + ?.get("clientPubkey") + ?.jsonPrimitive + ?.content + calls += Call(name, caller, args) + + scriptedResults[name]?.let { return success(id, it) } + + val structured = + when (name) { + CoordinatorMethod.KP_PUBLISH.wire -> { + if (rejectPublication) { + return JsonRpcFailure(id, JsonRpcError(-32000, "publication rejected")) + } + val ref = args.str(CoordinatorFields.KP_REF) + // `spec/00.md` §7: cordn has no KeyPackage event kind, so the + // signed publication payload IS this request event. A real + // coordinator reaches back into its transport for it and + // stores it verbatim; storing anything else here would make + // `kp_take` unverifiable and hide the §9 checks from tests. + // Whether it is reusable is a property of the KeyPackage + // itself, so a real coordinator reads it out of the bytes + // rather than trusting a flag. Ours does the same, which is + // why `kp_list` can report it and a take can respect it. + val lastResort = isLastResort(args.str(CoordinatorFields.KP_64)) + publications[ref] = StoredKeyPackage(caller.orEmpty(), ref, lastResort, clock++, request.event) + buildJsonObject { + put(CoordinatorFields.KP_REF, ref) + put(CoordinatorFields.LAST_RESORT, lastResort) + put(CoordinatorFields.AT, clock) + } + } + + CoordinatorMethod.KP_LIST.wire -> + buildJsonObject { + put( + CoordinatorFields.KEY_PACKAGES, + buildJsonArray { + publications.values.forEach { + add( + buildJsonObject { + put(CoordinatorFields.PK, it.pubKey) + put(CoordinatorFields.KP_REF, it.keyPackageRef) + put(CoordinatorFields.LAST_RESORT, it.lastResort) + put(CoordinatorFields.AT, it.at) + }, + ) + } + }, + ) + } + + CoordinatorMethod.KP_TAKE.wire -> { + val id = args.str(CoordinatorFields.ID) + val stored = publications[id] ?: publications.values.firstOrNull { it.pubKey == id } + // A take is a take: the method is `consumeKeyPackage` and a + // single-use KeyPackage is gone once somebody has it, which + // is what makes a client's pool drain and need topping up. + // A last-resort package survives, by definition -- it can + // back several Welcomes. + if (stored != null && !stored.lastResort) publications.remove(stored.keyPackageRef) + buildJsonObject { + if (stored?.publicationEvent == null) { + put(CoordinatorFields.KEY_PACKAGE, JsonNull) + } else { + put( + CoordinatorFields.KEY_PACKAGE, + buildJsonObject { + put(CoordinatorFields.PK, stored.pubKey) + put(CoordinatorFields.KP_REF, stored.keyPackageRef) + put(CoordinatorFields.LAST_RESORT, stored.lastResort) + put(CoordinatorFields.AT, stored.at) + put( + CoordinatorFields.EVENT, + Json.parseToJsonElement(OptimizedJsonMapper.toJson(stored.publicationEvent)), + ) + }, + ) + } + } + } + + CoordinatorMethod.KP_REMOVE.wire -> { + if (rejectRemoval) { + return JsonRpcFailure(id, JsonRpcError(-32000, "removal rejected")) + } + val refs = args[CoordinatorFields.KP_REFS]!!.jsonArray.map { it.jsonPrimitive.content } + refs.forEach { publications.remove(it) } + buildJsonObject { + put(CoordinatorFields.KP_REFS, buildJsonArray { refs.forEach { add(Json.parseToJsonElement("\"$it\"")) } }) + } + } + + CoordinatorMethod.WELCOME_TAKE.wire -> { + val consumed = + args[CoordinatorFields.CONSUMED] + ?.jsonArray + ?.map { + it.jsonObject.str(CoordinatorFields.KP_REF) to it.jsonObject[CoordinatorFields.AT]!!.jsonPrimitive.long + }.orEmpty() + welcomes.removeAll { w -> + consumed.any { it.first == w.str(CoordinatorFields.KP_REF) && it.second == w[CoordinatorFields.AT]!!.jsonPrimitive.long } + } + // Addressed, not broadcast: `welcome-delivery.md` has the + // coordinator store a Welcome "addressed to a specific + // invited member" and serve it on that member's fetch. + // Returning everyone's would let one account join a group it + // was never invited to -- and would make a two-party test + // pass for the wrong reason. + val mine = welcomes.filter { it.str(CoordinatorFields.TARGET_PK) == caller } + buildJsonObject { put(CoordinatorFields.WELCOMES, buildJsonArray { mine.forEach { add(it) } }) } + } + + CoordinatorMethod.WELCOME_STORE.wire -> { + // The stored record is the arguments PLUS the `at` the + // coordinator assigns: `welcome_take` reports it, and + // `consumed` identifies a record by (kp_ref, at) because one + // last-resort KeyPackage can back several Welcomes. Storing + // the bare arguments loses it -- which only a real + // store-then-fetch round trip notices. + val at = clock++ + welcomes += JsonObject(args + mapOf(CoordinatorFields.AT to JsonPrimitive(at))) + buildJsonObject { put(CoordinatorFields.AT, at) } + } + + CoordinatorMethod.JOIN_REQUEST_STORE.wire -> { + // One `at`, stored and returned. Taking it once and using + // it twice is the point: the caller acks with what it was + // told, so a stored value that differs by even one can + // never be retired. + val at = clock++ + joinRequests += + buildJsonObject { + put(CoordinatorFields.GID, args.str(CoordinatorFields.GID)) + put(CoordinatorFields.PK, caller.orEmpty()) + put(CoordinatorFields.KP_REF, args.str(CoordinatorFields.KP_REF)) + put(CoordinatorFields.AT, at) + } + buildJsonObject { put(CoordinatorFields.AT, at) } + } + + CoordinatorMethod.JOIN_REQUEST_TAKE_MANY.wire -> { + // `consumed` retires, exactly as `welcome_take` does above. + // Ignoring it made retirement a no-op in every test that + // runs against this fixture, so an answered request came + // back forever and nothing could catch it. + val consumed = + args[CoordinatorFields.CONSUMED] + ?.jsonArray + ?.map { + Triple( + it.jsonObject.str(CoordinatorFields.GID), + it.jsonObject.str(CoordinatorFields.PK), + it.jsonObject[CoordinatorFields.AT]!!.jsonPrimitive.long, + ) + }.orEmpty() + joinRequests.removeAll { r -> + consumed.any { + it.first == r.str(CoordinatorFields.GID) && + it.second == r.str(CoordinatorFields.PK) && + it.third == r[CoordinatorFields.AT]!!.jsonPrimitive.long + } + } + + val gids = args[CoordinatorFields.GROUPS]!!.jsonArray.map { it.jsonObject.str(CoordinatorFields.GID) } + buildJsonObject { + put( + CoordinatorFields.REQUESTS, + buildJsonArray { joinRequests.filter { it.str(CoordinatorFields.GID) in gids }.forEach { add(it) } }, + ) + } + } + + CoordinatorMethod.MSG_POST.wire -> { + val gid = args.str(CoordinatorFields.GID) + val cursor = seed(gid, args.str(CoordinatorFields.MSG_64)) + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.CURSOR, cursor) + put(CoordinatorFields.AT, clock) + } + } + + CoordinatorMethod.MSG_FETCH_MANY.wire -> + buildJsonObject { + put(CoordinatorFields.MESSAGES, buildJsonArray { after(args).forEach { add(it) } }) + } + + CoordinatorMethod.MSG_SUB_MANY.wire -> { + // The one tool whose answer is not its result. A real + // coordinator holds the call open and pushes each message + // as a CEP-41 stream fragment; the result arrives when the + // stream ends. [emitStream] is how a fixture reaches the + // transport to do that -- the handler signature returns one + // message, which is exactly what a subscription is not. + val token = progressToken(params) + val emit = emitStream + if (token != null && emit != null) { + var progress = 1.0 + val frames = mutableListOf() + frames += OpenStreamFrame.start(token, progress).envelope.toNotification() + after(args).forEachIndexed { index, message -> + progress += 1.0 + frames += + OpenStreamFrame + .chunk(token, progress, index.toLong(), Json.encodeToString(JsonObject.serializer(), message)) + .envelope + .toNotification() + } + frames += OpenStreamFrame.close(token, progress + 1.0).envelope.toNotification() + emit(frames, caller.orEmpty(), request.event.id) + } + buildJsonObject {} + } + + else -> buildJsonObject {} + } + + return success(id, structured) + } + + private fun success( + id: JsonRpcId, + structured: JsonObject, + ) = JsonRpcSuccess( + id, + buildJsonObject { + put("content", buildJsonArray {}) + put(CoordinatorFields.STRUCTURED_CONTENT, structured) + }, + ) + + /** The messages each requested group has after its cursor. */ + fun after(args: JsonObject): List = + args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry -> + val group = entry.jsonObject + val gid = group.str(CoordinatorFields.GID) + val cursor = group[CoordinatorFields.AFTER]?.jsonPrimitive?.long ?: 0L + messages[gid].orEmpty().filter { it[CoordinatorFields.CURSOR]!!.jsonPrimitive.long > cursor } + } + + private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnCredential.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnCredential.kt new file mode 100644 index 0000000000..8b198457b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnCredential.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.groups + +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * How cordn writes a Nostr pubkey into an MLS BasicCredential. + * + * **64 ASCII bytes of lowercase hex, not the 32 raw bytes.** `spec/00.md` §6 + * requires "the canonical encoded Nostr public key" and then explicitly + * declines to say which encoding, leaving it "an application-wide convention + * to be fixed uniformly by implementations". The reference implementation + * fixed it as UTF-8 of the hex string + * (`packages/cli/src/utils/mlsIdentity.ts:createCredential`), and the + * coordinator reads it back with a `TextDecoder` and string-compares it to the + * calling event's pubkey (`packages/server/src/coordinatorMethods.ts:128`). + * + * Marmot picked the other one — raw 32 bytes — so a Marmot KeyPackage and a + * cordn KeyPackage are not interchangeable, and this is the single line where + * that divergence lives. It is a one-line change on either side today and + * unfixable once either has users at scale; see §4.1 of + * `quartz/plans/2026-09-17-cordn-interop.md`, which is still open upstream. + * + * A credential is a CLAIM, never proof. `spec/00.md` §6 is explicit that + * BasicCredential alone establishes nothing; the binding comes from the signed + * publication payload, which [com.vitorpamplona.quartz.cordn.spec00Coordinator] + * verifies. + */ +object CordnCredential { + /** The BasicCredential a cordn leaf carries for [pubKeyHex]. */ + fun of(pubKeyHex: HexKey): Credential.Basic { + require(isCanonical(pubKeyHex)) { + "cordn credential identity must be 64 lowercase hex chars, was '$pubKeyHex'" + } + return Credential.Basic(pubKeyHex.encodeToByteArray()) + } + + /** + * The account hex this credential claims, or null if it is not a cordn + * one. + * + * Null rather than an exception: a leaf in a mixed or malformed tree is an + * ordinary thing to walk past, and the callers that care about the + * difference check it explicitly. + */ + fun identityOrNull(credential: Credential?): HexKey? { + val identity = (credential as? Credential.Basic)?.identity ?: return null + if (identity.size != HEX_LENGTH) return null + val hex = + try { + identity.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + return null + } + return if (isCanonical(hex)) hex else null + } + + /** The account hex claimed by [leaf], or null. */ + fun identityOrNull(leaf: LeafNode?): HexKey? = identityOrNull(leaf?.credential) + + /** + * Every member's account hex, by leaf index. + * + * Use this rather than `MlsGroup.memberIdentityHex`, which hex-encodes the + * credential bytes — correct for a binding that stores a raw key, and for + * cordn it returns 128 characters of hex-of-hex. Leaves whose credential is + * not a cordn identity are skipped rather than reported as garbage. + */ + fun membersOf(group: MlsGroup): Map = group.members().mapNotNull { (index, leaf) -> identityOrNull(leaf)?.let { index to it } }.toMap() + + /** The set of accounts holding at least one leaf. One account may hold several. */ + fun memberIdentities(group: MlsGroup): Set = membersOf(group).values.toSet() + + private fun isCanonical(hex: String) = hex.length == HEX_LENGTH && hex.all { it in HEX_ALPHABET } + + private const val HEX_LENGTH = 64 + private const val HEX_ALPHABET = "0123456789abcdef" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnGroupPolicy.kt new file mode 100644 index 0000000000..a83066cf05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnGroupPolicy.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.groups + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.group.GroupView +import com.vitorpamplona.quartz.mls.group.MlsExporterLabel +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.PendingProposal +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * cordn's profile, as an [MlsGroupPolicy]. + * + * Pass it wherever an [MlsGroup] is created, joined or restored and the group + * gets cordn's capabilities, its `required_capabilities`, its extension + * registry and its payload exporter in one argument: + * + * ```kotlin + * val group = MlsGroup.create( + * CordnCredential.of(myPubKeyHex).identity, + * policy = CordnGroupPolicy, + * initialExtensions = listOf(metadata.toExtension()), + * ) + * ``` + * + * ## Admin authorization + * + * `spec/01.md` §5.3 leaves the meaning of `admin_pubkeys` to the application + * and says only that an EMPTY list is egalitarian mode. The reference client + * fills that in, and because MLS has no server that can police membership, + * it does so on both sides of every commit: it refuses to build an add, a + * remove or a metadata change unless the local member is an admin, and it + * rejects an inbound commit carrying one of those from a member who is not. + * + * [authorizeCommit] implements exactly that rule, and the engine calls it in + * both directions, so one check covers both. Matching it is not cosmetic — + * accepting a commit the rest of the group rejects forks the epoch, and MLS + * does not recover from a fork. Being *stricter* than the reference would fork + * it the other way, which is why the gate is the reference's three proposal + * types and nothing else: an Update, a SelfRemove or a PSK from any member + * stays allowed, so nobody can be trapped in a group they may not leave. + * + * Egalitarian mode is a permanent choice, not a bootstrap window: an empty + * list means every member administers, so the gate opens rather than closes. + */ +object CordnGroupPolicy : MlsGroupPolicy { + /** + * `spec/01.md` §7: a client claiming support for `cordn_group_metadata` + * MUST advertise `0xC04D`, and a group using the extension must not add a + * member who does not. + * + * Nothing else is listed. RFC 9420 §7.2 forbids advertising DEFAULT types, + * and cordn requires no non-default proposal — in particular no + * `self_remove`, which Marmot requires and cordn does not. + */ + override val defaultLeafCapabilities: Capabilities + get() = Capabilities(extensions = listOf(CordnGroupMetadata.EXTENSION_TYPE)) + + /** + * None, deliberately. + * + * `spec/01.md` §6 requires a member of a group using `0xC04D` to advertise + * it, and the reference client does — so RFC 9420 §13.4 can be enforced + * here from capabilities alone, with no exemption to weaken it. cordn has + * no deployed groups predating that rule to be kind to. + */ + override val knownExtensionTypes: Set = emptySet() + + /** + * None. + * + * `spec/01.md` §6 says a group MAY omit the metadata extension entirely + * and stay valid, so requiring it at epoch 0 would refuse groups the spec + * allows. The reference client agrees: it advertises `0xC04D` in + * capabilities and installs no `required_capabilities` + * (`packages/cli/src/utils/mlsIdentity.ts:createKeyPackageCapabilities`). + * + * A caller that wants the stricter group passes + * [requiredCapabilitiesExtension] explicitly. + */ + override val defaultRequiredCapabilities: Extension? get() = null + + /** + * `MLS-Exporter("cordn", "group-payload", 32)` — `spec/03.md` §4. + * + * Identical machinery to Marmot's seal and a different key by one label, + * which is the whole reason the two never decrypt each other's traffic. + */ + override val commitExporter: MlsExporterLabel get() = PAYLOAD_EXPORTER + + /** `MLS-Exporter("cordn", "group-payload", 32)`, for application messages too. */ + val PAYLOAD_EXPORTER = MlsExporterLabel("cordn", "group-payload".encodeToByteArray(), 32) + + /** + * `MLS-Exporter("cordn", "encrypted-media", 32)`. + * + * `spec/applications/encrypted-media.md` §3.1: the media key is derived + * from the epoch's exporter secret and is never transmitted. A separate + * context from [PAYLOAD_EXPORTER] because the two layers are independent — + * the spec is explicit that they "use distinct exporter contexts and do not + * interact". + */ + val MEDIA_EXPORTER = MlsExporterLabel("cordn", "encrypted-media".encodeToByteArray(), 32) + + /** + * An explicit `required_capabilities` naming `0xC04D`, for a group that + * wants every member to be able to read its metadata. + * + * Optional by design (see [defaultRequiredCapabilities]). Installing it + * makes the group refuse any leaf that does not advertise `0xC04D` — + * including, today, every Marmot KeyPackage. + */ + fun requiredCapabilitiesExtension(): Extension { + val writer = TlsWriter() + val exts = TlsWriter() + exts.putUint16(CordnGroupMetadata.EXTENSION_TYPE) + writer.putOpaqueVarInt(exts.toByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } + + /** + * The KeyPackage-level `app_data_dictionary` marking a last-resort + * KeyPackage. + * + * `spec/00.md` §11 defers to "the last-resort extension defined by MLS", + * and the reference implementation reads that as the extensions-draft + * component `0x0004` inside `app_data_dictionary` (`0x0006`), with empty + * data (`packages/core/src/lastResortKeyPackage.ts`). Marmot's MIP-era + * profile instead sets bare extension `0x000A`, so the two disagree about + * how a last-resort KeyPackage is even recognised — our + * `MlsKeyPackage.isLastResort()` accepts both carriers, which is why it + * reads a cordn KeyPackage correctly. + */ + fun lastResortExtension(): Extension = AppDataDictionary(listOf(ComponentData(ComponentsList.LAST_RESORT_KEY_PACKAGE_ID, ByteArray(0)))).toExtension() + + /** + * Leaf capabilities for a last-resort KeyPackage: [defaultLeafCapabilities] + * plus `app_data_dictionary`, since the leaf must say it understands the + * carrier it is using. + */ + fun lastResortLeafCapabilities(): Capabilities = + Capabilities( + extensions = listOf(CordnGroupMetadata.EXTENSION_TYPE, AppDataDictionary.EXTENSION_TYPE), + ) + + /** + * Refuses a commit that adds, removes or rewrites metadata on behalf of a + * member `admin_pubkeys` does not name. See the class KDoc for why this + * matches the reference client exactly rather than approximately. + * + * [committerLeafIndex] is the committer, which is who the reference checks + * for a commit. A proposal one member sent by reference and an admin then + * committed is therefore allowed — on both implementations, the admin who + * committed it is the one answering for it. + */ + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + if (proposals.isEmpty()) return + + if (adminIdentitiesIn(group.extensions).isEmpty()) return + if (proposals.none { it.proposal.needsAdmin() }) return + + check(isAdminLeaf(group, committerLeafIndex)) { + "cordn: only admin_pubkeys may add, remove or rewrite group metadata; leaf " + + "$committerLeafIndex is not an admin" + } + } + + /** + * The admin set [extensions] names, or empty for egalitarian. + * + * A metadata extension too malformed to decode reads as egalitarian rather + * than taking the group down with it — the same call Marmot's policy makes + * about its own components. It is not a way in: installing metadata takes a + * GroupContextExtensions commit, which this gate already covers, so nobody + * outside the admin set can put a broken extension there in the first + * place. A group whose metadata was malformed from creation was never + * administrable by anyone. + */ + fun adminIdentitiesIn(extensions: List): Set = + runCatching { CordnGroupMetadata.fromExtensions(extensions)?.adminPubkeys } + .getOrNull() + .orEmpty() + .toSet() + + /** True if the account [pubKey] may add, remove or rewrite metadata here. */ + fun isAdmin( + group: GroupView, + pubKey: HexKey, + ): Boolean { + val admins = adminIdentitiesIn(group.extensions) + return admins.isEmpty() || pubKey in admins + } + + /** + * True if the member at [leafIndex] may. + * + * The comparison happens in credential-bytes space, not account space, and + * deliberately: [GroupView.memberIdentityHex] hexes whatever the credential + * holds, and a cordn credential holds the account key as its 64 ASCII hex + * characters (see [CordnCredential]), so that accessor returns 128 + * characters which are never a pubkey. Marmot stores raw bytes and can + * compare directly; comparing an account pubkey against this without + * converting would match nothing and reject every commit in a group that + * names admins. Mapping the admin list forwards rather than decoding the + * leaf backwards keeps untrusted bytes out of the decoder entirely. + */ + fun isAdminLeaf( + group: GroupView, + leafIndex: Int, + ): Boolean { + val admins = adminIdentitiesIn(group.extensions) + if (admins.isEmpty()) return true + + val credentialHex = group.memberIdentityHex(leafIndex) ?: return false + return credentialHex in admins.mapTo(mutableSetOf()) { it.encodeToByteArray().toHexKey() } + } + + /** True if the local member may. */ + fun isLocalAdmin(group: GroupView): Boolean = isAdminLeaf(group, group.myLeafIndex) + + /** + * The three proposal types the reference client gates, and only those: + * `add`, `remove` and `group_context_extensions`, matching its + * `addMember` / `removeMember` / `updateGroupMetadata`. + */ + private fun Proposal.needsAdmin(): Boolean = this is Proposal.Add || this is Proposal.Remove || this is Proposal.GroupContextExtensions +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorAdvertisement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorAdvertisement.kt new file mode 100644 index 0000000000..56e241d201 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorAdvertisement.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools + +/** + * Recognising a cordn coordinator in a crowd of ContextVM servers. + * + * ## There is no cordn marker + * + * cordn defines no announcement kind, no `t` tag and no registry — a + * coordinator announces itself with the same CEP-6 pair (kind 11316 + 11317) + * as any other MCP server. So "is this a coordinator?" can only be answered + * the way MCP answers every question about a server: by what it serves. + * + * Measured against the public relays on 2026-09-22: of the newest 100 kind + * 11317 announcements, 42 advertised exactly this toolset and 58 were + * unrelated MCP servers (metadata, CI, currency). The names separate them + * cleanly, which is what makes this predicate worth having rather than a + * guess. + * + * ## What it does NOT establish + * + * An announcement is a claim, signed only by the key that was going to sign it + * anyway. Matching here means a server *says* it serves these eleven tools; it + * does not mean the server is reachable, is still running, implements them + * correctly, or is trustworthy. Nothing in the protocol should branch on it — + * it is a filter for a list a human then chooses from, and `spec/00.md` §8.5 + * still holds that a coordinator's identity is its pubkey and nothing else. + */ +object CoordinatorAdvertisement { + /** + * The eleven tools of `spec/00.md`, by wire name. + * + * Derived from [CoordinatorMethod] rather than written out, so a tool added + * to the protocol cannot be left out of the predicate. + */ + val REQUIRED_TOOLS: Set = CoordinatorMethod.entries.mapTo(LinkedHashSet()) { it.wire } + + /** + * Whether an announced tool list is a cordn coordinator's. + * + * Extra tools do not disqualify: a server may serve cordn alongside + * anything else, and a client that refused those would exclude a + * coordinator for offering more than the minimum. + */ + fun matches(tools: AnnouncedTools): Boolean = tools.serves(REQUIRED_TOOLS) + + /** The tools of [REQUIRED_TOOLS] this server does not advertise. */ + fun missingFrom(tools: AnnouncedTools): Set = REQUIRED_TOOLS - tools.names +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClient.kt new file mode 100644 index 0000000000..8e2044f6de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClient.kt @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.TimeoutMode +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The cordn coordinator's eleven tools, typed. + * + * ## Identity is not a parameter + * + * Each method's signing identity comes from [CoordinatorMethod], not from the + * call site. That is the point: `msg_post` under the stable key would tie every + * message you send to your real npub, forever, on a coordinator that keeps + * ordered history — and it would look exactly like a working call. There is no + * override, so the mistake is not available. + * + * Read [CoordinatorMethod]'s KDoc before assuming the split buys more than it + * does. Admission (`join_request_store`, `welcome_store`/`welcome_take`) names + * real keys on both ends by design. + * + * ## Encryption is pinned + * + * The transport must be configured to require encryption. The ContextVM SDK + * defaults to `OPTIONAL`, which resolves from negotiated session state, so a + * coordinator that simply does not announce `support_encryption` gets + * plaintext JSON-RPC on public relays — `gid`s, target pubkeys, KeyPackages and + * cursors, readable by any relay operator. `:contextvm`'s `CvmGiftWrap` + * defaults to `REQUIRED` and fails closed; this class does not undo that. + */ +class CoordinatorClient( + private val mcp: CvmMcpClient, + /** Applied to every call. Coordinator work is storage, not computation. */ + private val timeoutMs: Long = CvmTransport.DEFAULT_TIMEOUT_MS, +) : ICoordinator { + /** Performs the MCP handshake. Optional, but it is where CEP-35 tags ride. */ + suspend fun initialize() = mcp.initialize() + + override val oversizedTransfers get() = mcp.oversizedTransfersCompleted + + /** + * Handshakes and reports what the coordinator says about itself. + * + * Null when the server answered but named nothing, which MCP allows. Every + * field is a claim the coordinator makes about itself — see + * [CoordinatorServerInfo]; the pubkey is the identity (§8.5). + */ + suspend fun serverInfo(): CoordinatorServerInfo? = CoordinatorServerInfo.from(initialize())?.takeIf { !it.isEmpty } + + // ---- stable identity ------------------------------------------------- + + /** + * Publishes a KeyPackage. + * + * The signed publication payload §7 requires IS this call's request event, + * which the coordinator stores verbatim and serves back from [takeKeyPackage]. + * Nothing extra is signed here, and nothing else can be: the transport owns + * the event. + */ + override suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage = + call(CoordinatorMethod.KP_PUBLISH, KeyPackagePublication.arguments(keyPackageRef, keyPackageBase64)).let { + PublishedKeyPackage( + keyPackageRef = it.str(CoordinatorFields.KP_REF), + lastResort = it.bool(CoordinatorFields.LAST_RESORT), + at = it.num(CoordinatorFields.AT), + ) + } + + /** Withdraws published KeyPackages. Authorized by us being their owner (§12). */ + override suspend fun removeKeyPackages(keyPackageRefs: List): List { + require(keyPackageRefs.isNotEmpty()) { "kp_remove needs at least one ref" } + val args = + buildJsonObject { + put(CoordinatorFields.KP_REFS, buildJsonArray { keyPackageRefs.forEach { add(JsonPrimitive(it)) } }) + } + return call(CoordinatorMethod.KP_REMOVE, args)[CoordinatorFields.KP_REFS] + ?.jsonArray + ?.map { it.jsonPrimitive.content } + .orEmpty() + } + + /** + * Drains this account's pending Welcomes, acknowledging any already joined. + * + * [consumed] retires records the coordinator would otherwise keep serving. + * A last-resort KeyPackage can back several Welcomes, which is why a record + * is identified by `(kp_ref, at)` rather than by `kp_ref` alone. + */ + override suspend fun takeWelcomes(consumed: List): List { + val args = + buildJsonObject { + if (consumed.isNotEmpty()) { + put( + CoordinatorFields.CONSUMED, + buildJsonArray { + consumed.forEach { + add( + buildJsonObject { + put(CoordinatorFields.KP_REF, it.keyPackageRef) + put(CoordinatorFields.AT, it.at) + }, + ) + } + }, + ) + } + } + return call(CoordinatorMethod.WELCOME_TAKE, args).list(CoordinatorFields.WELCOMES) { + PendingWelcome( + keyPackageRef = it.str(CoordinatorFields.KP_REF), + welcomeBase64 = it.str(CoordinatorFields.WELCOME_64), + at = it.num(CoordinatorFields.AT), + after = it.numOrNull(CoordinatorFields.AFTER), + ) + } + } + + /** + * Asks to join [gid] with [keyPackageRef]. + * + * Rides the stable identity because the request is "npub X wants into group + * G" — there is no version of this call that does not name the asker. + */ + override suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long = + call( + CoordinatorMethod.JOIN_REQUEST_STORE, + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.KP_REF, keyPackageRef) + }, + ).num(CoordinatorFields.AT) + + // ---- ephemeral identity ---------------------------------------------- + + /** Every KeyPackage this coordinator holds. */ + override suspend fun listKeyPackages(): List = + call(CoordinatorMethod.KP_LIST, buildJsonObject {}).list(CoordinatorFields.KEY_PACKAGES) { + AvailableKeyPackage( + pubKey = it.str(CoordinatorFields.PK), + keyPackageRef = it.str(CoordinatorFields.KP_REF), + lastResort = it.bool(CoordinatorFields.LAST_RESORT), + at = it.num(CoordinatorFields.AT), + ) + } + + /** + * Takes a KeyPackage by ref or by account hex (`id` accepts either). + * + * The result is NOT usable until [KeyPackagePublication.verify] has passed + * on its publication event. Null means the coordinator holds nothing + * matching. + */ + override suspend fun takeKeyPackage(id: String): TakenKeyPackage? { + val result = call(CoordinatorMethod.KP_TAKE, buildJsonObject { put(CoordinatorFields.ID, id) }) + val entry = result[CoordinatorFields.KEY_PACKAGE]?.takeIf { it is JsonObject }?.jsonObject ?: return null + val eventJson = + entry[CoordinatorFields.EVENT]?.jsonObject + ?: throw CoordinatorException("kp_take result carries no publication event") + return TakenKeyPackage( + pubKey = entry.str(CoordinatorFields.PK), + keyPackageRef = entry.str(CoordinatorFields.KP_REF), + lastResort = entry.bool(CoordinatorFields.LAST_RESORT), + at = entry.num(CoordinatorFields.AT), + publicationEvent = parseEvent(eventJson), + ) + } + + /** + * Leaves a Welcome for [targetPubKey]. + * + * [after] tells the joiner which cursor to start their history from, so + * they do not replay epochs they cannot decrypt. + */ + override suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long?, + ): Long = + call( + CoordinatorMethod.WELCOME_STORE, + buildJsonObject { + put(CoordinatorFields.TARGET_PK, targetPubKey) + put(CoordinatorFields.KP_REF, keyPackageRef) + put(CoordinatorFields.WELCOME_64, welcomeBase64) + after?.let { put(CoordinatorFields.AFTER, it) } + }, + ).num(CoordinatorFields.AT) + + /** Drains join requests for the groups we administer, acknowledging handled ones. */ + override suspend fun takeJoinRequests( + gids: List, + consumed: List, + ): List { + require(gids.isNotEmpty()) { "join_request_take_many needs at least one group" } + val args = + buildJsonObject { + put( + CoordinatorFields.GROUPS, + buildJsonArray { gids.forEach { gid -> add(buildJsonObject { put(CoordinatorFields.GID, gid) }) } }, + ) + if (consumed.isNotEmpty()) { + put( + CoordinatorFields.CONSUMED, + buildJsonArray { + consumed.forEach { + add( + buildJsonObject { + put(CoordinatorFields.GID, it.gid) + put(CoordinatorFields.PK, it.pubKey) + put(CoordinatorFields.AT, it.at) + }, + ) + } + }, + ) + } + } + return call(CoordinatorMethod.JOIN_REQUEST_TAKE_MANY, args).list(CoordinatorFields.REQUESTS) { + JoinRequest( + gid = it.str(CoordinatorFields.GID), + pubKey = it.str(CoordinatorFields.PK), + keyPackageRef = it.str(CoordinatorFields.KP_REF), + at = it.num(CoordinatorFields.AT), + ) + } + } + + /** Posts one sealed payload to a group's stream. */ + override suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage = + call( + CoordinatorMethod.MSG_POST, + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.MSG_64, sealedBase64) + }, + ).let { + PostedMessage( + gid = it.str(CoordinatorFields.GID), + cursor = it.num(CoordinatorFields.CURSOR), + at = it.num(CoordinatorFields.AT), + ) + } + + /** + * Fetches history for several groups, each after its own cursor. + * + * One page. The coordinator decides how many it returns, so a caller + * catching up loops until a page comes back empty — see + * [com.vitorpamplona.quartz.cordn.sync.GroupSync]. + */ + override suspend fun fetchMessages(cursors: Map): List { + require(cursors.isNotEmpty()) { "msg_fetch_many needs at least one group" } + val args = buildJsonObject { put(CoordinatorFields.GROUPS, groupsArray(cursors)) } + return call(CoordinatorMethod.MSG_FETCH_MANY, args).list(CoordinatorFields.MESSAGES, ::groupMessage) + } + + /** + * Subscribes to live delivery. + * + * Each message arrives as a CEP-41 open-stream fragment whose payload is + * one `GroupMessage` JSON object. The call does not return until the + * coordinator closes the stream, so run it in its own coroutine; `close` + * does not complete the request, so the returned list is the whole run's + * traffic, not a partial view. + */ + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + require(cursors.isNotEmpty()) { "msg_sub_many needs at least one group" } + val args = buildJsonObject { put(CoordinatorFields.GROUPS, groupsArray(cursors)) } + val result = + mcp.callTool( + name = CoordinatorMethod.MSG_SUB_MANY.wire, + arguments = args, + identity = CoordinatorMethod.MSG_SUB_MANY.identity, + timeoutMs = timeoutMs, + // A budget, not a failure: the caller re-opens on it, and a + // busy stream bounded by silence would never come back. + timeoutMode = TimeoutMode.TOTAL, + onStreamFragment = { fragment -> + // A malformed frame is the coordinator's problem, not a + // reason to tear down a live subscription over other groups. + runCatching { groupMessage(Json.parseToJsonElement(fragment).jsonObject) } + .getOrNull() + ?.let(onMessage) + }, + ) + if (result.isError) throw CoordinatorException("msg_sub_many failed: ${result.error?.message}") + } + + // ---- plumbing -------------------------------------------------------- + + private fun groupsArray(cursors: Map) = + buildJsonArray { + cursors.forEach { (gid, after) -> + add( + buildJsonObject { + put(CoordinatorFields.GID, gid) + // The schema types `after` as a positive int, so a + // first-ever fetch omits it rather than sending 0. + after?.takeIf { it > 0 }?.let { put(CoordinatorFields.AFTER, it) } + }, + ) + } + } + + private fun groupMessage(json: JsonObject) = + GroupMessage( + gid = json.str(CoordinatorFields.GID), + cursor = json.num(CoordinatorFields.CURSOR), + sealedBase64 = json.str(CoordinatorFields.MSG_64), + at = json.num(CoordinatorFields.AT), + ) + + private fun parseEvent(json: JsonObject): Event = + try { + OptimizedJsonMapper.fromJson(json.toString()) + } catch (e: Exception) { + throw CoordinatorException("coordinator served a publication event we cannot parse", e) + } + + private suspend fun call( + method: CoordinatorMethod, + arguments: JsonObject, + ): JsonObject { + val result = mcp.callTool(method.wire, arguments, method.identity, timeoutMs) + if (result.isError) { + throw CoordinatorException("${method.wire} failed: ${result.error?.message ?: "unknown error"}") + } + val body = result.result as? JsonObject ?: throw CoordinatorException("${method.wire} returned no result object") + return body[CoordinatorFields.STRUCTURED_CONTENT]?.jsonObject + ?: throw CoordinatorException("${method.wire} returned no structuredContent") + } + + private fun JsonObject.list( + key: String, + map: (JsonObject) -> T, + ): List = this[key]?.jsonArray?.map { map(it.jsonObject) }.orEmpty() + + private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.content ?: throw CoordinatorException("coordinator result is missing `$key`") + + private fun JsonObject.num(key: String) = this[key]?.jsonPrimitive?.long ?: throw CoordinatorException("coordinator result is missing `$key`") + + private fun JsonObject.numOrNull(key: String) = this[key]?.jsonPrimitive?.long + + private fun JsonObject.bool(key: String) = this[key]?.jsonPrimitive?.content?.toBoolean() ?: false +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorMethods.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorMethods.kt new file mode 100644 index 0000000000..d3c7ed7d89 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorMethods.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.transport.DualSigner + +/** + * The coordinator's eleven MCP tools, and which identity each one rides. + * + * Names and argument keys are from + * `packages/core/src/contracts.ts` — the reference implementation's zod + * schemas, which are the only normative statement of the wire shape + * (`spec/00.md` describes the model, not the field names). + * + * ## The identity column is the privacy model + * + * Every call carries an authenticated caller pubkey that the coordinator + * derives from the signed inner 25910 event, so "which key signed this" is not + * something a client can decline to answer — only something it can choose. + * cordn-web splits them deliberately, and this enum records that split so a + * caller cannot get it wrong by omission: a stable-identity call is one where + * the coordinator MUST know who you are (it is your inbox, or your KeyPackage, + * or your request to join), and everything else rides a throwaway. + * + * The split is real but partial, and §8 of the plan has the detail. Two things + * worth knowing at the call site: + * + * - **Admission is in the clear on both ends.** `join_request_store` names + * your real key and the `gid`; `welcome_store` names the target's real key + * and `welcome_take` is called by it. For any group joined through a share + * link the coordinator observes real-identity membership directly. That is + * structural, not a bug to route around. + * - **The ephemeral key is per session, not per message.** One pseudonym posts, + * fetches and subscribes across every group you hold on that coordinator for + * the life of the session, so the `gid` set it touches is a stable + * fingerprint linking those groups together. [CoordinatorClient] takes the + * throwaway signer from its caller precisely so the caller can decide how + * often to rotate it. + */ +enum class CoordinatorMethod( + val wire: String, + val identity: DualSigner.Identity, +) { + /** Publish a KeyPackage. The publication payload IS this call's signed event. */ + KP_PUBLISH("kp_publish", DualSigner.Identity.STABLE), + + /** Withdraw published KeyPackages. Authorized by the caller being their owner. */ + KP_REMOVE("kp_remove", DualSigner.Identity.STABLE), + + /** Drain this account's pending Welcomes. It is our own inbox. */ + WELCOME_TAKE("welcome_take", DualSigner.Identity.STABLE), + + /** Ask to join a group. The whole point is to name who is asking. */ + JOIN_REQUEST_STORE("join_request_store", DualSigner.Identity.STABLE), + + /** Look up someone's KeyPackages. Reveals "who is being added to a group". */ + KP_LIST("kp_list", DualSigner.Identity.EPHEMERAL), + + /** Consume a KeyPackage by ref. */ + KP_TAKE("kp_take", DualSigner.Identity.EPHEMERAL), + + /** Leave a Welcome for someone. Names the target, not the sender. */ + WELCOME_STORE("welcome_store", DualSigner.Identity.EPHEMERAL), + + /** Drain join requests for groups we administer. */ + JOIN_REQUEST_TAKE_MANY("join_request_take_many", DualSigner.Identity.EPHEMERAL), + + /** Post a sealed payload. */ + MSG_POST("msg_post", DualSigner.Identity.EPHEMERAL), + + /** Catch up on a group's history. */ + MSG_FETCH_MANY("msg_fetch_many", DualSigner.Identity.EPHEMERAL), + + /** Live delivery, over a CEP-41 open stream. */ + MSG_SUB_MANY("msg_sub_many", DualSigner.Identity.EPHEMERAL), +} + +/** Argument and result field names, from `packages/core/src/contracts.ts`. */ +object CoordinatorFields { + const val KP_REF = "kp_ref" + const val KP_REFS = "kp_refs" + const val KP_64 = "kp_64" + const val ID = "id" + const val PK = "pk" + const val LAST_RESORT = "last_resort" + const val AT = "at" + const val EVENT = "event" + const val KEY_PACKAGE = "keyPackage" + const val KEY_PACKAGES = "keyPackages" + const val TARGET_PK = "target_pk" + const val WELCOME_64 = "welcome_64" + const val WELCOMES = "welcomes" + const val CONSUMED = "consumed" + const val GID = "gid" + const val GROUPS = "groups" + const val REQUESTS = "requests" + const val MSG_64 = "msg_64" + const val MESSAGES = "messages" + const val CURSOR = "cursor" + const val AFTER = "after" + const val SUBSCRIBED = "subscribed" + + /** MCP puts a tool's typed output here; `content` carries the display form. */ + const val STRUCTURED_CONTENT = "structuredContent" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfo.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfo.kt new file mode 100644 index 0000000000..93cf6d69cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfo.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * What a coordinator says about itself in the MCP `initialize` handshake. + * + * ## Every field is a claim + * + * A coordinator's name and version are strings it chose, signed with nothing + * but the key that was already going to sign the response. They are useful for + * telling two coordinators apart in a list and for reporting a bug against the + * right software; they are **not** identity, which is the pubkey and only the + * pubkey (`spec/00.md` §8.5). A UI must not present them as verified, and + * nothing in the protocol should branch on them. + * + * [protocolVersion] is the one field worth acting on: a coordinator answering + * a version this client does not implement is a coordinator whose later + * answers may not mean what they appear to. + */ +data class CoordinatorServerInfo( + val name: String?, + val version: String?, + val protocolVersion: String?, + /** The capability object verbatim, for a screen that wants to show it raw. */ + val capabilities: JsonObject?, +) { + /** Nothing usable came back, so there is nothing worth showing. */ + val isEmpty: Boolean get() = name == null && version == null && protocolVersion == null + + companion object { + /** + * Reads [response] if it is a successful `initialize` result. + * + * Every field is optional and a missing one becomes null rather than an + * error: MCP allows a server to omit `serverInfo` entirely, and a + * handshake that worked should not be reported as a failure because + * the server declined to name itself. + */ + fun from(response: JsonRpcMessage): CoordinatorServerInfo? { + val result = (response as? JsonRpcSuccess)?.result as? JsonObject ?: return null + val info = result["serverInfo"] as? JsonObject + return CoordinatorServerInfo( + name = info?.get("name")?.jsonPrimitive?.contentOrNullSafe(), + version = info?.get("version")?.jsonPrimitive?.contentOrNullSafe(), + protocolVersion = result["protocolVersion"]?.jsonPrimitive?.contentOrNullSafe(), + capabilities = result["capabilities"]?.jsonObject, + ) + } + } +} + +/** + * The string content, or null where the value is JSON `null`. + * + * `jsonPrimitive.content` renders a JSON null as the four-character string + * "null", which would put the word "null" on a settings screen as a + * coordinator's name. + */ +private fun JsonPrimitive.contentOrNullSafe(): String? = if (this is JsonNull) null else content.takeIf { it.isNotEmpty() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorTypes.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorTypes.kt new file mode 100644 index 0000000000..7e8618fdb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorTypes.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** A KeyPackage the coordinator is holding, as `kp_list` reports it. */ +data class AvailableKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, +) + +/** + * A KeyPackage taken from the coordinator, with the signed publication payload + * that binds it to its owner. + * + * [publicationEvent] is the `kp_publish` request event itself, stored verbatim + * and served back (`spec/00.md` §7). Verify it with + * [KeyPackagePublication.verify] before touching [keyPackageBase64]: an + * unverified pair is a coordinator's word about whose key this is, which §10 + * explicitly refuses to rely on. + */ +data class TakenKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + val publicationEvent: Event, +) { + /** + * The KeyPackage bytes as base64, read back out of the publication event + * rather than from a coordinator-supplied field. + * + * Deliberately not a stored property: the point of §9 is that the KeyPackage + * you use is the one inside the signed payload, not one handed over + * alongside it. + */ + fun keyPackageBase64(): String? = KeyPackagePublication.keyPackageBase64Of(publicationEvent) +} + +/** A Welcome waiting in our inbox. */ +data class PendingWelcome( + val keyPackageRef: String, + val welcomeBase64: String, + val at: Long, + /** The cursor to resume a group's history from, when the inviter set one. */ + val after: Long? = null, +) + +/** Someone asking to join a group we administer. */ +data class JoinRequest( + val gid: String, + val pubKey: HexKey, + val keyPackageRef: String, + val at: Long, +) + +/** One sealed payload from a group's ordered stream. */ +data class GroupMessage( + val gid: String, + /** + * This coordinator's ordering primitive for this group. + * + * `spec/00.md` §4-5: scoped to one group on one coordinator, and + * explicitly NOT a message identity. The canonical id is the envelope's + * (`spec/02.md` §7); a cursor cannot survive a coordinator change and two + * coordinators will not agree on one. + */ + val cursor: Long, + val sealedBase64: String, + val at: Long, +) + +/** What a welcome the caller has already joined looks like when acknowledging it. */ +data class ConsumedWelcomeRef( + val keyPackageRef: String, + val at: Long, +) + +/** A join request the caller has handled and wants retired. */ +data class ConsumedJoinRequestRef( + val gid: String, + val pubKey: HexKey, + val at: Long, +) + +/** Result of publishing a KeyPackage. */ +data class PublishedKeyPackage( + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, +) + +/** Result of posting a message. */ +data class PostedMessage( + val gid: String, + val cursor: Long, + val at: Long, +) + +/** Raised when the coordinator answers with a JSON-RPC error or an unusable body. */ +class CoordinatorException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/ICoordinator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/ICoordinator.kt new file mode 100644 index 0000000000..36746a4d04 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/ICoordinator.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The eleven coordinator tools, as a contract. + * + * [CoordinatorClient] is the only real implementation and this interface adds + * nothing to it — no defaults, no behaviour. It exists so the layers above + * (`CordnGroupSync`, and the group manager in `commons`) can be exercised + * without standing up a relay, a transport and a server, which is otherwise the + * price of testing a cursor loop. + * + * What it deliberately does **not** expose is identity. Which key signs which + * call is fixed by [CoordinatorMethod] per `spec/00.md` §8, and a substitute + * implementation cannot widen that, because there is no parameter to widen. + */ +interface ICoordinator { + suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage + + suspend fun removeKeyPackages(keyPackageRefs: List): List + + suspend fun listKeyPackages(): List + + suspend fun takeKeyPackage(id: String): TakenKeyPackage? + + suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long? = null, + ): Long + + suspend fun takeWelcomes(consumed: List = emptyList()): List + + suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long + + suspend fun takeJoinRequests( + gids: List, + consumed: List = emptyList(), + ): List + + suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage + + suspend fun fetchMessages(cursors: Map): List + + suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) + + /** + * How many responses this coordinator chunked over CEP-22 so far. + * + * Diagnostics, and the one thing here that is not a tool. Nothing in the + * binding reads it; a live interop run does, to tell a server that had to + * chunk from one that never did - which is otherwise invisible, because a + * reassembled response is indistinguishable from a direct one by design. + * + * Defaulted so a substitute implementation is not forced to fake a number + * about a transfer profile it does not have. + */ + val oversizedTransfers: Int get() = 0 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/KeyPackagePublication.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/KeyPackagePublication.kt new file mode 100644 index 0000000000..6cd39f6d84 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/KeyPackagePublication.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Verifying that a coordinator-served KeyPackage really belongs to the account + * it claims (`spec/00.md` §7 and §9). + * + * ## The payload is the request event + * + * cordn has no KeyPackage event kind. §7 requires a "signed publication + * payload" binding publisher, bytes and lookup fields, and the reference + * implementation satisfies it with the **`kp_publish` ContextVM request event + * itself**: the coordinator reaches back into the transport for the signed + * kind-25910 event, stores it verbatim, and returns it from `kp_take` + * (`packages/server/src/coordinatorServer.ts` → + * `transport.getNostrRequestEvent`). + * + * So the KeyPackage is recovered by parsing JSON-RPC out of an event's + * `content`, and the identity binding is "the credential inside the KeyPackage + * equals the event's `pubkey`". That means the invariant rides the shape of a + * JSON-RPC envelope rather than a stable event schema — a rename in the + * arguments object changes the wire format, and the reference client already + * carries a fallback from exactly that happening once + * (`kp_64 ?? keyPackageBase64`). We accept both for the same reason. + * + * Worth proposing upstream: give publication its own signed payload or its own + * kind. It would also let a cordn KeyPackage be published to relays and + * consumed with no coordinator at all. + * + * ## Why the client checks at all + * + * §8 makes the coordinator validate the same things. §9 makes us do it anyway, + * and §10 says why: coordinator checks are admission control, not a trust + * anchor. A coordinator that skipped them — or was modified to — could + * otherwise hand us any account's name over any account's key material, and we + * would invite the wrong person into a group. + */ +object KeyPackagePublication { + private const val LEGACY_KP_FIELD = "keyPackageBase64" + + /** The arguments a `kp_publish` call carries. */ + fun arguments( + keyPackageRef: String, + keyPackageBase64: String, + ): JsonObject = + buildJsonObject { + put(CoordinatorFields.KP_REF, keyPackageRef) + put(CoordinatorFields.KP_64, keyPackageBase64) + } + + /** + * The base64 KeyPackage inside a publication event, or null when the event + * is not a `kp_publish` call in a shape we recognise. + */ + fun keyPackageBase64Of(publicationEvent: Event): String? { + val params = + try { + Json.parseToJsonElement(publicationEvent.content).jsonObject["params"]?.jsonObject + } catch (e: IllegalArgumentException) { + return null + } ?: return null + val arguments = params["arguments"]?.jsonObject ?: return null + return (arguments[CoordinatorFields.KP_64] ?: arguments[LEGACY_KP_FIELD])?.jsonPrimitive?.content + } + + /** + * Runs every §9 check and returns the decoded KeyPackage. + * + * @throws CoordinatorException naming the check that failed. Each one is a + * different lie: a bad signature means the payload was not written by its + * claimed author, a missing KeyPackage means the coordinator substituted + * its own representation (§7 forbids it), and a credential mismatch means + * someone published another account's key under their own name. + */ + @OptIn(ExperimentalEncodingApi::class) + fun verify(publicationEvent: Event): VerifiedKeyPackage { + if (!publicationEvent.verify()) { + throw CoordinatorException("KeyPackage publication payload signature is invalid") + } + + val base64 = + keyPackageBase64Of(publicationEvent) + ?: throw CoordinatorException("KeyPackage publication payload carries no KeyPackage") + + val bytes = + try { + Base64.decode(base64) + } catch (e: IllegalArgumentException) { + throw CoordinatorException("KeyPackage publication payload KeyPackage is not valid base64", e) + } + + val keyPackage = + try { + MlsKeyPackage.decodeTls(TlsReader(bytes)) + } catch (e: Exception) { + throw CoordinatorException("KeyPackage publication payload KeyPackage does not decode", e) + } + + val claimed = CordnCredential.identityOrNull(keyPackage.leafNode) + if (claimed == null) { + throw CoordinatorException( + "KeyPackage credential is not a cordn identity: expected 64 hex ASCII bytes in a BasicCredential", + ) + } + if (claimed != publicationEvent.pubKey) { + throw CoordinatorException( + "KeyPackage credential identity $claimed does not match its publisher ${publicationEvent.pubKey}", + ) + } + + return VerifiedKeyPackage(publicationEvent.pubKey, keyPackage, bytes) + } + + /** Verifies, or returns null. For walking a list where one bad entry is not fatal. */ + fun verifyOrNull(publicationEvent: Event): VerifiedKeyPackage? = + try { + verify(publicationEvent) + } catch (e: CoordinatorException) { + null + } +} + +/** A KeyPackage whose identity binding has been checked against its signed payload. */ +data class VerifiedKeyPackage( + /** The account that signed the publication, and whose credential this carries. */ + val pubKey: HexKey, + val keyPackage: MlsKeyPackage, + val bytes: ByteArray, +) { + override fun equals(other: Any?): Boolean = this === other || (other is VerifiedKeyPackage && pubKey == other.pubKey && bytes.contentEquals(other.bytes)) + + override fun hashCode(): Int = pubKey.hashCode() * 31 + bytes.contentHashCode() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadata.kt new file mode 100644 index 0000000000..0d9a232066 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadata.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec01GroupMetadata + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The `cordn_group_metadata` GroupContext extension (`0xC04D`), from + * `spec/01.md`. + * + * Shared presentation metadata that rides inside MLS group state, so it is + * authenticated by ordinary Proposal/Commit processing rather than by + * coordinator-local storage. It is optional: a group may carry none and remain + * valid. + * + * ## The admin model is not Marmot's + * + * `spec/01.md` §5.3: an EMPTY [adminPubkeys] means **egalitarian** — every + * member has the same administrative level — and a non-empty one means only + * those accounts are admins. Marmot's MIP-03 reads an empty admin set as + * "bootstrap, gate open", which looks the same and means something else: there + * it is a transient state before an admin is named, here it is a permanent + * choice. Do not reuse either side's authorization code for the other. + */ +data class CordnGroupMetadata( + /** UTF-8 display name. May be empty. */ + val name: String, + /** UTF-8 description. Empty means absent. */ + val description: String = "", + /** + * Admin accounts as lowercase 64-char hex, encoded on the wire as + * concatenated raw 32-byte keys. Empty means egalitarian mode. + */ + val adminPubkeys: List = emptyList(), + /** A short glyph or emoji standing in for an image. Empty means absent. */ + val icon: String = "", + /** Advisory image URL, possibly a `data:` URL. Empty means absent. */ + val imageUrl: String = "", +) { + init { + adminPubkeys.forEach { + require(it.length == PUBKEY_HEX_LENGTH && it.all { c -> c in HEX_ALPHABET }) { + "cordn group metadata admin pubkey must be 64 lowercase hex chars, was '$it'" + } + } + require(adminPubkeys.size == adminPubkeys.toSet().size) { + "cordn group metadata admin pubkeys must not contain duplicates" + } + } + + /** True when the group names no admins, i.e. every member is equally privileged. */ + val isEgalitarian: Boolean get() = adminPubkeys.isEmpty() + + fun encode(): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putOpaque2(name.encodeToByteArray()) + writer.putOpaque2(description.encodeToByteArray()) + writer.putOpaque2(adminPubkeys.fold(ByteArray(0)) { acc, key -> acc + key.hexToByteArray() }) + writer.putOpaque2(icon.encodeToByteArray()) + writer.putOpaque2(imageUrl.encodeToByteArray()) + return writer.toByteArray() + } + + fun toExtension(): Extension = Extension(EXTENSION_TYPE, encode()) + + companion object { + /** `cordn_group_metadata`, in the MLS private-use extension range. */ + const val EXTENSION_TYPE = 0xC04D + + /** Version 0 is reserved and MUST be rejected (`spec/01.md` §4). */ + const val VERSION = 1 + + private const val PUBKEY_HEX_LENGTH = 64 + private const val HEX_ALPHABET = "0123456789abcdef" + + /** + * Decodes the extension payload. + * + * Each field is a plain uint16 length prefix, NOT an MLS varint vector. + * `spec/01.md` §3 says "TLS presentation language with MLS + * variable-length vector encoding conventions" and then writes + * `opaque Name<0..2^16-1>`, which are two different encodings; the + * reference implementation + * (`packages/cli/src/groupMetadata.ts:encodeField`) emits uint16, so + * that is what interoperates. Worth raising upstream — the prose and + * the code disagree and only one of them is on the wire. + */ + fun decode(data: ByteArray): CordnGroupMetadata { + val reader = TlsReader(data) + val version = reader.readUint16() + require(version == VERSION) { "unsupported cordn group metadata version: $version" } + + val name = reader.readOpaque2().decodeToStringStrict("name") + val description = reader.readOpaque2().decodeToStringStrict("description") + val adminBytes = reader.readOpaque2() + val icon = reader.readOpaque2().decodeToStringStrict("icon") + val imageUrl = reader.readOpaque2().decodeToStringStrict("image_url") + + require(!reader.hasRemaining) { "unexpected trailing bytes in cordn group metadata" } + require(adminBytes.size % KEY_SIZE == 0) { + "cordn group metadata admin_pubkeys length must be a multiple of $KEY_SIZE, was ${adminBytes.size}" + } + + val admins = + (0 until adminBytes.size / KEY_SIZE).map { + adminBytes.copyOfRange(it * KEY_SIZE, (it + 1) * KEY_SIZE).toHexKey() + } + return CordnGroupMetadata(name, description, admins, icon, imageUrl) + } + + /** The group's metadata, or null when it carries none. */ + fun fromExtensions(extensions: List): CordnGroupMetadata? = extensions.firstOrNull { it.extensionType == EXTENSION_TYPE }?.let { decode(it.extensionData) } + + private const val KEY_SIZE = 32 + + /** + * `spec/01.md` §9 requires rejecting invalid UTF-8 rather than + * substituting replacement characters, which is what + * `decodeToString()` does by default. + */ + private fun ByteArray.decodeToStringStrict(field: String): String = + try { + decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn group metadata $field is not valid UTF-8", e) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndex.kt new file mode 100644 index 0000000000..b74c86238b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndex.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One delivered message: the envelope, and where the coordinator put it. + * + * The cursor is carried only for tie-breaking (see [CordnAnnotationIndex]) and + * is never an identity — `spec/02.md` §7 and `spec/00.md` §4-5 are both explicit + * that a cursor is scoped to one coordinator and one group. + */ +data class CordnDeliveredMessage( + val envelope: CordnEnvelope, + val cursor: Long, +) + +/** + * Reactions, edits, deletions and pins folded onto the messages they modify. + * + * Annotations can target any message in the group, so resolving them per row + * while rendering is quadratic in a list that is usually virtualised. This + * folds them once. + * + * ## The authorization rules are here, not in the UI + * + * They differ per annotation and they are the whole reason this is protocol + * code rather than view-model code: + * + * - **Edits and deletions are author-only.** The sender must be the sender of + * the target. Without that check anyone in the group can rewrite anyone's + * words, which is worse than no edit feature at all. + * - **A deletion must name the target's kind.** A `k` tag that disagrees with + * the stored message is a mismatch, not a match with a typo. + * - **Pins are any-member.** Deliberately, and matching the reference client: + * pinning is a shared act in a group where `admin_pubkeys` is usually empty + * (`spec/01.md` — an empty admin set means egalitarian, permanently). + * - **Deletion beats edit.** A deleted message stops accepting edits, so a + * late edit cannot resurrect text its author already withdrew. That is why + * deletions are folded before edits and the order is load-bearing. + * + * Everything here is authenticated before it arrives: the envelope's `pubKey` + * was bound to the MLS sender by [CordnApplicationMessage.open], so "same + * author" is a real check rather than a claim comparison. + */ +class CordnAnnotationIndex private constructor( + /** Every message by envelope id, annotations included. */ + val byId: Map, + /** target id → emoji → the senders who reacted with it. */ + val reactions: Map>>, + /** target id → the winning edit. */ + val edits: Map, + /** Targets whose deletion passed every check. */ + val deleted: Set, + /** target id → the winning pin op. Only `ADD` entries are actually pinned. */ + val pins: Map, +) { + /** Who pinned a message and when, kept so a pin list can be ordered. */ + data class PinState( + val targetId: HexKey, + val op: CordnMessageReferences.PinOp, + val pinnedBy: HexKey, + val pinnedAt: Long, + val cursor: Long, + ) + + /** The text to show for [id]: its newest accepted edit, or the original. */ + fun contentOf(id: HexKey): String? = edits[id]?.envelope?.content ?: byId[id]?.envelope?.content + + fun isEdited(id: HexKey): Boolean = edits.containsKey(id) + + fun isDeleted(id: HexKey): Boolean = deleted.contains(id) + + fun isPinned(id: HexKey): Boolean = pins[id]?.op == CordnMessageReferences.PinOp.ADD + + /** Currently pinned targets, newest pin first. */ + fun pinnedIds(): List = + pins.values + .filter { it.op == CordnMessageReferences.PinOp.ADD } + .sortedWith(compareByDescending { it.pinnedAt }.thenByDescending { it.cursor }) + .map { it.targetId } + + companion object { + /** + * Folds [messages] — a whole group's stream, annotations included. + * + * Four passes, and the order matters exactly once: deletions before + * edits, so a withdrawn message cannot be edited back into existence. + * The rest are independent. + */ + fun of(messages: List): CordnAnnotationIndex { + val byId = messages.associateBy { it.envelope.id } + + val reactions = mutableMapOf>>() + messages.forEach { message -> + val ref = + CordnMessageReferences.reaction( + message.envelope.kind, + message.envelope.content, + message.envelope.tags, + ) ?: return@forEach + reactions + .getOrPut(ref.targetId) { mutableMapOf() } + .getOrPut(ref.reaction) { mutableSetOf() } + .add(message.envelope.pubKey) + } + + val deleted = mutableSetOf() + messages.forEach { message -> + val ref = CordnMessageReferences.delete(message.envelope.kind, message.envelope.tags) ?: return@forEach + val target = byId[ref.targetId] ?: return@forEach + // A `k` that disagrees with the stored message is a mismatch. + if (ref.targetKind != target.envelope.kind) return@forEach + // Author-only. The pubkeys are MLS-authenticated, not claimed. + if (target.envelope.pubKey != message.envelope.pubKey) return@forEach + deleted.add(ref.targetId) + } + + val edits = mutableMapOf() + messages.forEach { message -> + val ref = + CordnMessageReferences.edit( + message.envelope.kind, + message.envelope.content, + message.envelope.tags, + ) ?: return@forEach + if (ref.targetId in deleted) return@forEach + val target = byId[ref.targetId] ?: return@forEach + if (target.envelope.pubKey != message.envelope.pubKey) return@forEach + + val current = edits[ref.targetId] + // Newest wins. Ties break on cursor, which the coordinator makes + // monotonic (spec/00.md §4) -- two edits in the same second are + // otherwise a coin flip that two clients could call differently. + if (current == null || + message.envelope.createdAt > current.envelope.createdAt || + (message.envelope.createdAt == current.envelope.createdAt && message.cursor > current.cursor) + ) { + edits[ref.targetId] = message + } + } + + val pins = mutableMapOf() + messages.forEach { message -> + val ref = CordnMessageReferences.pin(message.envelope.kind, message.envelope.tags) ?: return@forEach + // No author check: pinning is any-member. See the class KDoc. + val current = pins[ref.targetId] + if (current == null || + message.envelope.createdAt > current.pinnedAt || + (message.envelope.createdAt == current.pinnedAt && message.cursor > current.cursor) + ) { + pins[ref.targetId] = + PinState( + targetId = ref.targetId, + op = ref.op, + pinnedBy = message.envelope.pubKey, + pinnedAt = message.envelope.createdAt, + cursor = message.cursor, + ) + } + } + + return CordnAnnotationIndex(byId, reactions, edits, deleted, pins) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnApplicationMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnApplicationMessage.kt new file mode 100644 index 0000000000..dd84dfffa6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnApplicationMessage.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.group.DecryptedMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Sending and receiving a cordn application message: envelope, MLS, seal. + * + * ## The authenticated-sender binding + * + * `spec/02.md` §5 says the envelope's `pubkey` must equal "the authenticated + * sender identity derived from the sender's MLS credential" and stops there. + * The reference implementation does something more specific that the spec never + * mentions: it puts the account pubkey in MLS **`authenticated_data`** + * (`packages/cli/src/session.ts:1000`) and, on receive, **rejects any + * application message whose AAD is empty** before even looking at the envelope + * (`packages/cli/src/groupSync.ts:247`). + * + * So a message without it is not merely unattributed, it is refused. That makes + * the AAD a wire requirement rather than an optimisation, and it is the reason + * this class exists instead of callers reaching for `MlsGroup.encrypt` directly. + * + * Using the AAD rather than the leaf credential also has a reason: the + * credential names whichever key holds the leaf, and a linked device's leaf is + * not the account. The AAD says who is *speaking*. + * + * `authenticated_data` is authenticated but **not encrypted**, so this binding + * costs metadata: any party holding the ciphertext can read the sender's + * account pubkey. The coordinator holds every ciphertext. cordn's reference + * client accepts that trade; it is worth knowing it was made. + */ +object CordnApplicationMessage { + /** + * Builds, frames and seals an application message. + * + * @return the base64 sealed payload to hand to `msg_post`. + */ + fun seal( + group: MlsGroup, + senderPubKey: HexKey, + envelope: CordnEnvelope, + ): String { + require(envelope.pubKey == senderPubKey) { + "envelope pubkey ${envelope.pubKey} does not match the sender $senderPubKey" + } + val mlsMessage = group.encrypt(envelope.encode(), authenticatedData = senderPubKey.encodeToByteArray()) + return SealedPayload.seal(mlsMessage, SealedPayload.applicationKey(group)) + } + + /** + * Opens a sealed application message and returns its envelope. + * + * Every check `spec/02.md` §5 and the reference client apply, in the order + * that makes each one meaningful: open the seal, let MLS authenticate the + * sender, read the sender from the AAD, then hold the envelope to it. + * + * @throws IllegalArgumentException naming the check that failed. + */ + fun open( + group: MlsGroup, + sealedBase64: String, + ): ReceivedMessage { + val mlsMessage = SealedPayload.open(sealedBase64, SealedPayload.applicationKey(group)) + val decrypted = group.decrypt(mlsMessage) + return open(decrypted) + } + + /** As [open], for a message some other path has already decrypted. */ + fun open(decrypted: DecryptedMessage): ReceivedMessage { + // Empty is a rejection, not a default. Treating it as "unknown sender" + // would let anyone drop the field and post as nobody in particular, + // which the envelope's own `pubkey` would then be free to fill in. + require(decrypted.authenticatedData.isNotEmpty()) { + "cordn application message carries no authenticated sender" + } + val sender = + try { + decrypted.authenticatedData.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn authenticated sender is not valid UTF-8", e) + } + + return ReceivedMessage( + sender = sender, + senderLeafIndex = decrypted.senderLeafIndex, + epoch = decrypted.epoch, + // Holds the envelope to the MLS-authenticated sender, which is the + // only thing making an unsigned envelope trustworthy at all. + envelope = CordnEnvelope.decode(decrypted.content, senderIdentity = sender), + ) + } + + /** The exporter binding both directions use, for callers that need the key itself. */ + val exporter get() = CordnGroupPolicy.PAYLOAD_EXPORTER +} + +/** An application message that passed every authentication check. */ +data class ReceivedMessage( + /** The account pubkey MLS authenticated, from `authenticated_data`. */ + val sender: HexKey, + /** Which leaf sent it. Not the same as [sender] for a linked device. */ + val senderLeafIndex: Int, + val epoch: Long, + val envelope: CordnEnvelope, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt new file mode 100644 index 0000000000..11879dd57b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The on-disk form of one delivered message. + * + * ## Why the plaintext is what gets stored + * + * A cordn payload is sealed twice — the MLS application message, then + * [com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload] under an + * exporter key — and both keys are derived per epoch. Once the group ratchets + * forward, the ciphertext the coordinator still holds is unreadable to us, and + * the cursor has in any case advanced past it. Ingestion is the only moment the + * message is in the clear, so this is not a cache in front of a source of + * truth: it is the only copy. + * + * ## Why the cursor rides along + * + * A room orders on the cursor and the unread divider compares against it, and + * it is not derivable from the envelope — the envelope's `created_at` is the + * sender's clock, which is a claim, while the cursor is the coordinator's + * sequence. Storing the envelope alone would lose the ordering the room is + * built on. + */ +object CordnDeliveredMessageCodec { + const val VERSION = 1 + + private const val V = "v" + private const val CURSOR = "c" + private const val ENVELOPE = "e" + + fun encode(message: CordnDeliveredMessage): String = + Json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put(V, VERSION) + put(CURSOR, message.cursor) + put(ENVELOPE, message.envelope.toJsonObject()) + }, + ) + + /** + * Reads one entry back. + * + * Throws on anything malformed rather than returning null, so a caller + * reading a log decides for itself whether one bad entry drops the line or + * the conversation. [decodeOrNull] is the "drop the line" form. + */ + fun decode(entry: String): CordnDeliveredMessage { + val json = + Json.parseToJsonElement(entry) as? JsonObject + ?: throw IllegalArgumentException("a stored cordn message must be a JSON object") + + val version = + json[V]?.jsonPrimitive?.content?.toIntOrNull() + ?: throw IllegalArgumentException("a stored cordn message is missing `$V`") + require(version == VERSION) { "unknown stored cordn message version: $version" } + + val cursor = + json[CURSOR]?.jsonPrimitive?.long + ?: throw IllegalArgumentException("a stored cordn message is missing `$CURSOR`") + + val envelope = + json[ENVELOPE]?.jsonObject + ?: throw IllegalArgumentException("a stored cordn message is missing `$ENVELOPE`") + + return CordnDeliveredMessage(CordnEnvelope.fromJsonObject(envelope), cursor) + } + + /** + * [decode], or null when the entry cannot be read. + * + * Loading a room uses this: one corrupted entry — a half-written segment, a + * format from a version that did not ship — should cost that message, not + * every message behind it in the log. + */ + fun decodeOrNull(entry: String): CordnDeliveredMessage? = + try { + decode(entry) + } catch (e: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelope.kt new file mode 100644 index 0000000000..03eccdeadd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelope.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The Nostr-shaped application payload a cordn message carries, from + * `spec/02.md`. + * + * A NIP-01 event with no `sig`. The signature is not missing by oversight — + * §9 removes it deliberately, because MLS has already authenticated the sender + * and a second signature would mean a second signer round trip (possibly to a + * remote signer) for a proof nobody needs. Reusing the event shape buys the + * `kind`/`tags` vocabulary: kind 9 chat, 1111 threaded replies, 7 reactions. + * + * ## `pubkey` is a claim until you check it + * + * The envelope is not self-authenticating. `spec/02.md` §5 requires the + * receiver to reject any envelope whose `pubkey` differs from the MLS + * authenticated sender of the enclosing application message — which is why + * [decode] demands that identity rather than offering it as an option. A + * decoder that skipped it would let any member of a group post as any other, + * with no signature anywhere to contradict them. + */ +data class CordnEnvelope( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Int, + val tags: Array>, + val content: String, +) { + /** The id these fields actually hash to, per NIP-01. */ + fun computedId(): HexKey = EventHasher.hashId(pubKey, createdAt, kind, tags, content) + + fun toJson(): String = Json.encodeToString(JsonObject.serializer(), toJsonObject()) + + fun toJsonObject(): JsonObject = + buildJsonObject { + put(ID, id) + put(PUBKEY, pubKey) + put(CREATED_AT, createdAt) + put(KIND, kind) + put( + TAGS, + buildJsonArray { + tags.forEach { tag -> add(buildJsonArray { tag.forEach { add(JsonPrimitive(it)) } }) } + }, + ) + put(CONTENT, content) + } + + /** The UTF-8 bytes that go inside the MLS application message (`spec/02.md` §3). */ + fun encode(): ByteArray = toJson().encodeToByteArray() + + // Array fields, so the generated equals would compare references. + override fun equals(other: Any?): Boolean = + this === other || + ( + other is CordnEnvelope && + id == other.id && + pubKey == other.pubKey && + createdAt == other.createdAt && + kind == other.kind && + content == other.content && + tags.size == other.tags.size && + tags.indices.all { tags[it].contentEquals(other.tags[it]) } + ) + + override fun hashCode(): Int = id.hashCode() + + companion object { + private const val ID = "id" + private const val PUBKEY = "pubkey" + private const val CREATED_AT = "created_at" + private const val KIND = "kind" + private const val TAGS = "tags" + private const val CONTENT = "content" + private const val SIG = "sig" + + /** Builds an envelope, deriving its [id] from the rest (`spec/02.md` §4). */ + fun build( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array> = emptyArray(), + content: String, + ) = CordnEnvelope(EventHasher.hashId(pubKey, createdAt, kind, tags, content), pubKey, createdAt, kind, tags, content) + + /** + * Decodes an MLS application payload into an envelope. + * + * @param senderIdentity the account hex the MLS sender's credential + * binds, from [com.vitorpamplona.quartz.cordn.groups.CordnCredential]. Not + * optional: see the class KDoc. + */ + fun decode( + payload: ByteArray, + senderIdentity: HexKey, + ): CordnEnvelope { + val json = + try { + Json.parseToJsonElement(payload.decodeToString(throwOnInvalidSequence = true)) as? JsonObject + ?: throw IllegalArgumentException("cordn envelope must be a JSON object") + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn envelope is not valid UTF-8", e) + } + + require(SIG !in json) { "cordn envelope must not carry a `sig` (spec/02.md §2)" } + + val envelope = fromJsonObject(json) + + // Both checks are MUSTs, and each covers a different lie: the id + // check catches a rewritten body, the pubkey check catches a member + // posting under someone else's name. fromJsonObject does the first, + // because a body that does not hash to its id is malformed wherever + // it came from; only the second needs the MLS sender. + require(envelope.pubKey == senderIdentity) { + "cordn envelope claims pubkey ${envelope.pubKey} but the MLS sender is $senderIdentity" + } + return envelope + } + + /** + * Reads an envelope out of its JSON form, checking that the body hashes + * to the id it carries. + * + * Separate from [decode] because a re-read from our own storage has no + * MLS sender to compare against — the sender was checked when the + * message was first ingested, and the plaintext has been ours since. + * What is still worth checking on the way back in is integrity, which + * the id covers. + */ + fun fromJsonObject(json: JsonObject): CordnEnvelope { + val envelope = + CordnEnvelope( + id = json.str(ID), + pubKey = json.str(PUBKEY), + createdAt = + json[CREATED_AT]?.jsonPrimitive?.long + ?: throw IllegalArgumentException("cordn envelope is missing `$CREATED_AT`"), + kind = + json[KIND]?.jsonPrimitive?.content?.toIntOrNull() + ?: throw IllegalArgumentException("cordn envelope is missing `$KIND`"), + tags = json.tagArray(), + content = json.str(CONTENT), + ) + + require(envelope.id == envelope.computedId()) { + "cordn envelope id ${envelope.id} does not match its contents (${envelope.computedId()})" + } + return envelope + } + + private fun JsonObject.str(key: String): String = this[key]?.jsonPrimitive?.content ?: throw IllegalArgumentException("cordn envelope is missing `$key`") + + private fun JsonObject.tagArray(): Array> { + val tags = this[TAGS] as? JsonArray ?: throw IllegalArgumentException("cordn envelope is missing `$TAGS`") + return Array(tags.size) { i -> + val tag = tags[i] as? JsonArray ?: throw IllegalArgumentException("cordn envelope tag $i is not an array") + Array(tag.size) { j -> tag[j].jsonPrimitive.content } + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKinds.kt new file mode 100644 index 0000000000..e82af46f5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKinds.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +/** + * The `kind` values a cordn chat uses, and what they mean. + * + * `spec/02.md` §6 names three of these (9 chat, 1111 threaded reply, 7 + * reaction) as conventions to reuse "when their semantics match", and then says + * plainly that there is **no required set**. Edits, deletions and pins are + * therefore not protocol at all — they are application conventions, and the + * reference client picked numbers for them. + * + * **We follow cordn-web.** Being the second implementation of an unspecified + * convention is how it becomes a specification; inventing a third numbering + * would leave two clients that silently no-op on each other's edits. The cost + * is that these differ from Marmot's, which is fine — the two protocols do not + * interoperate and each carries its own table: + * + * | Purpose | cordn (here) | Marmot | + * | ------- | ------------ | ------ | + * | Chat | 9 | 9 | + * | Threaded reply | 1111 | 1111 | + * | Reaction | 7 | 7 | + * | Edit | **1010** | 1009 | + * | Deletion | 5 | — | + * | Pin | **1011** | — | + * | System row | derived, never sent | 1210, on the wire | + * + * Worth asking upstream to write the last three down. + */ +object CordnMessageKinds { + /** NIP-C7 chat message. `spec/02.md` §6. */ + const val TEXT = 9 + + /** NIP-22 comment. §6 prefers this over NIP-C7's reply convention. */ + const val THREAD_REPLY = 1111 + + /** NIP-25 reaction; `content` is the emoji. §6. */ + const val REACTION = 7 + + /** In-place replacement of a message's text. Not in any spec. */ + const val EDIT = 1010 + + /** NIP-09-shaped deletion of one message. Not in any spec. */ + const val DELETION = 5 + + /** Group-scoped pin/unpin, carrying an `op` tag. Not in any spec. */ + const val PIN = 1011 + + /** + * A row derived from an MLS Commit — "X added Y", "the name changed". + * + * Negative because it is **not a wire kind**: nothing sends it, and nothing + * should. cordn-web derives these client-side from the Commits it applies, + * and that is strictly better than Marmot's on-the-wire kind 1210 for one + * reason — a derived row cannot disagree with the MLS state it describes, + * because it *is* that state. It also costs no bytes and cannot be forged + * by a member who simply sends one. + */ + const val SYSTEM = -1 + + /** + * Kinds that modify another message rather than being one. + * + * An annotation never gets its own row in the stream; it is folded into its + * target by [CordnAnnotationIndex]. Getting this set wrong shows up as + * reactions rendering as blank messages. + */ + fun isAnnotation(kind: Int): Boolean = kind == REACTION || kind == EDIT || kind == DELETION || kind == PIN + + fun isSystem(kind: Int): Boolean = kind == SYSTEM +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageReferences.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageReferences.kt new file mode 100644 index 0000000000..803fb7f622 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageReferences.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull + +/** + * How one cordn message points at another, in both directions. + * + * `spec/02.md` §7 fixes the only part of this the protocol cares about: a + * reference names the target's **envelope `id`**, never a coordinator cursor, + * which is a delivery-order primitive that two coordinators will not agree on. + * Everything below that — which tags, in what shape — follows the NIPs §6 names + * and, for the kinds no spec covers, the reference client. + * + * Parse and build live in one file deliberately. They are two halves of one + * format, and the failure mode of splitting them is a client that emits tags + * its own parser rejects. + */ +object CordnMessageReferences { + // ---- inbound ------------------------------------------------------- + + /** A NIP-22 thread position: the root of the thread and the direct parent. */ + data class ThreadReference( + val rootId: HexKey, + val rootPubKey: HexKey, + val rootKind: Int, + val parentId: HexKey, + val parentPubKey: HexKey, + val parentKind: Int, + ) + + /** A NIP-25 reaction, with the emoji the sender chose. */ + data class ReactionReference( + val targetId: HexKey, + val targetPubKey: HexKey, + val targetKind: Int, + val reaction: String, + ) + + /** An edit. Carries only the target — the new text is the envelope's content. */ + data class EditReference( + val targetId: HexKey, + ) + + /** A deletion. [targetKind] is checked against the target before it counts. */ + data class DeleteReference( + val targetId: HexKey, + val targetKind: Int, + ) + + /** A pin or unpin. */ + data class PinReference( + val targetId: HexKey, + val op: PinOp, + ) + + enum class PinOp( + val wire: String, + ) { + ADD("add"), + REMOVE("remove"), + ; + + companion object { + fun of(wire: String?): PinOp? = entries.firstOrNull { it.wire == wire } + } + } + + /** + * Where [tags] places a message in a thread, or null if it places it nowhere. + * + * NIP-22 splits root from parent by case: uppercase `E`/`K`/`P` for the + * thread root, lowercase `e`/`k`/`p` for the message being replied to. All + * four of `E`/`K`/`e`/`k` must be present and the kinds must be numbers — + * a half-formed thread tag is treated as no thread rather than guessed at, + * because guessing reparents a reply under the wrong message. + * + * The pubkeys fall back to the `e`-tag's 4th element, which is where NIP-10 + * style tags carry them. + */ + fun thread(tags: TagArray): ThreadReference? { + val rootEvent = tags.tag("E") ?: return null + val rootKind = tags.tagValue("K")?.toIntOrNull() ?: return null + val parentEvent = tags.tag("e") ?: return null + val parentKind = tags.tagValue("k")?.toIntOrNull() ?: return null + + val rootId = rootEvent.getOrNull(1)?.ifEmpty { null } ?: return null + val parentId = parentEvent.getOrNull(1)?.ifEmpty { null } ?: return null + val rootPubKey = (tags.tagValue("P") ?: rootEvent.getOrNull(3))?.ifEmpty { null } ?: return null + val parentPubKey = (tags.tagValue("p") ?: parentEvent.getOrNull(3))?.ifEmpty { null } ?: return null + + return ThreadReference(rootId, rootPubKey, rootKind, parentId, parentPubKey, parentKind) + } + + /** The reaction [kind]/[content]/[tags] express, or null if they do not. */ + fun reaction( + kind: Int, + content: String, + tags: TagArray, + ): ReactionReference? { + if (kind != CordnMessageKinds.REACTION) return null + + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + val targetPubKey = tags.tagValue("p")?.ifEmpty { null } ?: return null + val targetKind = tags.tagValue("k")?.toIntOrNull() ?: return null + // An empty reaction is not a reaction. NIP-25 gives `content` meaning + // here, and a blank one would render as an invisible chip. + val reaction = content.trim().ifEmpty { null } ?: return null + + return ReactionReference(targetId, targetPubKey, targetKind, reaction) + } + + /** + * The edit [kind]/[content]/[tags] express. + * + * Blank content is refused: an edit to nothing is a deletion, and those are + * a different kind with a different authorization rule. + */ + fun edit( + kind: Int, + content: String, + tags: TagArray, + ): EditReference? { + if (kind != CordnMessageKinds.EDIT || content.isBlank()) return null + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + return EditReference(targetId) + } + + /** The deletion [kind]/[tags] express. */ + fun delete( + kind: Int, + tags: TagArray, + ): DeleteReference? { + if (kind != CordnMessageKinds.DELETION) return null + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + val targetKind = tags.tagValue("k")?.toIntOrNull() ?: return null + return DeleteReference(targetId, targetKind) + } + + /** The pin or unpin [kind]/[tags] express. An unknown `op` is neither. */ + fun pin( + kind: Int, + tags: TagArray, + ): PinReference? { + if (kind != CordnMessageKinds.PIN) return null + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + val op = PinOp.of(tags.tagValue("op")) ?: return null + return PinReference(targetId, op) + } + + // ---- outbound ------------------------------------------------------ + + /** The fields of a message being replied to, reacted to, edited or deleted. */ + data class Target( + val id: HexKey, + val pubKey: HexKey, + val kind: Int, + /** The target's own tags, read to find the thread root. Empty is fine. */ + val tags: TagArray = emptyArray(), + ) + + /** What one outbound message will be, once its kind and tags are decided. */ + data class Outbound( + val kind: Int, + val content: String, + val tags: TagArray, + ) + + /** + * Resolves a send into its kind, content and tags. + * + * One function rather than a kind switch beside a tag switch, because the + * two have to agree and keeping them apart is how they stop agreeing. The + * ordering below is the precedence: a pin is a pin even if content was + * typed, and a delete ignores content entirely. + */ + fun outbound( + content: String, + extraTags: TagArray = emptyArray(), + replyTo: Target? = null, + reactionTo: Target? = null, + editTo: Target? = null, + deleteTo: Target? = null, + pinTo: Target? = null, + pinOp: PinOp = PinOp.ADD, + ): Outbound { + pinTo?.let { + return Outbound(CordnMessageKinds.PIN, "", pinTags(it, pinOp)) + } + reactionTo?.let { + // Not trimmed: an emoji is content, and trimming a reaction that is + // deliberately whitespace-adjacent would change what was sent. + return Outbound(CordnMessageKinds.REACTION, content, reactionTags(it)) + } + deleteTo?.let { + return Outbound(CordnMessageKinds.DELETION, "", deleteTags(it)) + } + editTo?.let { + return Outbound(CordnMessageKinds.EDIT, content.trim(), editTags(it) + extraTags) + } + return Outbound( + kind = if (replyTo != null) CordnMessageKinds.THREAD_REPLY else CordnMessageKinds.TEXT, + content = content.trim(), + tags = (replyTo?.let(::replyTags) ?: emptyArray()) + extraTags, + ) + } + + /** + * NIP-22 reply tags. + * + * The root is read out of the target's own `E`/`K`/`P` when it has them — + * replying to a reply keeps the original root — and is the target itself + * otherwise, which is the first reply in a thread. + */ + fun replyTags(target: Target): TagArray { + val rootEvent = target.tags.tag("E") + val rootId = rootEvent?.getOrNull(1)?.ifEmpty { null } ?: target.id + val rootPubKey = + (target.tags.tagValue("P") ?: rootEvent?.getOrNull(3))?.ifEmpty { null } ?: target.pubKey + val rootKind = target.tags.tagValue("K") ?: target.kind.toString() + + return arrayOf( + arrayOf("E", rootId, "", rootPubKey), + arrayOf("K", rootKind), + arrayOf("P", rootPubKey), + arrayOf("e", target.id, "", target.pubKey), + arrayOf("k", target.kind.toString()), + arrayOf("p", target.pubKey), + ) + } + + fun reactionTags(target: Target): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("p", target.pubKey), + arrayOf("k", target.kind.toString()), + ) + + fun editTags(target: Target): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("p", target.pubKey), + arrayOf("k", target.kind.toString()), + ) + + /** No `p`: a deletion names what is being removed, not who to notify. */ + fun deleteTags(target: Target): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("k", target.kind.toString()), + ) + + fun pinTags( + target: Target, + op: PinOp, + ): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("p", target.pubKey), + arrayOf("k", target.kind.toString()), + arrayOf("op", op.wire), + ) + + // ---- plumbing ------------------------------------------------------ + + private fun TagArray.tag(name: String): Array? = fastFirstOrNull { it.isNotEmpty() && it[0] == name } + + private fun TagArray.tagValue(name: String): String? = tag(name)?.getOrNull(1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec03Payloads/SealedPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec03Payloads/SealedPayload.kt new file mode 100644 index 0000000000..dd1d770b41 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec03Payloads/SealedPayload.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec03Payloads + +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The outer seal every cordn message carries, from `spec/03.md` §4. + * + * ``` + * base64( nonce[12] || ChaCha20-Poly1305(key, nonce, mlsMessageBytes, aad = "") ) + * key = MLS-Exporter("cordn", "group-payload", 32) + * ``` + * + * This is why a coordinator sees nothing. MLS has already encrypted the + * message; this second layer means the coordinator cannot even tell a Commit + * from a chat line, so it cannot derive group state from traffic it is + * forbidden to parse. + * + * ## Byte-identical to Marmot's kind-445 seal, and deliberately separate code + * + * Same AEAD, same 12-byte random nonce, same empty AAD, same + * `base64(nonce‖ct‖tag)` layout as + * `marmot/mip03GroupMessages/GroupEventEncryption`. Only the exporter label + * differs, which is the entire reason the two protocols never read each + * other's traffic. Importing the Marmot one here would save a few lines and + * put one protocol's seal on another's wire, where a later Marmot-side change + * would silently break cordn interop. + */ +object SealedPayload { + /** 12-byte nonce plus a 16-byte tag, before any ciphertext (`spec/03.md` §4). */ + const val MIN_SIZE = 28 + + private const val NONCE_SIZE = 12 + private val EMPTY_AAD = ByteArray(0) + + /** + * Seals [mlsMessageBytes] under [key]. + * + * The nonce is fresh per payload and MUST NOT repeat under one key. + * `spec/03.md` §4 makes that a requirement rather than advice: ChaCha20 + * is a stream cipher, so a repeat under the same epoch key leaks the XOR + * of two plaintexts to anyone holding both — including the coordinator, + * which holds every payload by construction. + */ + @OptIn(ExperimentalEncodingApi::class) + fun seal( + mlsMessageBytes: ByteArray, + key: ByteArray, + ): String { + val nonce = RandomInstance.bytes(NONCE_SIZE) + return Base64.encode(nonce + ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, key)) + } + + /** Opens a sealed payload, or throws if it is malformed or fails AEAD verification. */ + @OptIn(ExperimentalEncodingApi::class) + fun open( + sealedBase64: String, + key: ByteArray, + ): ByteArray { + val payload = + try { + Base64.decode(sealedBase64) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("cordn sealed payload is not valid base64", e) + } + require(payload.size >= MIN_SIZE) { + "cordn sealed payload must be at least $MIN_SIZE bytes, was ${payload.size}" + } + return ChaCha20Poly1305.decrypt( + payload.copyOfRange(NONCE_SIZE, payload.size), + EMPTY_AAD, + payload.copyOfRange(0, NONCE_SIZE), + key, + ) + } + + /** + * The epoch key for an APPLICATION message: the sender's current epoch + * (`spec/03.md` §5). + */ + fun applicationKey(group: MlsGroup): ByteArray = CordnGroupPolicy.PAYLOAD_EXPORTER.let { group.exporterSecret(it.label, it.context, it.length) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSync.kt new file mode 100644 index 0000000000..8c3af2af25 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSync.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage + +/** + * Fetch-then-subscribe over a set of groups on one coordinator. + * + * ## Why the order matters + * + * `msg_sub_many` takes a cursor per group and delivers from there, so it looks + * like subscribing alone would do. It would not: a subscription is a live + * stream, and the catch-up it performs at open is bounded by what the + * coordinator is willing to push in one go. Draining history with + * `msg_fetch_many` first, then subscribing from the freshest cursor, is what + * makes the two paths meet exactly once — which matters because MLS is a + * sequence, not a set. A Commit processed out of order is a Commit that fails, + * and the epoch it would have produced never arrives. + * + * Both paths feed [GroupInbox.accept], so a message seen twice — over the tail + * of catch-up and the head of the subscription — is recognised the same way + * either time. + */ +class CordnGroupSync( + private val client: ICoordinator, + /** Inbox per `gid`. The caller owns them, because they outlive a sync run. */ + private val inboxes: MutableMap = mutableMapOf(), +) { + /** The inbox for [gid], created at cursor 0 if this is the first time. */ + fun inbox(gid: String): GroupInbox = inboxes.getOrPut(gid) { GroupInbox() } + + /** Resumes [gid] from a persisted cursor. */ + fun restore( + gid: String, + cursor: GroupCursor, + echoes: EchoState = EchoState(), + ) { + inboxes[gid] = GroupInbox(cursor, echoes) + } + + /** The cursors to persist. */ + fun cursors(): Map = inboxes.mapValues { it.value.cursor } + + /** + * The echo bookkeeping to persist, per group. + * + * Saved on the same beat as [cursors] and for the same reason: a cursor + * that outlives the process while the record of what is ours does not + * leaves a client re-reading its own traffic with no way to recognise it. + * See [EchoState]. + */ + fun echoes(): Map = inboxes.mapValues { it.value.echoes() } + + /** + * Drains history for [gids] until every group is current. + * + * The coordinator decides page size, so "current" means a page came back + * with nothing new for any group. [maxPages] bounds that loop: a group with + * years of history should not block startup forever, and a coordinator that + * kept returning the same page would otherwise spin. + * + * @return every message drained, in the order it was delivered. + */ + suspend fun catchUp( + gids: Collection, + maxPages: Int = DEFAULT_MAX_PAGES, + onMessage: (String, Ingestion) -> Unit, + ): Int { + require(gids.isNotEmpty()) { "catchUp needs at least one group" } + var delivered = 0 + + repeat(maxPages) { + val page = client.fetchMessages(gids.associateWith { inbox(it).cursor.afterOrNull() }) + // An empty page is the only honest "you are current" signal: the + // coordinator does not say how much is left. + if (page.isEmpty()) return delivered + + val before = gids.associateWith { inbox(it).cursor.lastCursor } + page.forEach { message -> onMessage(message.gid, inbox(message.gid).accept(message)) } + delivered += page.size + + // A page that moved no cursor means the coordinator is serving the + // same records back. Stopping beats looping: the alternative is a + // silent infinite fetch against a broken or hostile server. + if (gids.none { inbox(it).cursor.lastCursor > (before[it] ?: 0) }) return delivered + } + return delivered + } + + /** + * Subscribes from each group's current cursor and delivers until the + * coordinator closes the stream. + * + * Call [catchUp] first. Suspends for the life of the subscription, so give + * it its own coroutine. + */ + suspend fun subscribe( + gids: Collection, + timeoutMs: Long, + onMessage: (String, Ingestion) -> Unit, + ) { + require(gids.isNotEmpty()) { "subscribe needs at least one group" } + client.subscribeMessages( + cursors = gids.associateWith { inbox(it).cursor.afterOrNull() }, + timeoutMs = timeoutMs, + ) { message -> + onMessage(message.gid, inbox(message.gid).accept(message)) + } + } + + /** + * Posts a sealed application message and records it as ours. + * + * Recording it by the cursor the coordinator assigns is what stops the echo + * being re-ingested as somebody else's message a moment later. + */ + suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage = + client.postMessage(gid, sealedBase64).also { + inbox(gid).recordOwnMessage(it.cursor) + } + + /** + * Posts a sealed Commit and registers it as a pending epoch operation. + * + * @param localStateApplied whether the new epoch is already adopted locally. + * Pass false when posting before adopting — the echo then becomes the + * instruction to apply it, which is how a client that died mid-post + * recovers. + */ + suspend fun postCommit( + gid: String, + sealedBase64: String, + localStateApplied: Boolean = true, + ): PostedMessage { + // Registered BEFORE the call returns, because the subscription can + // deliver the echo while `postMessage` is still awaiting its own + // response. Registering afterwards leaves a window in which our own + // Commit looks like a stranger's and gets applied twice. + inbox(gid).expectEcho(PendingEpochOperation(sealedBase64, localStateApplied)) + return client.postMessage(gid, sealedBase64) + } + + /** Groups with a Commit posted but not yet seen coming back. */ + fun unconfirmed(): Map> = inboxes.mapValues { it.value.pending() }.filterValues { it.isNotEmpty() } + + companion object { + /** Enough for a long history, short of letting startup hang. */ + const val DEFAULT_MAX_PAGES = 64 + } +} + +/** One message drained from a group, with what the inbox decided about it. */ +data class DeliveredMessage( + val gid: String, + val message: GroupMessage, + val ingestion: Ingestion, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupCursor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupCursor.kt new file mode 100644 index 0000000000..19f6fc71ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupCursor.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage + +/** + * Where a group's catch-up has reached on one coordinator. + * + * A cursor is coordinator-local and group-scoped (`spec/00.md` §4): it orders + * this group's stream on this coordinator and means nothing anywhere else. + * Persist it per (coordinator, gid) pair and never treat it as a message + * identity — the canonical id is the envelope's (`spec/02.md` §7). + */ +data class GroupCursor( + /** + * The value to pass as `after` on the next fetch. + * + * Advances past every message the stream delivers, INCLUDING ones we could + * not or chose not to process. That is not sloppiness: a message sealed + * under an epoch we never had is undecryptable forever, so a cursor that + * refused to move past it would re-fetch it on every catch-up and never + * reach the messages behind it. + */ + val fetchCursor: Long = 0, + /** The highest cursor ever seen, which only ever moves forward. */ + val lastCursor: Long = 0, +) { + fun advancedTo(cursor: Long) = GroupCursor(fetchCursor = cursor, lastCursor = maxOf(lastCursor, cursor)) + + /** What to send as `after`, or null on a first-ever fetch. */ + fun afterOrNull(): Long? = fetchCursor.takeIf { it > 0 } +} + +/** + * What to do with one inbound message. + * + * The decision is separated from the doing because it is the part that is easy + * to get wrong and hard to notice: every branch advances the cursor, and the + * difference between them is only whether MLS state moves. + */ +sealed interface Ingestion { + /** The cursor to advance to, whatever the outcome. */ + val cursor: Long + + /** + * Our own Commit coming back, already applied locally when we posted it. + * + * Skip it. Feeding our own Commit back through the engine would advance + * the epoch a second time and desynchronize us from every other member — + * the failure looks like "my messages stopped decrypting" several epochs + * later, with nothing pointing at the cause. + */ + data class SelfEchoConfirmed( + override val cursor: Long, + val sealedBase64: String, + ) : Ingestion + + /** + * Our own Commit coming back, NOT yet applied locally. + * + * Process it. This is the crash-recovery case: we posted, the coordinator + * accepted, and we died before adopting the new epoch. The echo is the only + * copy of that Commit we will ever be offered. + */ + data class SelfEchoUnapplied( + override val cursor: Long, + val sealedBase64: String, + ) : Ingestion + + /** An application message we sent. Already in our own history. */ + data class OwnMessage( + override val cursor: Long, + ) : Ingestion + + /** Someone else's traffic. Hand it to MLS. */ + data class Process( + override val cursor: Long, + val sealedBase64: String, + ) : Ingestion +} + +/** + * A Commit we have posted and are waiting to see come back. + * + * Keyed by the exact sealed base64, which is what makes the match reliable: + * `spec/03.md` §4 requires a fresh random nonce per payload, so the sealed form + * of a Commit is unique to the one posting of it. Matching on cursor instead + * would break the moment a coordinator renumbered, and matching on the + * plaintext would need us to decrypt our own traffic to recognise it. + */ +data class PendingEpochOperation( + val sealedBase64: String, + /** + * False when we posted but had not yet adopted the post-Commit state — + * the case [Ingestion.SelfEchoUnapplied] exists for. + */ + val localStateApplied: Boolean = true, +) + +/** + * Per-group ingestion state: the cursor, our pending Commits, and the cursors + * of application messages we sent. + * + * One path for catch-up and live delivery, as the reference requires: a message + * arriving over `msg_sub_many` and the same message arriving over + * `msg_fetch_many` must be treated identically, or a client that reconnects + * mid-stream processes something twice. + */ +class GroupInbox( + var cursor: GroupCursor = GroupCursor(), + echoes: EchoState = EchoState(), +) { + private val pendingOperations = echoes.pendingCommits.associateByTo(mutableMapOf()) { it.sealedBase64 } + private val ownMessageCursors = echoes.ownMessageCursors.toMutableSet() + + /** Records a Commit we just posted, so its echo is recognised. */ + fun expectEcho(operation: PendingEpochOperation) { + pendingOperations[operation.sealedBase64] = operation + } + + /** Records an application message we sent, by the cursor the coordinator gave it. */ + fun recordOwnMessage(cursor: Long) { + ownMessageCursors += cursor + } + + /** Pending Commits still unconfirmed. */ + fun pending(): List = pendingOperations.values.toList() + + /** + * Classifies [message] and advances the cursor. + * + * Advancing here rather than at each call site is the whole point: three of + * the four outcomes do nothing else, and a caller that forgot one would + * silently re-fetch forever. + */ + fun accept(message: GroupMessage): Ingestion { + cursor = cursor.advancedTo(message.cursor) + + val pending = pendingOperations[message.sealedBase64] + if (pending != null) { + pendingOperations.remove(message.sealedBase64) + return if (pending.localStateApplied) { + Ingestion.SelfEchoConfirmed(message.cursor, message.sealedBase64) + } else { + Ingestion.SelfEchoUnapplied(message.cursor, message.sealedBase64) + } + } + + if (ownMessageCursors.remove(message.cursor)) { + return Ingestion.OwnMessage(message.cursor) + } + + return Ingestion.Process(message.cursor, message.sealedBase64) + } + + /** + * Advances past a message MLS could not process. + * + * Undecryptable is not always an error worth stopping for: a message from + * an epoch we joined after, or one from a generation whose ratchet has + * moved on, is permanently unreadable and will be just as unreadable next + * time. The cursor has already advanced in [accept]; this exists so the + * intent is written down at the call site rather than inferred from its + * absence. + */ + fun skipUnprocessable(cursor: Long) { + this.cursor = this.cursor.advancedTo(cursor) + } + + /** + * The bookkeeping to persist beside [cursor]. + * + * Own-message cursors at or below the fetch cursor are dropped: the stream + * has already delivered them, so they can never come round again, and + * keeping them would grow the record for the life of the group. + * + * Pending Commits are kept until their echo matches, however long that + * takes, because the echo is the only copy that will ever be offered and + * [Ingestion.SelfEchoUnapplied] is how a client that died mid-post applies + * its own Commit. + */ + fun echoes(): EchoState = + EchoState( + pendingCommits = pendingOperations.values.toList(), + ownMessageCursors = ownMessageCursors.filter { it > cursor.fetchCursor }.sorted(), + ) +} + +/** + * The part of a [GroupInbox] that has to outlive the process. + * + * Found by running against a real coordinator, not by reading. Posting + * deliberately does not advance the cursor — a lower cursor may still hold + * somebody else's unprocessed message — so a client that exits between + * posting and ingesting comes back with a cursor that will re-read its own + * traffic. Without this record it cannot tell that it is its own: its Commit + * was sealed under an epoch key it has since left, and its message came from a + * ratchet generation already consumed, so both arrive as + * [Ingestion.Process] and fail to open. + * + * The visible damage is a gap in the sender's own conversation. The worse, + * narrower damage is that [Ingestion.SelfEchoUnapplied] — the recovery path + * for a client that posted a Commit and died before adopting it — depends on + * exactly the record that dying used to destroy. + * + * Every client needs this, not only a process-per-command one: a phone killed + * between sending a message and syncing is the ordinary case, not a corner. + */ +data class EchoState( + val pendingCommits: List = emptyList(), + val ownMessageCursors: List = emptyList(), +) { + val isEmpty: Boolean get() = pendingCommits.isEmpty() && ownMessageCursors.isEmpty() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/tlv/CordnStrictTlv.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/tlv/CordnStrictTlv.kt new file mode 100644 index 0000000000..ee1cdac086 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/tlv/CordnStrictTlv.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.tlv + +/** + * A strict TLV parse for cordn's bech32 payloads. + * + * `Tlv.parse` in quartz stops silently at a malformed tuple, which is right for + * NIP-19 — a truncated `nprofile` still names a usable pubkey, and half an + * answer beats none. It is wrong for cordn's payloads, where a dropped tail + * changes where the reader goes: a group ref quietly losing its coordinator + * reaches for a default instead, and a handoff code quietly losing its relays + * looks for the tip in the wrong place. + * + * Unknown TYPES are still ignored. That is forward compatibility, and a + * different thing from a truncated payload. + */ +object CordnStrictTlv { + /** + * Parses [data], or throws naming [subject] and the rule it broke. + * + * @param subject what the caller is decoding, for the error message — + * "cordn group ref", "handoff code". + */ + fun parse( + data: ByteArray, + subject: String, + ): Map> { + val result = mutableMapOf>() + var pos = 0 + while (pos < data.size) { + require(pos + 2 <= data.size) { "$subject has a truncated TLV header" } + val type = data[pos] + val length = data[pos + 1].toUByte().toInt() + require(pos + 2 + length <= data.size) { + "$subject TLV type $type declares $length bytes but only ${data.size - pos - 2} remain" + } + result.getOrPut(type) { mutableListOf() }.add(data.copyOfRange(pos + 2, pos + 2 + length)) + pos += 2 + length + } + return result + } + + /** Decodes UTF-8, or throws naming [subject] and [field]. */ + fun utf8( + bytes: ByteArray, + subject: String, + field: String, + ): String = + try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("$subject $field is not valid UTF-8", e) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index b1cb1f8f79..a1c6d5fb53 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -21,25 +21,25 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceAdmission import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceResolution import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceStatus import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.MarmotConvergenceEngine +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.Welcome import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index 4674d4bfbd..a8f2136f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.currentGroupState import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -160,15 +161,15 @@ class MarmotOutboundProcessor( * The commit bytes are already MLS-formatted (PublicMessage envelope). * * @param nostrGroupId the Nostr group ID - * @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.framedCommitBytes] + * @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.mls.group.StagedCommit.framedCommitBytes] * @param exporterKey optional explicit outer-encryption key. Callers * publishing a Commit MUST pass the **pre-commit** exporter secret - * (from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.preCommitExporterSecret]) + * (from [com.vitorpamplona.quartz.mls.group.StagedCommit.preCommitExporterSecret]) * so that other existing members at epoch N can decrypt and process * the commit. If null, falls back to the current epoch's exporter * secret — which is only correct when the commit has *not* been * applied locally yet (i.e. this call is made before - * [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.mergeStagedCommit]). + * [com.vitorpamplona.quartz.mls.group.MlsGroup.mergeStagedCommit]). * @return the signed GroupEvent ready for relay publishing */ suspend fun buildCommitEvent( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt index 6de406ed73..051aaf9742 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeGiftWrap -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.messages.CommitResult import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt index b3041004a3..87e659e327 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt index 5604b8f24b..29ae1ce609 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents +import com.vitorpamplona.quartz.mls.components.ComponentsList + /** * Marmot app-component ids, from the spec's `foundation/registries.md`. * @@ -39,11 +41,14 @@ package com.vitorpamplona.quartz.marmot.appComponents object AppComponentIds { // ---- upstream, draft-ietf-mls-extensions-10 ---- + // These three are the draft's, not Marmot's, so they are defined in the + // engine beside the dictionary that carries them and re-exposed here. + /** `app_components`: the supported (LeafNode) or required (GroupContext) id list. */ - const val APP_COMPONENTS = 0x0001 + const val APP_COMPONENTS = ComponentsList.APP_COMPONENTS_ID /** `safe_aad`: component-separated framing for MLS `authenticated_data`. */ - const val SAFE_AAD = 0x0002 + const val SAFE_AAD = ComponentsList.SAFE_AAD_ID /** * `last_resort_key_package`: empty-data marker in a KeyPackage's own @@ -51,7 +56,7 @@ object AppComponentIds { * MIP-era profile marked last resort with extension `0x000a`, which is now * the `self_remove` PROPOSAL type. */ - const val LAST_RESORT_KEY_PACKAGE = 0x0004 + const val LAST_RESORT_KEY_PACKAGE = ComponentsList.LAST_RESORT_KEY_PACKAGE_ID // ---- Marmot private range ---- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt index a8661a747b..0ee1543459 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt @@ -22,15 +22,17 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentData -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519KeyPair -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -161,7 +163,7 @@ object CurrentProfileGroupFactory { signingKey = leaf.signatureKeyPair.privateKey, leafSignatureKeyPair = leaf.signatureKeyPair, leafExtensions = leaf.leafExtensions, - capabilities = MlsGroup.currentProfileLeafCapabilities(), + capabilities = MarmotCapabilities.currentProfileLeaf(), keyPackageExtensions = keyPackageExtensions, ) } @@ -218,8 +220,11 @@ object CurrentProfileGroupFactory { signingKey = leaf.signatureKeyPair.privateKey, initialExtensions = listOf(dictionary.toExtension()), leafExtensions = leaf.leafExtensions, - capabilities = MlsGroup.currentProfileLeafCapabilities(), - requiredCapabilities = MlsGroup.buildCurrentProfileRequiredCapabilitiesExtension(), + // The current profile shares Marmot's authorization rules but + // advertises a different capability set, so both are named here. + policy = MarmotGroupPolicy, + capabilities = MarmotCapabilities.currentProfileLeaf(), + requiredCapabilities = MarmotCapabilities.currentProfileRequired(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt index 8b5cb8d6a6..b642fe72d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** One blob-store endpoint and the locator kind it serves. */ data class BlobStoreEndpointV2( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt index faed1c71a4..d9c3bd76a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt index 5466ae18c3..48612365b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * `marmot.group.avatar-url.v1`, component `0x8007` — a group avatar behind an diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt index 395c6d403f..6f6a24c958 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt index 3bec7f7365..fd82ed0d64 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * `marmot.group.profile.v1`, component `0x8001` — the group's display name and diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt index 9bf7ccd3ea..681abf3628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.tree.Extension /** * The current profile's read view of a GroupContext `app_data_dictionary` — the diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt index 4b240ee781..b229c2e073 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * `marmot.group.message-retention.v1`, component `0x8005` — disappearing diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt index 6f407002ea..863a19f4a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt index f42e44e7e7..af7450e93d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents.agentTextStream -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt index cb9e29d73a..416d95b79e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot.appComponents.agentTextStream import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.ComponentData /** * `marmot.group.agent-text-stream.quic.v1` (component `0x8006`). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt index 300838f9e5..b38fcde197 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents.agentTextStream -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * The rolling hash a stream's final kind-9 chat publishes as `stream-hash`, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt index e5c72045b1..e6816f5ec7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.foundation.authorizationProofs -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt new file mode 100644 index 0000000000..8f16cce811 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * The MLS capability sets that identify a group as Marmot's. + * + * These used to be defaults baked into `MlsGroup.create`, which is why a + * plain RFC 9420 group could not be created at all: every group came out + * requiring `marmot_group_data`. They are unchanged, only relocated, and the + * engine now reaches them through [MarmotGroupPolicy]. + * + * Marmot has two profiles and a client must be able to read either, so both + * sets live here: + * + * - **MIP-era** — `0xF2EE` carries all group state, `self_remove` is required. + * - **current** — `app_data_dictionary` (`0x0006`) carries it as components, + * with `app_data_update` (`0x0008`) to change them. + */ +object MarmotCapabilities { + /** Marmot Group Data Extension type (MIP-01). */ + const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE + + /** + * Default MLS leaf Capabilities that advertise support for Marmot's + * required extensions and proposals so new members can join a group + * whose `required_capabilities` lists them. + */ + fun mipLeaf(): Capabilities = + Capabilities( + extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * The MIP-era leaf set as a published KeyPackage advertises it. + * + * Same as [mipLeaf] plus `0x000A` as an EXTENSION, which OpenMLS validation + * requires on a last-resort KeyPackage. Note `0x000A` appears in both lists + * meaning different things: as an extension it is `last_resort`, as a + * proposal it is `self_remove`. + * + * The order is load-bearing and must not be tidied: these bytes go into + * published KeyPackages, and `KeyPackageBundleStore`'s v4 snapshot format + * is defined by them. + */ + fun mipKeyPackageLeaf(): Capabilities = + Capabilities( + extensions = listOf(MlsKeyPackage.LAST_RESORT_EXTENSION_TYPE, MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * Build an MLS `required_capabilities` extension that marks Marmot's + * mandatory interop set as required for all members (RFC 9420 §7.2): + * extensions = [marmot_group_data (0xF2EE)] + * proposals = [self_remove (0x000A)] + * credentials = [Basic (0x0001)] + */ + fun mipRequired(): Extension = + requiredCapabilities( + extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * Leaf capabilities for the current profile. + * + * RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only + * the draft `app_data_dictionary` extension and the `app_data_update` + * proposal appear — `required_capabilities` support is implicit. + * + * The legacy `0xF2EE` group-data extension is advertised alongside + * them, and that is not a hedge. A capability says "this client can + * handle it", not "this group uses it", and a group that REQUIRES + * `0xF2EE` refuses to add a leaf that does not advertise it. Without + * this line a current-profile KeyPackage would be un-addable to every + * legacy group that already exists — the exact mirror of the interop + * failure the current profile was adopted to fix. + * + * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: + * a group carrying component `0x8006` with `required_member_roles` + * naming `receive` refuses a leaf that does not advertise it. The + * reference client puts exactly that policy into EVERY group it + * creates, so without this line an Amethyst KeyPackage cannot be + * invited into one at all. + * + * We stop at receive. `send` and `fanout` are not here because we do + * not originate previews from the app, and a capability is a standing + * promise rather than a hedge. + */ + fun currentProfileLeaf(): Capabilities = + Capabilities( + extensions = + listOf( + AppDataDictionary.EXTENSION_TYPE, + MarmotGroupData.EXTENSION_ID_INT, + AgentTextStreamRoles.RECEIVE_CAPABILITY, + ), + proposals = listOf(MlsGroup.APP_DATA_UPDATE_PROPOSAL_TYPE, MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * `required_capabilities` for a new current-profile group: extension + * `0x0006` and proposal `0x0008`. + * + * The Marmot components a group requires are negotiated in the + * upstream `app_components` component INSIDE the dictionary, not here — + * MLS `RequiredCapabilities` carries only MLS-level primitives. + */ + fun currentProfileRequired(): Extension = + requiredCapabilities( + extensions = listOf(AppDataDictionary.EXTENSION_TYPE), + proposals = listOf(MlsGroup.APP_DATA_UPDATE_PROPOSAL_TYPE), + ) + + /** Encodes an RFC 9420 §7.2 `required_capabilities` extension over Basic credentials. */ + private fun requiredCapabilities( + extensions: List, + proposals: List, + ): Extension { + val writer = TlsWriter() + // extensions: uint16 each + val exts = TlsWriter() + extensions.forEach { exts.putUint16(it) } + writer.putOpaqueVarInt(exts.toByteArray()) + // proposals: uint16 each + val props = TlsWriter() + proposals.forEach { props.putUint16(it) } + writer.putOpaqueVarInt(props.toByteArray()) + // credentials: uint16 each + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt new file mode 100644 index 0000000000..7001abc36c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.GroupView +import com.vitorpamplona.quartz.mls.group.MlsExporterLabel +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.PendingProposal +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * Marmot's authorization rules (MIP-01 and MIP-03), as an [MlsGroupPolicy]. + * + * These used to live inside `MlsGroup`, which meant the RFC 9420 engine knew + * the word "admin" — a concept RFC 9420 does not have. They are unchanged + * here; only where they run has moved. + * + * Stateless, so one instance serves every group. + */ +object MarmotGroupPolicy : MlsGroupPolicy { + /** + * The MIP-era leaf set. A current-profile group names + * [MarmotCapabilities.currentProfileLeaf] explicitly instead: the two + * profiles differ in what they advertise but share these authorization + * rules, so the policy carries the older default and the factory that + * knows it is building a current-profile group overrides it. + */ + override val defaultLeafCapabilities: Capabilities get() = MarmotCapabilities.mipLeaf() + + override val defaultRequiredCapabilities: Extension get() = MarmotCapabilities.mipRequired() + + /** + * Marmot's two group-context extension types, exempted from the §13.4 + * all-members-support check. + * + * Both are advertised by [MarmotCapabilities.mipLeaf] and by the current + * profile, so a group built entirely by this client passes the check on + * capabilities alone. The exemption is for the groups that are already out + * there, whose older leaves predate the advertisement - dropping it would + * make this client refuse to apply a GroupContextExtensions proposal in a + * group it is happily a member of. + */ + override val knownExtensionTypes: Set = + setOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE, AppDataDictionary.EXTENSION_TYPE) + + /** + * `MLS-Exporter("marmot", "group-event", 32)` — the outer + * ChaCha20-Poly1305 key for a kind:445 GroupEvent. A commit must be sealed + * under the PRE-commit epoch so members still at epoch N can open it. + */ + override val commitExporter: MlsExporterLabel + get() = MlsExporterLabel("marmot", "group-event".encodeToByteArray(), 32) + + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + enforceAuthorizedProposalSet(group, proposals, committerLeafIndex) + enforceNoAdminDepletion(group, proposals) + } + + override fun authorizeSelfRemove(group: GroupView) { + check(!isLocalAdmin(group)) { + "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" + } + } + + override fun validateJoin(group: GroupView) { + requireAgentTextStreamRoles(group) + } + + /** + * The admin set named by [extensions], preferring the current profile. + * + * Decodes ONLY the admin policy, never the whole component set. Authorization + * must not depend on the validity of components it does not read: a + * malformed group profile is a defect worth surfacing where the profile is + * used, but it must not make the group un-committable by taking the admin + * check down with it. + * + * Reads whichever profile this group is on: the current profile's + * `marmot.group.admin-policy.v1` component (`0x8003`) when present, + * otherwise MIP-01's `admin_pubkeys` field inside `marmot_group_data` + * (`0xF2EE`). Empty means the group names no admins at all, which happens + * during bootstrap and in groups that carry neither. + */ + fun adminIdentitiesIn(extensions: List): Set { + val policyBytes = AppDataDictionary.fromExtensionsOrEmpty(extensions)[AdminPolicyV1.COMPONENT_ID] + if (policyBytes != null) return AdminPolicyV1.decode(policyBytes).adminHexKeys.toSet() + return MarmotGroupData + .fromExtensions(extensions) + ?.adminPubkeys + ?.toSet() + .orEmpty() + } + + /** True if the member at [leafIndex] is an ACTIVE admin: named in the admin set and still holding a leaf. */ + fun isLeafAdmin( + group: GroupView, + leafIndex: Int, + ): Boolean { + val id = group.memberIdentityHex(leafIndex) ?: return false + return id in adminIdentitiesIn(group.extensions) + } + + /** True if the local member is an active admin. */ + fun isLocalAdmin(group: GroupView): Boolean = isLeafAdmin(group, group.myLeafIndex) + + /** + * MIP-03: a non-admin may commit only a single self-Update, or a set made + * entirely of their own SelfRemoves. + * + * The "self-only" rule is checked against the committer; when the committer + * is an admin the rule is skipped entirely so admin-folded inbound proposals + * (e.g. another member's `SelfRemove` referenced by an admin's GCE commit) + * are accepted. + */ + internal fun enforceAuthorizedProposalSet( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + if (proposals.isEmpty()) return + // Reads whichever profile the group is on: the admin-policy component + // (0x8003) for current-profile groups, `marmot_group_data` (0xF2EE) + // for legacy ones. An empty set means bootstrap — no admins named yet — + // and the gate stays open, mirroring MlsGroupManager.updateGroupExtensions. + val admins = adminIdentitiesIn(group.extensions) + if (admins.isEmpty() || isLeafAdmin(group, committerLeafIndex)) return + + val allSelfRemove = + proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == committerLeafIndex } + if (allSelfRemove) return + + val singleSelfUpdate = + proposals.size == 1 && + proposals[0].proposal is Proposal.Update && + proposals[0].senderLeafIndex == committerLeafIndex + if (singleSelfUpdate) return + + throw IllegalStateException( + "MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " + + "proposals; got ${proposals.map { it.proposal::class.simpleName }} from leaf $committerLeafIndex", + ) + } + + /** + * Reject any commit that would leave the group without at least one member + * still listed in `admin_pubkeys` (MIP-03 admin depletion guard). + * + * We simulate the post-commit member set and the post-commit `admin_pubkeys` + * list, then require a non-empty intersection. The guard is only active + * once the group has a configured admin set — it does not kick in during + * bootstrap before any admin is named. + */ + internal fun enforceNoAdminDepletion( + group: GroupView, + proposals: List, + ) { + val currentAdmins = adminIdentitiesIn(group.extensions) + if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete. + + // Resolve the effective admin list after this commit. Three carriers can + // change it, and they are checked in the order the commit applies them: + // an AppDataUpdate on 0x8003 (current profile), then a + // GroupContextExtensions proposal replacing the whole extension list + // (either profile). AppDataUpdate is resolved last because + // `applyAppDataUpdateProposals` runs after the rest of the list. + val gce = + proposals + .asSequence() + .map { it.proposal } + .filterIsInstance() + .lastOrNull() + val extensionsAfterGce = gce?.extensions ?: group.extensions + + val adminUpdate = + proposals + .asSequence() + .map { it.proposal } + .filterIsInstance() + .lastOrNull { it.componentId == AdminPolicyV1.COMPONENT_ID } + + val adminSet = + when (val operation = adminUpdate?.operation) { + is Proposal.AppDataUpdate.Operation.Update -> + AdminPolicyV1.decode(operation.data).adminHexKeys.toSet() + + // Removing the admin policy is never valid — it is the sole + // admin authority for the group's lifetime — so an empty set + // here trips the depletion check below, which is the outcome + // we want. + Proposal.AppDataUpdate.Operation.Remove -> emptySet() + + null -> adminIdentitiesIn(extensionsAfterGce) + } + check(adminSet.isNotEmpty()) { + "commit would leave the group with no admins (admin depletion)" + } + + // Compute which leaves remain after applying Removes/SelfRemoves. + val removedLeaves = mutableSetOf() + for (pending in proposals) { + when (val p = pending.proposal) { + is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex) + is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex) + else -> Unit + } + } + + val remainingAdminIdentities = mutableSetOf() + for (i in 0 until group.leafCount) { + if (i in removedLeaves) continue + val id = group.memberIdentityHex(i) ?: continue + if (id in adminSet) remainingAdminIdentities.add(id) + } + + check(remainingAdminIdentities.isNotEmpty()) { + "MIP-03: commit would leave the group without any admin members" + } + } + + /** + * Enforce the `0x8006` component's `required_member_roles` mask over + * the joining tree. + * + * A group carrying the agent-text-stream component requires each named + * role as an MLS leaf capability (`0xF2D1` receive, `0xF2D2` send, + * `0xF2D4` fanout). Advertising the component id alone is not enough — + * that only says "understands the component"; the role capability says + * "can actually do this". + */ + private fun requireAgentTextStreamRoles(group: GroupView) { + val policy = + AppDataDictionary + .fromExtensionsOrEmpty(group.extensions)[AgentTextStreamQuicPolicyV1.COMPONENT_ID] + ?.let { AgentTextStreamQuicPolicyV1.decode(it) } ?: return + val required = policy.requiredRoleCapabilities() + if (required.isEmpty()) return + + val myLeaf = group.leafCapabilities(group.myLeafIndex) + requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" } + val missing = required.filterNot { myLeaf.extensions.contains(it) } + require(missing.isEmpty()) { + "Joiner does not advertise agent text stream roles this group requires: " + + missing.joinToString { AppComponentIds.toHex(it) } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt new file mode 100644 index 0000000000..db32823fd2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +// Marmot's reads of an MlsGroup's GroupContext. +// +// These were methods on MlsGroup itself, which put MIP-01 extension parsing and +// Nostr routing ids inside an RFC 9420 engine. None of them needs the group's +// internals — every one goes through the public extensions / exporterSecret / +// view() surface — so they live here as extensions, and the engine no longer +// knows Marmot exists. + +/** Parsed Marmot Group Data Extension from the current GroupContext, or null. */ +fun MlsGroup.currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(extensions) + +/** The current profile's component view of this GroupContext. */ +fun MlsGroup.currentGroupState(): MarmotGroupState = MarmotGroupState.fromExtensions(extensions) + +/** + * The `nostr_group_id` this group routes kind-445 traffic under, from + * whichever profile the group is actually using. + * + * A current-profile group carries it in the `marmot.transport.nostr.routing.v1` + * component (`0x8004`); a legacy group carries it inside the monolithic + * `0xF2EE` extension. Reading only the legacy one leaves us unable to join + * any group a current-profile client created — the routing id is required + * to subscribe at all, so the failure is total rather than partial. + */ +fun MlsGroup.currentNostrGroupId(): HexKey? = + currentGroupState().routing?.nostrGroupIdHex + ?: currentMarmotData()?.nostrGroupId + +/** + * The group's configured admin account identities, as lowercase hex. + * + * Empty means the group names no admins at all, which happens during + * bootstrap and in groups that carry neither carrier. + */ +fun MlsGroup.currentAdminIdentities(): Set = MarmotGroupPolicy.adminIdentitiesIn(extensions) + +/** True if the local member is an active admin. */ +fun MlsGroup.isLocalAdmin(): Boolean = MarmotGroupPolicy.isLocalAdmin(view()) + +/** + * `MLS-Exporter("marmot", "agent-text-stream-quic", 32)` — the secret every + * member of this epoch derives per-stream record keys from. Per-stream and + * per-record separation is entirely in the HKDF key context, so this one + * secret covers every stream in the epoch. + */ +fun MlsGroup.agentTextStreamSecret(): ByteArray = + exporterSecret( + AgentTextStreamCrypto.EXPORTER_LABEL, + AgentTextStreamCrypto.EXPORTER_CONTEXT, + AgentTextStreamCrypto.SECRET_LENGTH, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotMessageStore.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotMessageStore.kt index 4af30c5c3c..0aa6701074 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotMessageStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.groups /** * Encrypted local storage for decrypted Marmot inner event JSONs. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt index eac5c5b10a..86fd4bca74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt @@ -18,22 +18,30 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.groups import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1 -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult -import com.vitorpamplona.quartz.marmot.mls.messages.ExternalJoinResult -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.group.DecryptedMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.group.OwnSenderRatchet +import com.vitorpamplona.quartz.mls.group.RetainedEpochSecrets +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.messages.ExternalJoinResult +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.schedule.SecretTree +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.sync.Mutex @@ -137,7 +145,9 @@ class MlsGroupManager( continue } val state = MlsGroupState.decodeTls(stateBytes) - val group = MlsGroup.restore(state) + // main needs the local binding for the sender-ratchet + // restore below; this branch supplies Marmot's policy. + val group = MlsGroup.restore(state, MarmotGroupPolicy) groups[nostrGroupId] = group Log.d(TAG) { "restoreAll(): restored group $nostrGroupId (${stateBytes.size} bytes)" } @@ -227,7 +237,7 @@ class MlsGroupManager( if (!changed) return@withLock val outgoing = current?.retainedSecrets() - groups[nostrGroupId] = MlsGroup.restore(state) + groups[nostrGroupId] = MlsGroup.restore(state, MarmotGroupPolicy) // Retain by outgoing epoch even when the epoch NUMBER is unchanged: a // same-epoch rewind swaps one epoch-N state for a different one, and // the abandoned N still has traffic addressed to it. @@ -287,11 +297,11 @@ class MlsGroupManager( nostrGroupId: HexKey, identity: ByteArray, signingKey: ByteArray? = null, - initialExtensions: List = emptyList(), + initialExtensions: List = emptyList(), ): MlsGroup = mutex.withLock { Log.d(TAG) { "createGroup($nostrGroupId): creating new MLS group" } - val group = MlsGroup.create(identity, signingKey, initialExtensions) + val group = MlsGroup.create(identity, signingKey, initialExtensions, policy = MarmotGroupPolicy) groups[nostrGroupId] = group persistGroup(nostrGroupId) Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" } @@ -322,7 +332,7 @@ class MlsGroupManager( hintNostrGroupId: HexKey? = null, ): Pair = mutex.withLock { - val group = MlsGroup.processWelcome(welcomeBytes, bundle) + val group = MlsGroup.processWelcome(welcomeBytes, bundle, MarmotGroupPolicy) val derivedId = group.currentNostrGroupId() @@ -362,7 +372,7 @@ class MlsGroupManager( signingKey: ByteArray? = null, ): ExternalJoinResult = mutex.withLock { - val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey) + val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey, policy = MarmotGroupPolicy) groups[nostrGroupId] = result.group persistGroup(nostrGroupId) result @@ -426,7 +436,7 @@ class MlsGroupManager( mutex.withLock { val live = requireGroup(nostrGroupId) val priorState = live.saveState() - val clone = MlsGroup.restore(priorState) + val clone = MlsGroup.restore(priorState, MarmotGroupPolicy) val result = prepare(clone) StagedCommit(result, priorState, clone.saveState()) } @@ -620,7 +630,7 @@ class MlsGroupManager( senderLeafIndex: Int, confirmationTag: ByteArray, signature: ByteArray = ByteArray(0), - wireFormat: com.vitorpamplona.quartz.marmot.mls.framing.WireFormat = com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + wireFormat: com.vitorpamplona.quartz.mls.framing.WireFormat = com.vitorpamplona.quartz.mls.framing.WireFormat.PUBLIC_MESSAGE, ) = mutex.withLock { val group = requireGroup(nostrGroupId) @@ -724,7 +734,7 @@ class MlsGroupManager( // command, so we MUST persist here or reloaded state // silently reverts to the pre-commit extensions (including // admin list). - if (result.contentType == com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT && group.epoch != preEpoch) { + if (result.contentType == com.vitorpamplona.quartz.mls.framing.ContentType.COMMIT && group.epoch != preEpoch) { pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) } @@ -1049,15 +1059,15 @@ class MlsGroupManager( try { val secretTree = SecretTree(retained.encryptionSecret, retained.leafCount) val mlsMsg = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls(TlsReader(messageBytes)) - if (mlsMsg.wireFormat != com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PRIVATE_MESSAGE) { + if (mlsMsg.wireFormat != com.vitorpamplona.quartz.mls.framing.WireFormat.PRIVATE_MESSAGE) { return null } val privMsg = - com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage + com.vitorpamplona.quartz.mls.framing.PrivateMessage .decodeTls(TlsReader(mlsMsg.payload)) if (privMsg.epoch != retained.epoch) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupStateStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupStateStore.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupStateStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupStateStore.kt index 5a9307b614..0fc5016446 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupStateStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupStateStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.groups /** * Interface for encrypted local storage of MLS group state. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt index 5686cd69d1..e377378aeb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.AppComponentsTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProposalsTag -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index d125828abf..72e12f6a1a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -21,21 +21,21 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.SNAPSHOT_VERSION import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.LeafNodeSource +import com.vitorpamplona.quartz.mls.tree.Lifetime import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.Log @@ -700,18 +700,7 @@ class KeyPackageRotationManager( encryptionKey = encryptionKey, signatureKey = signatureKey, credential = Credential.Basic(identity), - capabilities = - Capabilities( - extensions = - listOf( - 0x000A, // LastResort (required by OpenMLS validation) - 0xF2EE, // NostrGroupData (required by group's RequiredCapabilities) - ), - proposals = - listOf( - 0x000A, // SelfRemove (required by group's RequiredCapabilities) - ), - ), + capabilities = MarmotCapabilities.mipKeyPackageLeaf(), leafNodeSource = LeafNodeSource.KEY_PACKAGE, lifetime = Lifetime(notBefore = now, notAfter = now + KEY_PACKAGE_LIFETIME_SECONDS), extensions = emptyList(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt index a5fd89b6ee..b46881df81 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt @@ -29,14 +29,14 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProposalsTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index ae0e358e4d..2d583f637e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.quartz.marmot.mip01Groups import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData.Companion.decodeTls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt index 9600161e76..20556c0db4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.mip01Groups -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * MIP-01 image & Blossom upload key derivations. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt index d4285db2a5..be5f751e03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.mip04EncryptedMedia -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt index 91b0760946..bd2111dede 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log @@ -421,7 +423,9 @@ class MarmotConvergenceEngine( mutex.withLock { contexts[groupId]?.candidateStates?.values?.mapNotNull { state -> try { - MlsGroup.restore(state).exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MarmotGroupPolicy.commitExporter.let { + MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret(it.label, it.context, it.length) + } } catch (_: Exception) { null } @@ -451,7 +455,7 @@ class MarmotConvergenceEngine( // A clone per attempt: decrypting advances the secret // tree, and a candidate state gets tried by every // message that failed canonically. - MlsGroup.restore(state).decrypt(mlsBytes) + MlsGroup.restore(state, MarmotGroupPolicy).decrypt(mlsBytes) } catch (_: Exception) { continue } @@ -460,7 +464,7 @@ class MarmotConvergenceEngine( stateId = stateId, epoch = decrypted.epoch, senderLeafIndex = decrypted.senderLeafIndex, - senderAccount = MlsGroup.restore(state).memberIdentityHex(decrypted.senderLeafIndex), + senderAccount = MlsGroup.restore(state, MarmotGroupPolicy).memberIdentityHex(decrypted.senderLeafIndex), content = decrypted.content, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt index 369233a9c1..e8f25d488c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt index 9bd0fa79c9..a4399a029b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt @@ -20,13 +20,17 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -43,7 +47,21 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * "roll it back afterwards" is the kind of thing that works until the day an * exception escapes halfway through. */ -class MlsCandidateStateEngine : CandidateStateEngine { +class MlsCandidateStateEngine( + /** + * The rules a restored group is judged by. + * + * Passed on EVERY restore below, and that is the whole point of naming it + * here. `MlsGroup.restore` defaults to [MlsGroupPolicy.Permissive], whose + * `authorizeCommit` is a no-op — so a restore that forgets the policy + * turns [isAuthorized] into a function that returns true for every commit + * it can parse. The MIP-03 gate and the admin-depletion guard used to live + * inside `MlsGroup` and could not be left out; since they moved behind + * this seam, leaving it out is a silent authorization bypass rather than a + * compile error. It is a constructor parameter so a test can see it. + */ + private val policy: MlsGroupPolicy = MarmotGroupPolicy, +) : CandidateStateEngine { /** * Identity of a retained state. * @@ -68,7 +86,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { if (pubMsg.epoch != state.groupContext.epoch) return false return try { - MlsGroup.restore(state).verifyPublicMessageCommitMembershipTag(pubMsg) + MlsGroup.restore(state, policy).verifyPublicMessageCommitMembershipTag(pubMsg) } catch (_: Exception) { false } @@ -81,7 +99,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { try { // A clone, so the retained snapshot is untouched no matter how this // attempt ends. - val group = MlsGroup.restore(state) + val group = MlsGroup.restore(state, policy) group.processFramedCommit(commit) group.saveState() } catch (_: Exception) { @@ -94,7 +112,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { ): ByteArray? { val pubMsg = publicMessageOrNull(commit) ?: return null return try { - MlsGroup.restore(parent).memberIdentity(pubMsg.sender.leafIndex) + MlsGroup.restore(parent, policy).memberIdentity(pubMsg.sender.leafIndex) } catch (_: Exception) { null } @@ -113,7 +131,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { ): Boolean { val pubMsg = publicMessageOrNull(commit) ?: return false return try { - val group = MlsGroup.restore(parent) + val group = MlsGroup.restore(parent, policy) // A group that names no admins yet is bootstrapping; the gate is // open there for the same reason the local path leaves it open. if (group.currentAdminIdentities().isEmpty()) return true @@ -129,7 +147,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { ): TipPriority { val pubMsg = publicMessageOrNull(commit) ?: return TipPriority.ORDINARY return try { - val group = MlsGroup.restore(parent) + val group = MlsGroup.restore(parent, policy) // Privileged exactly when the rule that applies REQUIRES an active // admin. A commit a non-admin could also have made is ordinary even // when an admin happened to send it. @@ -145,7 +163,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { // is strict, so malformed or unsorted component bytes throw rather // than producing a lenient value. An admin policy that named nobody // would also fail its own constructor. - val group = MlsGroup.restore(resulting) + val group = MlsGroup.restore(resulting, policy) // Decoding the component set IS the validation: every component // decoder is strict, so malformed, unsorted or duplicated bytes // throw rather than yielding a lenient value, and an admin policy diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md new file mode 100644 index 0000000000..17e85c0ffd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md @@ -0,0 +1,89 @@ +# `mls/` — the RFC 9420 engine + +A binding-agnostic MLS (RFC 9420) implementation: TLS presentation-language +codec, HPKE, X25519/Ed25519, the ratchet tree, the key schedule, the secret +tree, message framing, proposals, commits, Welcome, and the `app_data_dictionary` +component carrier from `draft-ietf-mls-extensions-10`. + +**It knows nothing about Marmot, Nostr, or cordn, and must stay that way.** The +invariant, over the shipped code: + +```bash +grep -rn 'import com.vitorpamplona.quartz.marmot' \ + --include='*.kt' quartz/src/{commonMain,jvmAndroid,appleMain,linuxMain}/kotlin/com/vitorpamplona/quartz/mls/ +``` + +That must print nothing. Tests are deliberately outside it: a couple under +`mls/components/` decode real Marmot components as fixtures, because the point +of an interop test is to exercise the engine against payloads that actually +exist. A fixture is data; an import in the engine is a dependency. + +It lived under `marmot/` until Stage 1 of +`quartz/plans/2026-09-17-cordn-interop.md`, and `:quic` was already importing +`crypto/X25519` from there for its TLS 1.3 handshake — a use that is neither +Marmot nor MLS, and the reason the extraction was overdue. + +## Using it + +```kotlin +val group = MlsGroup.create(identity) // a plain RFC 9420 group +val group = MlsGroup.create(identity, policy = MarmotGroupPolicy) // Marmot's profile +``` + +The default group requires nothing beyond RFC 9420: no `required_capabilities`, +no leaf capabilities (§7.2 forbids advertising DEFAULT types), no commit +exporter, and no limit on who may commit what. + +## The `MlsGroupPolicy` seam + +RFC 9420 says who may *send* a proposal and never who may *commit* one. Real +deployments need more — Marmot's MIP-03 names admin accounts and allows everyone +else only a self-Update or a SelfRemove — so the engine asks a policy wherever +the spec defers to the application: + +| Hook | Called from | Marmot uses it for | +| ---- | ----------- | ------------------ | +| `authorizeCommit` | `commit()` and both inbound commit paths | MIP-03 proposal-set rules + admin depletion | +| `authorizeSelfRemove` | `proposeSelfRemove`, `buildSelfRemoveProposalMessage` | admin must self-demote first | +| `validateJoin` | `processWelcome` | `required_member_roles` on the agent-text-stream component | +| `defaultLeafCapabilities` | every leaf the engine builds | `0xF2EE` + `self_remove` | +| `defaultRequiredCapabilities` | `create()`, epoch 0 | the MIP-era interop set | +| `knownExtensionTypes` | GroupContextExtensions validation | `0xF2EE` | +| `commitExporter` | `CommitResult.preCommitExporterSecret` | `MLS-Exporter("marmot", "group-event", 32)` | + +Three things to know before you add a binding: + +1. **One argument selects a whole profile.** `policy` supplies the rules *and* + the capability defaults *and* the exporter binding, so `capabilities` and + `requiredCapabilities` default from it. Adopting a profile is one argument, + not five. + +2. **A policy is behaviour, not state.** It is deliberately absent from + `MlsGroupState`, so whatever restores a group must pass the same policy it + was created with. A restore that forgets it gets a group that silently skips + the binding's rules. In Marmot only `MlsGroupManager` restores a group in + order to commit with it, which is what keeps that narrow. + +3. **A policy gets a `GroupView`, not the `MlsGroup`.** A policy holding the + group could commit or rotate keys from inside the check meant to gate exactly + that. + +The default is permissive rather than closed. Closed would make the engine +unusable without a policy and would push callers into writing an +allow-everything one anyway; open puts each restriction in the binding that +documents it. The cost is item 2 above. + +## Where the bindings live + +- `quartz/…/marmot/groups/` — `MarmotGroupPolicy`, `MarmotCapabilities`, + `MarmotGroupViews` (Marmot's reads of a GroupContext, as extension functions), + plus `MlsGroupManager` and the two stores, all keyed on `nostrGroupId`. +- `quartz/…/marmot/mipXX…/` — the Nostr event layer. + +## Tests + +`quartz/src/commonTest/…/mls/` runs everywhere; `quartz/src/jvmAndroidTest/…/mls/` +holds the tests needing real secp256k1/JNI. `mls/interop/` replays the RFC 9420 +test vectors. `group/MlsGroupPolicySeamTest` pins the seam itself with a +recording policy; `marmot/groups/MarmotPolicySeamTest` pins that Marmot's rules +travel with `MarmotGroupPolicy` and not with the engine. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsReader.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsReader.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsReader.kt index 75fcdebbe9..dfd2dffeee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsReader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.codec +package com.vitorpamplona.quartz.mls.codec /** * Decoder for TLS presentation language (RFC 8446 Section 3). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsSerializable.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsSerializable.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsSerializable.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsSerializable.kt index bf8fbb95ca..00652cf6c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsSerializable.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsSerializable.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.codec +package com.vitorpamplona.quartz.mls.codec /** * Interface for types that can be serialized to TLS presentation language diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsWriter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsWriter.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsWriter.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsWriter.kt index e147ac4836..da611b9bed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsWriter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsWriter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.codec +package com.vitorpamplona.quartz.mls.codec /** * Encoder for TLS presentation language (RFC 8446 Section 3). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionary.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionary.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionary.kt index 6faf55d423..88ad278666 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionary.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionary.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension /** * The `app_data_dictionary` MLS extension (draft-ietf-mls-extensions-10 §4.6), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentData.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentData.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentData.kt index 585adb9a8e..1f6103ac09 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentData.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * One entry in an [AppDataDictionary] (draft-ietf-mls-extensions-10 §4.6). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentsList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentsList.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt index e3d58942e1..0f2ea86fe9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentsList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * The `ComponentsList` payload shared by the upstream `app_components` @@ -113,6 +113,14 @@ object ComponentsList { /** Component id of the upstream `safe_aad` list. */ const val SAFE_AAD_ID = 0x0002 + /** + * `last_resort_key_package`: empty-data marker in a KeyPackage's own + * dictionary. Note this is a component, NOT an MLS extension type — the + * MIP-era profile marked last resort with extension `0x000a`, which is now + * the `self_remove` PROPOSAL type. + */ + const val LAST_RESORT_KEY_PACKAGE_ID = 0x0004 + /** The supported/required id list carried by [dictionary], or empty when absent. */ fun supportedOrRequired(dictionary: AppDataDictionary): List = dictionary[APP_COMPONENTS_ID]?.let { decode(it) } ?: emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Curve25519Field.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Curve25519Field.kt index 97ee380680..5e7c0d776c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Curve25519Field.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto /** * Field arithmetic over GF(2^255-19) for Curve25519 and Ed25519. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt index 168b586808..08a468fbdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto /** * Ed25519 digital signature operations for MLS ciphersuite 0x0001. @@ -66,8 +66,16 @@ expect object Ed25519 { fun publicFromPrivate(privateKey: ByteArray): ByteArray /** - * Rebuild a key pair from a 32-byte seed (RFC 8032 §5.1.5), for keys stored as seeds only. - * @return pair of (privateKey: 64 bytes seed+public, publicKey: 32 bytes) + * Rebuilds a key pair from its 32-byte seed. + * + * [generateKeyPair] makes a fresh random one, which is right for a real + * client and useless for anything that has to reproduce a specific key: + * an interop fixture generated by another implementation, a test vector, or + * a private key stored in someone else's layout (ts-mls keeps the seed in a + * PKCS#8 blob, so the seed is all you get back out). + * + * @param seed exactly 32 bytes. The public half is derived, never supplied, + * so a mismatched pair cannot be constructed here. */ fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Hpke.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Hpke.kt index fcc7990cdd..3eee283298 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Hpke.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.ciphers.AESGCM diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/MlsCryptoProvider.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/MlsCryptoProvider.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/MlsCryptoProvider.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/MlsCryptoProvider.kt index 6f12ec5a71..712cc798d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/MlsCryptoProvider.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/MlsCryptoProvider.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.ciphers.AESGCM diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.kt index ea6f18ae98..7920ecefdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto /** * X25519 Diffie-Hellman key exchange for MLS DHKEM (RFC 9180). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/ContentType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/ContentType.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/ContentType.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/ContentType.kt index 0d23249604..da524ffd52 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/ContentType.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/ContentType.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.framing +package com.vitorpamplona.quartz.mls.framing /** * MLS ContentType (RFC 9420 Section 6.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/MlsMessage.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/MlsMessage.kt index f01a6f4658..fdbebe7ffa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/MlsMessage.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.framing +package com.vitorpamplona.quartz.mls.framing -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.Proposal /** * MLS MLSMessage (RFC 9420 Section 6). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index 4a6c73ae6c..2c211240c4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -18,61 +18,53 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519KeyPair -import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.Sender -import com.vitorpamplona.quartz.marmot.mls.framing.SenderType -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.framing.encodeSender -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.Companion.externalJoin -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult -import com.vitorpamplona.quartz.marmot.mls.messages.EncryptedGroupSecrets -import com.vitorpamplona.quartz.marmot.mls.messages.ExternalJoinResult -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo -import com.vitorpamplona.quartz.marmot.mls.messages.GroupSecrets -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.messages.ProposalOrRef -import com.vitorpamplona.quartz.marmot.mls.messages.UpdatePath -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome -import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree -import com.vitorpamplona.quartz.marmot.mls.schedule.SenderRatchetState -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime -import com.vitorpamplona.quartz.marmot.mls.tree.PathSecretAndKey -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree -import com.vitorpamplona.quartz.marmot.mls.tree.UpdatePathNode -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.crypto.Hpke +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.Sender +import com.vitorpamplona.quartz.mls.framing.SenderType +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.framing.encodeSender +import com.vitorpamplona.quartz.mls.group.MlsGroup.Companion.externalJoin +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.messages.EncryptedGroupSecrets +import com.vitorpamplona.quartz.mls.messages.ExternalJoinResult +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.messages.GroupInfo +import com.vitorpamplona.quartz.mls.messages.GroupSecrets +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.messages.ProposalOrRef +import com.vitorpamplona.quartz.mls.messages.UpdatePath +import com.vitorpamplona.quartz.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.schedule.EpochSecrets +import com.vitorpamplona.quartz.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.schedule.SecretTree +import com.vitorpamplona.quartz.mls.schedule.SenderRatchetState +import com.vitorpamplona.quartz.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.LeafNodeSource +import com.vitorpamplona.quartz.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.tree.PathSecretAndKey +import com.vitorpamplona.quartz.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.tree.UpdatePathNode +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.mac.MacInstance @@ -105,8 +97,8 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance * // Decrypt application message * val decrypted = group.decrypt(encrypted) * - * // Export key for Marmot outer encryption - * val key = group.exporterSecret("marmot", "group-event", 32) + * // Export key for a binding's own outer encryption + * val key = group.exporterSecret("myapp", "group-event".encodeToByteArray(), 32) * ``` */ private fun constantTimeEquals( @@ -133,7 +125,7 @@ class MlsGroup private constructor( private var interimTranscriptHash: ByteArray, private val pskStore: MutableMap = mutableMapOf(), private val pendingProposals: MutableList = mutableListOf(), - private val sentKeys: MutableMap = mutableMapOf(), + private val sentKeys: MutableMap = mutableMapOf(), /** Staged keys from proposeSigningKeyRotation — only promoted on successful commit */ private var pendingSigningKey: ByteArray? = null, private var pendingEncryptionKey: ByteArray? = null, @@ -151,11 +143,17 @@ class MlsGroup private constructor( * for us". */ private val pathPrivateKeys: MutableMap = mutableMapOf(), + /** + * The application's authorization rules. See [MlsGroupPolicy]: RFC 9420 + * itself places no limit on who may commit what, so the default allows + * everything the protocol allows and a binding supplies its own. + */ + private val policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, ) { val groupId: ByteArray get() = groupContext.groupId val epoch: Long get() = groupContext.epoch val leafIndex: Int get() = myLeafIndex - val extensions: List get() = groupContext.extensions + val extensions: List get() = groupContext.extensions /** * True while our own leaf is still live in the tree. @@ -169,6 +167,22 @@ class MlsGroup private constructor( */ fun isLocalMember(): Boolean = myLeafIndex < tree.leafCount && tree.getLeaf(myLeafIndex) != null + /** + * The read-only projection this group hands to its [MlsGroupPolicy]. + * + * Public because a binding's own checks want the same view the engine + * gives the policy — Marmot's "only admins may change group extensions" + * gate runs in `MlsGroupManager`, before any commit is staged. + */ + fun view(): GroupView = + GroupView( + extensions = groupContext.extensions, + leafCount = tree.leafCount, + myLeafIndex = myLeafIndex, + identityAt = { memberIdentityHex(it) }, + capabilitiesAt = { tree.getLeaf(it)?.capabilities }, + ) + /** * Read-only snapshot of the staged-proposal pool. Exposed at module * scope so tests can inspect what `proposeAdd` / `proposeRemove` / @@ -210,92 +224,38 @@ class MlsGroup private constructor( return w.toByteArray() } - // --- Marmot admin helpers (MIP-01 / MIP-03) --- + // --- Member identity --- /** Raw BasicCredential identity bytes of the member at the given leaf, or null. */ fun memberIdentity(leafIndex: Int): ByteArray? = (tree.getLeaf(leafIndex)?.credential as? Credential.Basic)?.identity - /** Lowercase hex of the member's BasicCredential identity, or null. */ - fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.toHexKey() + /** + * Lowercase hex OF THE CREDENTIAL BYTES at [leafIndex], or null. + * + * This hex-encodes whatever the credential holds, which is the account key + * only for a binding that stores it as raw bytes — Marmot does. A binding + * that stores an already-encoded identity gets the hex of that encoding: + * cordn writes 64 ASCII characters of hex, so this returns 128 characters + * of nothing useful. Such a binding should read the credential itself + * (`CordnCredential.identityOrNull`) rather than call this. + * + * Kept as-is because it is what Marmot means everywhere it is used, and + * because a function that guessed which encoding a credential used would + * be worse than one that says plainly what it does. + */ + fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.let { Hex.encode(it) } /** Lowercase hex of the local member's BasicCredential identity, or null. */ fun myIdentityHex(): String? = memberIdentityHex(myLeafIndex) - /** Parsed Marmot Group Data Extension from the current GroupContext, or null. */ - fun currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(groupContext.extensions) - - /** The current profile's component view of this GroupContext. */ - fun currentGroupState(): MarmotGroupState = MarmotGroupState.fromExtensions(groupContext.extensions) - - /** - * The `nostr_group_id` this group routes kind-445 traffic under, from - * whichever profile the group is actually using. - * - * A current-profile group carries it in the `marmot.transport.nostr.routing.v1` - * component (`0x8004`); a legacy group carries it inside the monolithic - * `0xF2EE` extension. Reading only the legacy one leaves us unable to join - * any group a current-profile client created — the routing id is required - * to subscribe at all, so the failure is total rather than partial. - */ - fun currentNostrGroupId(): HexKey? = - currentGroupState().routing?.nostrGroupIdHex - ?: currentMarmotData()?.nostrGroupId - - /** - * The group's configured admin account identities, as lowercase hex. - * - * Reads whichever profile this group is on: the current profile's - * `marmot.group.admin-policy.v1` component (`0x8003`) when present, - * otherwise MIP-01's `admin_pubkeys` field inside `marmot_group_data` - * (`0xF2EE`). Empty means the group names no admins at all, which happens - * during bootstrap and in groups that carry neither. - * - * The current profile is checked first because a group can only be one of - * the two — MDK rejects a group that requires both proof profiles — and a - * current-profile group is the one whose authorization we must not skip. - */ - fun currentAdminIdentities(): Set = adminIdentitiesIn(groupContext.extensions) - - /** - * The admin set named by [extensions], preferring the current profile. - * - * Decodes ONLY the admin policy, never the whole component set. Authorization - * must not depend on the validity of components it does not read: a - * malformed group profile is a defect worth surfacing where the profile is - * used, but it must not make the group un-committable by taking the admin - * check down with it. - */ - private fun adminIdentitiesIn(extensions: List): Set { - val policyBytes = AppDataDictionary.fromExtensionsOrEmpty(extensions)[AdminPolicyV1.COMPONENT_ID] - if (policyBytes != null) return AdminPolicyV1.decode(policyBytes).adminHexKeys.toSet() - return MarmotGroupData - .fromExtensions(extensions) - ?.adminPubkeys - ?.toSet() - .orEmpty() - } - /** * Account identities holding at least one current member leaf, as hex. * - * Admin authority is per ACCOUNT, not per leaf: a multi-device account - * shares one admin entry across all of its leaves. + * A set of ACCOUNTS, not leaves: one account may hold several leaves (one + * per device), and every binding that asks this question means the account. */ fun currentMemberIdentities(): Set = (0 until tree.leafCount).mapNotNullTo(mutableSetOf()) { memberIdentityHex(it) } - /** True if the local member is an active admin. */ - fun isLocalAdmin(): Boolean = isLeafAdmin(myLeafIndex) - - /** - * True if the member at [leafIndex] is an ACTIVE admin: listed in the - * group's admin set and still holding a leaf. The leaf lookup satisfies - * the second half by construction. - */ - fun isLeafAdmin(leafIndex: Int): Boolean { - val id = memberIdentityHex(leafIndex) ?: return false - return id in currentAdminIdentities() - } - // --- State Persistence --- /** @@ -471,7 +431,7 @@ class MlsGroup private constructor( pskId: ByteArray, psk: ByteArray, ) { - pskStore[pskId.toHexKey()] = psk + pskStore[Hex.encode(pskId)] = psk } /** @@ -509,7 +469,7 @@ class MlsGroup private constructor( */ leafSignatureKeyPair: Ed25519KeyPair? = null, leafExtensions: List = emptyList(), - capabilities: Capabilities = marmotLeafCapabilities(), + capabilities: Capabilities = policy.defaultLeafCapabilities, keyPackageExtensions: List = emptyList(), /** The leaf's lifetime. Null uses the Marmot window (84 days, backdated an hour for clock skew). */ lifetime: Lifetime? = null, @@ -571,15 +531,14 @@ class MlsGroup private constructor( /** * Create a SelfRemove proposal. * - * Per MIP-01/MIP-03, members listed in `admin_pubkeys` MUST NOT issue a - * SelfRemove — they have to first publish a GroupContextExtensions proposal - * removing themselves from the admin list (self-demotion). This guard - * enforces that rule at the local sender. + * Gated by [MlsGroupPolicy.authorizeSelfRemove], because a binding may + * restrict who can leave unilaterally — Marmot makes an admin self-demote + * through a GroupContextExtensions proposal first. Catching it here rather + * than on arrival turns a commit every peer would refuse into a local + * error. */ fun proposeSelfRemove(): Proposal.SelfRemove { - check(!isLocalAdmin()) { - "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" - } + policy.authorizeSelfRemove(view()) val proposal = Proposal.SelfRemove() pendingProposals.add(PendingProposal(proposal, myLeafIndex)) return proposal @@ -618,7 +577,7 @@ class MlsGroup private constructor( signingKey = newSigKp.privateKey, groupId = groupId, leafIndex = myLeafIndex, - capabilities = currentLeaf?.capabilities ?: marmotLeafCapabilities(), + capabilities = currentLeaf?.capabilities ?: policy.defaultLeafCapabilities, leafExtensions = currentLeaf?.extensions ?: emptyList(), ) @@ -710,25 +669,16 @@ class MlsGroup private constructor( fun commit(): CommitResult { val proposals = pendingProposals.toList() - // --- MIP-03 authorization gate ----------------------------------------- - // - // Non-admin senders may only issue one of two restricted commit shapes: - // (a) a single self-Update targeting their own leaf, or - // (b) one or more SelfRemove proposals, all by themselves (no mixing). - // - // Admins may commit any proposal type. - enforceAuthorizedProposalSet(proposals) - - // Reject commits that would leave the group without a usable admin - // (i.e. no remaining member appears in the post-commit admin list). - enforceNoAdminDepletion(proposals) + // The application's gate on who may commit what. RFC 9420 has none of + // its own, so a group with the default policy accepts any valid set. + policy.authorizeCommit(view(), proposals, myLeafIndex) // Capture the pre-commit exporter secret BEFORE any mutation. // Publishers of the outbound kind:445 MUST outer-encrypt with this // key (epoch N) so that other existing members at epoch N can decrypt // and process the commit. See CommitResult.preCommitExporterSecret. val preCommitExporterSecret = - exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + policy.commitExporter?.let { exporterSecret(it.label, it.context, it.length) } ?: ByteArray(0) // Snapshot the pre-proposal extensions. GroupContextExtensions proposals // mutate `groupContext.extensions` the moment they're applied, but @@ -790,6 +740,7 @@ class MlsGroup private constructor( val leafIndex = applyProposalAdd(p) addedMembers.add(leafIndex to p.keyPackage) } + enforceRequiredCapabilities() // Generate new path secrets on the updated tree val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH) @@ -878,7 +829,7 @@ class MlsGroup private constructor( computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes) for (nodeIdx in filteredDp) { val existing = tree.getNode(nodeIdx) - if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (existing is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { tree.setParent( nodeIdx, existing.parentNode.copy( @@ -923,7 +874,7 @@ class MlsGroup private constructor( groupId = groupId, leafIndex = myLeafIndex, parentHash = leafParentHash, - capabilities = previousLeaf?.capabilities ?: marmotLeafCapabilities(), + capabilities = previousLeaf?.capabilities ?: policy.defaultLeafCapabilities, leafExtensions = previousLeaf?.extensions ?: emptyList(), ) encryptionPrivateKey = newEncKp.privateKey @@ -953,11 +904,11 @@ class MlsGroup private constructor( val node = tree.getNode(resNode) ?: return@mapNotNull null val recipientPub = when (node) { - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Leaf -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Leaf -> { node.leafNode.encryptionKey } - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent -> { node.parentNode.encryptionKey } } @@ -1128,6 +1079,21 @@ class MlsGroup private constructor( */ fun encrypt( plaintext: ByteArray, + /** + * MLS `authenticated_data`: authenticated but NOT encrypted. + * + * The AEAD covers it, so a recipient knows the sender wrote it and the + * delivery service cannot alter it — but the delivery service can read + * it, which is the whole trade. RFC 9420 §6 leaves the contents to the + * application. + * + * cordn puts the sender's account pubkey here and rejects any + * application message that arrives with this field empty, because that + * is what binds an unsigned envelope to an MLS sender. Marmot leaves it + * empty and carries the same binding elsewhere. Empty is the default + * because a binding that does not use the field should not be paying a + * metadata cost for it. + */ authenticatedData: ByteArray = ByteArray(0), ): ByteArray { // Trim sentKeys if it grows too large @@ -1785,10 +1751,9 @@ class MlsGroup private constructor( } // Resolve proposal references against our pending pool BEFORE - // applying anything, so MIP-03 authorization can run on a static - // snapshot of (proposal, original-sender-leaf) pairs and so the - // depletion guard can simulate the post-commit tree shape from the - // pre-commit state. + // applying anything, so the policy sees a static snapshot of + // (proposal, original-sender-leaf) pairs and can simulate the + // post-commit shape from pre-commit state. val resolvedPending = mutableListOf() for (proposalOrRef in commit.proposals) { when (proposalOrRef) { @@ -1822,17 +1787,16 @@ class MlsGroup private constructor( } } - // MIP-03 authorization & admin-depletion gates on inbound commits - // (mirror what `commit()` enforces locally — without these a peer - // could send us a non-admin GCE rename, a non-admin Remove, or a - // commit that empties `admin_pubkeys` and we'd silently apply it). + // The policy's gate on inbound commits, mirroring what `commit()` + // enforces locally. Without it a peer could send us anything its own + // copy of the rules would have refused and we would silently apply + // it — authorization has to run on both ends or it runs on neither. // External commits get a pass: the sender doesn't have a leaf yet, // so the admin lookup is moot, and an external joiner can't include // arbitrary proposals — only Add/Remove/PSK/ExternalInit per // RFC 9420 §12.4.3.2. if (!isExternalCommit) { - enforceAuthorizedProposalSet(resolvedPending, committerLeafIndex = senderLeafIndex) - enforceNoAdminDepletion(resolvedPending) + policy.authorizeCommit(view(), resolvedPending, senderLeafIndex) } // Apply the resolved proposals. Matches the committer's order: apply @@ -1874,6 +1838,7 @@ class MlsGroup private constructor( for ((add, _) in referenceAddSenders) { newLeavesInCommit.add(applyProposalAdd(add)) } + enforceRequiredCapabilities() // If the proposals just removed *us*, there is no path-decrypt to do // and no confirmation_tag to verify against our (now bogus) commit @@ -1946,7 +1911,7 @@ class MlsGroup private constructor( val (filteredDp, filteredCp) = tree.filteredDirectPath(senderLeafIndex) for (nodeIdx in filteredDp) { val existing = tree.getNode(nodeIdx) - if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (existing is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { tree.setParent( nodeIdx, existing.parentNode.copy( @@ -2156,9 +2121,10 @@ class MlsGroup private constructor( /** * MLS-Exporter function for deriving application-specific keys. * - * Marmot uses: - * exporterSecret("marmot", "group-event".toByteArray(), 32) - * to derive the outer ChaCha20-Poly1305 key for GroupEvents. + * Marmot, for instance, derives the outer ChaCha20-Poly1305 key for its + * GroupEvents with label "marmot" and context "group-event" — see + * [MlsGroupPolicy.commitExporter], which is how the engine reaches it + * without naming any one binding. */ fun exporterSecret( label: String, @@ -2166,19 +2132,6 @@ class MlsGroup private constructor( length: Int, ): ByteArray = KeySchedule.mlsExporter(epochSecrets.exporterSecret, label, context, length) - /** - * `MLS-Exporter("marmot", "agent-text-stream-quic", 32)` — the secret every - * member of this epoch derives per-stream record keys from. Per-stream and - * per-record separation is entirely in the HKDF key context, so this one - * secret covers every stream in the epoch. - */ - fun agentTextStreamSecret(): ByteArray = - exporterSecret( - AgentTextStreamCrypto.EXPORTER_LABEL, - AgentTextStreamCrypto.EXPORTER_CONTEXT, - AgentTextStreamCrypto.SECRET_LENGTH, - ) - // --- External Join Support (RFC 9420 Section 8.3, 12.4.3.2) --- /** @@ -2289,8 +2242,8 @@ class MlsGroup private constructor( var pskSecret = zero for ((index, p) in pskProposals.withIndex()) { val pskValue = - pskStore[p.pskId.toHexKey()] - ?: throw IllegalStateException("PSK not found in store: ${p.pskId.toHexKey()}") + pskStore[Hex.encode(p.pskId)] + ?: throw IllegalStateException("PSK not found in store: ${Hex.encode(p.pskId)}") val pskExtracted = MlsCryptoProvider.hkdfExtract(zero, pskValue) val pskLabel = buildPskLabel(p, index, count) val pskInput = @@ -2395,7 +2348,7 @@ class MlsGroup private constructor( ): Pair, ByteArray> = computeSenderParentHashes(tree, senderLeafIndex, preUpdateSiblingHashes) private fun computeSenderParentHashes( - tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree, + tree: com.vitorpamplona.quartz.mls.tree.RatchetTree, senderLeafIndex: Int, preUpdateSiblingHashes: Map, ): Pair, ByteArray> { @@ -2426,7 +2379,7 @@ class MlsGroup private constructor( val parentIdx = filteredDp[i + 1] val parentLevel = filteredLevels[i + 1] val parentNode = tree.getNode(parentIdx) - if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (parentNode !is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { hashes[xIdx] = ByteArray(0) continue } @@ -2449,7 +2402,7 @@ class MlsGroup private constructor( val immediateParentLevel = filteredLevels.first() val immediateParent = tree.getNode(immediateParentIdx) val leafParentHash = - if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (immediateParent is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { val siblingTreeHash = preUpdateSiblingHashes[immediateParentLevel] ?: error("missing pre-update sibling tree hash at level $immediateParentLevel") @@ -2615,8 +2568,7 @@ class MlsGroup private constructor( fun isCommitAuthorized(pubMsg: PublicMessage): Boolean { val proposals = resolveCommitProposals(pubMsg) ?: return false return try { - enforceAuthorizedProposalSet(proposals, committerLeafIndex = pubMsg.sender.leafIndex) - enforceNoAdminDepletion(proposals) + policy.authorizeCommit(view(), proposals, pubMsg.sender.leafIndex) true } catch (_: Exception) { false @@ -2756,122 +2708,25 @@ class MlsGroup private constructor( } /** - * MIP-03 authorization gate. + * RFC 9420 §12.1.7: a commit is invalid if it leaves the group with a + * `required_capabilities` extension some member does not satisfy. * - * Once the group has at least one admin configured in `admin_pubkeys`, - * non-admin senders may only issue: - * - a single self-Update proposal, or - * - one-or-more SelfRemove proposals authored by the committer. + * Run AFTER every proposal in the commit has been applied, which is what + * makes the spec's parenthetical fall out for free: the tree already + * includes members added in this commit and excludes members removed by + * it, so iterating the current leaves is exactly the right set. * - * Admins may commit any proposal type. Before any admin is configured - * (group bootstrap) the check is relaxed, mirroring the bootstrap policy - * in [MlsGroupManager.updateGroupExtensions]. - * - * [committerLeafIndex] is the leaf that signed the commit — `myLeafIndex` - * for our own outbound commits, `pubMsg.sender.leafIndex` for inbound - * commits. The "self-only" rule is checked against the committer; when - * the committer is an admin the rule is skipped entirely so admin-folded - * inbound proposals (e.g. another member's `SelfRemove` referenced by - * an admin's GCE commit) are accepted. + * The failure this prevents is a split group. A GroupContextExtensions + * proposal that raises the bar above what a sitting member advertises is + * rejected by every peer that checks and accepted by every peer that does + * not, and the two halves diverge at the next epoch with nothing pointing + * at the cause. */ - internal fun enforceAuthorizedProposalSet( - proposals: List, - committerLeafIndex: Int = myLeafIndex, - ) { - if (proposals.isEmpty()) return - // Reads whichever profile the group is on: the admin-policy component - // (0x8003) for current-profile groups, `marmot_group_data` (0xF2EE) - // for legacy ones. An empty set means bootstrap — no admins named yet — - // and the gate stays open, mirroring MlsGroupManager.updateGroupExtensions. - val admins = currentAdminIdentities() - if (admins.isEmpty() || isLeafAdmin(committerLeafIndex)) return - - val allSelfRemove = - proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == committerLeafIndex } - if (allSelfRemove) return - - val singleSelfUpdate = - proposals.size == 1 && - proposals[0].proposal is Proposal.Update && - proposals[0].senderLeafIndex == committerLeafIndex - if (singleSelfUpdate) return - - throw IllegalStateException( - "MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " + - "proposals; got ${proposals.map { it.proposal::class.simpleName }} from leaf $committerLeafIndex", - ) - } - - /** - * Reject any commit that would leave the group without at least one member - * still listed in `admin_pubkeys` (MIP-03 admin depletion guard). - * - * We simulate the post-commit member set and the post-commit `admin_pubkeys` - * list, then require a non-empty intersection. The guard is only active - * once the group has a configured admin set — it does not kick in during - * bootstrap before any admin is named. - */ - internal fun enforceNoAdminDepletion(proposals: List) { - val currentAdmins = currentAdminIdentities() - if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete. - - // Resolve the effective admin list after this commit. Three carriers can - // change it, and they are checked in the order the commit applies them: - // an AppDataUpdate on 0x8003 (current profile), then a - // GroupContextExtensions proposal replacing the whole extension list - // (either profile). AppDataUpdate is resolved last because - // `applyAppDataUpdateProposals` runs after the rest of the list. - val gce = - proposals - .asSequence() - .map { it.proposal } - .filterIsInstance() - .lastOrNull() - val extensionsAfterGce = gce?.extensions ?: groupContext.extensions - - val adminUpdate = - proposals - .asSequence() - .map { it.proposal } - .filterIsInstance() - .lastOrNull { it.componentId == AdminPolicyV1.COMPONENT_ID } - - val adminSet = - when (val operation = adminUpdate?.operation) { - is Proposal.AppDataUpdate.Operation.Update -> - AdminPolicyV1.decode(operation.data).adminHexKeys.toSet() - - // Removing the admin policy is never valid — it is the sole - // admin authority for the group's lifetime — so an empty set - // here trips the depletion check below, which is the outcome - // we want. - Proposal.AppDataUpdate.Operation.Remove -> emptySet() - - null -> adminIdentitiesIn(extensionsAfterGce) - } - check(adminSet.isNotEmpty()) { - "commit would leave the group with no admins (admin depletion)" - } - - // Compute which leaves remain after applying Removes/SelfRemoves. - val removedLeaves = mutableSetOf() - for (pending in proposals) { - when (val p = pending.proposal) { - is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex) - is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex) - else -> Unit - } - } - - val remainingAdminIdentities = mutableSetOf() + private fun enforceRequiredCapabilities() { + val required = findRequiredCapabilities(groupContext.extensions) ?: return for (i in 0 until tree.leafCount) { - if (i in removedLeaves) continue - val id = memberIdentityHex(i) ?: continue - if (id in adminSet) remainingAdminIdentities.add(id) - } - - check(remainingAdminIdentities.isNotEmpty()) { - "MIP-03: commit would leave the group without any admin members" + val leaf = tree.getLeaf(i) ?: continue + requireCapabilitiesMeetRequirements(leaf.capabilities, required, "Member leaf $i") } } @@ -2913,11 +2768,25 @@ class MlsGroup private constructor( } is Proposal.GroupContextExtensions -> { - // RFC 9420 §13.4: an extension in use by the group MUST be supported by all members. Types this - // implementation knows are accepted as before; any other type is accepted when every member's leaf - // advertises it in capabilities.extensions. + // RFC 9420 §12.1.7: a wholesale replacement, not a merge. Its + // own validity rule concerns `required_capabilities` and is + // checked in [enforceRequiredCapabilities] once every proposal + // in the commit has been applied -- the membership it must hold + // over is the post-commit one. + // + // §13.4 adds the rule enforced here: "an extension in use by + // the group MUST be supported by all members of the group". + // That is a statement about member capabilities, not about a + // list of types this implementation happens to recognise, so + // the escape hatch is the RFC's own default types (§7.2, which + // forbids listing those in capabilities at all) plus whatever + // the binding declares -- never "types we have heard of". for (ext in proposal.extensions) { - require(ext.extensionType in KNOWN_EXTENSION_TYPES || allMembersSupportExtension(ext.extensionType)) { + require( + ext.extensionType in DEFAULT_EXTENSION_TYPES || + ext.extensionType in policy.knownExtensionTypes || + allMembersSupportExtension(ext.extensionType), + ) { "Unsupported extension type: ${ext.extensionType}" } } @@ -3240,7 +3109,7 @@ class MlsGroup private constructor( * (without constructing an [MlsGroup] first). */ private fun computeExternalSenderParentHashes( - tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree, + tree: com.vitorpamplona.quartz.mls.tree.RatchetTree, senderLeafIndex: Int, preUpdateSiblingHashes: Map, ): Pair, ByteArray> { @@ -3253,7 +3122,7 @@ class MlsGroup private constructor( val xIdx = directPath[i] val parentIdx = directPath[i + 1] val parentNode = tree.getNode(parentIdx) - if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (parentNode !is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { hashes[xIdx] = ByteArray(0) continue } @@ -3269,7 +3138,7 @@ class MlsGroup private constructor( val immediateParentIdx = directPath.first() val immediateParent = tree.getNode(immediateParentIdx) val leafParentHash = - if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (immediateParent is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { val siblingTreeHash = preUpdateSiblingHashes[0] ?: error("missing pre-update sibling tree hash at leaf level") @@ -3301,19 +3170,42 @@ class MlsGroup private constructor( // (0x0002 is ratchet_tree — putting it here makes GroupContext // unreadable to OpenMLS/MDK, which type-validates extensions by // context.) - private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003 + const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003 // RFC 9420 §13.3 IANA registry: 0x0004 is external_pub. // (0x0003 is required_capabilities — using it here makes // external-join GroupInfos unreadable to OpenMLS/MDK.) private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0004 - private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004 + + // 0x0005, not 0x0004: the two sat on the same value here, which made + // the pair indistinguishable anywhere they were compared as a set. + private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0005 + + private const val APPLICATION_ID_EXTENSION_TYPE = 0x0001 + + /** + * The extension types RFC 9420 §7.2 calls "default". + * + * A capabilities field MUST NOT list them, so they can never satisfy + * the §13.4 all-members-support rule and have to be exempt from it + * instead. This is the whole exemption: a type being one the code + * happens to recognise is not a reason to skip the check, and a + * binding's own extension types come from [MlsGroupPolicy.knownExtensionTypes]. + */ + private val DEFAULT_EXTENSION_TYPES = + setOf( + APPLICATION_ID_EXTENSION_TYPE, + RATCHET_TREE_EXTENSION_TYPE, + REQUIRED_CAPABILITIES_EXTENSION_TYPE, + EXTERNAL_PUB_EXTENSION_TYPE, + EXTERNAL_SENDERS_EXTENSION_TYPE, + ) /** MLS self_remove proposal type (MIP-00 / MIP-03). */ - private const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A + const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A /** MLS extensions draft `app_data_update` proposal type. */ - private const val APP_DATA_UPDATE_PROPOSAL_TYPE = 0x0008 + const val APP_DATA_UPDATE_PROPOSAL_TYPE = 0x0008 /** How far back a fresh KeyPackage LeafNode's `not_before` is set. */ private const val LIFETIME_SKEW_SECONDS = 3_600L @@ -3325,50 +3217,6 @@ class MlsGroup private constructor( */ private const val LIFETIME_SPAN_SECONDS = 84L * 24 * 60 * 60 - /** Marmot Group Data Extension type (MIP-01). */ - private const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE - - /** Known extension types that this implementation accepts. */ - private val KNOWN_EXTENSION_TYPES = - setOf( - RATCHET_TREE_EXTENSION_TYPE, - REQUIRED_CAPABILITIES_EXTENSION_TYPE, - EXTERNAL_PUB_EXTENSION_TYPE, - EXTERNAL_SENDERS_EXTENSION_TYPE, - MARMOT_GROUP_DATA_EXTENSION_TYPE, - // The current profile's carrier for all app-owned group state. - // A group can arrive at one either by being created with it or - // by a GroupContextExtensions proposal that installs it. - AppDataDictionary.EXTENSION_TYPE, - ) - - /** - * Build an MLS `required_capabilities` extension that marks Marmot's - * mandatory interop set as required for all members (RFC 9420 §7.2): - * extensions = [marmot_group_data (0xF2EE)] - * proposals = [self_remove (0x000A)] - * credentials = [Basic (0x0001)] - */ - private fun buildMarmotRequiredCapabilitiesExtension(): Extension { - val writer = TlsWriter() - // extensions: uint16 each - val exts = TlsWriter() - exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE) - writer.putOpaqueVarInt(exts.toByteArray()) - // proposals: uint16 each - val props = TlsWriter() - props.putUint16(SELF_REMOVE_PROPOSAL_TYPE) - writer.putOpaqueVarInt(props.toByteArray()) - // credentials: uint16 each - val creds = TlsWriter() - creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) - writer.putOpaqueVarInt(creds.toByteArray()) - return Extension( - extensionType = REQUIRED_CAPABILITIES_EXTENSION_TYPE, - extensionData = writer.toByteArray(), - ) - } - /** * Parsed view of the RFC 9420 §7.2 `required_capabilities` extension. * @@ -3520,107 +3368,6 @@ class MlsGroup private constructor( return null } - /** - * Default MLS leaf Capabilities that advertise support for Marmot's - * required extensions and proposals so new members can join a group - * whose `required_capabilities` lists them. - */ - private fun marmotLeafCapabilities(): Capabilities = - Capabilities( - extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), - proposals = listOf(SELF_REMOVE_PROPOSAL_TYPE), - ) - - /** - * Enforce the `0x8006` component's `required_member_roles` mask over - * the joining tree. - * - * A group carrying the agent-text-stream component requires each named - * role as an MLS leaf capability (`0xF2D1` receive, `0xF2D2` send, - * `0xF2D4` fanout). Advertising the component id alone is not enough — - * that only says "understands the component"; the role capability says - * "can actually do this". - */ - private fun requireAgentTextStreamRoles( - extensions: List, - tree: RatchetTree, - myLeafIndex: Int, - ) { - val policy = - AppDataDictionary - .fromExtensionsOrEmpty(extensions)[AgentTextStreamQuicPolicyV1.COMPONENT_ID] - ?.let { AgentTextStreamQuicPolicyV1.decode(it) } ?: return - val required = policy.requiredRoleCapabilities() - if (required.isEmpty()) return - - val myLeaf = tree.getLeaf(myLeafIndex) - requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" } - val missing = required.filterNot { myLeaf.capabilities.extensions.contains(it) } - require(missing.isEmpty()) { - "Joiner does not advertise agent text stream roles this group requires: " + - missing.joinToString { AppComponentIds.toHex(it) } - } - } - - /** - * Leaf capabilities for the current profile. - * - * RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only - * the draft `app_data_dictionary` extension and the `app_data_update` - * proposal appear — `required_capabilities` support is implicit. - * - * The legacy `0xF2EE` group-data extension is advertised alongside - * them, and that is not a hedge. A capability says "this client can - * handle it", not "this group uses it", and a group that REQUIRES - * `0xF2EE` refuses to add a leaf that does not advertise it. Without - * this line a current-profile KeyPackage would be un-addable to every - * legacy group that already exists — the exact mirror of the interop - * failure the current profile was adopted to fix. - * - * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: - * a group carrying component `0x8006` with `required_member_roles` - * naming `receive` refuses a leaf that does not advertise it. The - * reference client puts exactly that policy into EVERY group it - * creates, so without this line an Amethyst KeyPackage cannot be - * invited into one at all. - * - * We stop at receive. `send` and `fanout` are not here because we do - * not originate previews from the app, and a capability is a standing - * promise rather than a hedge. - */ - fun currentProfileLeafCapabilities(): Capabilities = - Capabilities( - extensions = - listOf( - AppDataDictionary.EXTENSION_TYPE, - MarmotGroupData.EXTENSION_ID_INT, - AgentTextStreamRoles.RECEIVE_CAPABILITY, - ), - proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE), - ) - - /** - * `required_capabilities` for a new current-profile group: extension - * `0x0006` and proposal `0x0008`. - * - * The Marmot components a group requires are negotiated in the - * upstream `app_components` component INSIDE the dictionary, not here — - * MLS `RequiredCapabilities` carries only MLS-level primitives. - */ - fun buildCurrentProfileRequiredCapabilitiesExtension(): Extension { - val writer = TlsWriter() - val exts = TlsWriter() - exts.putUint16(AppDataDictionary.EXTENSION_TYPE) - writer.putOpaqueVarInt(exts.toByteArray()) - val props = TlsWriter() - props.putUint16(APP_DATA_UPDATE_PROPOSAL_TYPE) - writer.putOpaqueVarInt(props.toByteArray()) - val creds = TlsWriter() - creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) - writer.putOpaqueVarInt(creds.toByteArray()) - return Extension(REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) - } - /** * Create a new MLS group with a single member (the creator). */ @@ -3635,14 +3382,32 @@ class MlsGroup private constructor( * added later by a proposal. */ leafExtensions: List = emptyList(), - capabilities: Capabilities = marmotLeafCapabilities(), /** - * The `required_capabilities` extension for epoch 0. Defaults to - * the MIP-era set; a current-profile group passes - * [buildCurrentProfileRequiredCapabilitiesExtension]. + * The application's rules for this group. Also supplies the + * defaults below, so one argument selects a whole profile. + */ + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + capabilities: Capabilities = policy.defaultLeafCapabilities, + /** + * The `required_capabilities` extension for epoch 0. Null means + * the group carries none, which is the RFC 9420 default; a Marmot + * current-profile group passes + * [com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities.currentProfileRequired]. + */ + requiredCapabilities: Extension? = policy.defaultRequiredCapabilities, + /** + * The RFC 9420 `group_id` for the new group. Null generates a + * random 32-byte one, which is the right default: §8.1 only + * requires it to be unique, and a random id tells a receiver + * nothing. + * + * A binding may need to choose it. cordn's reference client sets + * `group_id = utf8(gid)` so that a joiner can recover the delivery + * id from a Welcome, which otherwise carries no way to learn it — + * see `CordnGroupManager`. Anything a binding puts here is visible + * to whoever handles the ciphertext, so it must carry nothing the + * group would not publish. */ - requiredCapabilities: Extension? = buildMarmotRequiredCapabilitiesExtension(), - /** The group's MLS `group_id`. Null picks 32 random bytes. */ groupId: ByteArray? = null, ): MlsGroup { val sigKp = @@ -3652,7 +3417,7 @@ class MlsGroup private constructor( } ?: Ed25519.generateKeyPair() val encKp = X25519.generateKeyPair() - val groupId = groupId ?: MlsCryptoProvider.randomBytes(32) + val newGroupId = groupId ?: MlsCryptoProvider.randomBytes(32) val leafNode = buildLeafNode( @@ -3669,14 +3434,14 @@ class MlsGroup private constructor( tree.setLeaf(0, leafNode) val treeHash = tree.treeHash() - // Start with required_capabilities + whatever the caller wants to - // bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so - // new peers who join later can see the group name without first - // decrypting a pre-membership bootstrap commit — see MIP-03). + // Start with required_capabilities, when the profile has any, plus + // whatever the caller wants baked into epoch 0 — typically the + // binding's own group-metadata extension, so a later joiner can read + // it without first decrypting a pre-membership bootstrap commit. val baseExtensions = listOfNotNull(requiredCapabilities) val groupContext = GroupContext( - groupId = groupId, + groupId = newGroupId, epoch = 0, treeHash = treeHash, confirmedTranscriptHash = ByteArray(0), @@ -3702,6 +3467,7 @@ class MlsGroup private constructor( signingPrivateKey = sigKp.privateKey, encryptionPrivateKey = encKp.privateKey, interimTranscriptHash = ByteArray(0), + policy = policy, ) } @@ -3714,6 +3480,7 @@ class MlsGroup private constructor( fun processWelcome( welcomeBytes: ByteArray, bundle: KeyPackageBundle, + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, ): MlsGroup { val mlsMsg = MlsMessage.decodeTls(TlsReader(welcomeBytes)) require(mlsMsg.wireFormat == WireFormat.WELCOME) { "Expected Welcome message" } @@ -3854,7 +3621,15 @@ class MlsGroup private constructor( // must advertise. MLS cannot enforce it, so a joiner that skipped // this check would join a group it can never satisfy and have // every one of its commits refused by peers that do check. - requireAgentTextStreamRoles(groupContext.extensions, tree, myLeafIndex) + policy.validateJoin( + GroupView( + extensions = groupContext.extensions, + leafCount = tree.leafCount, + myLeafIndex = myLeafIndex, + identityAt = { leaf -> (tree.getLeaf(leaf)?.credential as? Credential.Basic)?.identity?.let { Hex.encode(it) } }, + capabilitiesAt = { tree.getLeaf(it)?.capabilities }, + ), + ) // Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3) // For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh) @@ -3930,6 +3705,7 @@ class MlsGroup private constructor( signingPrivateKey = bundle.signaturePrivateKey, encryptionPrivateKey = bundle.encryptionPrivateKey, interimTranscriptHash = interimTranscriptHash, + policy = policy, ) groupSecrets.pathSecret?.let { pathSecret -> val ancestorIdx = joined.directPathIndexOfAncestorWith(groupInfo.signer) @@ -3963,7 +3739,8 @@ class MlsGroup private constructor( groupInfoBytes: ByteArray, identity: ByteArray, signingKey: ByteArray? = null, - capabilities: Capabilities = marmotLeafCapabilities(), + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + capabilities: Capabilities = policy.defaultLeafCapabilities, leafExtensions: List = emptyList(), ): ExternalJoinResult { val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes)) @@ -4073,7 +3850,7 @@ class MlsGroup private constructor( computeExternalSenderParentHashes(tree, myLeafIndex, preUpdateSiblingHashes) for (nodeIdx in BinaryTree.directPath(myLeafIndex, tree.leafCount)) { val existing = tree.getNode(nodeIdx) - if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (existing is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { tree.setParent( nodeIdx, existing.parentNode.copy( @@ -4116,11 +3893,11 @@ class MlsGroup private constructor( val node = tree.getNode(resNode) ?: return@mapNotNull null val recipientPub = when (node) { - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Leaf -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Leaf -> { node.leafNode.encryptionKey } - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent -> { node.parentNode.encryptionKey } } @@ -4193,6 +3970,7 @@ class MlsGroup private constructor( signingPrivateKey = sigKp.privateKey, encryptionPrivateKey = encKp.privateKey, interimTranscriptHash = interimTranscriptHash, + policy = policy, ) // Wrap the commit in a PublicMessage envelope so existing members @@ -4239,7 +4017,10 @@ class MlsGroup private constructor( * or senders we never decrypted) simply re-derive from generation 0 on * first use — safe, because those messages were already processed. */ - fun restore(state: MlsGroupState): MlsGroup { + fun restore( + state: MlsGroupState, + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + ): MlsGroup { val tree = RatchetTree.decodeTls(TlsReader(state.treeBytes)) val secretTree = SecretTree(state.encryptionSecret, tree.leafCount) secretTree.importSenderStates(state.senderRatchetStates) @@ -4256,6 +4037,7 @@ class MlsGroup private constructor( interimTranscriptHash = state.interimTranscriptHash, pathPrivateKeys = state.pathPrivateKeys.toMutableMap(), pendingProposals = state.pendingProposals.toMutableList(), + policy = policy, ) } @@ -4271,7 +4053,7 @@ class MlsGroup private constructor( groupId: ByteArray? = null, leafIndex: Int? = null, parentHash: ByteArray? = null, - capabilities: Capabilities = marmotLeafCapabilities(), + capabilities: Capabilities, leafExtensions: List = emptyList(), lifetime: Lifetime? = null, ): LeafNode { @@ -4366,12 +4148,10 @@ class MlsGroup private constructor( * return value is the epoch this message must be outer-encrypted under. */ fun buildSelfRemoveProposalMessage(): Pair { - check(!isLocalAdmin()) { - "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" - } + policy.authorizeSelfRemove(view()) val preCommitExporterSecret = - exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + policy.commitExporter?.let { exporterSecret(it.label, it.context, it.length) } ?: ByteArray(0) val proposal = Proposal.SelfRemove() val proposalBytes = proposal.toTlsBytes() @@ -4585,7 +4365,14 @@ data class DecryptedMessage( val contentType: ContentType, val content: ByteArray, val epoch: Long, - /** The message's `authenticated_data` (RFC 9420 §6.3.2), verified by the AEAD. */ + /** + * MLS `authenticated_data` as the sender wrote it — authenticated by the + * AEAD, but readable by anyone who carried the message. + * + * Empty when the sender set none. A binding that puts meaning here (cordn + * binds the sender's account pubkey) must treat empty as a rejection rather + * than a default, or an attacker simply omits the field. + */ val authenticatedData: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt new file mode 100644 index 0000000000..33ea399729 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * The application's rules about who in a group may do what. + * + * RFC 9420 says who may *send* a proposal and never who may *commit* one: + * beyond the protocol's own validity checks, any member may commit anything. + * Real deployments need more than that — Marmot's MIP-03 names a set of admin + * accounts and allows everyone else only a self-Update or a SelfRemove — but + * that is the application's rule, not the protocol's, and an engine with one + * binding's rules compiled into it cannot host a second binding. + * + * Every hook defaults to permissive, and that direction is deliberate. A + * policy defaulting to closed would make the engine unusable without one and + * would tempt callers into a "policy that allows everything" anyway; defaulting + * to open puts each restriction in the binding that actually documents it. + * + * The cost of that choice is that a group built or restored without its policy + * silently drops the binding's rules. A policy is behaviour, not state, so it + * is **not** carried in [MlsGroupState] — whatever restores a group has to + * supply the same policy it was created with. For Marmot that is + * `MlsGroupManager`, which is the only thing that restores a group in order to + * commit with it. + */ +interface MlsGroupPolicy { + /** + * Rejects, by throwing, a commit the application does not allow. + * + * Called for both directions — before building a local commit and before + * applying an inbound one — with [committerLeafIndex] identifying whose + * commit it is. Returning normally means "allowed"; the engine's own RFC + * 9420 validation runs regardless and is not something a policy can waive. + */ + fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) = Unit + + /** + * Rejects, by throwing, a SelfRemove the local member is not allowed to + * issue. + * + * Separate from [authorizeCommit] because it gates a *proposal* at its + * sender rather than a commit: Marmot requires an admin to first + * self-demote through a GroupContextExtensions proposal, and catching that + * locally is the difference between a clear error here and a commit every + * peer silently refuses. + */ + fun authorizeSelfRemove(group: GroupView) = Unit + + /** + * Rejects, by throwing, a group this client should not finish joining. + * + * Runs once the Welcome has been processed far enough to see the group's + * extensions and tree, so a policy can enforce requirements MLS itself + * cannot carry — a component that demands leaf capabilities outside + * `required_capabilities`, for instance. Failing here beats joining a group + * whose every commit peers would reject. + */ + fun validateJoin(group: GroupView) = Unit + + /** + * Leaf capabilities a new leaf advertises when the caller names none. + * + * Empty by default: RFC 9420 §7.2 forbids advertising the DEFAULT + * extension and proposal types, so a group that requires nothing beyond + * them needs nothing here. + */ + val defaultLeafCapabilities: Capabilities get() = Capabilities() + + /** + * The epoch-0 `required_capabilities` extension when the caller names none. + * + * Null means the group carries no such extension at all, which is the RFC + * 9420 default — requirements only ever restrict which leaves may join, so + * a binding that needs one says so. + */ + val defaultRequiredCapabilities: Extension? get() = null + + /** + * How this binding derives the pre-commit exporter secret a [CommitResult] + * carries, or null if it seals nothing outside MLS. + * + * A binding that wraps MLS messages in its own encryption needs a key both + * the committer and the members still at epoch N can derive, and RFC 9420 + * gives it one through `MLS-Exporter` — but the label is the application's, + * and `"marmot"` was hardcoded here. Null yields the empty secret that + * [CommitResult] already defaults to. + */ + val commitExporter: MlsExporterLabel? get() = null + + /** + * Extension types this binding's own members are known to support, even + * when an older member's leaf does not advertise them. + * + * RFC 9420 §13.4 makes every GroupContext extension mandatory for every + * member, and the engine enforces that from leaf capabilities. A binding + * whose own group-metadata extension predates it advertising that type has + * groups in the wild that would fail the check; listing the type here is + * how such a binding keeps them working, and it is the binding's statement + * about its own members - not a licence to skip the rule for types nobody + * declared. + */ + val knownExtensionTypes: Set get() = emptySet() + + companion object { + /** RFC 9420 exactly as written: any member may commit anything valid. */ + val Permissive: MlsGroupPolicy = object : MlsGroupPolicy {} + } +} + +/** + * The read-only slice of a group that a policy decision may look at. + * + * A projection rather than the [MlsGroup] itself. A policy handed the group + * could commit, rotate keys, or mutate epoch state from inside the very check + * meant to gate those things; passing only what a decision needs makes that + * impossible to write by accident. The accessors are functions rather than + * materialised collections so that a policy which inspects one leaf does not + * pay for walking the whole tree. + */ +class GroupView( + /** + * GroupContext extensions as they stand *before* the commit under + * consideration applies. A commit that replaces them carries the + * replacement in its own GroupContextExtensions proposal, which the policy + * reads from the proposal list. + */ + val extensions: List, + /** Leaf count of the ratchet tree, counting blank leaves. */ + val leafCount: Int, + /** The local member's leaf index. */ + val myLeafIndex: Int, + private val identityAt: (Int) -> String?, + private val capabilitiesAt: (Int) -> Capabilities?, +) { + /** Lowercase hex of the BasicCredential identity at [leafIndex], or null if blank. */ + fun memberIdentityHex(leafIndex: Int): String? = identityAt(leafIndex) + + /** Capabilities advertised by the leaf at [leafIndex], or null if blank. */ + fun leafCapabilities(leafIndex: Int): Capabilities? = capabilitiesAt(leafIndex) +} + +/** + * The three inputs to `MLS-Exporter` (RFC 9420 §8.5) that identify one + * application's key derivation. + * + * Not a data class: [context] is a ByteArray, whose `equals` is identity, so + * generated equality would quietly be wrong. + */ +class MlsExporterLabel( + val label: String, + val context: ByteArray, + val length: Int, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupState.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupState.kt index a27fdce2d3..4b4907cc2c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupState.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets -import com.vitorpamplona.quartz.marmot.mls.schedule.SenderRatchetState +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.schedule.EpochSecrets +import com.vitorpamplona.quartz.mls.schedule.SenderRatchetState import com.vitorpamplona.quartz.utils.sha256.sha256 /** @@ -47,7 +47,7 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * [senderRatchetStates] carries each sender's live SecretTree ratchet position * (RFC 9420 §9). Preserving it is what stops the restored local member from * re-emitting an already-used generation within the same epoch — see - * [com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree.exportSenderStates]. + * [com.vitorpamplona.quartz.mls.schedule.SecretTree.exportSenderStates]. * It is optional (empty for STATE_VERSION 1 blobs) so older persisted state * still decodes. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Commit.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Commit.kt index 9931ec2362..54e4c6400a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Commit.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.UpdatePathNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.UpdatePathNode /** * MLS Commit (RFC 9420 Section 12.4). @@ -93,7 +93,7 @@ data class UpdatePath( * Result of creating a Commit: the MLS messages to distribute. * * [commitBytes] is the raw TLS-encoded [Commit] struct (RFC 9420 §12.4), useful - * for unit tests and the [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processCommit] + * for unit tests and the [com.vitorpamplona.quartz.mls.group.MlsGroup.processCommit] * entry point. For on-the-wire distribution, callers MUST publish * [framedCommitBytes] (the MlsMessage(PublicMessage(FramedContent(commit))) envelope) * so that receivers can parse the sender's leaf index and confirmation tag. @@ -130,17 +130,17 @@ data class CommitResult( } /** - * Result of [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.externalJoin]. + * Result of [com.vitorpamplona.quartz.mls.group.MlsGroup.externalJoin]. * * [commitBytes] is the raw TLS-encoded [Commit] struct. For on-the-wire * distribution to existing group members, callers MUST publish [framedCommitBytes] * — a `MlsMessage(PublicMessage(FramedContent(commit)))` envelope with sender * `new_member_commit` (RFC 9420 §12.4.3) — so that receivers can parse the * confirmation_tag and process the commit via - * [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processFramedCommit]. + * [com.vitorpamplona.quartz.mls.group.MlsGroup.processFramedCommit]. */ data class ExternalJoinResult( - val group: com.vitorpamplona.quartz.marmot.mls.group.MlsGroup, + val group: com.vitorpamplona.quartz.mls.group.MlsGroup, val commitBytes: ByteArray, val framedCommitBytes: ByteArray, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/KeyPackageBundleCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/KeyPackageBundleCodec.kt new file mode 100644 index 0000000000..91bee57cb0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/KeyPackageBundleCodec.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.messages + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter + +/** + * Persisting a [KeyPackageBundle] — the published KeyPackage plus the three + * private keys only its owner holds. + * + * A published KeyPackage is useless to its publisher without these: the Welcome + * that admits them is encrypted to the init key, and the leaf they land on is + * signed with the signature key. Lose the bundle and the invitation cannot be + * opened; the group has to re-add them from a fresh one. + * + * **Whatever stores the output must encrypt it at rest.** This is key material, + * and the format makes no attempt to protect it — that is the storage layer's + * job, and saying so here is the only warning it gets. + * + * Binding-agnostic on purpose. Marmot serialises bundles inside its own + * rotation snapshot, which also carries rotation state it alone has; cordn has + * no rotation state and no KeyPackage event kind at all (`spec/00.md` §4.2), so + * it needs the bundle by itself. Neither binding is the right home for a codec + * over a plain RFC 9420 structure, so it lives here with the structure. + */ +object KeyPackageBundleCodec { + /** + * Bumped only for a breaking layout change. + * + * [decode] refuses anything it does not know rather than guessing, and + * refusing is the right failure: a misread bundle yields key material that + * is silently wrong, which surfaces much later as a Welcome that will not + * open. + */ + const val VERSION = 1 + + fun encode(bundle: KeyPackageBundle): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putOpaque4(bundle.keyPackage.toTlsBytes()) + writer.putOpaque2(bundle.initPrivateKey) + writer.putOpaque2(bundle.encryptionPrivateKey) + writer.putOpaque2(bundle.signaturePrivateKey) + return writer.toByteArray() + } + + fun decode(bytes: ByteArray): KeyPackageBundle { + val reader = TlsReader(bytes) + val version = reader.readUint16() + require(version == VERSION) { "unknown KeyPackageBundle layout version $version" } + return KeyPackageBundle( + keyPackage = MlsKeyPackage.decodeTls(TlsReader(reader.readOpaque4())), + initPrivateKey = reader.readOpaque2(), + encryptionPrivateKey = reader.readOpaque2(), + signaturePrivateKey = reader.readOpaque2(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/MlsKeyPackage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/MlsKeyPackage.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt index a2cd4a3422..ee76dd9751 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/MlsKeyPackage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode /** * MLS KeyPackage (RFC 9420 Section 10). @@ -93,7 +93,7 @@ data class MlsKeyPackage( */ fun isLastResort(): Boolean = extensions.any { it.extensionType == LAST_RESORT_EXTENSION_TYPE } || - AppDataDictionary.fromExtensionsOrEmpty(extensions).contains(AppComponentIds.LAST_RESORT_KEY_PACKAGE) + AppDataDictionary.fromExtensionsOrEmpty(extensions).contains(ComponentsList.LAST_RESORT_KEY_PACKAGE_ID) /** * Encode the TBS (to-be-signed) portion for signature verification. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Proposal.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Proposal.kt index afd21f4161..e87c3a97ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Proposal.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode /** * MLS Proposal types (RFC 9420 Section 12). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Welcome.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Welcome.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Welcome.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Welcome.kt index a237669f95..bbaaf38aaf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Welcome.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Welcome.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.HpkeCiphertext -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.HpkeCiphertext +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.tree.Extension /** * MLS Welcome message (RFC 9420 Section 12.4.3.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/KeySchedule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/KeySchedule.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/KeySchedule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/KeySchedule.kt index 38b0712c80..046a36a7c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/KeySchedule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/KeySchedule.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.schedule +package com.vitorpamplona.quartz.mls.schedule -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * MLS Key Schedule (RFC 9420 Section 8). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/SecretTree.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/SecretTree.kt index 6e33b96d40..7ce790586a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/SecretTree.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.schedule +package com.vitorpamplona.quartz.mls.schedule -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.tree.BinaryTree /** * MLS Secret Tree (RFC 9420 Section 9). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/BinaryTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/BinaryTree.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/BinaryTree.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/BinaryTree.kt index a95ce22fef..e8cf80a520 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/BinaryTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/BinaryTree.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree /** * Left-balanced binary tree index arithmetic for MLS ratchet trees (RFC 9420 Section 7.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/LeafNode.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/LeafNode.kt index 6ae1597895..4acc2b9cb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/LeafNode.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * MLS Credential (RFC 9420 Section 5.3). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/ParentNode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/ParentNode.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/ParentNode.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/ParentNode.kt index 868f1dd9fc..336cfea3fd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/ParentNode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/ParentNode.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * MLS ParentNode (RFC 9420 Section 7.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/RatchetTree.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/RatchetTree.kt index 91994ca1cc..bb9606b5bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/RatchetTree.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * MLS Ratchet Tree (RFC 9420 Section 7). @@ -471,7 +471,7 @@ class RatchetTree( // reject the UpdatePath with `UpdatePathError(PathMismatch)`. val nodeSecret = MlsCryptoProvider.deriveSecret(currentSecret, "node") val kp = - com.vitorpamplona.quartz.marmot.mls.crypto.Hpke + com.vitorpamplona.quartz.mls.crypto.Hpke .deriveKeyPair(nodeSecret) results.add(PathSecretAndKey(currentSecret, kp.privateKey, kp.publicKey)) @@ -620,7 +620,7 @@ sealed class TreeNode : TlsSerializable { /** UpdatePath node: public key + encrypted path secrets for copath nodes */ data class UpdatePathNode( val encryptionKey: ByteArray, - val encryptedPathSecret: List, + val encryptedPathSecret: List, ) : TlsSerializable { override fun encodeTls(writer: TlsWriter) { writer.putOpaqueVarInt(encryptionKey) @@ -645,7 +645,7 @@ data class UpdatePathNode( encryptionKey = reader.readOpaqueVarInt(), encryptedPathSecret = reader.readVectorVarInt { - com.vitorpamplona.quartz.marmot.mls.crypto.HpkeCiphertext + com.vitorpamplona.quartz.mls.crypto.HpkeCiphertext .decodeTls(it) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index a56fb390f4..e57e7c0882 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -519,7 +519,7 @@ class SQLiteEventStore( // ROLLBACK shouldn't mask the original cause. runCatching { db.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT $sp") } runCatching { db.execSQL("RELEASE SAVEPOINT $sp") } - classifyRowError(e) + classifyRowError(e, event, db) } } @@ -531,8 +531,25 @@ class SQLiteEventStore( * I/O error, schema drift) is the store failing to write an acceptable * event: `Failed`, so a rising count is loud instead of blending into * the duplicate tally. + * + * Message text is the fast path, not the contract: which exception a + * driver throws and what it puts in `getMessage()` is the driver's + * business. Android's `SQLiteConnection` wraps constraint failures in + * an `android.database.SQLException` carrying a **null** message, + * where the bundled JVM driver spells out + * `UNIQUE constraint failed: …`. Classifying off text alone therefore + * turned every duplicate into a `Failed` on Android — an `OK false` + * the client retries forever. So [db] is asked instead: this runs after + * the savepoint rollback, so the connection shows the pre-insert state + * and the two questions that separate a duplicate from a genuine write + * failure ("is this id already here?", "does a stored version already + * beat this one?") have exact answers, on every driver. */ - private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome { + private fun classifyRowError( + e: Throwable, + event: Event, + db: SQLiteConnection, + ): IEventStore.InsertOutcome { val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED // A second copy of an event the store already holds trips the unique index on // event_headers.id. That is not a refusal of the event but a statement that it @@ -542,25 +559,108 @@ class SQLiteEventStore( if (message.contains(DUPLICATE_ID_CONSTRAINT)) { return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) } - // The replaceable / addressable unique indexes fire only when the supersession - // trigger found nothing older to delete, i.e. the stored version already wins - // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. - if (message.contains(SUPERSEDED_CONSTRAINT)) { - return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) - } - val refusal = + // Trigger RAISEs and the immutability guards name themselves, and leave no + // database-visible trace to ask about, so they are decided by text alone. + val namedRefusal = message.contains("blocked:") || message.contains("duplicate:") || message.contains(RejectionReason.PREFIX_REPLACED) || - message.contains("not allowed") || - message.contains("constraint", ignoreCase = true) - return if (refusal) { + message.contains("not allowed") + if (namedRefusal) return IEventStore.InsertOutcome.Rejected(message) + + // Ask the database the two questions the unique indexes answer, in the + // order that makes the answer driver-independent. The id question goes + // first because *which* index a re-offered replaceable event trips is up + // to SQLite: re-inserting a stored replaceable byte-for-byte violates + // both `event_headers.id` and `replaceable_idx`, and only the id lookup + // says the same thing on every driver ("already have this event" — which + // is also the truer sentence). It costs one point lookup on an already + // open connection, on the rejected path only. + if (isAlreadyStored(event.id, db)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) + } + // The replaceable / addressable unique indexes fire only when the supersession + // trigger found nothing older to delete, i.e. the stored version already wins + // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. + if (message.contains(SUPERSEDED_CONSTRAINT) || isSupersededByStored(event, db)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) + } + + return if (message.contains("constraint", ignoreCase = true)) { IEventStore.InsertOutcome.Rejected(message) } else { IEventStore.InsertOutcome.Failed(message) } } + /** + * Whether [id] is already in `event_headers` — the unique index on + * `event_headers.id` restated as a question, for drivers that don't say + * which index they tripped. Any failure answers "no": the point is to + * recognize a duplicate, and a connection too broken to answer is a + * write failure, which is what the caller falls through to. + */ + private fun isAlreadyStored( + id: String, + db: SQLiteConnection, + ): Boolean = + runCatching { + db.prepare("SELECT 1 FROM event_headers WHERE id = ? LIMIT 1").use { stmt -> + stmt.bindText(1, id) + stmt.step() + } + }.getOrDefault(false) + + /** + * Whether a stored version already beats [event] at its replaceable / + * addressable coordinate (STORE-W01/W02) — the exact complement of + * [displacedBy]'s predicate, so a stored row that the supersession + * trigger *would* have deleted doesn't count. That precision matters + * here: this is the fallback for unrecognized failures, and a disk + * error while inserting a winning replaceable event must stay `Failed` + * rather than turn into a silent `OK true`. An equal id is the + * duplicate case and is answered before this one. + */ + private fun isSupersededByStored( + event: Event, + db: SQLiteConnection, + ): Boolean { + val addressable = event.kind.isAddressable() && event is AddressableEvent + val sql = + when { + event.kind.isReplaceable() -> + """ + SELECT 1 FROM event_headers + WHERE kind = ? AND pubkey = ? + AND (created_at > ? OR (created_at = ? AND id < ?)) + LIMIT 1 + """.trimIndent() + + addressable -> + """ + SELECT 1 FROM event_headers + WHERE kind = ? AND pubkey = ? AND d_tag = ? + AND kind >= 30000 AND kind < 40000 + AND (created_at > ? OR (created_at = ? AND id < ?)) + LIMIT 1 + """.trimIndent() + + else -> return false + } + return runCatching { + db.prepare(sql).use { stmt -> + var i = 1 + stmt.bindLong(i++, event.kind.toLong()) + stmt.bindText(i++, event.pubKey) + if (addressable) stmt.bindText(i++, (event as AddressableEvent).dTag()) + stmt.bindLong(i++, event.createdAt) + stmt.bindLong(i++, event.createdAt) + stmt.bindText(i, event.id) + stmt.step() + } + }.getOrDefault(false) + } + inner class Transaction internal constructor( val db: SQLiteConnection, private val delta: LiveIndexDelta?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt index 902016f868..894571e7fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip17Dm.files import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -65,6 +66,14 @@ class ChatMessageEncryptedFileHeaderEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + /** + * The amplitude ladder a voice recording carries, when the sender attached + * one. Read-only: nothing in this app writes it on a kind 15 yet, but the + * renderer draws real bars instead of a bare transport the moment a client + * that does shows up. + */ + fun waveform() = tags.firstNotNullOfOrNull(WaveformTag::parse)?.wave + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt index a74614dda6..f15272c2c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt @@ -56,7 +56,7 @@ class SharedKeyCache { * hex string). The precomputed [hash] is only a bucket selector — [equals] does * the authoritative full-content comparison, so hash collisions can never return * the wrong peer's secret. Callers must treat the passed arrays as immutable - * (the same value-type contract [com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair] + * (the same value-type contract [com.vitorpamplona.quartz.mls.crypto.X25519KeyPair] * relies on when used as a map key). */ private class CacheKey( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 76257ca74d..15d8d83388 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -465,6 +467,8 @@ class EventFactory { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig) + CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig) AgentTurnMetricEvent.KIND -> AgentTurnMetricEvent(id, pubKey, createdAt, tags, content, sig) EngramEvent.KIND -> EngramEvent(id, pubKey, createdAt, tags, content, sig) AgentProfileEvent.KIND -> AgentProfileEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalization.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalization.kt new file mode 100644 index 0000000000..d1f920ffa1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalization.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.jcs + +/** + * RFC 8785 JSON Canonicalization Scheme (JCS). + * + * Produces the one serialization of a JSON value that every conformant + * implementation agrees on, so a hash over the result is portable. Used wherever + * a protocol hashes structured data rather than bytes it was handed — ContextVM + * needs it twice (CEP-8's canonical invocation identity and CEP-15's schema + * hash), and it is generic enough to belong here rather than in that module. + * + * The rules: + * - no insignificant whitespace + * - object members sorted by key, compared as UTF-16 code units + * - strings escaped minimally, with non-ASCII left literal (output is UTF-8) + * - numbers serialized exactly as ECMAScript `Number.prototype.toString()` + * + * The number rule is the subtle one and [canonicalNumber] implements it in full. + */ +object JsonCanonicalization { + /** + * Serializes [value] canonically. + * + * [value] is a plain JSON tree: `Map`, `List`, [String], + * [Boolean], a number, or null. Using plain types rather than a JSON library's + * node types keeps this usable from any module and any serializer. + */ + fun canonicalize(value: Any?): String = StringBuilder().also { write(value, it) }.toString() + + private fun write( + value: Any?, + out: StringBuilder, + ) { + when (value) { + null -> out.append("null") + is Boolean -> out.append(if (value) "true" else "false") + is String -> writeString(value, out) + is Map<*, *> -> writeObject(value, out) + is List<*> -> writeArray(value, out) + is Double -> out.append(canonicalNumber(value)) + is Float -> out.append(canonicalNumber(value.toDouble())) + is Int -> out.append(canonicalNumber(value.toDouble())) + is Long -> out.append(canonicalNumber(value.toDouble())) + is Short -> out.append(canonicalNumber(value.toDouble())) + is Byte -> out.append(canonicalNumber(value.toDouble())) + else -> throw IllegalArgumentException("cannot canonicalize ${value::class.simpleName}") + } + } + + private fun writeObject( + value: Map<*, *>, + out: StringBuilder, + ) { + out.append('{') + value.entries + .map { (key, entry) -> + (key as? String ?: throw IllegalArgumentException("object keys must be strings")) to entry + } + // RFC 8785 sorts by UTF-16 code unit, which is exactly what Kotlin's + // natural String ordering does. Do not swap this for a locale-aware + // or codepoint-aware comparison. + .sortedBy { it.first } + .forEachIndexed { index, (key, entry) -> + if (index > 0) out.append(',') + writeString(key, out) + out.append(':') + write(entry, out) + } + out.append('}') + } + + private fun writeArray( + value: List<*>, + out: StringBuilder, + ) { + out.append('[') + value.forEachIndexed { index, entry -> + if (index > 0) out.append(',') + write(entry, out) + } + out.append(']') + } + + private fun writeString( + value: String, + out: StringBuilder, + ) { + out.append('"') + value.forEach { char -> + when (char) { + '"' -> out.append("\\\"") + '\\' -> out.append("\\\\") + '\b' -> out.append("\\b") + '\u000C' -> out.append("\\f") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + '\t' -> out.append("\\t") + else -> + if (char < '\u0020') { + // Only C0 controls without a short escape use \u, and the + // hex digits are lowercase. + out.append("\\u").append(char.code.toString(16).padStart(4, '0')) + } else { + // Everything else stays literal, non-ASCII included: the + // canonical form is UTF-8, not \u-escaped ASCII. + out.append(char) + } + } + } + out.append('"') + } + + /** + * Serializes [value] as ECMAScript `Number.prototype.toString()` does, which + * is what RFC 8785 requires and is *not* what any JVM/Kotlin `toString()` + * produces (`1.0E30` where ECMAScript says `1e+30`). + * + * The digits come from the platform's [Double.toString], but they are then + * shortened explicitly until the shortest form that still round-trips is + * found. That extra step is not optional: JVM prints [Double.MIN_VALUE] as + * `4.9E-324` while ECMAScript requires `5e-324`, so trusting the platform to + * already be shortest produces a different hash from every other conformant + * implementation. Shortening here makes the result platform-independent. + */ + fun canonicalNumber(value: Double): String { + if (value.isNaN() || value.isInfinite()) { + throw IllegalArgumentException("JCS cannot represent $value") + } + // ECMAScript prints both zeroes as "0"; JCS inherits that, so -0.0 and + // 0.0 canonicalize identically. + if (value == 0.0) return "0" + if (value < 0) return "-" + canonicalNumber(-value) + + val raw = value.toString() + val exponentSplit = raw.indexOfFirst { it == 'e' || it == 'E' } + val mantissa = if (exponentSplit >= 0) raw.substring(0, exponentSplit) else raw + val exponent = if (exponentSplit >= 0) raw.substring(exponentSplit + 1).toInt() else 0 + + val pointIndex = mantissa.indexOf('.') + val digits = if (pointIndex >= 0) mantissa.removeRange(pointIndex, pointIndex + 1) else mantissa + val fractionLength = if (pointIndex >= 0) mantissa.length - pointIndex - 1 else 0 + + // `s` is the shortest digit string, `n` its decimal exponent, such that + // value = s * 10^(n - k) with k = s.length. This is ECMAScript's (s, n, k). + val trailingZeros = digits.length - digits.trimEnd('0').length + val initial = digits.trim('0').ifEmpty { "0" } + val (significant, n) = + shorten(value, initial, initial.length + exponent - fractionLength + trailingZeros) + val k = significant.length + + return when { + // Integral, short enough to print plainly. + n in k..21 -> significant + "0".repeat(n - k) + // Has a fractional part but no exponent needed. + n in 1..21 -> significant.substring(0, n) + "." + significant.substring(n) + // Small enough for a leading "0." but not for an exponent. + n in -5..0 -> "0." + "0".repeat(-n) + significant + // Exponential form. + k == 1 -> significant + "e" + exponentSuffix(n - 1) + else -> significant.substring(0, 1) + "." + significant.substring(1) + "e" + exponentSuffix(n - 1) + } + } + + /** + * Finds the shortest digit string that still parses back to [value]. + * + * ECMAScript defines the digits as the fewest that round-trip, so a platform + * that prints more (JVM does, for some subnormals) has to be corrected here + * or the canonical form diverges. + */ + private fun shorten( + value: Double, + digits: String, + exponent: Int, + ): Pair { + for (length in 1 until digits.length) { + val (candidate, candidateExponent) = roundTo(digits, exponent, length) + val rebuilt = "${candidate}e${candidateExponent - candidate.length}".toDouble() + if (rebuilt == value) return candidate to candidateExponent + } + return digits to exponent + } + + /** Rounds [digits] to [length] significant digits, half-up, carrying into [exponent]. */ + private fun roundTo( + digits: String, + exponent: Int, + length: Int, + ): Pair { + val kept = digits.substring(0, length) + if (digits[length] < '5') return kept to exponent + + val incremented = (kept.toLong() + 1).toString() + // "99" + 1 becomes "100": one digit longer, so drop the last and shift + // the exponent rather than growing the significand. + return if (incremented.length > length) { + incremented.substring(0, length) to exponent + 1 + } else { + incremented.padStart(length, '0') to exponent + } + } + + private fun exponentSuffix(exponent: Int) = if (exponent >= 0) "+$exponent" else "-${-exponent}" +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapPolicyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapPolicyTest.kt new file mode 100644 index 0000000000..f50414ad24 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapPolicyTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep04Encryption + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** `CVM-4-*` and `CVM-19-*`: encryption policy and wrap-kind negotiation. */ +class CvmGiftWrapPolicyTest { + @Test + fun `CVM-4-01 REQUIRED refuses to downgrade for a peer that cannot encrypt`() { + // The whole reason this client does not use the permissive default: + // a coordinator that never advertises encryption would otherwise get + // plaintext JSON-RPC on public relays. + val crypto = CvmGiftWrap(encryptionMode = EncryptionMode.REQUIRED) + assertTrue(crypto.shouldEncrypt(peerSupportsEncryption = true)) + assertFailsWith { crypto.shouldEncrypt(peerSupportsEncryption = false) } + } + + @Test + fun `CVM-4-02 OPTIONAL follows the peer and DISABLED never encrypts`() { + val optional = CvmGiftWrap(encryptionMode = EncryptionMode.OPTIONAL) + assertTrue(optional.shouldEncrypt(peerSupportsEncryption = true)) + assertFalse(optional.shouldEncrypt(peerSupportsEncryption = false)) + + val disabled = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + assertFalse(disabled.shouldEncrypt(peerSupportsEncryption = true)) + } + + @Test + fun `CVM-19-01 prefers the ephemeral wrap when the peer supports it`() { + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL) + assertEquals(CvmKinds.EPHEMERAL_GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = true)) + } + + @Test + fun `CVM-19-02 falls back to the persistent wrap rather than refusing`() { + // Preferring 21059 must never mean refusing to talk to a 1059-only + // server: CEP-19 is additive, not a requirement. + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL) + assertEquals(CvmKinds.GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = false)) + } + + @Test + fun `CVM-19-03 a persistent-mode client stays on 1059 regardless`() { + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT) + assertEquals(CvmKinds.GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = true)) + } + + @Test + fun `CVM-4-03 advertises the capability flags matching its own configuration`() { + val ephemeral = CvmGiftWrap().capabilityTags().map { it[0] } + assertTrue(ephemeral.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue(ephemeral.contains(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + + val persistent = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT).capabilityTags().map { it[0] } + assertTrue(persistent.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertFalse( + persistent.contains(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL), + "a persistent-mode client must not claim ephemeral support", + ) + + assertTrue(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED).capabilityTags().isEmpty()) + } + + @Test + fun `CVM-4-04 reads the peer's advertised encryption flags`() { + val crypto = CvmGiftWrap() + val tags = arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue(crypto.peerSupportsEncryption(tags)) + assertFalse(crypto.peerSupportsEphemeral(tags)) + + val both = tags + arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + assertTrue(crypto.peerSupportsEphemeral(both)) + } + + @Test + fun `CVM-19-04 both wrap kinds are recognised on the way in`() { + // A client subscribes to both because CEP-19's fallback means either may + // arrive regardless of which it sends. + assertTrue(CvmKinds.isGiftWrap(CvmKinds.GIFT_WRAP)) + assertTrue(CvmKinds.isGiftWrap(CvmKinds.EPHEMERAL_GIFT_WRAP)) + assertFalse(CvmKinds.isGiftWrap(CvmKinds.MESSAGE)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt new file mode 100644 index 0000000000..da76d4711c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `CVM-8-*` and `CVM-21-*`: pricing, payment lifecycles and canonical identity. */ +class PaymentTest { + private val lightning = Pmi.LIGHTNING_BOLT11 + private val cashuDirect = Pmi("bitcoin-cashu-v4-direct") + + private fun params(json: String): JsonObject = (JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"m","params":$json}""") as JsonRpcRequest).params!! + + private fun request( + method: String, + json: String, + ) = JsonRpcRequest(JsonRpcId.Num(1), method, params(json)) + + // --- CEP-21 PMI --- + + @Test + fun `CVM-21-01 accepts the W3C PMI format and rejects anything else`() { + assertEquals("bitcoin-lightning-bolt11", lightning.value) + assertNull(Pmi.parseOrNull("Bitcoin-Lightning")) + assertNull(Pmi.parseOrNull("bitcoin_lightning")) + assertFailsWith { Pmi("UPPER") } + } + + @Test + fun `CVM-21-02 detects the -direct bearer settlement suffix`() { + assertTrue(cashuDirect.supportsDirectPayment) + assertFalse(lightning.supportsDirectPayment) + } + + // --- cap tag --- + + @Test + fun `CVM-8-01 parses a fixed price`() { + val tag = CapTag.parse(arrayOf("cap", "tool:get_weather", "100", "sats"))!! + assertEquals(CapabilityKind.TOOL, tag.kind) + assertEquals("get_weather", tag.name) + assertEquals(Price.Fixed(100), tag.price) + assertEquals("sats", tag.unit) + } + + @Test + fun `CVM-8-02 parses an inclusive range price`() { + val tag = CapTag.parse(arrayOf("cap", "prompt:summarize", "100-1000", "sats"))!! + assertEquals(CapabilityKind.PROMPT, tag.kind) + assertEquals(Price.Range(100, 1000), tag.price) + assertTrue(tag.price.includes(500)) + assertFalse(tag.price.includes(1001)) + } + + @Test + fun `CVM-8-03 round-trips a cap tag`() { + val tag = CapTag(CapabilityKind.RESOURCE, "file://x", Price.Range(1, 2), "usd") + assertEquals(tag, CapTag.parse(tag.toTag())) + } + + @Test + fun `CVM-8-04 rejects a cap tag without a typed capability prefix`() { + assertNull(CapTag.parse(arrayOf("cap", "get_weather", "100", "sats"))) + } + + // --- canonical invocation identity --- + + @Test + fun `CVM-8-10 excludes _meta so a regenerated progressToken still matches`() { + // MCP regenerates progressToken on every callTool. Without the exclusion + // a retry could never match a paid authorization. + val first = + request( + "tools/call", + """{"name":"get_weather","arguments":{"location":"NY"},"_meta":{"progressToken":"a"}}""", + ) + val retry = + request( + "tools/call", + """{"name":"get_weather","arguments":{"location":"NY"},"_meta":{"progressToken":"b"}}""", + ) + assertEquals(CanonicalInvocation.identityOf(first), CanonicalInvocation.identityOf(retry)) + } + + @Test + fun `CVM-8-11 is unaffected by the JSON-RPC id`() { + val a = JsonRpcRequest(JsonRpcId.Num(1), "tools/call", params("""{"name":"x"}""")) + val b = JsonRpcRequest(JsonRpcId.Text("other"), "tools/call", params("""{"name":"x"}""")) + assertEquals(CanonicalInvocation.identityOf(a), CanonicalInvocation.identityOf(b)) + } + + @Test + fun `CVM-8-12 is unaffected by params member order`() { + val a = request("tools/call", """{"name":"x","arguments":{"a":1,"b":2}}""") + val b = request("tools/call", """{"arguments":{"b":2,"a":1},"name":"x"}""") + assertEquals(CanonicalInvocation.identityOf(a), CanonicalInvocation.identityOf(b)) + } + + @Test + fun `CVM-8-13 changes when the semantic arguments change`() { + val ny = request("tools/call", """{"name":"get_weather","arguments":{"location":"NY"}}""") + val sf = request("tools/call", """{"name":"get_weather","arguments":{"location":"SF"}}""") + assertNotEquals(CanonicalInvocation.identityOf(ny), CanonicalInvocation.identityOf(sf)) + } + + @Test + fun `CVM-8-14 changes when the method changes`() { + assertNotEquals( + CanonicalInvocation.identityOf(request("tools/call", """{"name":"x"}""")), + CanonicalInvocation.identityOf(request("prompts/get", """{"name":"x"}""")), + ) + } + + @Test + fun `CVM-8-15 binds the authorization to the requesting client`() { + val call = request("tools/call", """{"name":"x"}""") + assertNotEquals( + CanonicalInvocation.authorizationKey("aa".repeat(32), call), + CanonicalInvocation.authorizationKey("bb".repeat(32), call), + ) + } + + @Test + fun `CVM-8-16 semanticParams strips only _meta and leaves the rest intact`() { + // The exclusion is for identity only: the handler still needs the full + // params at execution time, so this must not mutate the original. + val original = params("""{"name":"x","arguments":{"a":1},"_meta":{"progressToken":"t"}}""") + val semantic = CanonicalInvocation.semanticParams(original) + + assertFalse(semantic.containsKey("_meta")) + assertTrue(semantic.containsKey("name")) + assertTrue(semantic.containsKey("arguments")) + assertTrue(original.containsKey("_meta"), "the source params must not be mutated") + } + + // --- lifecycle negotiation --- + + @Test + fun `CVM-8-20 an absent payment_interaction tag means transparent`() { + val session = PaymentSession() + session.observeServerTags(emptyArray()) + assertEquals(PaymentInteraction.TRANSPARENT, session.effectiveMode) + assertFalse(session.negotiationFailed) + } + + @Test + fun `CVM-8-21 explicit gating is in force once the server echoes it`() { + val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) + session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode) + assertFalse(session.negotiationFailed) + } + + @Test + fun `CVM-8-22 a server that ignores the request is a failed negotiation, not a downgrade`() { + val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) + session.observeServerTags(emptyArray()) + assertTrue(session.negotiationFailed, "silent fallback must be visible to the caller") + } + + @Test + fun `CVM-8-23 a client needing visible payments will not auto-pay after a failed negotiation`() { + // The client half of the no-silent-fallback rule: a handler that simply + // pays whatever it is asked would violate it. + val session = + PaymentSession( + requested = PaymentInteraction.EXPLICIT_GATING, + requiresVisiblePayments = true, + supportedPmis = listOf(lightning), + ) + session.observeServerTags(emptyArray()) + + val demand = PaymentRequest(amount = 100.0, pmi = lightning, payRequest = "lnbc...") + assertFalse(session.mayAutoPay(demand)) + } + + @Test + fun `CVM-8-24 the same client does auto-pay once explicit gating is accepted`() { + val session = + PaymentSession( + requested = PaymentInteraction.EXPLICIT_GATING, + requiresVisiblePayments = true, + supportedPmis = listOf(lightning), + ) + session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + assertTrue(session.mayAutoPay(PaymentRequest(100.0, lightning, "lnbc..."))) + } + + @Test + fun `CVM-8-25 never auto-pays a PMI it cannot settle`() { + val session = PaymentSession(supportedPmis = listOf(lightning)) + session.observeServerTags(emptyArray()) + assertFalse(session.mayAutoPay(PaymentRequest(100.0, cashuDirect, "cashuB..."))) + } + + @Test + fun `CVM-8-26 a later payment_interaction tag upserts the session mode`() { + val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) + session.observeServerTags(emptyArray()) + assertEquals(PaymentInteraction.TRANSPARENT, session.effectiveMode) + + session.observeLaterServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode) + } + + @Test + fun `CVM-8-27 an absent tag on a later message inherits the current mode`() { + val session = PaymentSession() + session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + session.observeLaterServerTags(emptyArray()) + assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode) + } + + @Test + fun `CVM-8-28 advertises the requested mode and its PMIs on the first message`() { + val session = + PaymentSession( + requested = PaymentInteraction.EXPLICIT_GATING, + supportedPmis = listOf(lightning, cashuDirect), + ) + val tags: List = session.negotiationTags() + assertContentEquals(arrayOf("payment_interaction", "explicit_gating"), tags[0]) + assertContentEquals(arrayOf("pmi", lightning.value), tags[1]) + assertContentEquals(arrayOf("pmi", cashuDirect.value), tags[2]) + } + + @Test + fun `CVM-8-29 omits the tag when requesting the default mode`() { + assertTrue(PaymentSession().negotiationTags().isEmpty()) + } + + @Test + fun `CVM-8-30 intersects PMIs preserving the server's preference order`() { + val session = PaymentSession(supportedPmis = listOf(cashuDirect, lightning)) + assertEquals(listOf(lightning, cashuDirect), session.intersectPmis(listOf(lightning, cashuDirect))) + assertEquals(listOf(lightning), session.intersectPmis(listOf(Pmi("unknown-rail"), lightning))) + } + + // --- messages --- + + @Test + fun `CVM-8-40 parses a payment_required notification`() { + val notification = + JsonRpcNotification( + "notifications/payment_required", + params( + """{"amount":100,"pay_req":"lnbc...","pmi":"bitcoin-lightning-bolt11", + "description":"tool run","ttl":600,"_meta":{"note":"x"}}""", + ), + ) + val parsed = PaymentMessages.paymentRequired(notification)!! + assertEquals(100.0, parsed.amount) + assertEquals(lightning, parsed.pmi) + assertEquals("lnbc...", parsed.payRequest) + assertEquals(600L, parsed.ttl) + assertEquals("tool run", parsed.description) + } + + @Test + fun `CVM-8-41 parses accepted and rejected notifications`() { + val accepted = + PaymentMessages.paymentAccepted( + JsonRpcNotification( + "notifications/payment_accepted", + params("""{"amount":100,"pmi":"bitcoin-lightning-bolt11"}"""), + ), + )!! + assertEquals(100.0, accepted.amount) + + val rejected = + PaymentMessages.paymentRejected( + JsonRpcNotification( + "notifications/payment_rejected", + params("""{"pmi":"bitcoin-cashu-v4-direct","message":"Insufficient","amount":150}"""), + ), + )!! + assertEquals(cashuDirect, rejected.pmi) + assertEquals(150.0, rejected.amount) + } + + @Test + fun `CVM-8-42 reads payment options off a -32042 error`() { + val error = + JsonRpcError( + JsonRpcError.PAYMENT_REQUIRED, + "Payment Required", + params( + """{"instructions":"pay then retry","payment_options":[ + {"amount":100,"pmi":"bitcoin-lightning-bolt11","pay_req":"lnbc..."}]}""", + ), + ) + val options = PaymentMessages.paymentOptions(error)!! + assertEquals(1, options.size) + assertEquals(lightning, options[0].pmi) + assertEquals("pay then retry", PaymentMessages.instructions(error)) + } + + @Test + fun `CVM-8-43 reads retry_after off a -32043 error`() { + val error = + JsonRpcError( + JsonRpcError.PAYMENT_PENDING, + "Payment Pending", + params("""{"instructions":"retry later","retry_after":5}"""), + ) + assertEquals(5L, PaymentMessages.retryAfter(error)) + assertNull(PaymentMessages.paymentOptions(error), "a pending error carries no options") + } + + @Test + fun `CVM-8-44 ignores a non-payment error`() { + val error = JsonRpcError(JsonRpcError.INVALID_PARAMS, "Unsupported payment_interaction") + assertNull(PaymentMessages.paymentOptions(error)) + assertNull(PaymentMessages.retryAfter(error)) + } + + @Test + fun `CVM-8-45 selects the first offered option it can settle`() { + val session = PaymentSession(supportedPmis = listOf(lightning)) + val options = + listOf( + PaymentRequest(100.0, cashuDirect, "cashuB..."), + PaymentRequest(100.0, lightning, "lnbc..."), + ) + assertEquals(lightning, session.selectPayable(options)?.pmi) + assertNull(session.selectPayable(listOf(PaymentRequest(1.0, cashuDirect, "x")))) + } + + @Test + fun `CVM-8-46 parses direct_payment offers in request order and the change tag`() { + val tags = + arrayOf( + PaymentTags.directPayment(cashuDirect, "token-a"), + PaymentTags.directPayment(lightning, "token-b"), + ) + assertEquals(listOf(cashuDirect to "token-a", lightning to "token-b"), PaymentTags.parseDirectPayments(tags)) + assertEquals(cashuDirect to "rest", PaymentTags.parseChange(arrayOf(PaymentTags.change(cashuDirect, "rest")))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchemaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchemaTest.kt new file mode 100644 index 0000000000..a11035c061 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchemaTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep15CommonSchemas + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `CVM-15-*`: common tool schemas. */ +class CommonToolSchemaTest { + private fun obj(json: String) = + JsonRpcCodec + .decode("""{"jsonrpc":"2.0","id":1,"method":"m","params":$json}""") + .let { (it as com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest).params!! } + + private val plainInput = + obj( + """{"type":"object","properties":{"text":{"type":"string"}, + "target_language":{"type":"string"}},"required":["text","target_language"]}""", + ) + + private val documentedInput = + obj( + """{"type":"object","title":"Translate input","description":"args", + "properties":{"text":{"type":"string","description":"Text to translate","examples":["hi"]}, + "target_language":{"type":"string","description":"ISO 639-1","default":"en"}}, + "required":["text","target_language"],"x-vendor-note":"internal"}""", + ) + + @Test + fun `CVM-15-01 the same interface documented differently yields the same hash`() { + // This is the entire point of the CEP: providers compete on docs and + // quality while remaining interchangeable. + assertEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_text", documentedInput), + ) + } + + @Test + fun `CVM-15-02 strips annotation keywords at every nesting level`() { + val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject + assertFalse(normalized.containsKey("title")) + assertFalse(normalized.containsKey("description")) + + val properties = normalized["properties"] as JsonObject + val text = properties["text"] as JsonObject + assertFalse(text.containsKey("description"), "nested description must be stripped too") + assertFalse(text.containsKey("examples")) + + val target = properties["target_language"] as JsonObject + assertFalse(target.containsKey("default")) + } + + @Test + fun `CVM-15-03 strips vendor extensions by prefix`() { + val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject + assertFalse(normalized.keys.any { it.startsWith("x-") }) + } + + @Test + fun `CVM-15-04 keeps structural keywords`() { + val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject + assertEquals("object", (normalized["type"] as JsonPrimitive).content) + assertTrue(normalized.containsKey("properties")) + assertTrue(normalized.containsKey("required")) + } + + @Test + fun `CVM-15-05 normalizes inside arrays`() { + val schema = + obj( + """{"anyOf":[{"type":"string","description":"a"},{"type":"number","title":"b"}]}""", + ) + val normalized = CommonToolSchema.normalize(schema) as JsonObject + val branches = normalized["anyOf"]!! + assertEquals( + """{"anyOf":[{"type":"string"},{"type":"number"}]}""", + normalized.toString(), + ) + assertEquals(2, (branches as kotlinx.serialization.json.JsonArray).size) + } + + @Test + fun `CVM-15-06 the tool name is part of the hash`() { + assertNotEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_prose", plainInput), + ) + } + + @Test + fun `CVM-15-07 adding an outputSchema changes the hash`() { + val output = obj("""{"type":"object","properties":{"translated_text":{"type":"string"}}}""") + assertNotEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_text", plainInput, output), + ) + } + + @Test + fun `CVM-15-08 member order in the source schema does not change the hash`() { + // JCS sorts keys, so a server emitting members in a different order + // still lands on the same identity. + val reordered = + obj( + """{"required":["text","target_language"],"properties":{ + "target_language":{"type":"string"},"text":{"type":"string"}},"type":"object"}""", + ) + assertEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_text", reordered), + ) + } + + @Test + fun `CVM-15-09 verify recomputes rather than trusting the advertised hash`() { + val correct = CommonToolSchema.hash("translate_text", plainInput) + val tool = + buildJsonObject { + put("name", JsonPrimitive("translate_text")) + put("inputSchema", plainInput) + put("_meta", CommonToolSchema.metaFor(correct)) + } + assertTrue(CommonToolSchema.verify(tool)) + assertEquals(correct, CommonToolSchema.hashOf(tool)) + } + + @Test + fun `CVM-15-10 verify rejects a tool advertising someone else's hash`() { + val tool = + buildJsonObject { + put("name", JsonPrimitive("translate_text")) + put("inputSchema", plainInput) + put("_meta", CommonToolSchema.metaFor("00".repeat(32))) + } + assertFalse(CommonToolSchema.verify(tool), "a mismatched hash must not be accepted") + } + + @Test + fun `CVM-15-11 a bespoke tool advertises no hash and does not verify`() { + val tool = + buildJsonObject { + put("name", JsonPrimitive("bespoke")) + put("inputSchema", plainInput) + } + assertNull(CommonToolSchema.advertisedHash(tool)) + assertFalse(CommonToolSchema.verify(tool)) + } + + @Test + fun `CVM-15-12 builds and parses the NIP-73 discovery tags`() { + val hash = CommonToolSchema.hash("translate_text", plainInput) + assertContentEquals( + arrayOf("i", hash, "translate_text"), + CommonToolSchema.externalIdTag(hash, "translate_text"), + ) + assertContentEquals( + arrayOf("k", "io.contextvm/common-schema"), + CommonToolSchema.externalKindTag(), + ) + + val parsed = + CommonToolSchema.parseExternalIds( + arrayOf( + CommonToolSchema.externalIdTag(hash, "translate_text"), + CommonToolSchema.externalKindTag(), + arrayOf("p", "irrelevant"), + ), + ) + assertEquals(listOf(hash to "translate_text"), parsed) + } + + @Test + fun `CVM-15-13 the hash is stable across runs`() { + // Pins the wire value so a refactor of normalization or JCS that changes + // identity shows up as a failure here rather than as silent divergence + // from every other implementation. + val hash = CommonToolSchema.hash("echo", obj("""{"type":"object"}""")) + assertEquals(64, hash.length, "sha256 hex") + assertEquals(hash, CommonToolSchema.hash("echo", obj("""{"type":"object"}"""))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt new file mode 100644 index 0000000000..eb1f15a2e2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * `CVM-22-*`: bounded oversized payload transfer. + * + * The CEP is written almost entirely as failure conditions, so most of this is + * negative. A suite of happy paths would prove nothing. + */ +class OversizedTransferTest { + private val token = ProgressToken.Text("req-123") + + private val payload = + JsonRpcCodec.encode( + JsonRpcSuccess( + JsonRpcId.Num(1), + buildJsonObject { put("text", JsonPrimitive("a".repeat(200))) }, + ), + ) + + private fun sender(chunkChars: Int = 64) = OversizedTransferSender(chunkChars) + + private fun receiver( + limits: OversizedLimits = OversizedLimits(), + requireAccept: Boolean = false, + ) = OversizedTransferReceiver(token, limits, requireAccept) + + // --- happy paths --- + + @Test + fun `CVM-22-01 round-trips a fragmented message through the frames`() { + val frames = sender().frame(token, payload) + val rx = receiver() + + var completed: OversizedProgressResult.Completed? = null + frames.forEach { frame -> + val result = rx.accept(frame) + if (result is OversizedProgressResult.Completed) completed = result + } + + assertEquals(payload, completed?.raw) + assertEquals(JsonRpcCodec.decode(payload), completed?.message) + } + + @Test + fun `CVM-22-02 assembles out-of-order chunks by progress, not arrival order`() { + // Relays may reorder. `progress` is the canonical assembly index. + val frames = sender().frame(token, payload) + val start = frames.first() + val end = frames.last() + val chunks = frames.drop(1).dropLast(1).reversed() + + val rx = receiver() + rx.accept(start) + chunks.forEach { rx.accept(it) } + val result = rx.accept(end) + + assertIs(result) + assertEquals(payload, result.raw) + } + + @Test + fun `CVM-22-03 surfaces nothing before validation succeeds`() { + val frames = sender().frame(token, payload) + val rx = receiver() + + frames.dropLast(1).forEach { + assertTrue( + rx.accept(it) is OversizedProgressResult.Continue, + "no payload may be surfaced before end validates", + ) + } + } + + @Test + fun `CVM-22-04 a bootstrap transfer asks the receiver to send accept`() { + val frames = sender().frame(token, payload) + val rx = receiver(requireAccept = true) + assertIs(rx.accept(frames.first())) + } + + @Test + fun `CVM-22-05 abort is terminal`() { + val rx = receiver() + rx.accept(sender().frame(token, payload).first()) + + val aborted = rx.accept(OversizedFrame.abort(token, 99.0, "peer gave up")) + assertIs(aborted) + assertEquals("peer gave up", aborted.reason) + assertTrue(rx.isTerminal) + + assertFailsWith { + rx.accept(OversizedFrame.chunk(token, 100.0, "x")) + } + } + + @Test + fun `CVM-22-06 the sender never splits a surrogate pair`() { + // Each emoji is a surrogate pair. Splitting one would still produce two + // valid JSON strings, so only the reassembled bytes catch it. + val emoji = "🚀".repeat(40) + val text = JsonRpcCodec.encode(JsonRpcSuccess(JsonRpcId.Num(1), JsonPrimitive(emoji))) + + val frames = sender(chunkChars = 9).frame(token, text) + frames.filterIsInstance().forEach { chunk -> + assertTrue( + chunk.data.isEmpty() || !chunk.data.last().isHighSurrogate(), + "a fragment must not end on an unpaired high surrogate", + ) + } + + val rx = receiver() + val result = frames.map { rx.accept(it) }.last() + assertIs(result) + assertEquals(text, result.raw) + } + + // --- rejections --- + + @Test + fun `CVM-22-10 rejects a digest mismatch`() { + val frames = sender().frame(token, payload).toMutableList() + val start = frames.first() as OversizedFrame.Start + frames[0] = + OversizedFrame.start( + token, + start.progress, + OversizedFrame.DIGEST_PREFIX_SHA256 + "00".repeat(32), + start.totalBytes, + start.totalChunks, + ) + + val rx = receiver() + assertFailsWith { frames.forEach { rx.accept(it) } } + } + + @Test + fun `CVM-22-11 rejects a totalBytes mismatch`() { + val frames = sender().frame(token, payload).toMutableList() + val start = frames.first() as OversizedFrame.Start + frames[0] = + OversizedFrame.start(token, start.progress, start.digest, start.totalBytes + 1, start.totalChunks) + + val rx = receiver() + assertFailsWith { frames.forEach { rx.accept(it) } } + } + + @Test + fun `CVM-22-12 rejects a totalChunks mismatch`() { + val frames = sender().frame(token, payload).toMutableList() + val start = frames.first() as OversizedFrame.Start + frames[0] = + OversizedFrame.start(token, start.progress, start.digest, start.totalBytes, start.totalChunks + 1) + + val rx = receiver() + assertFailsWith { frames.forEach { rx.accept(it) } } + } + + @Test + fun `CVM-22-13 rejects a chunk before accept in a bootstrap transfer`() { + val frames = sender().frame(token, payload) + val rx = receiver(requireAccept = true) + rx.accept(frames.first()) + assertFailsWith { rx.accept(frames[1]) } + } + + @Test + fun `CVM-22-14 rejects non-monotonic progress`() { + val rx = receiver() + rx.accept(OversizedFrame.start(token, 5.0, digestOf(""), 0, 0)) + assertFailsWith { + rx.accept(OversizedFrame.chunk(token, 4.0, "x")) + } + } + + @Test + fun `CVM-22-15 rejects end with unresolved gaps`() { + val frames = sender().frame(token, payload) + val rx = receiver() + rx.accept(frames.first()) + // Deliver every chunk but the last, then end. + frames.drop(1).dropLast(2).forEach { rx.accept(it) } + assertFailsWith { rx.accept(frames.last()) } + } + + @Test + fun `CVM-22-16 rejects an unknown completionMode at parse time`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OVERSIZED)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(OversizedFrame.START)) + put(OversizedFrame.COMPLETION_MODE, JsonPrimitive("stream-someday")) + put(OversizedFrame.DIGEST, JsonPrimitive(digestOf(""))) + put(OversizedFrame.TOTAL_BYTES, JsonPrimitive(0)) + put(OversizedFrame.TOTAL_CHUNKS, JsonPrimitive(0)) + }, + ) + assertFailsWith { OversizedFrame.parseOrNull(envelope) } + } + + @Test + fun `CVM-22-17 rejects declared totals over local policy at start`() { + val rx = receiver(limits = OversizedLimits(maxTotalBytes = 10, maxTotalChunks = 2)) + assertFailsWith { + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 1_000_000, 1)) + } + } + + @Test + fun `CVM-22-17b refuses at start a transfer larger than the chunk buffer`() { + // Every chunk is held until `end`, so maxPendingChunks is the ceiling + // on a whole transfer, not a reordering window. It used to be checked + // only per chunk, which admitted anything under maxTotalChunks and + // then failed it at chunk 257 — after the sender had pushed megabytes + // for a refusal that was certain from the start frame. + val rx = receiver(limits = OversizedLimits(maxTotalChunks = 4_096, maxPendingChunks = 2)) + + val refused = + assertFailsWith { + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 100, 3)) + } + assertTrue( + refused.message!!.contains("buffer limit"), + "the reason should name the buffer, not the declared total: ${refused.message}", + ) + } + + @Test + fun `CVM-22-17c admits a transfer that exactly fills the buffer`() { + // The boundary in the allowed direction, so the check is an upper + // bound rather than an off-by-one that rejects a legal transfer. + val rx = receiver(limits = OversizedLimits(maxTotalChunks = 4_096, maxPendingChunks = 2)) + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 100, 2)) + } + + @Test + fun `CVM-22-18 rejects a chunk arriving before start`() { + val rx = receiver() + assertFailsWith { + rx.accept(OversizedFrame.chunk(token, 1.0, "x")) + } + } + + @Test + fun `CVM-22-19 rejects a second start on the same transfer`() { + val rx = receiver() + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 0, 0)) + assertFailsWith { + rx.accept(OversizedFrame.start(token, 2.0, digestOf(""), 0, 0)) + } + } + + @Test + fun `CVM-22-20 rejects a frame belonging to another transfer`() { + val rx = receiver() + assertFailsWith { + rx.accept(OversizedFrame.start(ProgressToken.Text("other"), 1.0, digestOf(""), 0, 0)) + } + } + + @Test + fun `CVM-22-21 rejects an unsupported digest algorithm`() { + val rx = receiver() + assertFailsWith { + rx.accept(OversizedFrame.start(token, 1.0, "md5:" + "00".repeat(16), 0, 0)) + } + } + + @Test + fun `CVM-22-22 rejects a duplicate chunk at the same progress`() { + val rx = receiver() + rx.accept(OversizedFrame.start(token, 1.0, digestOf("ab"), 2, 2)) + rx.accept(OversizedFrame.chunk(token, 2.0, "a")) + assertFailsWith { + // Same progress value, so this cannot be a distinct fragment. + rx.accept(OversizedFrame.chunk(token, 2.0, "b")) + } + } + + @Test + fun `CVM-22-23 the envelope round-trips through a progress notification`() { + val frame = sender().frame(token, payload).first() + val notification = frame.envelope.toNotification() + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(notification)) + + val envelope = ProgressEnvelope.parseOrNull(decoded as com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification) + assertEquals(frame, OversizedFrame.parseOrNull(envelope!!)) + } + + @Test + fun `CVM-22-24 ignores an envelope belonging to another transfer profile`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive("start")) + }, + ) + assertEquals(null, OversizedFrame.parseOrNull(envelope)) + } + + private fun digestOf(text: String) = + OversizedFrame.DIGEST_PREFIX_SHA256 + + com.vitorpamplona.quartz.utils.sha256 + .sha256(text.encodeToByteArray()) + .let { bytes -> bytes.joinToString("") { b -> (b.toInt() and 0xFF).toString(16).padStart(2, '0') } } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/DiscoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/DiscoveryTest.kt new file mode 100644 index 0000000000..3b0eae6ca9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/DiscoveryTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep35Discovery + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface +import com.vitorpamplona.quartz.contextvm.cep06Announcements.ServerAnnouncement +import com.vitorpamplona.quartz.contextvm.cep17RelayList.ServerRelay +import com.vitorpamplona.quartz.contextvm.cep24Reviews.ServerReview +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DiscoveryTest { + private val serverPubKey = "a".repeat(64) + + private fun event( + kind: Kind, + tags: Array, + content: String = "", + createdAt: Long = 1_700_000_000L, + pubKey: String = serverPubKey, + ) = Event( + id = "c".repeat(64), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "e".repeat(128), + ) + + // --- CEP-6 / CEP-35 discovery surface --- + + @Test + fun `CVM-6-01 parses the announcement discovery tags`() { + val surface = + DiscoverySurface.parse( + arrayOf( + arrayOf("name", "Example Server"), + arrayOf("about", "Public MCP provider"), + arrayOf("picture", "https://example.com/a.png"), + arrayOf("website", "https://example.com"), + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + CvmTags.flag(CvmTags.SUPPORT_OPEN_STREAM), + ), + ) + assertEquals("Example Server", surface.name) + assertEquals("https://example.com", surface.website) + assertTrue(surface.supportsEncryption) + assertTrue(surface.supportsOpenStream) + assertFalse(surface.supportsOversizedTransfer) + } + + @Test + fun `CVM-35-01 preserves unknown discovery tags and excludes routing`() { + val surface = + DiscoverySurface.parse( + arrayOf( + arrayOf("p", serverPubKey), + arrayOf("e", "b".repeat(64)), + arrayOf("some_future_capability", "v2"), + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + ), + ) + assertEquals(1, surface.unknownTags.size, "only the unrecognised tag is retained") + assertContentEquals(arrayOf("some_future_capability", "v2"), surface.rawTag("some_future_capability")) + assertNull(surface.rawTag("p"), "routing tags are not discovery") + } + + @Test + fun `CVM-35-02 the first peer message establishes the baseline`() { + val session = SessionDiscovery() + assertFalse(session.hasLearned) + + session.observe(arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), arrayOf("name", "First"))) + assertTrue(session.hasLearned) + assertEquals("First", session.peer?.name) + assertTrue(session.peer!!.supportsEncryption) + } + + @Test + fun `CVM-35-03 a later message is interpreted locally without mutating the baseline`() { + val session = SessionDiscovery() + session.observe(arrayOf(arrayOf("name", "First"), CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION))) + + val local = session.observe(arrayOf(arrayOf("cap", "tool:x", "100", "sats"))) + assertEquals(1, local.unknownTags.size, "the later tags are returned for local use") + assertEquals("First", session.peer?.name, "the baseline is unchanged") + assertTrue(session.peer!!.supportsEncryption) + } + + @Test + fun `CVM-35-04 a feature CEP can replace the baseline explicitly`() { + val session = SessionDiscovery() + session.observe(arrayOf(arrayOf("name", "First"))) + session.replaceBaseline(arrayOf(arrayOf("name", "Renamed"))) + assertEquals("Renamed", session.peer?.name) + } + + // --- CEP-6 announcements --- + + @Test + fun `CVM-6-02 parses each announcement kind and rejects others`() { + CvmKinds.ANNOUNCEMENTS.forEach { kind -> + assertNotNull(ServerAnnouncement.parseOrNull(event(kind, emptyArray(), """{"tools":[]}"""))) + } + assertNull(ServerAnnouncement.parseOrNull(event(1, emptyArray()))) + } + + @Test + fun `CVM-6-03 keeps the newest announcement per kind`() { + // Replaceable kinds: a stale event from a lagging relay must not + // overwrite a newer one already held. + val older = event(CvmKinds.SERVER_ANNOUNCEMENT, arrayOf(arrayOf("name", "old")), createdAt = 100) + val newer = event(CvmKinds.SERVER_ANNOUNCEMENT, arrayOf(arrayOf("name", "new")), createdAt = 200) + + val latest = ServerAnnouncement.latestPerKind(listOf(newer, older)) + assertEquals("new", latest[CvmKinds.SERVER_ANNOUNCEMENT]?.discovery?.name) + } + + @Test + fun `CVM-6-04 leaves the announcement content as text for the caller to decode`() { + val announcement = + ServerAnnouncement.parseOrNull( + event(CvmKinds.TOOLS_LIST, emptyArray(), """{"tools":[{"name":"x"}]}"""), + )!! + assertEquals("""{"tools":[{"name":"x"}]}""", announcement.content) + } + + // --- CEP-17 relay list --- + + @Test + fun `CVM-17-01 treats an unmarked relay as both read and write`() { + val relays = ServerRelay.parseAll(event(CvmKinds.RELAY_LIST, arrayOf(arrayOf("r", "wss://a")))) + assertEquals(listOf(ServerRelay("wss://a", read = true, write = true)), relays) + } + + @Test + fun `CVM-17-02 honours read and write markers when present`() { + val relays = + ServerRelay.parseAll( + event( + CvmKinds.RELAY_LIST, + arrayOf(arrayOf("r", "wss://r", "read"), arrayOf("r", "wss://w", "write")), + ), + ) + assertEquals(ServerRelay("wss://r", read = true, write = false), relays[0]) + assertEquals(ServerRelay("wss://w", read = false, write = true), relays[1]) + } + + @Test + fun `CVM-17-03 only a bidirectional relay can carry a full exchange`() { + // Kind 25910 is ephemeral, so a response missed on a write-only relay is + // simply gone -- both halves must share a relay. + val relays = + listOf( + ServerRelay("wss://both"), + ServerRelay("wss://read", read = true, write = false), + ) + assertEquals(listOf(ServerRelay("wss://both")), ServerRelay.operational(relays)) + } + + @Test + fun `CVM-17-04 ignores a blank relay url and a non-relay-list event`() { + assertTrue(ServerRelay.parseAll(event(CvmKinds.RELAY_LIST, arrayOf(arrayOf("r", "")))).isEmpty()) + assertTrue(ServerRelay.parseAll(event(1, arrayOf(arrayOf("r", "wss://a")))).isEmpty()) + } + + // --- CEP-24 reviews --- + + @Test + fun `CVM-24-01 a top-level review tags the announcement as both root and parent`() { + val tags = ServerReview.topLevelTags(serverPubKey, relayHint = "wss://r") + val names = tags.map { it[0] } + assertTrue(names.containsAll(listOf("A", "K", "P", "a", "k", "p"))) + assertEquals("11316:$serverPubKey:", tags.first { it[0] == "A" }[1]) + assertEquals("11316", tags.first { it[0] == "k" }[1]) + } + + @Test + fun `CVM-24-02 a reply keeps the root uppercase but moves the parent to the comment`() { + val parent = "b".repeat(64) + val author = "d".repeat(64) + val tags = ServerReview.replyTags(serverPubKey, parent, author) + + // Root stays on the announcement... + assertEquals("11316:$serverPubKey:", tags.first { it[0] == "A" }[1]) + assertEquals("11316", tags.first { it[0] == "K" }[1]) + // ...while the lowercase parent is the comment being answered. + assertEquals(parent, tags.first { it[0] == "e" }[1]) + assertEquals("1111", tags.first { it[0] == "k" }[1]) + assertEquals(author, tags.first { it[0] == "p" }[1]) + } + + @Test + fun `CVM-24-03 builds the addressable coordinate`() { + assertEquals("11316:$serverPubKey:", ServerReview.coordinate(serverPubKey)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamTest.kt new file mode 100644 index 0000000000..3726f310ea --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamTest.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep41OpenStreams + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** `CVM-41-*`: open-ended streams. */ +class OpenStreamTest { + private val token = ProgressToken.Text("req-123") + + private fun receiver( + requireAccept: Boolean = false, + policy: OpenStreamPolicy = OpenStreamPolicy(), + clock: () -> Long = { 0L }, + ) = OpenStreamReceiver(token, policy, requireAccept, clock) + + // --- happy paths --- + + @Test + fun `CVM-41-01 delivers contiguous chunks incrementally`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + + val first = rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "Hello")) + assertIs(first) + assertEquals(listOf("Hello"), first.fragments) + + val second = rx.accept(OpenStreamFrame.chunk(token, 3.0, 1, " world")) + assertIs(second) + assertEquals(listOf(" world"), second.fragments) + } + + @Test + fun `CVM-41-02 buffers a gap and releases the run once it closes`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + + // index 1 arrives first; a gap is not an error while the stream is live. + assertIs(rx.accept(OpenStreamFrame.chunk(token, 2.0, 1, "b"))) + assertIs(rx.accept(OpenStreamFrame.chunk(token, 3.0, 2, "c"))) + + val released = rx.accept(OpenStreamFrame.chunk(token, 4.0, 0, "a")) + assertIs(released) + assertEquals(listOf("a", "b", "c"), released.fragments) + } + + @Test + fun `CVM-41-03 a zero-chunk stream is valid`() { + // close straight after start, lastChunkIndex omitted. + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + val closed = rx.accept(OpenStreamFrame.close(token, 2.0)) + assertIs(closed) + assertEquals(null, closed.lastChunkIndex) + } + + @Test + fun `CVM-41-04 close with a satisfied lastChunkIndex succeeds`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + rx.accept(OpenStreamFrame.chunk(token, 3.0, 1, "b")) + + val closed = rx.accept(OpenStreamFrame.close(token, 4.0, lastChunkIndex = 1)) + assertIs(closed) + assertEquals(1L, closed.lastChunkIndex) + } + + @Test + fun `CVM-41-05 close without lastChunkIndex succeeds on an open-ended feed`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "tick")) + assertIs(rx.accept(OpenStreamFrame.close(token, 3.0))) + } + + @Test + fun `CVM-41-06 a ping must be answered with a pong carrying the same nonce`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + val event = rx.accept(OpenStreamFrame.ping(token, 2.0, "n-1")) + assertIs(event) + assertEquals("n-1", event.nonce) + } + + @Test + fun `CVM-41-07 pong progress has no ordering relationship to the ping`() { + // Pongs are matched by nonce only; each peer numbers its own sequence, + // so a pong may legitimately carry a lower progress than the ping. + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 10.0)) + rx.markProbeSent("n-1") + assertIs(rx.accept(OpenStreamFrame.pong(token, 1.0, "n-1"))) + assertFalse(rx.probeExpired(atMs = 1_000_000)) + } + + @Test + fun `CVM-41-08 keepalive fails the stream when a probe goes unanswered`() { + var clock = 0L + val rx = receiver(policy = OpenStreamPolicy(idleTimeoutMs = 30_000, probeTimeoutMs = 30_000)) { clock } + rx.accept(OpenStreamFrame.start(token, 1.0)) + + clock = 30_000 + assertTrue(rx.needsProbe(clock), "idle timeout elapsed, the peer must be probed") + + rx.markProbeSent("n-1", clock) + clock = 59_000 + assertFalse(rx.probeExpired(clock), "still inside the probe window") + + clock = 60_000 + assertTrue(rx.probeExpired(clock), "probe window elapsed with no matching pong") + } + + @Test + fun `CVM-41-09 a bootstrap stream asks the receiver to send accept`() { + val rx = receiver(requireAccept = true) + assertIs(rx.accept(OpenStreamFrame.start(token, 1.0))) + } + + @Test + fun `CVM-41-10 abort is terminal from either peer`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + val aborted = rx.accept(OpenStreamFrame.abort(token, 2.0, "resource exhaustion")) + assertIs(aborted) + assertEquals("resource exhaustion", aborted.reason) + assertTrue(rx.isTerminal) + } + + // --- rejections --- + + @Test + fun `CVM-41-20 rejects a second start on a live stream`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + assertFailsWith { rx.accept(OpenStreamFrame.start(token, 2.0)) } + } + + @Test + fun `CVM-41-21 rejects frames after close`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.close(token, 2.0)) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 3.0, 0, "late")) } + } + + @Test + fun `CVM-41-22 rejects close with a lastChunkIndex that is not satisfied`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + // Declares index 5 as the bound while only 0 arrived. + assertFailsWith { + rx.accept(OpenStreamFrame.close(token, 3.0, lastChunkIndex = 5)) + } + } + + @Test + fun `CVM-41-23 rejects close with a bound while a gap remains unresolved`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + rx.accept(OpenStreamFrame.chunk(token, 3.0, 2, "c")) // index 1 missing + assertFailsWith { + rx.accept(OpenStreamFrame.close(token, 4.0, lastChunkIndex = 2)) + } + } + + @Test + fun `CVM-41-24 rejects lastChunkIndex on a stream that carried no chunks`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + assertFailsWith { + rx.accept(OpenStreamFrame.close(token, 2.0, lastChunkIndex = 0)) + } + } + + @Test + fun `CVM-41-25 rejects a duplicate chunkIndex`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 1, "b")) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 3.0, 1, "b again")) } + } + + @Test + fun `CVM-41-26 rejects a chunkIndex that was already delivered`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 3.0, 0, "a again")) } + } + + @Test + fun `CVM-41-27 rejects a chunk before start`() { + val rx = receiver() + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 1.0, 0, "a")) } + } + + @Test + fun `CVM-41-28 rejects a chunk before accept in a bootstrap stream`() { + val rx = receiver(requireAccept = true) + rx.accept(OpenStreamFrame.start(token, 1.0)) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) } + } + + @Test + fun `CVM-41-29 rejects a repeated progress value from the peer`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 2.0, 1, "b")) } + } + + @Test + fun `CVM-41-30 rejects a frame belonging to another stream`() { + val rx = receiver() + assertFailsWith { + rx.accept(OpenStreamFrame.start(ProgressToken.Text("other"), 1.0)) + } + } + + @Test + fun `CVM-41-31 rejects an oversized nonce at parse time`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(OpenStreamFrame.PING)) + put(OpenStreamFrame.NONCE, JsonPrimitive("x".repeat(65))) + }, + ) + assertFailsWith { OpenStreamFrame.parseOrNull(envelope) } + } + + @Test + fun `CVM-41-32 rejects a chunk missing its chunkIndex`() { + // progress is not a chunk counter, so a chunk without chunkIndex cannot + // be positioned at all. + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(OpenStreamFrame.CHUNK)) + put(OpenStreamFrame.DATA, JsonPrimitive("a")) + }, + ) + assertFailsWith { OpenStreamFrame.parseOrNull(envelope) } + } + + @Test + fun `CVM-41-33 ignores a pong for a nonce we never sent`() { + // Not fatal: failing here would let a third party disrupt the stream by + // replaying a stale pong. It simply is not liveness evidence. + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.markProbeSent("real") + assertIs(rx.accept(OpenStreamFrame.pong(token, 2.0, "forged"))) + assertTrue(rx.probeExpired(atMs = 1_000_000), "the real probe is still outstanding") + } + + @Test + fun `CVM-41-34 the frame round-trips through a progress notification`() { + val frame = OpenStreamFrame.chunk(token, 2.0, 7, "payload") + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(frame.envelope.toNotification())) + val envelope = ProgressEnvelope.parseOrNull(decoded as JsonRpcNotification) + assertEquals(frame, OpenStreamFrame.parseOrNull(envelope!!)) + } + + @Test + fun `CVM-41-35 ignores an envelope belonging to another transfer profile`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OVERSIZED)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive("start")) + }, + ) + assertEquals(null, OpenStreamFrame.parseOrNull(envelope)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt new file mode 100644 index 0000000000..61cbd5490e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `CVM-CORE-02..06`: the kind 25910 envelope — content shape, addressing, + * correlation and the ephemeral-delivery consequence. + */ +class CvmMessageEventTest { + private val serverPubKey = "a".repeat(64) + private val requestEventId = "b".repeat(64) + + private val ping = JsonRpcRequest(JsonRpcId.Num(1), "ping") + + @Test + fun `CVM-CORE-02 content is a stringified JSON-RPC message, not an embedded object`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + + // The spec's examples print `content` unstringified for readability, + // which is the trap this asserts against: content is a String field + // whose own text is the JSON-RPC message. + assertEquals("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", template.content) + + // And it must survive the trip back through the codec. + assertEquals(ping, JsonRpcCodec.decode(template.content)) + } + + @Test + fun `CVM-CORE-03 addresses the peer with a p tag`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + assertContentEquals(arrayOf("p", serverPubKey), template.tags.first()) + } + + @Test + fun `CVM-CORE-04 correlates a response with an e tag naming the request event`() { + val template = CvmMessageEvent.build(ping, serverPubKey, inReplyTo = requestEventId) + val eTag = template.tags.first { it[0] == "e" } + assertContentEquals(arrayOf("e", requestEventId), eTag) + } + + @Test + fun `CVM-CORE-04 omits the e tag when the message is not a response`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + assertFalse(template.tags.any { it.isNotEmpty() && it[0] == "e" }) + } + + @Test + fun `CVM-CORE-03 reads addressing and correlation back off a parsed event`() { + val event = event(CvmMessageEvent.build(ping, serverPubKey, inReplyTo = requestEventId).tags) + + assertEquals(serverPubKey, event.recipient()) + assertEquals(requestEventId, event.inReplyTo()) + assertEquals(ping, event.message()) + } + + @Test + fun `CVM-CORE-03 tolerates an unaddressed event rather than throwing`() { + val event = event(emptyArray()) + assertNull(event.recipient()) + assertNull(event.inReplyTo()) + } + + @Test + fun `CVM-CORE-06 kind 25910 is ephemeral, so delivery has no replay`() { + // Consequence, not decoration: relays do not retain this kind, so a + // subscription must be live before the peer publishes. The transport's + // request API is built around this and the property is worth pinning. + assertTrue(CvmKinds.MESSAGE.isEphemeral()) + assertTrue(CvmKinds.isTransient(CvmKinds.MESSAGE)) + assertTrue(CvmKinds.isTransient(CvmKinds.EPHEMERAL_GIFT_WRAP)) + + // The CEP-19 motivation: the persistent wrap is *not* ephemeral, which + // is exactly why 21059 exists. + assertFalse(CvmKinds.isTransient(CvmKinds.GIFT_WRAP)) + } + + @Test + fun `CVM-35 discovery tags exclude routing tags but keep unknown ones`() { + val template = + CvmMessageEvent.build( + ping, + serverPubKey, + inReplyTo = requestEventId, + extraTags = + listOf( + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + arrayOf("some_future_tag", "value"), + ), + ) + + val discovery = event(template.tags).discoveryTags().map { it[0] } + + assertFalse(discovery.contains("p"), "p is routing, not discovery") + assertFalse(discovery.contains("e"), "e is routing, not discovery") + assertTrue(discovery.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue( + discovery.contains("some_future_tag"), + "CEP-35 requires unknown discovery tags to be preserved", + ) + } + + private fun event(tags: Array>) = + CvmMessageEvent( + Event( + id = "c".repeat(64), + pubKey = "d".repeat(64), + createdAt = 1_700_000_000L, + kind = CvmMessageEvent.KIND, + tags = tags, + content = JsonRpcCodec.encode(ping), + sig = "e".repeat(128), + ), + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodecTest.kt new file mode 100644 index 0000000000..a30d440449 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodecTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.jsonrpc + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `CVM-CORE-01`: the JSON-RPC layer round-trips every message class faithfully, + * and rejects payloads that are not well-formed JSON-RPC 2.0. + * + * Rule ids come from `quartz/plans/2026-09-17-cordn-interop.md` §6.5. When a CEP + * revises, the failing test names say what changed. + */ +class JsonRpcCodecTest { + private val toolsCall = + JsonRpcRequest( + id = JsonRpcId.Num(2), + method = "tools/call", + params = + buildJsonObject { + put("name", JsonPrimitive("kp_publish")) + put( + "arguments", + buildJsonObject { + put("kp_ref", JsonPrimitive("abc")) + put("kp_64", JsonPrimitive("BASE64")) + }, + ) + }, + ) + + @Test + fun `CVM-CORE-01 round-trips a request`() { + assertEquals(toolsCall, JsonRpcCodec.decode(JsonRpcCodec.encode(toolsCall))) + } + + @Test + fun `CVM-CORE-01 round-trips a notification`() { + val notification = + JsonRpcNotification( + method = "notifications/progress", + params = + buildJsonObject { + put("progressToken", JsonPrimitive("req-123")) + put("progress", JsonPrimitive(1)) + }, + ) + assertEquals(notification, JsonRpcCodec.decode(JsonRpcCodec.encode(notification))) + } + + @Test + fun `CVM-CORE-01 round-trips a success response`() { + val success = + JsonRpcSuccess( + id = JsonRpcId.Num(2), + result = buildJsonObject { put("cursor", JsonPrimitive(7)) }, + ) + assertEquals(success, JsonRpcCodec.decode(JsonRpcCodec.encode(success))) + } + + @Test + fun `CVM-CORE-01 round-trips an error response with data`() { + val failure = + JsonRpcFailure( + id = JsonRpcId.Num(2), + error = + JsonRpcError( + code = JsonRpcError.PAYMENT_REQUIRED, + message = "Payment Required", + data = buildJsonObject { put("instructions", JsonPrimitive("pay then retry")) }, + ), + ) + assertEquals(failure, JsonRpcCodec.decode(JsonRpcCodec.encode(failure))) + } + + @Test + fun `CVM-CORE-01 preserves a string id distinctly from a numeric one`() { + // MCP implementations use both. Normalising to String would make a + // string "2" and a numeric 2 indistinguishable on the wire, so the two + // must stay separate types through a round trip. + val text = toolsCall.copy(id = JsonRpcId.Text("2")) + val encodedText = JsonRpcCodec.encode(text) + val encodedNum = JsonRpcCodec.encode(toolsCall) + + assertTrue(encodedText.contains("\"id\":\"2\""), "string id must encode quoted: $encodedText") + assertTrue(encodedNum.contains("\"id\":2"), "numeric id must encode bare: $encodedNum") + assertEquals(text, JsonRpcCodec.decode(encodedText)) + assertEquals(toolsCall, JsonRpcCodec.decode(encodedNum)) + } + + @Test + fun `CVM-CORE-01 omits absent params rather than emitting null`() { + val encoded = JsonRpcCodec.encode(JsonRpcNotification("notifications/initialized")) + assertEquals("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", encoded) + } + + @Test + fun `CVM-CORE-01 keeps an unknown member out of the way of decoding`() { + // MCP grows fields and CEP-35 tells us to tolerate what we do not know. + val decoded = + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":1,"method":"ping","futureField":{"x":1}}""", + ) + assertEquals(JsonRpcRequest(JsonRpcId.Num(1), "ping"), decoded) + } + + @Test + fun `CVM-CORE-01 accepts a null id on an error response`() { + // JSON-RPC allows a null id when the request could not be parsed. + val decoded = + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}""", + ) + assertTrue(decoded is JsonRpcFailure) + assertNull(decoded.id) + assertEquals(JsonRpcError.PARSE_ERROR, decoded.error.code) + } + + @Test + fun `CVM-CORE-01 treats a method without an id as a notification`() { + val decoded = JsonRpcCodec.decode("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + assertEquals(JsonRpcNotification("notifications/initialized"), decoded) + } + + @Test + fun `CVM-CORE-01 preserves the params tree untouched`() { + // ContextVM transports MCP unmodified, so anything inside params has to + // survive verbatim -- including nesting we assign no meaning to. + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(toolsCall)) as JsonRpcRequest + val arguments = decoded.params!!["arguments"]!!.jsonObject + assertEquals("BASE64", arguments["kp_64"]!!.jsonPrimitive.content) + } + + // --- rejections: CVM-CORE-05 --- + + @Test + fun `CVM-CORE-05 rejects a wrong jsonrpc version`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"1.0","id":1,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a missing jsonrpc member`() { + assertFailsWith { + JsonRpcCodec.decode("""{"id":1,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a response carrying both result and error`() { + assertFailsWith { + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-1,"message":"x"}}""", + ) + } + } + + @Test + fun `CVM-CORE-05 rejects a message with neither method nor result nor error`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a method combined with a response body`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"ping","result":{}}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a success response without an id`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","result":{}}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a non-object payload`() { + assertFailsWith { JsonRpcCodec.decode("""["jsonrpc","2.0"]""") } + } + + @Test + fun `CVM-CORE-05 rejects malformed JSON`() { + assertFailsWith { JsonRpcCodec.decode("""{"jsonrpc":"2.0",""") } + } + + @Test + fun `CVM-CORE-05 rejects a fractional id`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1.5,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects non-object params`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"ping","params":[1,2]}""") + } + } + + @Test + fun `CVM-CORE-05 rejects an error object missing its code`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"error":{"message":"x"}}""") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt new file mode 100644 index 0000000000..2852d6c080 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * What the blob host is told, asserted field by field. + * + * Every one of these is a privacy property that regresses *without failing*: + * change the content type and the upload still works, the group still reads + * the file, and the only difference is that a server now knows it is holding a + * JPEG. There is no downstream check that would catch it, so these assertions + * are the check. + */ +class CordnBlobUploadTest { + private val key = ByteArray(32) { it.toByte() } + private val file = "a private photo".encodeToByteArray() + private val sealed = CordnMediaEncryption.encrypt(file, key, "image/jpeg", "holiday.jpg") + + @Test + fun `the host is given the ciphertext, and its hash names the blob`() { + val blob = CordnBlobUpload.of(sealed) + + // Blossom addresses a blob by the hash of what it stores. + assertContentEquals(sealed.ciphertext, blob.bytes) + assertEquals(sha256(sealed.ciphertext).toHexKey(), blob.hash) + assertEquals(sealed.ciphertext.size.toLong(), blob.length) + } + + @Test + fun `the host hash is not the imeta hash`() { + val blob = CordnBlobUpload.of(sealed) + + // Two hashes on purpose. Uploading under the plaintext hash would let + // anyone holding the original file prove this account uploaded it, and + // would make the blob's address a fingerprint of its contents. + assertNotEquals(sealed.plaintextHash.toHexKey(), blob.hash) + assertEquals(sha256(file).toHexKey(), sealed.plaintextHash.toHexKey()) + } + + @Test + fun `the declared type is opaque, never the real one`() { + val blob = CordnBlobUpload.of(sealed) + + assertEquals("application/octet-stream", blob.contentType) + assertEquals(CordnBlobUpload.OPAQUE, blob.contentType) + assertNotEquals(sealed.mimeType, blob.contentType) + } + + @Test + fun `the real filename does not reach the host`() { + val blob = CordnBlobUpload.of(sealed) + + assertEquals(blob.hash, blob.baseFileName) + assertNotEquals("holiday.jpg", blob.baseFileName) + } + + @Test + fun `no alt text and no content warning`() { + val blob = CordnBlobUpload.of(sealed) + + // Both are plaintext on a Blossom upload. An alt string describing a + // private photo is that photo's caption, given to the one party that + // was supposed to see nothing. + assertNull(blob.alt) + assertNull(blob.sensitiveContent) + } + + @Test + fun `upload, never the media endpoint`() { + // `/media` asks the server to re-encode. Re-encoding ciphertext + // destroys it, so an account with "optimize uploads" on would break + // every attachment and only the recipient would find out. + assertFalse(CordnBlobUpload.of(sealed).useMediaEndpoint) + } + + @Test + fun `the same file uploaded twice is a different blob`() { + // The nonce is fresh per encryption (see CordnMediaEncryptionTest), so + // the ciphertext hash differs. A host therefore cannot tell that the + // same picture was sent to two groups. + val again = CordnMediaEncryption.encrypt(file, key, "image/jpeg", "holiday.jpg") + + assertNotEquals(CordnBlobUpload.of(sealed).hash, CordnBlobUpload.of(again).hash) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt new file mode 100644 index 0000000000..817de45134 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CordnMediaEncryptionTest { + private val key = ByteArray(32) { it.toByte() } + private val file = "a small photo".encodeToByteArray() + + private fun roundTrip( + mime: String = "image/jpeg", + name: String = "photo.jpg", + ): CordnEncryptedMedia = CordnMediaEncryption.encrypt(file, key, mime, name) + + @Test + fun `a file round-trips`() { + val sealed = roundTrip() + + val opened = + CordnMediaEncryption.decrypt( + sealed.ciphertext, + key, + sealed.nonce, + sealed.plaintextHash, + sealed.mimeType, + sealed.filename, + ) + + assertContentEquals(file, opened) + assertContentEquals(sha256(file), sealed.plaintextHash) + } + + @Test + fun `the same file encrypted twice never reuses a nonce`() { + // The property this codec leans on harder than MIP-04 does: cordn uses + // the exporter output directly, so every file in an epoch shares one + // key and the nonce is all that separates two keystreams. + val nonces = (1..64).map { roundTrip().nonce.toList() }.toSet() + + assertEquals(64, nonces.size, "a nonce repeated under a shared key is a keystream break") + } + + @Test + fun `renaming a file breaks it, because the name is authenticated`() { + val sealed = roundTrip(name = "photo.jpg") + + assertFailsWith { + CordnMediaEncryption.decrypt(sealed.ciphertext, key, sealed.nonce, sealed.plaintextHash, sealed.mimeType, "invoice.pdf") + } + } + + @Test + fun `changing the declared type breaks it`() { + val sealed = roundTrip(mime = "image/jpeg") + + assertFailsWith { + CordnMediaEncryption.decrypt(sealed.ciphertext, key, sealed.nonce, sealed.plaintextHash, "application/pdf", sealed.filename) + } + } + + @Test + fun `a hash that does not describe the plaintext is refused`() { + val sealed = roundTrip() + val lying = sealed.plaintextHash.copyOf().also { it[0] = (it[0] + 1).toByte() } + + assertFailsWith { + CordnMediaEncryption.decrypt(sealed.ciphertext, key, sealed.nonce, lying, sealed.mimeType, sealed.filename) + } + } + + @Test + fun `a cordn blob is not a Marmot blob`() { + // §4.5: same exporter context word, different key derivation and + // different AAD. Confusing the two must fail loudly rather than + // produce plausible garbage, and this is the test that says so. + val sealed = roundTrip() + + assertFailsWith { + Mip04MediaEncryption.decrypt( + sealed.ciphertext, + key, + sealed.nonce, + sealed.plaintextHash, + sealed.mimeType, + sealed.filename, + ) + } + } + + @Test + fun `the cordn file key is not the Marmot file key for the same inputs`() { + val marmot = Mip04MediaEncryption.deriveFileKey(key, sha256(file), "image/jpeg", "photo.jpg") + + // cordn uses the exporter output as-is; Marmot expands it. If these + // ever matched, one of the two implementations would be wrong. + assertNotEquals(key.toList(), marmot.toList()) + } + + @Test + fun `an imeta tag round-trips through parse`() { + val sealed = roundTrip() + val tag = CordnMediaTag.build(sealed, url = "https://blossom.example.com/abc", dimensions = "800x600") + + val parsed = CordnMediaTag.parseAll(arrayOf(tag)).single() + + assertEquals("https://blossom.example.com/abc", parsed.url) + assertEquals(sealed.mimeType, parsed.mimeType) + assertEquals(sealed.filename, parsed.filename) + assertContentEquals(sealed.nonce, parsed.nonceBytes) + assertContentEquals(sealed.plaintextHash, parsed.hashBytes) + assertEquals("800x600", parsed.dimensions) + assertTrue(parsed.isImage) + assertFalse(parsed.isAudio) + } + + @Test + fun `a half-formed attachment is dropped rather than half-parsed`() { + // The only thing a caller could do with a partial descriptor is start + // a decrypt that must fail. A message with one broken attachment + // should still show its other attachments and its text. + val good = CordnMediaTag.build(roundTrip(), url = "https://blossom.example.com/ok") + val noNonce = arrayOf("imeta", "url https://blossom.example.com/bad", "m image/png", "filename x.png", "x " + "ab".repeat(32)) + val shortNonce = arrayOf("imeta", "url https://blossom.example.com/bad", "m image/png", "filename x.png", "x " + "ab".repeat(32), "n abcd") + + val parsed = CordnMediaTag.parseAll(arrayOf(noNonce, good, shortNonce)) + + assertEquals(1, parsed.size) + assertEquals("https://blossom.example.com/ok", parsed.single().url) + } + + @Test + fun `a filename containing a space survives the tag`() { + // imeta fields are "key value" strings, so only the FIRST space + // separates them. Splitting on every space would truncate any filename + // a person actually typed. + val sealed = roundTrip(name = "my holiday photo.jpg") + val parsed = CordnMediaTag.parseAll(arrayOf(CordnMediaTag.build(sealed, url = "https://b.example.com/x"))).single() + + assertEquals("my holiday photo.jpg", parsed.filename) + } + + @Test + fun `the tag carries the version and never the key`() { + // spec/applications/encrypted-media.md §4: `v cordn-em-v1` is required, + // and §3.1 says the key is "never transmitted, never stored in imeta". + // Amethyst used to do the opposite of both — ship a random key in a `k` + // field and omit `v` — and cordn.net rejected every attachment. + val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/y") + + assertTrue(tag.any { it == "${CordnMediaTag.VERSION} ${CordnMediaTag.VERSION_V1}" }, "no version field") + assertFalse(tag.any { it.startsWith("k ") }, "the file key went on the wire") + } + + @Test + fun `the display hints round-trip, including the waveform`() { + // The recorder measures amplitudes and the composer preview already + // draws them; without somewhere to put them they were dropped at upload + // and the sender's own voice note came back bar-less. + val sealed = roundTrip(mime = "audio/mp4a-latm", name = "note.mp4") + val tag = + CordnMediaTag.build( + media = sealed, + url = "https://b.example.com/a", + dimensions = "800x600", + blurhash = "LEHV6n", + thumbhash = "1QcSHQ", + alt = "a spoken note", + waveform = listOf(0.1f, 0.5f, 1.0f), + ) + + val parsed = CordnMediaTag.parseAll(arrayOf(tag)).single() + + assertEquals("800x600", parsed.dimensions) + assertEquals("LEHV6n", parsed.blurhash) + assertEquals("1QcSHQ", parsed.thumbhash) + assertEquals("a spoken note", parsed.alt) + assertEquals(listOf(0.1f, 0.5f, 1.0f), parsed.waveform) + assertTrue(parsed.isAudio) + } + + @Test + fun `a hint that is absent or malformed costs the hint, not the attachment`() { + // Every one of these is optional, so a reader that cannot make sense of + // one must still be able to fetch and open the file. + val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/b") + val brokenWave = tag + "${CordnMediaTag.WAVEFORM} not numbers" + + val bare = CordnMediaTag.parseAll(arrayOf(tag)).single() + val broken = CordnMediaTag.parseAll(arrayOf(brokenWave)).single() + + assertNull(bare.waveform) + assertNull(bare.alt) + assertNull(broken.waveform, "a malformed waveform should be dropped, not kept half-parsed") + assertEquals("https://b.example.com/b", broken.url) + } + + @Test + fun `a tag with no version or an unknown one is rejected`() { + // §4 makes this a MUST: the bytes under an unrecognised version are a + // format we have not agreed on, so guessing is worse than dropping. + val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/z") + val noVersion = tag.filterNot { it.startsWith("${CordnMediaTag.VERSION} ") }.toTypedArray() + val futureVersion = noVersion + "${CordnMediaTag.VERSION} cordn-em-v2" + + assertTrue(CordnMediaTag.parseAll(arrayOf(noVersion)).isEmpty(), "a tag with no version parsed") + assertTrue(CordnMediaTag.parseAll(arrayOf(futureVersion)).isEmpty(), "an unknown version parsed") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRefTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRefTest.kt new file mode 100644 index 0000000000..8b35445a23 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRefTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appGroupRef + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `spec/applications/group-ref.md`. + * + * The three golden strings are copied from the reference implementation's own + * suite (`packages/core/src/groupRef.test.ts`), where they are cross-checked + * against an independent TLV+bech32 assembly. That makes them a genuine + * cross-implementation vector rather than a record of what our encoder happens + * to emit — the thing a round-trip test can never tell you. + */ +class CordnGroupRefTest { + private val gid = "550e8400-e29b-41d4-a716-446655440000" + private val pubKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + private val relays = listOf("wss://relay.example.com", "wss://backup.example.com") + + private val goldenGidOnly = + "cordn1qqjr2dfsv5urgvps94jnywtz956rzep594snwvfk956rgd3kx56ngdpsxqcrqy4yh7d" + private val goldenGidPubKey = + "cordn1qysqzg69v7y6hn00qy352euf40x77qfrg4ncn27dauqjx3t83x4ummcqys6n2vr98q6rqvpdv5erjc3dxsckgdpdvymnzd3dxs6rvd34x56rgvpsxqcqfqnsvd" + private val goldenFull = + "cordn1qgthwumn8ghj7un9d3shjtn90psk6urvv5hxxmmdqgv8wumn8ghj7cnpvd4h2upwv4uxzmtsd3jjucm0d5qjqqfrg4ncn27dauqjx3t83x4ummcpydzk0zdtehhszg69v7y6hn00qqjr2dfsv5urgvps94jnywtz956rzep594snwvfk956rgd3kx56ngdpsxqcrqv7vzv4" + + @Test + fun weEncodeByteForByteWithTheReferenceImplementation() { + assertEquals(goldenGidOnly, CordnGroupRef(gid).encode()) + assertEquals(goldenGidPubKey, CordnGroupRef(gid, pubKey).encode()) + assertEquals(goldenFull, CordnGroupRef(gid, pubKey, relays).encode()) + } + + @Test + fun weDecodeTheReferenceImplementationsOutput() { + assertEquals(CordnGroupRef(gid), CordnGroupRef.decode(goldenGidOnly)) + assertEquals(CordnGroupRef(gid, pubKey), CordnGroupRef.decode(goldenGidPubKey)) + assertEquals(CordnGroupRef(gid, pubKey, relays), CordnGroupRef.decode(goldenFull)) + } + + @Test + fun theGidRoundTripsByteForByte() { + // §4.1: no trimming, no re-encoding. A gid is the coordinator's cursor + // key, so a decoder that tidied one would silently address another + // stream — or none. + val awkward = listOf(" leading", "trailing ", " ", "emoji-👍", "MiXeDcAsE", "a/b?c=d") + awkward.forEach { + assertEquals(it, CordnGroupRef.decode(CordnGroupRef(it).encode()).gid, "gid '$it' must survive the round trip") + } + } + + @Test + fun aRelayWithoutACoordinatorIsInvalid() { + // §5: a relay names where to reach *a coordinator*; with none named it + // has no referent. + assertFailsWith { + CordnGroupRef(gid, coordinatorPubKey = null, relays = listOf("wss://relay.example.com")) + } + } + + @Test + fun aWrongPrefixIsRejectedEvenWhenTheChecksumIsFine() { + // An `nprofile` is a perfectly valid bech32 string. It is not a group + // ref, and the prefix is the only thing that says so. + val notCordn = CordnGroupRef(gid).encode().replaceFirst("cordn1", "nprofile1") + assertFailsWith { CordnGroupRef.decode(notCordn) } + } + + @Test + fun corruptionIsRejectedRatherThanTruncated() { + // quartz's NIP-19 Tlv.parse stops silently at a bad tuple, which is + // right there and wrong here: dropping the tail of this ref turns one + // that names a coordinator into one that reaches for a default. + val valid = CordnGroupRef(gid, pubKey).encode() + val truncated = valid.dropLast(10) + assertNull(CordnGroupRef.decodeOrNull(truncated), "a truncated ref must not decode") + } + + @Test + fun mixedCaseIsRejected() { + val valid = CordnGroupRef(gid).encode() + val mixed = valid.take(valid.length / 2) + valid.drop(valid.length / 2).uppercase() + assertFailsWith { CordnGroupRef.decode(mixed) } + // All-upper is legal bech32 and must still decode. + assertEquals(gid, CordnGroupRef.decode(valid.uppercase()).gid) + } + + @Test + fun anEmptyOrOversizedGidIsRejected() { + assertFailsWith { CordnGroupRef("") } + // The TLV length field is one byte, so 255 is the hard ceiling. + assertTrue(CordnGroupRef("a".repeat(255)).encode().isNotEmpty()) + assertFailsWith { CordnGroupRef("a".repeat(256)) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadataTest.kt new file mode 100644 index 0000000000..5e3d493112 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadataTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec01GroupMetadata + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `spec/01.md` — the `cordn_group_metadata` extension (`0xC04D`). */ +class CordnGroupMetadataTest { + private val alice = "11".repeat(32) + private val bob = "22".repeat(32) + + @Test + fun theLayoutIsUint16LengthsNotMlsVarints() { + // Hand-derived from spec/01.md §3 rather than round-tripped, because a + // round trip agrees with itself no matter which length encoding we + // picked -- and the spec names two incompatible ones (see the codec's + // KDoc). 14 bytes: version, then five length-prefixed fields. + // + // 0001 version = 1 + // 0002 4869 name = "Hi" + // 0000 description + // 0000 admin_pubkeys + // 0000 icon + // 0000 image_url + assertEquals( + "0001000248690000000000000000", + CordnGroupMetadata(name = "Hi").encode().toHexKey(), + ) + } + + @Test + fun adminPubkeysAreConcatenatedRawKeys() { + val encoded = CordnGroupMetadata(name = "", adminPubkeys = listOf(alice, bob)).encode().toHexKey() + // version, empty name, empty description, then 64 bytes of admins. + assertTrue(encoded.startsWith("0001" + "0000" + "0000" + "0040"), "admins field must declare 0x40 = 64 bytes") + assertTrue(encoded.contains(alice + bob), "admins are raw 32-byte keys back to back, in order") + } + + @Test + fun anEmptyAdminListMeansEgalitarianNotBootstrap() { + // spec/01.md §5.3. Marmot reads an empty admin set as "bootstrap, gate + // still open"; cordn reads it as a permanent statement that everyone is + // equal. Same bytes, different meaning -- the reason neither side's + // authorization code can be reused for the other. + assertTrue(CordnGroupMetadata(name = "open").isEgalitarian) + assertTrue(!CordnGroupMetadata(name = "run", adminPubkeys = listOf(alice)).isEgalitarian) + } + + @Test + fun versionZeroIsRejected() { + val v0 = CordnGroupMetadata(name = "Hi").encode().also { it[1] = 0 } + assertFailsWith { CordnGroupMetadata.decode(v0) } + } + + @Test + fun trailingBytesAreRejected() { + // A future version appends fields, so trailing bytes are not harmless + // padding -- they mean this payload was written by something we cannot + // fully read, and §4 says only a version bump may introduce them. + val extra = CordnGroupMetadata(name = "Hi").encode() + byteArrayOf(0) + assertFailsWith { CordnGroupMetadata.decode(extra) } + } + + @Test + fun adminPubkeysNotAMultipleOf32AreRejected() { + // §9. Silently truncating would drop an admin, which is the direction + // that fails open. + val bad = CordnGroupMetadata(name = "", adminPubkeys = listOf(alice)).encode().dropLast(3).toByteArray() + assertFailsWith { CordnGroupMetadata.decode(bad) } + } + + @Test + fun invalidUtf8IsRejectedRatherThanReplaced() { + // §9 says reject. Kotlin's decodeToString() would substitute U+FFFD and + // hand back a group named something nobody chose. + // version=1, name length=2, name = C3 28 (a truncated 2-byte sequence), + // then four empty fields. Structurally valid, so the only thing that can + // reject it is the UTF-8 check. + val broken = byteArrayOf(0, 1, 0, 2) + byteArrayOf(0xC3.toByte(), 0x28) + ByteArray(8) + val error = assertFailsWith { CordnGroupMetadata.decode(broken) } + assertTrue( + error.message?.contains("UTF-8") == true, + "must fail on the encoding, not incidentally on length: got '${error.message}'", + ) + } + + @Test + fun duplicateAdminsAreRejected() { + assertFailsWith { CordnGroupMetadata(name = "", adminPubkeys = listOf(alice, alice)) } + } + + @Test + fun aFullPayloadRoundTrips() { + val full = + CordnGroupMetadata( + name = "Design 🎨", + description = "where the work happens", + adminPubkeys = listOf(alice, bob), + icon = "🎨", + imageUrl = "https://example.com/a.png", + ) + assertEquals(full, CordnGroupMetadata.decode(full.encode())) + } + + @Test + fun aGroupWithNoMetadataExtensionReadsAsNull() { + // §6: omitting it entirely is valid, so this must not throw. + assertNull(CordnGroupMetadata.fromExtensions(emptyList())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt new file mode 100644 index 0000000000..24d5ce7ef4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.PinOp +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.Target +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The authorization rules for annotations, which is what this index really is. + * + * Nothing about "fold reactions onto messages" is interesting. What is + * interesting is who is allowed to change whose message, and those rules differ + * per annotation — author-only for edits and deletions, any-member for pins — + * so each one gets a test that fails loudly if it is loosened. + */ +class CordnAnnotationIndexTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private var cursor = 0L + + private fun message( + author: HexKey, + kind: Int, + content: String, + tags: Array> = emptyArray(), + createdAt: Long = 1_757_000_000L, + ): CordnDeliveredMessage = + CordnDeliveredMessage( + envelope = CordnEnvelope.build(author, createdAt, kind, tags, content), + cursor = ++cursor, + ) + + private fun CordnDeliveredMessage.asTarget() = Target(envelope.id, envelope.pubKey, envelope.kind, envelope.tags) + + @Test + fun `reactions gather by emoji and by who sent them`() { + val note = message(alice, CordnMessageKinds.TEXT, "ship it") + val index = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.REACTION, "+", CordnMessageReferences.reactionTags(note.asTarget())), + message(alice, CordnMessageKinds.REACTION, "+", CordnMessageReferences.reactionTags(note.asTarget())), + message(bob, CordnMessageKinds.REACTION, "🎉", CordnMessageReferences.reactionTags(note.asTarget())), + ), + ) + + assertEquals(setOf(alice, bob), index.reactions[note.envelope.id]?.get("+")) + assertEquals(setOf(bob), index.reactions[note.envelope.id]?.get("🎉")) + } + + @Test + fun `only the author may edit, and the newest edit wins`() { + val note = message(alice, CordnMessageKinds.TEXT, "original", createdAt = 100) + val tags = CordnMessageReferences.editTags(note.asTarget()) + + val index = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.EDIT, "bob rewrote this", tags, createdAt = 200), + message(alice, CordnMessageKinds.EDIT, "first fix", tags, createdAt = 300), + message(alice, CordnMessageKinds.EDIT, "final fix", tags, createdAt = 400), + ), + ) + + assertEquals("final fix", index.contentOf(note.envelope.id)) + assertTrue(index.isEdited(note.envelope.id)) + } + + @Test + fun `an edit by someone else changes nothing at all`() { + // Stated separately because it is the whole point: without the author + // check, anyone in the group can rewrite anyone's words. + val note = message(alice, CordnMessageKinds.TEXT, "original") + val index = + CordnAnnotationIndex.of( + listOf( + note, + message( + bob, + CordnMessageKinds.EDIT, + "bob rewrote this", + CordnMessageReferences.editTags(note.asTarget()), + ), + ), + ) + + assertEquals("original", index.contentOf(note.envelope.id)) + assertTrue(!index.isEdited(note.envelope.id)) + } + + @Test + fun `only the author may delete`() { + val mine = message(alice, CordnMessageKinds.TEXT, "mine") + val theirs = message(bob, CordnMessageKinds.TEXT, "theirs") + + val index = + CordnAnnotationIndex.of( + listOf( + mine, + theirs, + message(alice, CordnMessageKinds.DELETION, "", CordnMessageReferences.deleteTags(mine.asTarget())), + message(alice, CordnMessageKinds.DELETION, "", CordnMessageReferences.deleteTags(theirs.asTarget())), + ), + ) + + assertTrue(index.isDeleted(mine.envelope.id)) + assertTrue(!index.isDeleted(theirs.envelope.id), "alice must not be able to delete bob's message") + } + + @Test + fun `a deletion whose k disagrees with the target is not a deletion`() { + val note = message(alice, CordnMessageKinds.TEXT, "mine") + val wrongKind = + arrayOf( + arrayOf("e", note.envelope.id, "", alice), + arrayOf("k", CordnMessageKinds.THREAD_REPLY.toString()), + ) + + val index = CordnAnnotationIndex.of(listOf(note, message(alice, CordnMessageKinds.DELETION, "", wrongKind))) + + assertTrue(!index.isDeleted(note.envelope.id), "a mismatched k is a mismatch, not a typo to forgive") + } + + @Test + fun `a deleted message stops accepting edits`() { + // Order is load-bearing in the fold: deletions are resolved before + // edits so a late edit cannot resurrect withdrawn text. + val note = message(alice, CordnMessageKinds.TEXT, "regrettable", createdAt = 100) + val index = + CordnAnnotationIndex.of( + listOf( + note, + message( + alice, + CordnMessageKinds.DELETION, + "", + CordnMessageReferences.deleteTags(note.asTarget()), + createdAt = 200, + ), + message( + alice, + CordnMessageKinds.EDIT, + "back again", + CordnMessageReferences.editTags(note.asTarget()), + createdAt = 300, + ), + ), + ) + + assertTrue(index.isDeleted(note.envelope.id)) + assertTrue(!index.isEdited(note.envelope.id), "an edit must not undo a deletion") + } + + @Test + fun `any member may pin, and the last write wins`() { + val note = message(alice, CordnMessageKinds.TEXT, "important", createdAt = 100) + val t = note.asTarget() + + val pinnedByBob = + CordnAnnotationIndex.of( + listOf(note, message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.ADD), 200)), + ) + assertTrue(pinnedByBob.isPinned(note.envelope.id), "pinning is any-member, unlike editing") + assertEquals(bob, pinnedByBob.pins[note.envelope.id]?.pinnedBy) + + val thenUnpinnedByAlice = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.ADD), 200), + message(alice, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.REMOVE), 300), + ), + ) + assertTrue(!thenUnpinnedByAlice.isPinned(note.envelope.id), "the newest op wins") + } + + @Test + fun `same-second pins break the tie on the cursor`() { + // Two clients acting in the same second must agree on the outcome. The + // coordinator's cursor is the only total order both can see. + val note = message(alice, CordnMessageKinds.TEXT, "important", createdAt = 100) + val t = note.asTarget() + + val index = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.ADD), 200), + message(alice, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.REMOVE), 200), + ), + ) + + assertTrue(!index.isPinned(note.envelope.id), "the later cursor decides when the timestamps match") + } + + @Test + fun `pinned messages come back newest first`() { + val first = message(alice, CordnMessageKinds.TEXT, "one", createdAt = 100) + val second = message(alice, CordnMessageKinds.TEXT, "two", createdAt = 110) + + val index = + CordnAnnotationIndex.of( + listOf( + first, + second, + message(alice, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(first.asTarget(), PinOp.ADD), 200), + message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(second.asTarget(), PinOp.ADD), 300), + ), + ) + + assertEquals(listOf(second.envelope.id, first.envelope.id), index.pinnedIds()) + } + + @Test + fun `an annotation pointing at nothing is ignored`() { + // Real case: catch-up starts after the target, so the annotation + // arrives without it. Dropping it beats rendering an orphan row. + val absent = Target("f".repeat(64), alice, CordnMessageKinds.TEXT) + val index = + CordnAnnotationIndex.of( + listOf( + message(alice, CordnMessageKinds.EDIT, "x", CordnMessageReferences.editTags(absent)), + message(alice, CordnMessageKinds.DELETION, "", CordnMessageReferences.deleteTags(absent)), + ), + ) + + assertTrue(index.edits.isEmpty()) + assertTrue(index.deleted.isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelopeTest.kt new file mode 100644 index 0000000000..ff3e2137af --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelopeTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** `spec/02.md` — the Nostr-shaped, unsigned application envelope. */ +class CordnEnvelopeTest { + private val alice = "11".repeat(32) + private val bob = "22".repeat(32) + + private fun chat( + pubKey: String = alice, + content: String = "hello", + ) = CordnEnvelope.build(pubKey = pubKey, createdAt = 1_700_000_000L, kind = 9, content = content) + + @Test + fun theIdIsNip01OverTheUnsignedFields() { + val envelope = chat() + assertEquals(envelope.computedId(), envelope.id) + assertEquals(64, envelope.id.length) + } + + @Test + fun aRewrittenBodyIsRejected() { + // §4: the receiver recomputes. Without that the envelope carries no + // integrity at all -- there is no signature to fall back on. + val tampered = chat().toJson().replace("\"hello\"", "\"goodbye\"") + assertFailsWith { + CordnEnvelope.decode(tampered.encodeToByteArray(), alice) + } + } + + @Test + fun anEnvelopeCannotClaimAnotherMembersPubkey() { + // §5, and the reason decode() demands the MLS sender identity rather + // than offering it. Bob's envelope is internally consistent -- its id + // hashes correctly over bob's pubkey -- so only the cross-check against + // the MLS sender catches it. + val fromBob = chat(pubKey = bob) + assertEquals(fromBob.computedId(), fromBob.id, "the forgery is self-consistent") + + val error = + assertFailsWith { + CordnEnvelope.decode(fromBob.encode(), senderIdentity = alice) + } + assertTrue(error.message?.contains("MLS sender") == true, "got '${error.message}'") + } + + @Test + fun aSigFieldIsRejected() { + // §2 and §8: absence of `sig` is an interop requirement, not a default. + // One carrying a signature came from something following different + // rules, and accepting it would let that signature look meaningful. + val withSig = chat().toJson().dropLast(1) + ",\"sig\":\"00\"}" + assertFailsWith { + CordnEnvelope.decode(withSig.encodeToByteArray(), alice) + } + } + + @Test + fun tagsSurviveTheRoundTrip() { + val reply = + CordnEnvelope.build( + pubKey = alice, + createdAt = 1_700_000_001L, + kind = 1111, + tags = arrayOf(arrayOf("E", "aa".repeat(32)), arrayOf("K", "9")), + content = "agreed", + ) + val decoded = CordnEnvelope.decode(reply.encode(), alice) + assertEquals(reply, decoded) + assertEquals("aa".repeat(32), decoded.tags[0][1]) + } + + @Test + fun theEncodingIsUtf8Json() { + val unicode = chat(content = "こんにちは 👋") + val decoded = CordnEnvelope.decode(unicode.encode(), alice) + assertEquals("こんにちは 👋", decoded.content) + assertEquals(unicode.id, decoded.id, "the id must hash the same over non-ASCII content") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt new file mode 100644 index 0000000000..8cc2c492f0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.PinOp +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.Target +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The cordn kind catalog and its reference format. + * + * `spec/02.md` §6 names chat, thread-reply and reaction and then says there is + * "no required set of kind values", so edits (1010), deletions (5) and pins + * (1011) are the reference client's conventions rather than protocol. We follow + * them; these tests are what "follow" means concretely, and are the place to + * look when cordn-web changes something. + */ +class CordnMessageKindsTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private fun target( + id: String = "1".repeat(64), + pubKey: String = alice, + kind: Int = CordnMessageKinds.TEXT, + tags: TagArray = emptyArray(), + ) = Target(id, pubKey, kind, tags) + + private fun TagArray.value(name: String): String? = firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1) + + @Test + fun `annotations never render as their own row`() { + listOf(CordnMessageKinds.REACTION, CordnMessageKinds.EDIT, CordnMessageKinds.DELETION, CordnMessageKinds.PIN) + .forEach { assertTrue(CordnMessageKinds.isAnnotation(it), "kind $it modifies a message, it is not one") } + + listOf(CordnMessageKinds.TEXT, CordnMessageKinds.THREAD_REPLY, CordnMessageKinds.SYSTEM) + .forEach { assertTrue(!CordnMessageKinds.isAnnotation(it), "kind $it is a message in its own right") } + + assertTrue(CordnMessageKinds.isSystem(CordnMessageKinds.SYSTEM)) + assertTrue(CordnMessageKinds.SYSTEM < 0, "a system row is derived, never sent — it must not collide with a wire kind") + } + + @Test + fun `the numbers are cordn-web's, and differ from Marmot's on purpose`() { + // Pinned because they are the interop surface with the only other cordn + // client, and because two of them disagree with Marmot (1009 edit, no + // pin) by decision rather than accident. + assertEquals(9, CordnMessageKinds.TEXT) + assertEquals(1111, CordnMessageKinds.THREAD_REPLY) + assertEquals(7, CordnMessageKinds.REACTION) + assertEquals(1010, CordnMessageKinds.EDIT) + assertEquals(5, CordnMessageKinds.DELETION) + assertEquals(1011, CordnMessageKinds.PIN) + } + + // ---- outbound ------------------------------------------------------ + + @Test + fun `plain text is kind 9 and trimmed`() { + val out = CordnMessageReferences.outbound(" hello ") + assertEquals(CordnMessageKinds.TEXT, out.kind) + assertEquals("hello", out.content) + assertTrue(out.tags.isEmpty()) + } + + @Test + fun `a reply is kind 1111 and carries NIP-22 root and parent tags`() { + val out = CordnMessageReferences.outbound("sure", replyTo = target()) + assertEquals(CordnMessageKinds.THREAD_REPLY, out.kind) + // First reply in a thread: the target is its own root. + assertEquals("1".repeat(64), out.tags.value("E")) + assertEquals("1".repeat(64), out.tags.value("e")) + assertEquals(alice, out.tags.value("P")) + assertEquals("9", out.tags.value("K")) + assertEquals("9", out.tags.value("k")) + } + + @Test + fun `replying to a reply keeps the original root`() { + // The case that makes threads flat instead of nested if it is wrong: + // the root must come from the parent's own E/K/P, not from the parent. + val rootId = "9".repeat(64) + val parent = + target( + id = "2".repeat(64), + pubKey = bob, + kind = CordnMessageKinds.THREAD_REPLY, + tags = + arrayOf( + arrayOf("E", rootId, "", alice), + arrayOf("K", "9"), + arrayOf("P", alice), + ), + ) + + val out = CordnMessageReferences.outbound("agreed", replyTo = parent) + + assertEquals(rootId, out.tags.value("E"), "the thread root must survive a nested reply") + assertEquals(alice, out.tags.value("P")) + assertEquals("9", out.tags.value("K")) + assertEquals("2".repeat(64), out.tags.value("e"), "the parent is the message replied to") + assertEquals(bob, out.tags.value("p")) + assertEquals("1111", out.tags.value("k")) + } + + @Test + fun `a reaction keeps its content untrimmed`() { + // The emoji is the payload. Trimming it would change what was sent. + val out = CordnMessageReferences.outbound(" 🤙 ", reactionTo = target()) + assertEquals(CordnMessageKinds.REACTION, out.kind) + assertEquals(" 🤙 ", out.content) + assertEquals("9", out.tags.value("k")) + } + + @Test + fun `an edit is trimmed and keeps extra tags`() { + val out = + CordnMessageReferences.outbound( + " fixed ", + extraTags = arrayOf(arrayOf("imeta", "url https://example.invalid/x")), + editTo = target(), + ) + assertEquals(CordnMessageKinds.EDIT, out.kind) + assertEquals("fixed", out.content) + assertEquals("url https://example.invalid/x", out.tags.value("imeta")) + } + + @Test + fun `a deletion carries no content and does not name a recipient`() { + val out = CordnMessageReferences.outbound("ignored", deleteTo = target()) + assertEquals(CordnMessageKinds.DELETION, out.kind) + assertEquals("", out.content) + assertNull(out.tags.value("p"), "a deletion names what is removed, not who to notify") + assertEquals("9", out.tags.value("k")) + } + + @Test + fun `a pin carries an op tag and defaults to add`() { + val add = CordnMessageReferences.outbound("", pinTo = target()) + assertEquals(CordnMessageKinds.PIN, add.kind) + assertEquals("add", add.tags.value("op")) + + val remove = CordnMessageReferences.outbound("", pinTo = target(), pinOp = PinOp.REMOVE) + assertEquals("remove", remove.tags.value("op")) + } + + // ---- inbound ------------------------------------------------------- + + @Test + fun `a reaction needs kind 7, e p k, and content`() { + val good = CordnMessageReferences.reactionTags(target()) + assertNotNull(CordnMessageReferences.reaction(7, "+", good)) + + assertNull(CordnMessageReferences.reaction(9, "+", good), "wrong kind") + assertNull(CordnMessageReferences.reaction(7, " ", good), "a blank reaction is an invisible chip") + assertNull(CordnMessageReferences.reaction(7, "+", arrayOf(arrayOf("e", "1".repeat(64)))), "no p or k") + assertNull( + CordnMessageReferences.reaction(7, "+", arrayOf(arrayOf("e", "x"), arrayOf("p", alice), arrayOf("k", "nine"))), + "a non-numeric k is not a kind", + ) + } + + @Test + fun `a thread needs both the uppercase root and the lowercase parent`() { + val full = CordnMessageReferences.replyTags(target()) + assertNotNull(CordnMessageReferences.thread(full)) + + // Half a thread tag is treated as no thread: guessing reparents a reply + // under the wrong message, which is worse than showing it unthreaded. + assertNull(CordnMessageReferences.thread(full.filterNot { it[0] == "E" }.toTypedArray())) + assertNull(CordnMessageReferences.thread(full.filterNot { it[0] == "k" }.toTypedArray())) + assertNull(CordnMessageReferences.thread(emptyArray())) + } + + @Test + fun `a thread pubkey falls back to the e-tag's fourth element`() { + val tags = + arrayOf( + arrayOf("E", "9".repeat(64), "", alice), + arrayOf("K", "9"), + arrayOf("e", "2".repeat(64), "", bob), + arrayOf("k", "9"), + ) + val thread = assertNotNull(CordnMessageReferences.thread(tags)) + assertEquals(alice, thread.rootPubKey) + assertEquals(bob, thread.parentPubKey) + } + + @Test + fun `an edit needs kind 1010, an e tag, and text`() { + val tags = CordnMessageReferences.editTags(target()) + assertEquals("1".repeat(64), assertNotNull(CordnMessageReferences.edit(1010, "new", tags)).targetId) + + assertNull(CordnMessageReferences.edit(1009, "new", tags), "1009 is Marmot's edit kind, not cordn's") + assertNull(CordnMessageReferences.edit(1010, " ", tags), "an edit to nothing is a deletion") + assertNull(CordnMessageReferences.edit(1010, "new", emptyArray())) + } + + @Test + fun `a deletion needs kind 5 with e and a numeric k`() { + val tags = CordnMessageReferences.deleteTags(target()) + val ref = assertNotNull(CordnMessageReferences.delete(5, tags)) + assertEquals(CordnMessageKinds.TEXT, ref.targetKind) + + assertNull(CordnMessageReferences.delete(9, tags)) + assertNull(CordnMessageReferences.delete(5, arrayOf(arrayOf("e", "1".repeat(64)))), "no k") + } + + @Test + fun `a pin needs kind 1011, an e tag, and a known op`() { + assertEquals( + PinOp.ADD, + assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(target(), PinOp.ADD))).op, + ) + assertEquals( + PinOp.REMOVE, + assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(target(), PinOp.REMOVE))).op, + ) + + assertNull( + CordnMessageReferences.pin(1011, arrayOf(arrayOf("e", "1".repeat(64)), arrayOf("op", "toggle"))), + "an unknown op is neither a pin nor an unpin", + ) + assertNull(CordnMessageReferences.pin(1011, arrayOf(arrayOf("e", "1".repeat(64)))), "no op") + } + + @Test + fun `every builder produces tags its own parser accepts`() { + // The failure this guards is a client that emits what it cannot read. + val t = target() + assertNotNull(CordnMessageReferences.thread(CordnMessageReferences.replyTags(t))) + assertNotNull(CordnMessageReferences.reaction(7, "+", CordnMessageReferences.reactionTags(t))) + assertNotNull(CordnMessageReferences.edit(1010, "x", CordnMessageReferences.editTags(t))) + assertNotNull(CordnMessageReferences.delete(5, CordnMessageReferences.deleteTags(t))) + assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(t, PinOp.ADD))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupInboxTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupInboxTest.kt new file mode 100644 index 0000000000..ee3b01e99f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupInboxTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The ingestion rules, which are the part of cordn where a mistake is silent. + * + * Every wrong answer here produces a client that works until it doesn't: an + * epoch applied twice desynchronizes MLS several commits later, and a cursor + * that refuses to advance stalls a group forever with no error anywhere. + */ +class GroupInboxTest { + private var cursor = 0L + + private fun msg( + sealed: String, + gid: String = "g1", + ) = GroupMessage(gid = gid, cursor = ++cursor, sealedBase64 = sealed, at = 1_700_000_000L + cursor) + + @Test + fun ourOwnCommitComesBackAsConfirmationNotAsWork() { + // The failure this prevents: feeding our own Commit through MLS again + // advances the epoch twice, and nothing complains until messages stop + // decrypting several epochs later. + val inbox = GroupInbox() + val commit = msg("Y29tbWl0") + inbox.expectEcho(PendingEpochOperation(commit.sealedBase64, localStateApplied = true)) + + assertIs(inbox.accept(commit)) + } + + @Test + fun anUnappliedCommitEchoIsWorkNotConfirmation() { + // Crash recovery: we posted, the coordinator took it, we died before + // adopting the new epoch. That echo is the only copy of the Commit we + // will ever be handed. + val inbox = GroupInbox() + val commit = msg("Y29tbWl0") + inbox.expectEcho(PendingEpochOperation(commit.sealedBase64, localStateApplied = false)) + + assertIs(inbox.accept(commit)) + } + + @Test + fun echoesAreMatchedOnCiphertextNotOnCursor() { + // spec/03.md §4 requires a fresh nonce per payload, so the sealed form + // is unique to one posting. Matching on cursor would break the moment a + // coordinator renumbered; matching on plaintext would mean decrypting + // our own traffic to recognise it. + val inbox = GroupInbox() + val mine = msg("bWluZQ==") + inbox.expectEcho(PendingEpochOperation(mine.sealedBase64)) + + // Same cursor space, different bytes: somebody else's Commit. + assertIs(inbox.accept(msg("dGhlaXJz"))) + // Ours, whenever it shows up. + assertIs(inbox.accept(mine.copy(cursor = ++cursor))) + } + + @Test + fun anEchoIsConsumedOnceSoAReplayIsStillProcessed() { + // A coordinator that served the same record twice must not be able to + // make us skip a genuine Commit that happens to repeat bytes. + val inbox = GroupInbox() + val commit = msg("Y29tbWl0") + inbox.expectEcho(PendingEpochOperation(commit.sealedBase64)) + + assertIs(inbox.accept(commit)) + assertIs(inbox.accept(commit.copy(cursor = ++cursor))) + } + + @Test + fun theCursorAdvancesOnEveryOutcome() { + // Including the ones that do no work. A cursor that only moved for + // messages we processed would re-fetch every skipped one forever. + val inbox = GroupInbox() + val mine = msg("bWluZQ==") + inbox.expectEcho(PendingEpochOperation(mine.sealedBase64)) + inbox.accept(mine) + assertEquals(mine.cursor, inbox.cursor.fetchCursor) + + val own = msg("b3du") + inbox.recordOwnMessage(own.cursor) + inbox.accept(own) + assertEquals(own.cursor, inbox.cursor.fetchCursor) + + val theirs = msg("dGhlaXJz") + inbox.accept(theirs) + assertEquals(theirs.cursor, inbox.cursor.fetchCursor) + } + + @Test + fun anUndecryptableMessageStillAdvancesTheCursor() { + // A message sealed under an epoch we never had is unreadable forever. + // Refusing to move past it stalls the group behind it permanently. + val inbox = GroupInbox() + val stale = msg("dW5yZWFkYWJsZQ==") + assertIs(inbox.accept(stale)) + inbox.skipUnprocessable(stale.cursor) + assertEquals(stale.cursor, inbox.cursor.fetchCursor) + } + + @Test + fun lastCursorNeverGoesBackwards() { + val inbox = GroupInbox() + inbox.accept(msg("YQ==").copy(cursor = 10)) + inbox.accept(msg("Yg==").copy(cursor = 4)) + assertEquals(10L, inbox.cursor.lastCursor, "lastCursor is a high-water mark") + assertEquals(4L, inbox.cursor.fetchCursor, "fetchCursor follows delivery order") + } + + @Test + fun aFirstFetchSendsNoCursorAtAll() { + // The coordinator's schema types `after` as a POSITIVE int, so 0 is not + // "from the beginning" -- it is out of range. + assertEquals(null, GroupCursor().afterOrNull()) + assertEquals(7L, GroupCursor(fetchCursor = 7).afterOrNull()) + } + + @Test + fun ourOwnApplicationMessagesAreRecognisedByCursor() { + val inbox = GroupInbox() + val sent = msg("aGVsbG8=") + inbox.recordOwnMessage(sent.cursor) + assertIs(inbox.accept(sent)) + assertTrue(inbox.pending().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt index 6af28d836a..e89867717e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt index 9c5ec40a54..e482fd29d1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngineAuthorizationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngineAuthorizationTest.kt new file mode 100644 index 0000000000..af4443fa60 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngineAuthorizationTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.protocolCore + +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The convergence engine's authorization gate, over real MLS groups. + * + * `MlsCandidateGraphTest` proves the graph algebra; this proves the one thing + * that decides whether a fork is *allowed* rather than merely well formed. + * + * It exists because the gate was silently off. MIP-03's rules used to live + * inside `MlsGroup` and ran on every path by construction; moving them behind + * `MlsGroupPolicy` made them something a caller has to ask for, and + * `MlsGroup.restore` defaults to `Permissive`, whose `authorizeCommit` does + * nothing. The engine restored without a policy, so `isAuthorized` returned + * true for every commit it could parse — a non-admin could remove an admin and + * convergence would accept the branch. + * + * Nothing failed while it was broken: the gate only says *no* to commits no + * honest client sends, so every existing test passed either way. That is the + * shape of bug this file is here to catch. + */ +class MlsCandidateStateEngineAuthorizationTest { + private val engine = MlsCandidateStateEngine() + + private fun account(seed: Byte) = ByteArray(32) { seed } + + /** + * A group whose only admin is Alice, with Bob as an ordinary member. + * + * Built through `MarmotGroupPolicy` rather than the default so the group + * is the one Marmot actually runs; a group created permissively would not + * carry the rules the gate reads. + */ + private fun adminGroup(): Pair { + val aliceId = account(0x0a) + val groupData = + MarmotGroupData( + nostrGroupId = ByteArray(32) { 0x11 }.toHexKey(), + name = "Admins", + adminPubkeys = listOf(aliceId.toHexKey()), + ) + + val alice = + MlsGroup.create( + identity = aliceId, + initialExtensions = listOf(groupData.toExtension()), + policy = MarmotGroupPolicy, + ) + val bobBundle = alice.createKeyPackage(identity = account(0x0b), signingKey = ByteArray(32) { 1 }) + val addBob = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Bob is restored WITHOUT Marmot's policy on purpose. The local gate + // refuses to author an unauthorized commit, which is correct and is + // why this fixture cannot use it: convergence exists to judge commits + // that arrived from somebody else's client, and a hostile or merely + // non-compliant one does not censor itself. A permissive Bob is the + // honest stand-in for that peer. + val bob = MlsGroup.processWelcome(addBob.welcomeBytes!!, bobBundle) + + return alice to bob + } + + @Test + fun `the group really does name one admin`() { + // Guards the fixture, not the engine. If the admin set came back empty + // the test below would pass through isAuthorized's bootstrap + // short-circuit and prove nothing at all. + val (alice, _) = adminGroup() + assertTrue(MarmotGroupPolicy.adminIdentitiesIn(alice.extensions).isNotEmpty(), "fixture names no admin") + } + + @Test + fun `a non-admin may not commit the removal of an admin`() { + val (alice, bob) = adminGroup() + val parent = alice.saveState() + + // Bob is an ordinary member. Removing Alice is exactly the change + // MIP-03 reserves to admins, and the admin-depletion guard refuses it + // besides — she is the only one. + val bobRemovesAlice = bob.removeMember(targetLeafIndex = 0) + + assertFalse( + engine.isAuthorized(parent, bobRemovesAlice.framedCommitBytes), + "a non-admin removing the only admin must not be authorized", + ) + } + + @Test + fun `an admin may commit the same kind of change`() { + // The other half: a gate that says no to everything would also satisfy + // the test above, so prove it still says yes where it should. Alice + // authors through her own (Marmot) policy, which is the real path. + val (alice, _) = adminGroup() + val parent = alice.saveState() + val aliceRemovesBob = MlsGroup.restore(parent, MarmotGroupPolicy).removeMember(targetLeafIndex = 1) + + assertTrue( + engine.isAuthorized(parent, aliceRemovesBob.framedCommitBytes), + "the admin's own removal of an ordinary member must stay authorized", + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/BinaryTreeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/BinaryTreeTest.kt similarity index 99% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/BinaryTreeTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/BinaryTreeTest.kt index 95923a6c3f..3209318fa3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/BinaryTreeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/BinaryTreeTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.BinaryTree import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/KeyScheduleTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/KeyScheduleTest.kt index a74c41b7a2..92ab553f5d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/KeyScheduleTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.schedule.KeySchedule import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsTypesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/MlsTypesTest.kt similarity index 88% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsTypesTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/MlsTypesTest.kt index 8d4a7c864e..f1c712a91a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsTypesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/MlsTypesTest.kt @@ -18,24 +18,24 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.messages.ProposalOrRef -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime -import com.vitorpamplona.quartz.marmot.mls.tree.ParentNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.messages.ProposalOrRef +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.LeafNodeSource +import com.vitorpamplona.quartz.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.tree.ParentNode import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TlsCodecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/TlsCodecTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TlsCodecTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/TlsCodecTest.kt index 3f56f4637d..b685e4b6f0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TlsCodecTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/TlsCodecTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupNegativeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupNegativeTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt index 4ebd900fe1..6f41f3911f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupNegativeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/CryptoBasicsInteropTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/CryptoBasicsInteropTest.kt index a4df60ed34..93ed531265 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/CryptoBasicsInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/KeyScheduleInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/KeyScheduleInteropTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/KeyScheduleInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/KeyScheduleInteropTest.kt index 9bf86aa232..136a815c47 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/KeyScheduleInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/KeyScheduleInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.schedule.KeySchedule import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MessageSerializationInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MessageSerializationInteropTest.kt similarity index 94% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MessageSerializationInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MessageSerializationInteropTest.kt index cb66e138b6..a7075c5a2f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MessageSerializationInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MessageSerializationInteropTest.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MlsInteropVectors.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MlsInteropVectors.kt similarity index 99% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MlsInteropVectors.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MlsInteropVectors.kt index ccc4b09645..7eca2558be 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MlsInteropVectors.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MlsInteropVectors.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/PassiveClientInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/PassiveClientInteropTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/PassiveClientInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/PassiveClientInteropTest.kt index abc82d58ca..41f8d77679 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/PassiveClientInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/PassiveClientInteropTest.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/SecretTreeInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/SecretTreeInteropTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/SecretTreeInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/SecretTreeInteropTest.kt index a3c3537c4a..b2fec38ca9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/SecretTreeInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/SecretTreeInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree +import com.vitorpamplona.quartz.mls.schedule.SecretTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TranscriptHashInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TranscriptHashInteropTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TranscriptHashInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TranscriptHashInteropTest.kt index 5695c4d1a2..a6e1cfb510 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TranscriptHashInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TranscriptHashInteropTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeKemInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeKemInteropTest.kt similarity index 94% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeKemInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeKemInteropTest.kt index e3ff383ca4..9a1c326d02 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeKemInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeKemInteropTest.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeMathInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeMathInteropTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeMathInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeMathInteropTest.kt index 9b29f57f66..92e64bebfa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeMathInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeMathInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.BinaryTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeOperationsInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt similarity index 95% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeOperationsInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt index b65b69de0b..b18acc5349 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeOperationsInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree -import com.vitorpamplona.quartz.marmot.mls.tree.TreeNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.tree.TreeNode import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeValidationInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeValidationInteropTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeValidationInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeValidationInteropTest.kt index dabb75ed9d..bbd2dddb4a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeValidationInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeValidationInteropTest.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/WelcomeInteropTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/WelcomeInteropTest.kt index e87e4425a3..15dea6ccee 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/WelcomeInteropTest.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo -import com.vitorpamplona.quartz.marmot.mls.messages.GroupSecrets -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.GroupInfo +import com.vitorpamplona.quartz.mls.messages.GroupSecrets +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt new file mode 100644 index 0000000000..6507de40f4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `batchInsert` must name *why* a row didn't land, because the relay turns + * that reason into the NIP-01 answer: [RejectionReason.DUPLICATE] and + * [RejectionReason.SUPERSEDED] are `OK true` ("already covered"), everything + * else is `OK false`, which clients retry. + * + * This suite pins the classification **independently of the SQLite driver's + * exception text**. The bundled JVM driver raises + * `UNIQUE constraint failed: event_headers.id`; Android's raises an + * `android.database.SQLException` whose message is `null`. Reading the text + * was therefore enough on one target and wrong on the other — a duplicate + * came back as `error: SQLException`, an `OK false` the client re-offers + * forever. Every case below runs on both targets and so fails on either if + * the classifier ever goes back to trusting a message. + */ +class InsertOutcomeClassificationTest : BaseDBTest() { + val signer = NostrSignerSync() + + private fun assertRejected( + expectedReason: String, + outcome: IEventStore.InsertOutcome, + ) { + assertTrue(outcome is IEventStore.InsertOutcome.Rejected, "expected Rejected, got $outcome") + assertEquals(expectedReason, outcome.reason) + } + + @Test + fun duplicateIdIsRejectedAsDuplicate() = + forEachDB { db -> + val event = signer.sign(TextNoteEvent.build("hello", createdAt = TimeUtils.now())) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(event))[0]) + assertRejected(RejectionReason.DUPLICATE, db.batchInsert(listOf(event))[0]) + } + + @Test + fun reofferingAStoredReplaceableIsRejectedAsDuplicate() = + forEachDB { db -> + // Byte-for-byte the stored version, so it violates the id index *and* + // replaceable_idx — and which one SQLite reports first is the driver's + // choice. "Already have this event" is the answer that holds on all of + // them. + val event = signer.sign(MetadataEvent.createNew("Vitor", createdAt = TimeUtils.now())) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(event))[0]) + assertRejected(RejectionReason.DUPLICATE, db.batchInsert(listOf(event))[0]) + } + + @Test + fun olderReplaceableIsRejectedAsSuperseded() = + forEachDB { db -> + val time = TimeUtils.now() + val older = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val newer = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) + assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(older))[0]) + } + + @Test + fun sameSecondReplaceableTieLoserIsRejectedAsSuperseded() = + forEachDB { db -> + val time = TimeUtils.now() + // NIP-01 breaks a created_at tie by lowest id, so the winner is + // decided by sorting, not by insertion order. + val (winner, loser) = + listOf( + signer.sign(MetadataEvent.createNew("Vitor A", createdAt = time)), + signer.sign(MetadataEvent.createNew("Vitor B", createdAt = time)), + ).sortedBy { it.id } + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(winner))[0]) + assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(loser))[0]) + } + + @Test + fun olderAddressableIsRejectedAsSuperseded() = + forEachDB { db -> + val time = TimeUtils.now() + val older = signer.sign(LongTextNoteEvent.build("v1", "title", dTag = "blog", createdAt = time)) + val newer = signer.sign(LongTextNoteEvent.build("v2", "title", dTag = "blog", createdAt = time + 1)) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) + assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(older))[0]) + } + + @Test + fun aNewerVersionStillLandsAtAnOccupiedCoordinate() = + forEachDB { db -> + // The guard against the classifier over-claiming: a coordinate is + // occupied here too, but this version wins, so nothing is rejected. + val time = TimeUtils.now() + val older = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val newer = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(older))[0]) + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderWaveformTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderWaveformTest.kt new file mode 100644 index 0000000000..80c83fcf10 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderWaveformTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files + +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The waveform an encrypted voice message carries. + * + * The renderer asks the event for this to decide between drawing real bars and + * a bare transport, so the tag name has to keep matching what a sender writes — + * which is what these assert, rather than the parser, which is WaveformTag's. + */ +class ChatMessageEncryptedFileHeaderWaveformTest { + private fun event(vararg tags: Array) = + ChatMessageEncryptedFileHeaderEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + tags = arrayOf(*tags), + content = "https://blossom.example/abc", + sig = "sig", + ) + + @Test + fun readsTheWaveformASenderAttached() { + val wave = listOf(0.0f, 0.5f, 1.0f) + + assertEquals(wave, event(WaveformTag.assemble(wave)).waveform()) + } + + @Test + fun aFileWithNoWaveformHasNone() { + assertNull(event(arrayOf("m", "audio/mp4")).waveform()) + } + + @Test + fun anUnparseableWaveformIsNoWaveformRatherThanACrash() { + // A peer can put anything in a tag, and a voice message that fails to + // render at all is worse than one that renders without bars. + assertNull(event(arrayOf(WaveformTag.TAG_NAME, "not json")).waveform()) + assertNull(event(arrayOf(WaveformTag.TAG_NAME, "[]")).waveform()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalizationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalizationTest.kt new file mode 100644 index 0000000000..ca871a7eeb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalizationTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.jcs + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * RFC 8785 conformance. + * + * The number cases are the ones that matter: they are where an implementation + * that "works" silently produces a different hash from every other one. + */ +class JsonCanonicalizationTest { + @Test + fun `sorts object keys by UTF-16 code unit`() { + val input = linkedMapOf("b" to 1, "a" to 2, "C" to 3, "ä" to 4) + // Uppercase sorts before lowercase, and non-ASCII after both. + assertEquals("""{"C":3,"a":2,"b":1,"ä":4}""", JsonCanonicalization.canonicalize(input)) + } + + @Test + fun `sorts nested objects too`() { + val input = mapOf("z" to linkedMapOf("y" to 1, "x" to 2)) + assertEquals("""{"z":{"x":2,"y":1}}""", JsonCanonicalization.canonicalize(input)) + } + + @Test + fun `preserves array order`() { + assertEquals("""[3,1,2]""", JsonCanonicalization.canonicalize(listOf(3, 1, 2))) + } + + @Test + fun `emits no insignificant whitespace`() { + val input = mapOf("a" to listOf(1, mapOf("b" to true)), "c" to null) + assertEquals("""{"a":[1,{"b":true}],"c":null}""", JsonCanonicalization.canonicalize(input)) + } + + @Test + fun `escapes only what RFC 8785 requires`() { + val input = + mapOf( + "k" to + "a\"b\\c\nd\te" + '\u0008' + "f" + '\u000C' + "g\rh" + '\u0001' + "i", + ) + assertEquals( + "{\"k\":\"a\\\"b\\\\c\\nd\\te\\bf\\fg\\rh\\u0001i\"}", + JsonCanonicalization.canonicalize(input), + ) + } + + @Test + fun `leaves non-ASCII literal rather than escaping it`() { + // The canonical form is UTF-8. Escaping to \u would be a different + // byte sequence and therefore a different hash. + assertEquals("""{"k":"héllo → 🚀"}""", JsonCanonicalization.canonicalize(mapOf("k" to "héllo → 🚀"))) + } + + // --- numbers: ECMAScript Number::toString --- + + @Test + fun `renders integral values without a decimal point`() { + assertEquals("0", JsonCanonicalization.canonicalNumber(0.0)) + assertEquals("1", JsonCanonicalization.canonicalNumber(1.0)) + assertEquals("123", JsonCanonicalization.canonicalNumber(123.0)) + assertEquals("-123", JsonCanonicalization.canonicalNumber(-123.0)) + } + + @Test + fun `renders negative zero as zero`() { + // ECMAScript prints both zeroes as "0", so they canonicalize identically. + assertEquals("0", JsonCanonicalization.canonicalNumber(-0.0)) + } + + @Test + fun `renders fractions plainly inside the non-exponential range`() { + assertEquals("1.5", JsonCanonicalization.canonicalNumber(1.5)) + assertEquals("0.5", JsonCanonicalization.canonicalNumber(0.5)) + assertEquals("-0.5", JsonCanonicalization.canonicalNumber(-0.5)) + assertEquals("0.000001", JsonCanonicalization.canonicalNumber(0.000001)) + } + + @Test + fun `switches to exponential below 1e-6`() { + // The boundary ECMAScript defines: 1e-6 prints plainly, 1e-7 does not. + assertEquals("1e-7", JsonCanonicalization.canonicalNumber(1e-7)) + assertEquals("1.5e-7", JsonCanonicalization.canonicalNumber(1.5e-7)) + } + + @Test + fun `switches to exponential at 1e21 and carries a plus sign`() { + // JVM toString gives "1.0E21"; ECMAScript and therefore JCS want "1e+21". + assertEquals("1e+21", JsonCanonicalization.canonicalNumber(1e21)) + assertEquals("1e+30", JsonCanonicalization.canonicalNumber(1e30)) + assertEquals("1.5e+30", JsonCanonicalization.canonicalNumber(1.5e30)) + } + + @Test + fun `prints 1e20 plainly because it is still inside the range`() { + assertEquals("100000000000000000000", JsonCanonicalization.canonicalNumber(1e20)) + } + + @Test + fun `renders the extremes of the double range`() { + assertEquals("5e-324", JsonCanonicalization.canonicalNumber(Double.MIN_VALUE)) + assertEquals("1.7976931348623157e+308", JsonCanonicalization.canonicalNumber(Double.MAX_VALUE)) + } + + @Test + fun `renders values that need every significant digit`() { + assertEquals("0.1", JsonCanonicalization.canonicalNumber(0.1)) + assertEquals("0.30000000000000004", JsonCanonicalization.canonicalNumber(0.1 + 0.2)) + assertEquals("9007199254740991", JsonCanonicalization.canonicalNumber(9007199254740991.0)) + } + + @Test + fun `integers arrive through the same path as doubles`() { + assertEquals("""{"a":1,"b":2}""", JsonCanonicalization.canonicalize(mapOf("a" to 1, "b" to 2L))) + } + + @Test + fun `rejects values JSON cannot represent`() { + assertFailsWith { JsonCanonicalization.canonicalNumber(Double.NaN) } + assertFailsWith { + JsonCanonicalization.canonicalNumber(Double.POSITIVE_INFINITY) + } + } + + @Test + fun `rejects a non-string object key`() { + assertFailsWith { + JsonCanonicalization.canonicalize(mapOf(1 to "a")) + } + } + + @Test + fun `rejects a type it cannot represent`() { + assertFailsWith { + JsonCanonicalization.canonicalize(mapOf("k" to Any())) + } + } + + @Test + fun `canonicalizes the RFC 8785 number sample`() { + // The number array from RFC 8785's worked example. Each entry exercises a + // different branch: full significant digits, the upper exponential + // boundary, a stripped trailing zero, a small plain fraction, and the + // lower exponential boundary. + val input = + mapOf( + "numbers" to listOf(333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001), + ) + assertEquals( + """{"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27]}""", + JsonCanonicalization.canonicalize(input), + ) + } + + @Test + fun `escapes a dollar sign and a solidus literally`() { + // Neither has a short escape in RFC 8785, and the solidus is explicitly + // NOT escaped even though JSON permits it. + assertEquals("{\"k\":\"$100/mo\"}", JsonCanonicalization.canonicalize(mapOf("k" to "\u0024100/mo"))) + } +} diff --git a/quartz/src/commonTest/resources/cordn/coordinator-contracts.json b/quartz/src/commonTest/resources/cordn/coordinator-contracts.json new file mode 100644 index 0000000000..07d89923e7 --- /dev/null +++ b/quartz/src/commonTest/resources/cordn/coordinator-contracts.json @@ -0,0 +1,386 @@ +{ + "_comment": "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.", + "generator": { + "cordnCore": "0.5.5" + }, + "methods": { + "publishKeyPackage": "kp_publish", + "listAvailableKeyPackages": "kp_list", + "consumeKeyPackage": "kp_take", + "removeKeyPackages": "kp_remove", + "fetchPendingWelcomes": "welcome_take", + "storeWelcome": "welcome_store", + "storeJoinRequest": "join_request_store", + "fetchManyPendingJoinRequests": "join_request_take_many", + "postGroupMessage": "msg_post", + "fetchManyGroupMessages": "msg_fetch_many", + "subscribeManyGroupMessages": "msg_sub_many" + }, + "contracts": { + "kp_publish": { + "input": { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "kp_64": "AAECAwQFBgc=" + }, + "output": { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "last_resort": false, + "at": 1757000000 + }, + "rejects": [ + { + "kp_64": "AAECAwQFBgc=" + }, + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "keyPackageBase64": "AAECAwQFBgc=" + } + ] + }, + "kp_list": { + "input": {}, + "output": { + "keyPackages": [ + { + "pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "last_resort": false, + "at": 1757000000 + }, + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100 + } + ] + }, + "rejects": [ + { + "keyPackages": [ + { + "pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "at": 1757000000 + } + ] + } + ] + }, + "kp_take": { + "input": { + "id": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + }, + "output": { + "keyPackage": { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100, + "event": { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": 1757000100, + "kind": 25910, + "tags": [ + [ + "p", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ], + "content": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"kp_publish\",\"arguments\":{\"kp_ref\":\"2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e\",\"kp_64\":\"AAECAwQFBgc=\"}}}", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "emptyOutput": { + "keyPackage": null + }, + "rejects": [ + { + "keyPackage": { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100 + } + } + ] + }, + "kp_remove": { + "input": { + "kp_refs": [ + "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e" + ] + }, + "output": { + "kp_refs": [ + "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + ] + }, + "rejects": [ + { + "kp_refs": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + }, + "welcome_take": { + "input": {}, + "inputWithConsumed": { + "consumed": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "at": 1757000000 + } + ] + }, + "output": { + "welcomes": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "welcome_64": "V2VsY29tZQ==", + "at": 1757000200, + "after": 12 + }, + { + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZTI=", + "at": 1757000300 + } + ] + }, + "rejects": [ + { + "consumed": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + } + ] + }, + "welcome_store": { + "input": { + "target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==", + "after": 12 + }, + "inputWithoutAfter": { + "target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==" + }, + "output": { + "at": 1757000200 + }, + "rejects": [ + { + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==" + } + ] + }, + "join_request_store": { + "input": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + }, + "output": { + "at": 1757000400 + }, + "rejects": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + }, + "join_request_take_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "inputWithConsumed": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ], + "consumed": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "at": 1757000400 + } + ] + }, + "output": { + "requests": [ + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "at": 1757000400, + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "rejects": [ + { + "requests": [ + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "at": 1757000400 + } + ] + }, + { + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + } + ] + }, + "msg_post": { + "input": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "msg_64": "c2VhbGVk" + }, + "output": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 42, + "at": 1757000500 + }, + "rejects": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "msg_fetch_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "inputWithCursor": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": 42 + } + ] + }, + "output": { + "messages": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 43, + "msg_64": "c2VhbGVkLTE=", + "at": 1757000600 + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 44, + "msg_64": "c2VhbGVkLTI=", + "at": 1757000700 + } + ] + }, + "rejects": [ + { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": "42" + } + ] + } + ] + }, + "msg_sub_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": 42 + } + ] + }, + "output": { + "subscribed": true, + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + }, + "streamFragment": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 45, + "msg_64": "c2VhbGVkLTM=", + "at": 1757000800 + }, + "rejects": [ + { + "subscribed": false, + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + } + ] + } + }, + "groupRefs": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "encoded": "cordn1qqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerz8f868g" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "encoded": "cordn1qysvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenqqysmxgvtxxpnrvcfdxfsnxefdx3nryced89snzepdxa3nvc34v56xgvmpxgcs59pdwr" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": [ + "wss://relay.example.com/" + ], + "encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uqjpnxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvqqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerzxaqmx8" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": [ + "wss://relay.example.com/", + "wss://relay2.example.com/" + ], + "encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uppjamnwvaz7tmjv4kxz7fj9ejhsctdwpkx2tnrdakj7qfqenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxqqfpkvsckvvrxxesj6vnpxdjj6drxxf3j6wtpx9jz6dmrxe3r2ef5vsekzv33f7g84r" + }, + { + "gid": "a", + "encoded": "cordn1qqqkzhjmr98" + }, + { + "gid": "grupo-café-éàü", + "encoded": "cordn1qqfxwun4wphj6cmpvmp6jtwr48p6psauqarqhy" + } + ], + "groupRefUppercase": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "encoded": "CORDN1QYSVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENQQYSMXGVTXXPNRVCFDXFSNXEFDX3NRYCED89SNZEPDXA3NVC34V56XGVMPXGCS59PDWR" + }, + "groupRefRejects": [ + "cordn1qqqqq", + "nostr1qqqqq", + "CORDN1QQQQQ", + "cordn", + "" + ] +} diff --git a/quartz/src/commonTest/resources/tsmls/README.md b/quartz/src/commonTest/resources/tsmls/README.md new file mode 100644 index 0000000000..adb9ff0c4e --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/README.md @@ -0,0 +1,35 @@ +# ts-mls interop fixtures + +Generated by **ts-mls** (the reference MLS implementation cordn's own client +uses), not by us. They are the cross-implementation half of +`spec00Coordinator`/`spec03Payloads` testing: everything else in this module +proves we agree with ourselves. + +Copied from **Staircase** — , +`conformance/fixtures/gen/`, commit `d9dd1a0` — which is MIT licensed +(Copyright (c) 2026 relay.tools). The generator that produced them is +`conformance/fixtures-gen/gen.ts` in that repository. + +Only the files our tests read are vendored; the originals include multi-device, +media and three-member fixtures we have no use for yet. + +## The lifecycle these describe + +Alice (`aa…aa`) creates a group with metadata, adds Bob (`bb…bb`), and sends an +application message. `gid` is the delivery id. + +| File | What | +| ---- | ---- | +| `alice.pk`, `bob.pk`, `gid` | the actors and the delivery group id | +| `bob-kp.bin`, `bob-privkp.bin`, `bob-kpref.hex` | Bob's KeyPackage, its private half in ts-mls's `privateKeyPackageEncoder` layout, and its RFC 9420 KeyPackageRef | +| `bob-lastresort-kp.bin`, `bob-lastresort-kpref.hex` | the same marked last-resort, via `app_data_dictionary` component `0x0004` | +| `meta-1.json` | the `cordn_group_metadata` Alice created the group with | +| `exporter-e0.hex` | `MLS-Exporter("cordn","group-payload",32)` at epoch 0 | +| `commit-add.b64`, `commit-add-sealed.b64` | the Add commit, raw and sealed under the PRE-commit epoch key | +| `welcome.b64` | the Welcome that admits Bob | +| `exporter-e1.hex` | the same exporter at epoch 1 | +| `app-1.b64`, `app-1-sealed.b64`, `envelope-1.json` | Alice's application message: MLS bytes, sealed form, and the envelope inside it | + +Note `app-1` carries `authenticated_data` = UTF-8 of Alice's pubkey. cordn +rejects an application message without it, and the spec never mentions it — see +`spec02Envelopes/CordnApplicationMessage`. diff --git a/quartz/src/commonTest/resources/tsmls/alice.pk b/quartz/src/commonTest/resources/tsmls/alice.pk new file mode 100644 index 0000000000..71b7a71962 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/alice.pk @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-1-sealed.b64 b/quartz/src/commonTest/resources/tsmls/app-1-sealed.b64 new file mode 100644 index 0000000000..854339faff --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-1-sealed.b64 @@ -0,0 +1 @@ +Vx7fA90kNPdkQTos2EUfMxTQyaxr31Z8Du+6xHDZW+EFSAIWBeWx7cYYk/o+f9hkX0WpRTIJAW18xlMSHZqaNJZTdKlKLA1BXo74LlmWkF2CklFSWzFCrVAZkdXLEFWv37CA/jqyx5vj92iECOFpPUjHupu/frnJs1SAsFnp+YVp8aejAf11BcaBIcJOOXhn8gy0vcK9BUSq5YEuSswWXFAdG/R8NmSV59f1+Juh0tr1/eJLeosiQH2jp/42k3yAfjRTQqAXxKOko/yqhmVyZzu/j/Mvu3xlVDEj8L4QrS8BshyYwmpRHCyQUNdCVGAkXcVcE5pUtqC7uqTrGZ2C0kA3AQvevSoFc4m+juTKzn/lYNrFmShNKQYuY2Ztv0XBXNnSHk46rOLcagB7tpHfBAZV12TDlfUBMuX1WgiI8vw5HTIuNHANIENo/ETNrTgT8mW6zM2WZBfoR3HJXfQWrmAtRsgsZ0mj1kwav+J8pMCYTRdnQeKNRuFq/NePSH3xRA7LREaQj3EafaYVSjP/cGDOOg3TFRdp8BSUrYqVN07FKyssDQkB/9ktCxUOWEkfZiI+1ScVa+XGdXpuzLK6mh1RIK900zLyDUbLCTh4Vab65eqV1qOJppKGWkW+KanMlRsNt68Y/eLFuAfzb/Ck8E3+ERqLXnH57NoH4e5C4QfG4i/s8cT1mgzZhD50Z/LnIAj+LYLpu0sMgeJiJXTbsQ== \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-1.b64 b/quartz/src/commonTest/resources/tsmls/app-1.b64 new file mode 100644 index 0000000000..3e8a5b8716 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-1.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEcClw/xgK9wHoxslXko2Mtf6lsYARPc+TLlYT9qEF6ofy1MNiHjW4JdFsUYHHvJlehr2R4Z03Urnxq9RZeUxQ3+NwuJQj83Uu3ooow9VBYbbqvW5nAngQOzmnbtIt+GxzfxQWRQyHIkiJpNaoKMVmmbjDhWMQ6ZrXZNxmKHFTU5IT+jqcaF1yzbR5a+mbftI9vTYKrQwzfcVduVUFoUePKFb+4W6TR7AGM8FBxidJPaS0l+Q7WHaK5pinNi+q1bpk5O5TigivuypkJMsLBid3mbcYylQf7Vhz0UGSlHJX0qFvsZIflInpBdUWqbSZVcjIu6AQNqyE0mu1kIuZD229x4687PwxN0wEZkBmQ9k02SzwyBtFwnPp4scQpqiV7cZSMMfjA85Hm2MngZJZmftIGq66GPXPm9WWD/3Jfo/oh6Q4/Y2QoIvVkDC/uo60dudwsDcFnOgJ10XM3w50eyfbpLLxdd9GKtOYsmK6dGxiZl46tZ4fPSh+PLemtnleSP8Bdh4+jyfwmP0mt5p8amyY0fSZPOC2iwP2l \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-2-sealed.b64 b/quartz/src/commonTest/resources/tsmls/app-2-sealed.b64 new file mode 100644 index 0000000000..789bba0abc --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-2-sealed.b64 @@ -0,0 +1 @@ +vflv1/yY10H3C5/V8XD78iY30V4UCV1RvwGyDLdCHGvtpMDlUYfD4vhs6q1X9bAZP/Xi0+82AS1Jv9rwOAB0qvbsZI1PPM20mK8qGk3SXrBMDd0ZephmAFTuDAcw0ioSd67O1FUepBG/5uyMr/Zk9NCS87RCcXGtY0M0OzcDz34ERrlUwlxMM9d2gPMgZAs8b1FuQf4uaKbuTE9XYYqaSXRbPDDTZ5RX7LFMPo9hqxbkWd/J/CPGvx0nzd4E/e3TjD5GdySW9huEXWvSQ9ietfHtj2UB/my9JP3JhJtUtewTCyTHLGdcYUZJwjDukPPFVp1v8snYKeis1r021ZvQmwQCeW6zKxGnT1qqbCWfsN8dibF8dRP/iOkML6gkFq9TFohAjExq8AVhde5uBy7OUtudcWD+Pl7wEVbwUpbLu166KoiIIAOEIJ6cRV87dvjMYPP5t50bnTbf4P0BPHBeDcQ1wnJ2x+id6qzEREXe1B3hDOIDky5JGUyLzA7u8qeR0hPLZle0B+uhJaM6pqgNBHl1ZzPL1ifSa6ZK/kJTOH/nk8agM06RsAR42scieP5XTbIhxC/mjFyAvWxvCO0Sd05qPiVRL6xg82KGPs7pXfNcRsrziLpLRN229RxiPCt3x6JgjA9KLFN65jNnofcEtp/OR1lvimtd/HW6qb5vxpXayAUbO1TyPIyj/0GlmQUHs4NIGCTD0vTdgXHGtuE4ZDqXG+Xd+rtl25Q7JdYmM/wfyr6sq7Iv4s7kt7tXjaUwkWCodxVbFHTmbdAQAujOXFaymOcJdhXapZgUmp0hqI+NacgXeBbtEe+gB90elgSCMRJp3NUp \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-2.b64 b/quartz/src/commonTest/resources/tsmls/app-2.b64 new file mode 100644 index 0000000000..28081d449e --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-2.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAgFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEc9O2FAe3ZQqYftiKbAFK9k30vIRV5VdnsMbkGC0HQBhwc/60MQyixbwotWEqvz8i8J/Go608Y61remWpuyl6zbxQETHEnkLcMTJJuJSFZZyKQXFbE+Rnsj1JdBTB7P9PjtOgpTbsyvPO31D2MeMymblzXkjMMw/3hUXZVCoDKiWmIuS6AahAS1vmkEzvJjaTItFP/pkTIk3FmcREouSajlhio3L70BC02clU6sGcu68Kb3OulfviPDc0AwLuvD2sOfLctYI6CHQQs9eFHafA48yalsMfR0lZyFvrIpr8KNurzOW7qcDo0iqfnuHy5vFPlPwn3qKB/XxP30e+whdog060TgYdbvMCeWXEMYdPOuOgpcSXaTMZnXeqZZTyh/PPLWAbzmFrRsfQoRWmktBikW3/nZyE6j3c8FGywrMiq7/kE8AXkC70Wh2nnLwA3PTbzvqo3uVXq3J06YNhi+DUCgVNoEZfcK5fpTCDoo7CLBJFofhXrrccM4pPCpm8wkpEvyeLi97nKOhsBnAIjTif8zdrrN0yhZVvlePuCUhFRDtVbcN9hR+6R/3sXHDMZ0rvDlpG2QHeZ/OtXOouLUngOE1L9h+q5iuYAsI7cZeXmdiv2+4Ut2I4X8038bWrNwbjffD2vpVQCYMuIgkk9FEQ= \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob-kp.bin b/quartz/src/commonTest/resources/tsmls/bob-kp.bin new file mode 100644 index 0000000000..79106a1ee4 Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob-kp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob-kpref.hex b/quartz/src/commonTest/resources/tsmls/bob-kpref.hex new file mode 100644 index 0000000000..49ff3ea2c3 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob-kpref.hex @@ -0,0 +1 @@ +946816156873cffa538f63644a2d44624d48e8fbb6b0958c58531d80f0e75f33 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob-lastresort-kp.bin b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kp.bin new file mode 100644 index 0000000000..2d125d7aac Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob-lastresort-kpref.hex b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kpref.hex new file mode 100644 index 0000000000..17bcb50589 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kpref.hex @@ -0,0 +1 @@ +a8b705cda5b133634d31f4e2f4438186b25b91d23d159600663ba2560fc58ffd \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob-privkp.bin b/quartz/src/commonTest/resources/tsmls/bob-privkp.bin new file mode 100644 index 0000000000..962baaba15 Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob-privkp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob.pk b/quartz/src/commonTest/resources/tsmls/bob.pk new file mode 100644 index 0000000000..0e71009335 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob.pk @@ -0,0 +1 @@ +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob2-kp.bin b/quartz/src/commonTest/resources/tsmls/bob2-kp.bin new file mode 100644 index 0000000000..f38381c97e Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob2-kp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob2-kpref.hex b/quartz/src/commonTest/resources/tsmls/bob2-kpref.hex new file mode 100644 index 0000000000..5054894b76 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob2-kpref.hex @@ -0,0 +1 @@ +c58c248465ee592c041b961981be8080bc6e772a5743138dbf3ccee8c3c0fcdb \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob2-privkp.bin b/quartz/src/commonTest/resources/tsmls/bob2-privkp.bin new file mode 100644 index 0000000000..7ed2524b42 Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob2-privkp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/commit-add-sealed.b64 b/quartz/src/commonTest/resources/tsmls/commit-add-sealed.b64 new file mode 100644 index 0000000000..aeedfe4d25 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-add-sealed.b64 @@ -0,0 +1 @@ +U2UAiQ2F5QLquDurFnYpGoSEH7koKkwAKyz6Bxyxr8UULVcaPJoBwr5jq6UYnUR7vR3RhmwkIrjYqd85r/lNljvcwyTzS0kWr3fBr3lM+8r8kB3pDzNlmKEm3wXR1nWIw52inGzTBfamyK/I6RtEjoK1FMCzt5/+h2ArjhHi1q58lbUI6SwPLtyYCYvpQRoP+Kb6uJdRdJyG33RZZ77J65ami45ANgaOWT5iegHEdnRPBCYLT8s1JNya/ejnV/ojBlQpMrjPPROuDsGYb8Cp18Q1msU3ASYMbtno8iYYkq1aMJU0O/sgKicXN0e+fDlMZfODfN2T7hzVrzhRR7ItuMNR85cjO+pHg+KW8invyD1L7QzFlALYlJPXiBY2bHmcGA/XVAPcnGpz/qV/EFm9gvuOol7sOnL+o4zwCyoC2SRNp/pe/9vH+48jI6YZ6uCkQY5j+AtsyOa2dDSfQ0vdUMKr+QgdDVaOloRXp+AR2AG5HS98pq32XUfCevZCV3Nru8bhnzH491YunbFjIiSJ/uCxTsYfjkquRXoKpwJ4mdV1wumbyRWmJIVQvdLPE+neP2HhM0k6CNcZKovfUBpGvBl7/0Mi2ax1qFfc6e1uvulAtUNDPDwzh0VKpioIgDjvGkFjmVawKbuEAzHfD19WJq4fe6YEKbcB8wfPN5sOu58UB7UamZ/B7RS3hnXeCog+DpRli9dtaVxmHCKp4G+eKOV3yRHTrHOyMqxx1lrCB8hVxRi38q+hrLT3IjIrU8V2mNjHF1ONLyFeQHjcBPlCycIs+MmWp0401I5dFntaTw== \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/commit-add.b64 b/quartz/src/commonTest/resources/tsmls/commit-add.b64 new file mode 100644 index 0000000000..cfc348f2ed --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-add.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAAMAHBR1JYMyulPydTNkijeWsL5bfIOcs3Lj3Svtb4lB+v/Sc3pqj2WxtHZgaVlZ+0gztUw+NqETsgSCLtUH1u6Twez81oull+LvM9JQdXacjSWqa0KhiNPU/AgCcwwLV29UriXPhPOgRlY713EZqwlewxgS2UB6I80XISbpFNHuiMFZiXCqraYewU7UhYsyjgWsax91Mc9ARHub9pI5NIICy/oiOEibO3RQfF1PZitbaMjWwDqmWmnd/Im7ZmAglL/HjYuFawYqiNlErPlC5WuudirWsaS9Kd02JWZicFjMaFtaMAZh3VSt2VQ3tmBIPTeoHtCL+728frA+Q60fw2s5MjU8vvfNTbXnRrMHYB+UTCHAFvTHFSI/1Pv/6o5y690AIkDWKmzdZUjAloK2mrcXgWAJuQEYODgTPtHTLiAzVC5vwoewqOjJv0yF0/WQv3XvzuIFlKev43uTmFWU281ZSu15hUaSEb4lx2zsph2cfxpMAKGq0N+uImcht16UoooN1aZ9DkwxcPVQKGQeHM/sw841i9AqDsspq9UIhWWCLFWmh0R1zY2AL/PZRy52N/vLZSo5+UocA9CgqOWuKPht0O4hO9sKYQD3hhdOeEiJffHO7N9UJgm3zruZ3BYZqyHA8uF8+UaKCA03VN4oGWFA+dZOgU/Yq9pJjQr8e3BrPkOSRlOtz7/pWMEBzUPHX7h02sIykjtNLJFW \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/commit-meta-sealed.b64 b/quartz/src/commonTest/resources/tsmls/commit-meta-sealed.b64 new file mode 100644 index 0000000000..0d1f6a4eb8 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-meta-sealed.b64 @@ -0,0 +1 @@ +VsgX7mX1z0ujSl2MOaQ58uj3BZ3UcMiG5gp0EGi1/6HynI8+BUU2WLTH7+La4DEHsX6M8JdKY9OVC9ComRQSU4p21G0Czfg6uo6kGYuBukUn9QUFwcBTr6sPXQJSNWwRcFuLKtqc+I+3OOla74xDnZhq4UptkML15/0CJdH3XWJDqzmjVDK0XFVU8D4jqrPhE06Mrfgz5Hp4kmSdlFQufT0L2s+lF1NBz84P4z1/tQe7CiLnf6b0a5Gw5erraW/P7bhMUN2hRm4TVsD9haz2BL1bqPuF+YgyoMa7FqZPlLYSIbqFjmBx11jTV/W3HFp29G8pt57HqBD5pZdNqQJT1mu4oNJjpP2tV9LjZ5i5K9ic1SufSTMk79WC3Hzxa0+iqABVtJC832qZmoFlFQK5kho+oHjRgbrUaKTml2kMVs1KockNBmbfiZNIDj59XPf759uvrLW2vrYDOoR57qeKSmAiZ/vXyaNgr9lwJpO7ORnXlf6n2/PwX0I4j9z9BxbtqqTjeRPObyrcEDhhc1NCwdvseabIy8FCnrxhfz1CeUjVZBNELEdcWojKEg7a/873lZNvksEk6rfnreZ1C/EbzDW2L+vtMo53oYwgIbhrPZsa4Miom1SnZ/LcYHDWlGE7SZFxotceJFsTlk8ahHz8JJhViK/RbGJFxuoGxtRfCRWfcacDc9Ea7LrvM98jugPnKei0SEMRIfqfvdaBu4NKYA6M0SB5KMh6dP+HdGWH6g9Ip0fX8xIiv6qeiku4lkcDxIg3rSg+/IbNwU6w4+ZWQHxEXSGTFCQqgIv2yHiMg1ZYXGjpdn9wOlLtxlpM3Jtlg38Lur/zPESCTCVEjrhsTdj+jAQegV0PQc5jH4AskPD4w0aQ54/RlulxLjqb+vy1XBM+yWuSFss0dned1Qz9rFotPQwBzG8J4nNeJcPcsVt2F2KWpU20a57m6PtVv7d6kYaEgXu/oxQhQBS1sNKaUeDE9bIcBM1yBl1DSreUwn2acQ== \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/commit-meta.b64 b/quartz/src/commonTest/resources/tsmls/commit-meta.b64 new file mode 100644 index 0000000000..5e811bf91b --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-meta.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQMAHGwxwtIyuyJLoVfk7uV6IjH3qlUnETdM12IZcrpCjTUNnvdEiR7tRiOQs0hv08jwpI6mw3uJ3aXEpUeQcAvs/Wunm1NuZX4itKRRT/eUfxicX1itiDHu0yknHCxwDQT2Z7blCF+Y5TPLk7bvK6Wui7bQ3gtbr7G0OYnc7yX6qez37sXOHCXK2FVoffuF71OWZj68hv3f4VFRKpQiynDL3ZJ4KSAEOyFjZsDKAPyzaXxw4fRhvGd0zn4PahKVqQqC88LNgLRmhsdrpa0aCB/F0NUYR3ezkmGTuCXHETxvKjnkE+3DM/s9GydkxHSfmYwjcqXAQF+obYnm+5gwTmzqu68o0q2irCq3pSp5VfevEmKanNgvujmLgWocJ+DO0ONSjWGCGOYfQXWOGKI1kVHmw6AneHNhVKkdBCPnCxMOEP6TYi6NBWCLld48lFp4HNDr4TICkFTNjJOHkmWLVOP4Bt8yTS4le98AcT9ybNPaHx+LqyKUXbr2MDogGCRJhiycZ3AN/xJLZH77XmO4CoFCzlTxUqej2T77fEbpVNW2LAPYPj0OsgVJICaKf3s1rYOvu6nB0gKsBhA5mJE1mPR5S2NPapzHmIG8vLKK9v8WkzW+M6BdpX/RysD08Cupl8cF7V1EM+zPWuSWlE24IfvYMeS358UuwU7lsM0st9keis+7flK1j6xpaAjven70JMUmPdfjcV23BiyFJ1pM+srhj+QKzCLSqEwSU00RXusZ/+hgbDrEd/LFnG1wtjdaLiQwpGgreHvqDczpvAkC+r2H61cZQ4ysXSjJ6z7TqyfTZiy90UNA07Zy7GShj0Xc/TNI1N5rbffPynOJ+qhD1FN/3CV2F6fXu8oY/mp4660mmT3xdmOjtr7XsSFII/LZGTN/apxiseZ49UPZ0Le3 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/envelope-1.json b/quartz/src/commonTest/resources/tsmls/envelope-1.json new file mode 100644 index 0000000000..95156d7ee3 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/envelope-1.json @@ -0,0 +1 @@ +{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000000,"kind":9,"tags":[["p","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]],"content":"hello from ts-mls","id":"92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/envelope-2.json b/quartz/src/commonTest/resources/tsmls/envelope-2.json new file mode 100644 index 0000000000..1ac321a1c4 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/envelope-2.json @@ -0,0 +1 @@ +{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000002,"kind":1111,"tags":[["e","92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601","","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],["k","9"]],"content":"reply at epoch 2 ✨","id":"e73aa0bc24b3f5fcb02547b00045d006859427888cc8f549048b1fd6ddf12234"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/exporter-e0.hex b/quartz/src/commonTest/resources/tsmls/exporter-e0.hex new file mode 100644 index 0000000000..d245a85273 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/exporter-e0.hex @@ -0,0 +1 @@ +141ec831ebd0c989ad977e6dd9b28eeaee8a608ff7fe137d52f571aa4045b680 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/exporter-e1.hex b/quartz/src/commonTest/resources/tsmls/exporter-e1.hex new file mode 100644 index 0000000000..8888211f9a --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/exporter-e1.hex @@ -0,0 +1 @@ +b4d177e140624c206b3e1890138b17cc2a2f889c3569faf3ccea77e6d6ed3d31 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/exporter-e2.hex b/quartz/src/commonTest/resources/tsmls/exporter-e2.hex new file mode 100644 index 0000000000..95b5d76f8e --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/exporter-e2.hex @@ -0,0 +1 @@ +b0ba537f4b2256e7c3473fbd078ebbc0362a81b0b5a0bcd0572e5b8ea0e7a593 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/gid b/quartz/src/commonTest/resources/tsmls/gid new file mode 100644 index 0000000000..41e91eb013 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/gid @@ -0,0 +1 @@ +staircase-conformance-gid-1 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/meta-1.json b/quartz/src/commonTest/resources/tsmls/meta-1.json new file mode 100644 index 0000000000..3f2d3189b3 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/meta-1.json @@ -0,0 +1 @@ +{"name":"Conformance","description":"ts-mls ↔ staircase","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/meta-2.json b/quartz/src/commonTest/resources/tsmls/meta-2.json new file mode 100644 index 0000000000..aa36fd56a7 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/meta-2.json @@ -0,0 +1 @@ +{"name":"Conformance (renamed)","description":"updated by ts-mls","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜","imageUrl":"https://example.invalid/g.png"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/welcome.b64 b/quartz/src/commonTest/resources/tsmls/welcome.b64 new file mode 100644 index 0000000000..23adf90bc0 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/welcome.b64 @@ -0,0 +1 @@ +AAEAAwABQHYglGgWFWhzz/pTj2NkSi1EYk1I6Pu2sJWMWFMdgPDnXzMgwoylwN0z/xCGTlObRBCnAtpCHmMJQmrrOOp2fXqpzD4zKK8OcMS/7X6046dq4S6jkvlqTOav50y1xZN8yI8iAbhQC99j+HDTzv2iCrTITCLyBEhcQ2+/u3EYUtX2UrD2qzWKM9quqjGiO5azt3hX7IFJ83O/xBdze3rewaEfoS9NuRaO1tyJtS39QG+HMGyU/V+DtyUWNdGlinmEcT35JljIip3wnwyxvf41bWTleS6Xk5qfhfAeHvmYFmHowKRwyd5rsnAHs+Lb1rOqWA1CmtSAYi0Kh0aXQSmsHiFPZvZhHV+y8kXAWwtqr58LWebQSPv6iwEb6wbgXKic6PSSrSdtbv1XgjjPVHdA696J5cxFsQpPL8/cX+UZJZVg98mLibBxbyNZDxDPWXB2im3cgpn+UoqoOaxlx0RfFZkOEaRPd+48QsITaRrbw4vs2Hoq6cQI1sUlMa8wwbNMtG4Ybm7foMaGaZmHG8REMfzcbi3NyolGmxBWEaNbrLXclrAqcH2gojj0B8jXiYGsA5D4moEiqPybwUdxdPD/DYzp0rvAbw6bWpWmin4c9r2Vmvvt5bhPd8cn+hmQchKvscAh0mf5GXTwa/rsypesJtF+8xo0st4bdUptfkJzbaadc6h4h31a+5/798GghhGkcteE0hPHzEHXAwnEaN8/yyMPRK6JRmqcLPqKNz4oPybPezu3+yi4DSlLYmStIynd/6jJKRoc/b3Yvjs2FTdT2hJgHtb1taOq4Tq8TxXaeILAIc6WX/bFnYc8IIb6XMMOoY7HBakN7sI5v18a7M1hmOSeycz62s3z1zn+gDfhHgh6Kpi4BqNFNTufKMJ3IF/Tm32I01Ao43ISKlqroQaL2GCZfLZE0aeYgKZT4HZhVS2KPN1CJ2H7bQC2fccoD6VbfXeXU2OP03bmjuY/JQdV6idY1CZeZ0y6ZkUxg2ab0FvRosarlsjeWt/JR5zuiIgqQmPWcjtu8fu2LjYgUO0yeN+/CgqfKWMXEemZXJMUUkx5ywF8qx5LAKNGZZBLpM2vDV1Ia3BPQhrzjGI3z5jTPW7SHZ2m0FSHMSEmPZg/wpELbBl8D/72qA6QyKs+15rChIITvPd/Cr7HgjvYfTO3wLYSn101r3H7eurqQJ6akChbfAOIA0d7/6eQNf0l5HR8M/3dwS9UwoH+6KguLCNMNfZDH4vCj+qTljS0Bff2H9Aq6OmUZZXQF8b+VQJuUAS8WLyVPx50SGEFiGqeymYgbcOnCXQ3ARbJjN6JkHnnehbNZhgpOciOQiE= \ No newline at end of file diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt similarity index 98% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt index fa500dc60a..027d6d8a8d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance import io.github.andreypfau.kotlinx.crypto.Sha512 @@ -115,7 +115,7 @@ actual object Ed25519 { } actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { - require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } val publicKey = derivePublicKey(seed) return Ed25519KeyPair(seed + publicKey, publicKey) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.jvmAndroid.kt similarity index 99% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.jvmAndroid.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.jvmAndroid.kt index 68d0e477f4..43193cba33 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.jvmAndroid.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapCryptoTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapCryptoTest.kt new file mode 100644 index 0000000000..a2e6127b95 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapCryptoTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep04Encryption + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * `CVM-4-*`: the gift wrap round trip against real NIP-44 and secp256k1. + * + * Lives in jvmTest rather than commonTest because it needs the secp256k1 JNI. + */ +class CvmGiftWrapCryptoTest { + private val clientSigner = NostrSignerInternal(KeyPair()) + private val serverSigner = NostrSignerInternal(KeyPair()) + private val crypto = CvmGiftWrap() + + private suspend fun innerMessage(): Event = + CvmMessageEvent.create( + message = JsonRpcRequest(JsonRpcId.Num(1), "tools/list"), + recipient = serverSigner.pubKey, + signer = clientSigner, + ) + + @Test + fun `CVM-4-10 round-trips a wrapped message and recovers the signed inner event`() = + runTest { + val inner = innerMessage() + val wrap = crypto.wrap(inner, serverSigner.pubKey) + + assertEquals(CvmKinds.EPHEMERAL_GIFT_WRAP, wrap.kind) + + val unwrapped = crypto.unwrap(wrap, serverSigner) + assertEquals(inner.id, unwrapped.id) + assertEquals(inner.pubKey, unwrapped.pubKey) + assertEquals(inner.content, unwrapped.content) + assertEquals(CvmKinds.MESSAGE, unwrapped.kind) + } + + @Test + fun `CVM-4-11 the inner event is signed by the real client, not the wrap key`() = + runTest { + // CEP-4 signs the inner event first and encrypts the whole signed + // event. The wrap's own signature only proves the throwaway key + // signed it, so authorship comes from the inner one. + val inner = innerMessage() + val wrap = crypto.wrap(inner, serverSigner.pubKey) + + assertEquals(clientSigner.pubKey, crypto.unwrap(wrap, serverSigner).pubKey) + assertNotEquals(clientSigner.pubKey, wrap.pubKey, "the wrap must not be signed by the client key") + } + + @Test + fun `CVM-4-12 each wrap uses a fresh throwaway key`() = + runTest { + val inner = innerMessage() + val first = crypto.wrap(inner, serverSigner.pubKey) + val second = crypto.wrap(inner, serverSigner.pubKey) + + assertNotEquals( + first.pubKey, + second.pubKey, + "reusing a wrap key would link two messages from the same sender", + ) + } + + @Test + fun `CVM-4-13 the wrap addresses the recipient with a p tag`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val pTag = wrap.tags.first { it[0] == "p" } + assertEquals(serverSigner.pubKey, pTag[1]) + } + + @Test + fun `CVM-4-14 the wrap timestamp is the real send time, not NIP-59's shifted one`() = + runTest { + // Found live, not by reading: this test used to assert the + // opposite, and it passed. The reference ContextVM server + // subscribes with `since = now` when it connects — the obvious + // filter for a live request stream — so a relay drops a wrap dated + // in the past and the request is never delivered. It does not + // fail; it times out, which is why a fixture server with no + // `since` filter could not catch it. + val before = TimeUtils.now() + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val after = TimeUtils.now() + + assertTrue(wrap.createdAt >= before, "a backdated wrap is invisible to a `since = now` subscriber") + assertTrue(wrap.createdAt <= after, "and a future-dated one is invisible to an `until` filter") + } + + @Test + fun `CVM-4-15 rejects an inner event whose signature does not verify`() = + runTest { + // Without this check, anyone able to encrypt to us could claim any + // pubkey: the wrap signature proves nothing about the inner author. + val inner = innerMessage() + val forged = + Event( + id = inner.id, + pubKey = inner.pubKey, + createdAt = inner.createdAt, + kind = inner.kind, + tags = inner.tags, + content = inner.content, + sig = "00".repeat(64), + ) + val wrap = crypto.wrap(forged, serverSigner.pubKey) + + assertFailsWith { crypto.unwrap(wrap, serverSigner) } + } + + @Test + fun `CVM-4-16 rejects a wrap addressed to someone else`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val eavesdropper = NostrSignerInternal(KeyPair()) + assertFailsWith { crypto.unwrap(wrap, eavesdropper) } + } + + @Test + fun `CVM-4-17 rejects a non-gift-wrap kind on both paths`() = + runTest { + val inner = innerMessage() + assertFailsWith { crypto.wrap(inner, serverSigner.pubKey, kind = 1) } + assertFailsWith { crypto.unwrap(inner, serverSigner) } + } + + @Test + fun `CVM-19-10 a persistent-mode client produces kind 1059`() = + runTest { + val persistent = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT) + val wrap = persistent.wrap(innerMessage(), serverSigner.pubKey) + assertEquals(CvmKinds.GIFT_WRAP, wrap.kind) + + // Either kind must unwrap regardless of which mode we prefer. + assertEquals(CvmKinds.MESSAGE, crypto.unwrap(wrap, serverSigner).kind) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClientTest.kt new file mode 100644 index 0000000000..1baf6c2801 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClientTest.kt @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.mcp + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedTransferSender +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamFrame +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.CvmRequest +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.contextvm.transport.TimeoutMode +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The MCP client end to end, including both transfer profiles. */ +class CvmMcpClientTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val clientSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + + private fun client() = + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(clientSigner, clientSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ) + + private fun fixture(handler: suspend (CvmRequest) -> JsonRpcMessage) = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + handler = handler, + ) + + /** The progressToken the client derives for the first call it makes. */ + private val firstCallToken = ProgressToken.Text("call-0") + + @Test + fun `a second call on one client still correlates`() = + runTest { + // Every test here made exactly one call, so a server that answered + // with a constant JSON-RPC id passed all of them -- and our own + // fixture did precisely that until a two-call cordn test hung on it. + // Ids advance per call and the client refuses a stale one, which is + // correct and invisible until something makes the second call. + val client = client() + val fixture = fixture { request -> JsonRpcSuccess(request.id, buildJsonObject { put("ok", JsonPrimitive(true)) }) } + fixture.start() + + val results = + coroutineScope { + val pending = + async { + listOf( + client.callTool("first", timeoutMs = 5_000), + client.callTool("second", timeoutMs = 5_000), + ) + } + while (!pending.isCompleted) { + yield() + fixture.pump() + yield() + } + pending.await() + } + + assertEquals(2, results.size) + results.forEach { assertFalse(it.isError) } + } + + @Test + fun `a stale response id is ignored rather than answering the wrong call`() = + runTest { + // The guard the test above depends on. A server pinned to id 0 + // answers the first call and nothing after it -- the request must + // time out rather than accept an answer to a question we already + // asked. + val client = client() + val fixture = fixture { JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject {}) } + fixture.start() + + coroutineScope { + val first = async { client.callTool("first", timeoutMs = 5_000) } + yield() + fixture.pump() + first.await() + } + + val second = + coroutineScope { + val pending = async { runCatching { client.callTool("second", timeoutMs = 200) } } + repeat(20) { + yield() + fixture.pump() + yield() + } + pending + } + assertTrue(second.await().isFailure, "a response carrying a stale id must not resolve the new call") + } + + @Test + fun `a tool call returns the server result`() = + runTest { + val fixture = + fixture { + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("cursor", JsonPrimitive(7)) }) + } + fixture.start() + + val result = + coroutineScope { + val pending = async { client().callTool("msg_post", timeoutMs = 5_000) } + yield() + fixture.pump() + pending.await() + } + + assertFalse(result.isError) + assertEquals( + 7, + result.result!! + .jsonObject["cursor"]!! + .jsonPrimitive.content + .toInt(), + ) + } + + @Test + fun `a tool call always carries a progressToken`() = + runTest { + // Without one a server MUST NOT start either transfer profile, so + // omitting it would silently cap every response at one relay event. + val fixture = fixture { JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject {}) } + fixture.start() + + coroutineScope { + val pending = async { client().callTool("x", timeoutMs = 5_000) } + yield() + fixture.pump() + pending.await() + } + + val meta = fixture.handledParams.first()!![McpParams.META]!!.jsonObject + assertEquals("call-0", meta[McpParams.PROGRESS_TOKEN]!!.jsonPrimitive.content) + } + + @Test + fun `an error response surfaces as an error rather than a throw`() = + runTest { + val fixture = + fixture { + JsonRpcFailure( + JsonRpcId.Num(0), + JsonRpcError(JsonRpcError.PAYMENT_REQUIRED, "Payment Required"), + ) + } + fixture.start() + + val result = + coroutineScope { + val pending = async { client().callTool("priced", timeoutMs = 5_000) } + yield() + fixture.pump() + pending.await() + } + + assertTrue(result.isError) + assertEquals(JsonRpcError.PAYMENT_REQUIRED, result.error!!.code) + assertNull(result.result) + } + + @Test + fun `a CEP-22 transfer reassembles into the effective response`() = + runTest { + val big = buildJsonObject { put("text", JsonPrimitive("x".repeat(400))) } + val serialized = JsonRpcCodec.encode(JsonRpcSuccess(JsonRpcId.Num(0), big)) + + // NO direct response, and that is the whole point: a chunked + // response REPLACES the direct one. An earlier version of this test + // had the fixture also answer normally, which meant the call was + // ended by that answer and the reassembly only had to win a + // tie-break. Against the reference coordinator, which sends frames + // and nothing else, the same code hung until its deadline. + val fixture = fixture { error("a chunked response is the only response") } + fixture.start() + + val result = + coroutineScope { + val pending = async { client().callTool("big", timeoutMs = 5_000) } + yield() + + OversizedTransferSender(chunkChars = 64).frame(firstCallToken, serialized).forEach { frame -> + fixture.reply(frame.envelope.toNotification(), clientSigner.pubKey, "0".repeat(64)) + } + pending.await() + } + + assertEquals( + "x".repeat(400), + result.result!! + .jsonObject["text"]!! + .jsonPrimitive.content, + "the reassembled payload is the response", + ) + } + + @Test + fun `a CEP-41 subscription ends on its budget instead of throwing`() = + runTest { + // The other half of the rule above, and the one that made every + // live subscription an exception: if `close` does not complete the + // request, an open-ended subscription has NO response to wait for, + // so its budget running out is the only way it can end. Under + // TimeoutMode.TOTAL that is a normal return, not a failure. + val fixture = fixture { error("a subscription has no response to give") } + fixture.start() + + val live = mutableListOf() + + val result = + coroutineScope { + val pending = + async { + client().callTool("sub", timeoutMs = 5_000, timeoutMode = TimeoutMode.TOTAL) { live += it } + } + yield() + + listOf( + OpenStreamFrame.start(firstCallToken, 1.0), + OpenStreamFrame.chunk(firstCallToken, 2.0, 0, "pushed"), + OpenStreamFrame.close(firstCallToken, 3.0, lastChunkIndex = 0), + ).forEach { frame -> + fixture.reply(frame.envelope.toNotification(), clientSigner.pubKey, "0".repeat(64)) + } + + // No pump: nothing answers, and the budget is the exit. + pending.await() + } + + assertEquals(listOf("pushed"), live, "fragments arrive as they are pushed") + assertEquals(listOf("pushed"), result.streamed) + assertNull(result.result, "there was no response, and that is not an error") + assertFalse(result.isError) + } + + @Test + fun `a CEP-41 stream delivers fragments but close does not complete the call`() = + runTest { + // The rule worth pinning end to end: close says no more frames, and + // the request is still only finished by its own JSON-RPC response. + val fixture = + fixture { + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("done", JsonPrimitive(true)) }) + } + fixture.start() + + val live = mutableListOf() + + val result = + coroutineScope { + val pending = + async { + client().callTool("stream", timeoutMs = 5_000) { live += it } + } + yield() + + listOf( + OpenStreamFrame.start(firstCallToken, 1.0), + OpenStreamFrame.chunk(firstCallToken, 2.0, 0, "Hello"), + OpenStreamFrame.chunk(firstCallToken, 3.0, 1, " world"), + OpenStreamFrame.close(firstCallToken, 4.0, lastChunkIndex = 1), + ).forEach { frame -> + fixture.reply(frame.envelope.toNotification(), clientSigner.pubKey, "0".repeat(64)) + } + + // Only now does the request's own response arrive. + fixture.pump() + pending.await() + } + + assertEquals(listOf("Hello", " world"), live) + assertEquals(listOf("Hello", " world"), result.streamed) + assertEquals( + true, + result.result!! + .jsonObject["done"]!! + .jsonPrimitive.content + .toBoolean(), + "the call is completed by its JSON-RPC response, not by close", + ) + } + + @Test + fun `initialize completes the handshake and sends initialized`() = + runTest { + val fixture = + fixture { + JsonRpcSuccess( + JsonRpcId.Num(0), + buildJsonObject { put("protocolVersion", JsonPrimitive(CvmMcpClient.PROTOCOL_VERSION)) }, + ) + } + fixture.start() + + coroutineScope { + val pending = async { client().initialize() } + yield() + fixture.pump() + pending.await() + } + + // The notification rides after the response, unsubscribed, so it + // shows up on the wire rather than in a correlation slot. + val methods = + relays.published.mapNotNull { event -> + runCatching { JsonRpcCodec.decode(event.content) }.getOrNull() + } + assertTrue( + methods.any { it is com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification && it.method == McpMethods.INITIALIZED }, + "the client must tell the server it is ready", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt new file mode 100644 index 0000000000..60bf2981a8 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt @@ -0,0 +1,572 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transport + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmEncryptionException +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.cep04Encryption.GiftWrapMode +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.CvmRequest +import com.vitorpamplona.quartz.contextvm.fixture.FixtureFaults +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * `CVM-CORE-*` and `CVM-16-*` driven end to end against the Tier C fixture. + * + * The fixture is what makes the negative half testable: no real server produces + * a mismatched correlation tag or a malformed body on request. + */ +class CvmTransportTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val signers = DualSigner(stableSigner, ephemeralSigner) + + private fun server( + faults: FixtureFaults = FixtureFaults(), + injectClientPubkey: Boolean = false, + discoveryTags: List> = emptyList(), + handler: suspend (CvmRequest) -> JsonRpcMessage = { + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("ok", JsonPrimitive(true)) }) + }, + ) = CvmFixtureServer( + relays = relays, + signer = serverSigner, + faults = faults, + injectClientPubkey = injectClientPubkey, + discoveryTags = discoveryTags, + handler = handler, + ) + + private fun transport(crypto: CvmGiftWrap = CvmGiftWrap()) = + CvmTransport( + relays = relays, + signers = signers, + serverPubKey = serverSigner.pubKey, + crypto = crypto, + ) + + /** Runs a request while pumping the fixture, so the answer arrives in-flight. */ + private suspend fun exchange( + fixture: CvmFixtureServer, + transport: CvmTransport, + request: JsonRpcRequest, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + timeoutMs: Long = 5_000, + ) = coroutineScope { + val pending = async { transport.request(request, identity, timeoutMs) } + yield() + fixture.pump() + pending.await() + } + + @Test + fun `CVM-CORE-10 completes a request against the fixture`() = + runTest { + val fixture = server() + fixture.start() + + val response = + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "tools/list")) + + assertIs(response) + } + + @Test + fun `CVM-CORE-11 subscribes before publishing so nothing is dropped`() = + runTest { + // The property that matters: an ephemeral kind published with nobody + // listening is gone. If the transport ever published first, the + // request event would land in `dropped`. + val fixture = server() + fixture.start() + + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + assertTrue(relays.dropped.isEmpty(), "dropped: ${relays.dropped.map { it.kind }}") + } + + @Test + fun `CVM-CORE-12 the relay drops an event nobody is subscribed to`() = + runTest { + // Guards the guard: confirms the fixture relay really does model + // ephemeral delivery, so the previous test is meaningful. + val fixture = server() + // deliberately not started + val transport = transport() + + assertFailsWith { + transport.request(JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 50) + } + assertTrue(relays.dropped.isNotEmpty()) + } + + @Test + fun `CVM-CORE-13 ignores a response whose JSON-RPC id does not match`() = + runTest { + val fixture = server(faults = FixtureFaults(mismatchedResponseId = true)) + fixture.start() + + assertFailsWith { + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(7), "ping"), timeoutMs = 200) + } + } + + @Test + fun `CVM-CORE-14 ignores a response correlated to a different request event`() = + runTest { + val fixture = server(faults = FixtureFaults(wrongCorrelationTag = true)) + fixture.start() + + assertFailsWith { + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 200) + } + } + + @Test + fun `CVM-CORE-15 accepts a response that omits the e tag but matches by id`() = + runTest { + // The `e` tag is the stronger signal but a peer may omit it; the + // JSON-RPC id still correlates. + val fixture = server(faults = FixtureFaults(omitCorrelationTag = true)) + fixture.start() + + val response = exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping")) + assertIs(response) + } + + @Test + fun `CVM-CORE-15b ignores a correctly correlated response from the wrong signer`() = + runTest { + // The forgery this closes. A client subscribes to everything + // `p`-tagged to its own key, so anyone on the relay can gift-wrap + // a well-formed response to it — the wrap's signature proves only + // that its throwaway key signed it, and the inner signature is + // verified without knowing who the signer ought to be. + // + // The forged event is published straight to the pool rather than + // through a second fixture. A fixture subscribes for traffic + // addressed to itself and would never see a request `p`-tagged to + // the real server, so it would answer nothing and this test would + // pass by timing out for the wrong reason. (It did, at first.) + // + // The impostor does everything right except be the coordinator: + // matching JSON-RPC id, addressed to the very key this call is + // listening on, and no `e` tag — which CVM-CORE-15 establishes is + // accepted on its own. Identity is the coordinator's pubkey and + // nothing else (spec/00.md §8.5). Without that check a stranger + // answers `kp_take` with their own KeyPackage and we invite them. + val impostor = NostrSignerInternal(KeyPair()) + val forged = + CvmMessageEvent.create( + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("ok", JsonPrimitive(true)) }), + ephemeralSigner.pubKey, + impostor, + ) + + val transport = transport() + val pending = async { transport.request(JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 300) } + yield() + relays.publish(CvmGiftWrap().wrap(forged, ephemeralSigner.pubKey)) + + assertFailsWith { pending.await() } + } + + @Test + fun `CVM-CORE-16 keeps waiting through a malformed payload`() = + runTest { + // A peer sending garbage must not fail an unrelated in-flight call. + val fixture = server(faults = FixtureFaults(malformedResponse = true)) + fixture.start() + + assertFailsWith { + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 200) + } + } + + @Test + fun `CVM-CORE-17 routes notifications without resolving the request`() = + runTest { + val fixture = server(faults = FixtureFaults(noisePrefix = 3)) + fixture.start() + + val seen = mutableListOf() + val transport = transport() + + val response = + coroutineScope { + val pending = + async { + transport.request(JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 5_000) { + seen += it.method + // null: a notification does not answer the call. + null + } + } + yield() + fixture.pump() + pending.await() + } + + assertEquals(3, seen.size, "every notification is delivered") + assertIs(response, "and none of them completed the request") + } + + @Test + fun `CVM-4-20 the request goes out encrypted by default`() = + runTest { + val fixture = server() + fixture.start() + + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + val first = relays.published.first() + assertTrue(CvmKinds.isGiftWrap(first.kind), "kind ${first.kind} is not a wrap") + assertNotEquals(ephemeralSigner.pubKey, first.pubKey, "the wrap hides the sender") + } + + @Test + fun `CVM-4-21 a disabled-encryption client publishes the bare message kind`() = + runTest { + val fixture = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED), + handler = { request -> JsonRpcSuccess(request.id, buildJsonObject {}) }, + ) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertEquals(CvmKinds.MESSAGE, relays.published.first().kind) + } + + @Test + fun `CVM-16-01 the server derives clientPubkey rather than trusting the client`() = + runTest { + val fixture = server(injectClientPubkey = true) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "tools/call", buildJsonObject { put("name", JsonPrimitive("x")) }), + ) + + val meta = fixture.handledParams.first()?.get(McpParams.META) as JsonObject + assertEquals( + ephemeralSigner.pubKey, + (meta[McpParams.CLIENT_PUBKEY] as JsonPrimitive).content, + "the injected identity is the event signer, not anything the client claimed", + ) + } + + @Test + fun `CVM-16-02 injection is off unless the server opts in`() = + runTest { + val fixture = server(injectClientPubkey = false) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(fixture.handledParams.first()?.containsKey(McpParams.META) != true) + } + + @Test + fun `CVM-35-10 learns the server baseline from its first direct message`() = + runTest { + val fixture = + server( + discoveryTags = + listOf( + arrayOf("name", "Fixture"), + CvmTags.flag(CvmTags.SUPPORT_OPEN_STREAM), + arrayOf("unknown_future", "keep-me"), + ), + ) + fixture.start() + + val transport = transport() + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + val peer = transport.peer!! + assertEquals("Fixture", peer.name) + assertTrue(peer.supportsOpenStream) + assertTrue( + peer.unknownTags.any { it[0] == "unknown_future" }, + "CEP-35 requires unknown discovery tags to survive", + ) + } + + @Test + fun `CVM-4-22 a peer that declares no encryption gets a refusal, not an unreadable wrap`() = + runTest { + // The point of EncryptionMode.REQUIRED. Before negotiation was + // wired up its input was a constant true, so this could never fire: + // we would have sent a wrap the peer cannot open and the caller + // would have seen a timeout with no reason. + val fixture = server(discoveryTags = listOf(arrayOf("name", "Plaintext only"))) + fixture.start() + val transport = transport() + + // First request establishes the baseline and still goes out under + // the optimistic assumption — the peer has not spoken yet. + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + assertFalse(transport.peer!!.supportsEncryption) + + val thrown = + runCatching { + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(1), "ping")) + }.exceptionOrNull() + + assertTrue(thrown is CvmEncryptionException, "expected a stated refusal, got $thrown") + } + + @Test + fun `CVM-4-23 a peer that declares encryption keeps getting wraps`() = + runTest { + val fixture = + server( + discoveryTags = listOf(arrayOf("name", "Encrypts"), CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)), + ) + fixture.start() + val transport = transport() + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + val second = + exchange( + fixture, + transport, // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(second is JsonRpcSuccess) + assertTrue(relays.published.all { CvmKinds.isGiftWrap(it.kind) }) + } + + @Test + fun `CVM-4-24 a peer that declares nothing at all is not read as declaring no`() = + runTest { + // The case that decides the whole design. A live cordn coordinator's + // kind-25910 responses carry only the routing tags `p` and `e` + // (observed on the public relays, 2026-09-23). Reading that silence + // as a full CEP-35 surface would conclude it supports nothing, and + // REQUIRED would refuse to talk to every deployed coordinator. + val fixture = server(discoveryTags = emptyList()) + fixture.start() + val transport = transport() + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + val second = + exchange( + fixture, + transport, // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(second is JsonRpcSuccess, "a silent peer must stay reachable") + assertTrue(relays.published.all { CvmKinds.isGiftWrap(it.kind) }) + } + + @Test + fun `CVM-19-05 the wrap kind follows what the peer declared`() = + runTest { + // CEP-19: preferring 21059 must never mean refusing a 1059-only + // server. A declared surface without the ephemeral flag downgrades. + // The surface must declare encryption, or OPTIONAL would send the + // second request unwrapped and the wrap kind would never be picked + // at all - which made an earlier version of this test vacuous. + assertEquals( + listOf(CvmKinds.GIFT_WRAP), + wrapKindsOfSecondRequest( + discoveryTags = listOf(arrayOf(CvmTags.SUPPORT_ENCRYPTION, "true"), arrayOf("name", "Persistent only")), + ), + ) + } + + @Test + fun `CVM-19-06 a peer that declares ephemeral support gets the ephemeral wrap`() = + runTest { + assertEquals( + listOf(CvmKinds.EPHEMERAL_GIFT_WRAP), + wrapKindsOfSecondRequest( + discoveryTags = + listOf( + arrayOf(CvmTags.SUPPORT_ENCRYPTION, "true"), + arrayOf(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL, "true"), + ), + ), + ) + } + + /** + * Runs two requests against a server declaring [discoveryTags] and returns + * the kinds we published on the second one - by then the peer's surface has + * been learned from its first reply. + */ + private suspend fun wrapKindsOfSecondRequest(discoveryTags: List>): List { + val fixture = server(discoveryTags = discoveryTags) + fixture.start() + val transport = transport(CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL, encryptionMode = EncryptionMode.OPTIONAL)) + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + relays.published.clear() + // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + // Only what WE sent: a wrap addressed to the server. The fixture's own + // replies are wrapped too and would otherwise be counted. + val ours = relays.published.filter { e -> e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == serverSigner.pubKey } } + assertTrue(ours.isNotEmpty(), "the second request should have been published") + return ours.map { it.kind }.distinct() + } + + @Test + fun `CVM-35-12 the session's first message declares this side's surface`() = + runTest { + // CEP-35 is symmetric, and a peer only offers a profile the other + // side declared: the reference ContextVM server chunks a CEP-22 + // response, and opens a CEP-41 stream, only for a client that said + // it can take one. A session that declared nothing capped every + // response at a single relay event. + // + // Read off the inner event, because that is where the server reads + // it: the wrap's own tags are just routing, and a surface put there + // would be both unread and public. + val seen = mutableListOf>() + + // Deliberately NOT the handshake: the surface has to ride whatever + // the first message is, or a client that opens with a tool call + // never declares at all. + declaring(seen, CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED), "msg_fetch_many") + + val declared = seen.single() + assertTrue(CvmTags.SUPPORT_OVERSIZED_TRANSFER in declared, "no CEP-22 flag in $declared") + assertTrue(CvmTags.SUPPORT_OPEN_STREAM in declared, "no CEP-41 flag in $declared") + // Encryption is off here, so there is no wrap we could open. + assertFalse(CvmTags.SUPPORT_ENCRYPTION in declared, "declared a wrap it will not open") + } + + @Test + fun `CVM-35-13 an encrypting client declares both wrap kinds`() = + runTest { + // Receive, not prefer. This client emits 1059, and still says it + // takes 21059, because the transport subscribes to both - saying + // otherwise would tell the peer to withhold something we can read. + val seen = mutableListOf>() + + declaring(seen, CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT, encryptionMode = EncryptionMode.OPTIONAL), "ping") + + val declared = seen.single() + assertTrue(CvmTags.SUPPORT_ENCRYPTION in declared, "no CEP-4 flag in $declared") + assertTrue(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL in declared, "no CEP-19 flag in $declared") + } + + @Test + fun `CVM-35-14 the surface is declared once, not on every message`() = + runTest { + val seen = mutableListOf>() + + declaring(seen, CvmGiftWrap(), "ping", "ping") + + assertEquals(2, seen.size, "both requests should have reached the server") + assertTrue(seen.first().isNotEmpty(), "the first message declared nothing") + assertEquals(emptyList(), seen.last(), "re-declared on a later message") + } + + /** + * Runs [methods] in one session, collecting each request's declared + * surface - the single-element tags on the event the server unwrapped. + */ + private suspend fun declaring( + into: MutableList>, + crypto: CvmGiftWrap, + vararg methods: String, + ) { + val fixture = + server(handler = { request -> + into += + request.event.tags + .filter { it.size == 1 } + .map { it[0] } + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("ok", JsonPrimitive(true)) }) + }) + fixture.start() + val transport = transport(crypto) + methods.forEach { exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), it)) } + } + + @Test + fun `CVM-35-11 the stable identity is used only when asked for`() = + runTest { + val fixture = server(injectClientPubkey = true) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "kp_publish"), + identity = DualSigner.Identity.STABLE, + ) + + val meta = fixture.handledParams.first()?.get(McpParams.META) as JsonObject + assertEquals(stableSigner.pubKey, (meta[McpParams.CLIENT_PUBKEY] as JsonPrimitive).content) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnAdminPolicyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnAdminPolicyTest.kt new file mode 100644 index 0000000000..5d830daf33 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnAdminPolicyTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.groups + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * That `admin_pubkeys` actually gates commits, the way the reference client + * gates its `addMember` / `removeMember` / `updateGroupMetadata`. + * + * The engine calls [CordnGroupPolicy.authorizeCommit] before building a local + * commit AND before applying an inbound one, so these tests cover both + * directions at once: a commit this policy refuses to build is the same commit + * it refuses to apply from someone else. That matters more than the local error + * — applying a commit the rest of the group rejects forks the epoch. + */ +class CordnAdminPolicyTest { + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + + private fun identity(pubKeyHex: String) = CordnCredential.of(pubKeyHex).identity + + /** A group created by [creator] whose metadata names [admins]. */ + private fun group( + creator: String, + admins: List, + ): MlsGroup = + MlsGroup.create( + identity = identity(creator), + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "Admin test", adminPubkeys = admins).toExtension()), + ) + + @Test + fun anEmptyAdminListLetsAnyMemberRewriteMetadata() { + val egalitarian = group(creator = alice, admins = emptyList()) + val before = egalitarian.epoch + + // spec/01.md §5.3: empty means egalitarian, permanently — not a + // bootstrap window that later closes. + egalitarian.proposeGroupContextExtensions(egalitarian.extensions) + egalitarian.commit() + + assertEquals(before + 1, egalitarian.epoch) + assertTrue(CordnGroupPolicy.isLocalAdmin(egalitarian.view())) + } + + @Test + fun aNonAdminCannotRewriteMetadata() { + val group = group(creator = alice, admins = listOf(bob)) + assertFalse(CordnGroupPolicy.isLocalAdmin(group.view()), "alice must not be an admin or this proves nothing") + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith { group.commit() } + } + + @Test + fun anAdminCan() { + val group = group(creator = alice, admins = listOf(alice)) + assertTrue(CordnGroupPolicy.isLocalAdmin(group.view())) + val before = group.epoch + + group.proposeGroupContextExtensions(group.extensions) + group.commit() + + assertEquals(before + 1, group.epoch) + } + + /** + * The regression that matters most: a cordn credential stores the account + * key as 64 ASCII hex characters, so hexing the credential bytes yields 128 + * characters. An admin check that compared an account pubkey against that + * would match nothing — and since the gate only fires when a commit needs + * an admin, the failure would look like "admins can never commit" rather + * than like a decoding bug. + */ + @Test + fun theAdminListIsComparedInTheCredentialsOwnEncoding() { + val group = group(creator = alice, admins = listOf(alice)) + + val credentialHex = group.view().let { it.memberIdentityHex(it.myLeafIndex) } + assertEquals(128, credentialHex?.length, "a cordn credential hexes to twice the pubkey length") + assertTrue(credentialHex != alice, "if these were equal this test would be vacuous") + + assertTrue(group.view().let { CordnGroupPolicy.isAdminLeaf(it, it.myLeafIndex) }) + } + + @Test + fun anUpdateIsNotAnAdminAction() { + val group = group(creator = alice, admins = listOf(bob)) + val before = group.epoch + + // Only add, remove and group_context_extensions are gated. Keeping the + // rest open is what stops a non-admin being trapped in a group whose + // keys they may never rotate. + group.proposeSigningKeyRotation() + group.commit() + + assertEquals(before + 1, group.epoch) + } + + @Test + fun anEmptyCommitIsNotAnAdminAction() { + val group = group(creator = alice, admins = listOf(bob)) + val before = group.epoch + + group.commit() + + assertEquals(before + 1, group.epoch) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt new file mode 100644 index 0000000000..509bf22c45 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * One engine, both credential encodings, side by side. + * + * §4.1 of `quartz/plans/2026-09-17-cordn-interop.md` records a hard + * incompatibility: Marmot writes a Nostr pubkey into a BasicCredential as the + * **raw 32 bytes**, cordn as **64 ASCII bytes of lowercase hex**. The decision + * was to implement both rather than wait for either ecosystem to move, which + * only works if the two profiles cannot be confused for one another — a leaf + * read under the wrong binding must come back as "not mine", never as a + * plausible-looking wrong pubkey. + * + * That is what this pins. It is cheap to get wrong in a way no single-binding + * test would notice: `MlsGroup.memberIdentityHex` hex-encodes credential bytes, + * so run over a cordn leaf it returns 128 characters of hex-of-hex — a string + * that looks like a pubkey, compares unequal to every real one, and would + * quietly drop a member from a UI list. + */ +class BothCredentialProfilesTest { + private val aliceHex = "aa".repeat(32) + private val bobHex = "bb".repeat(32) + + private fun marmotGroup() = MlsGroup.create(aliceHex.hexToByteArray(), policy = MarmotGroupPolicy) + + private fun cordnGroup() = + MlsGroup.create( + identity = CordnCredential.of(aliceHex).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "both profiles").toExtension()), + ) + + @Test + fun `the two encodings cannot collide, by length alone`() { + val marmot = Credential.Basic(aliceHex.hexToByteArray()) + val cordn = CordnCredential.of(aliceHex) + + assertEquals(32, marmot.identity.size) + assertEquals(64, cordn.identity.size) + assertNotEquals(marmot.identity.toHexKey(), cordn.identity.toHexKey()) + + // The disambiguation is structural, not heuristic: cordn's reader wants + // exactly 64 bytes, Marmot's wants exactly 32, and no byte string is + // both. Neither binding needs to guess which profile a leaf belongs to. + assertNull(CordnCredential.identityOrNull(marmot), "a Marmot credential must not read as a cordn identity") + } + + @Test + fun `a cordn credential round-trips through its own reader`() { + assertEquals(aliceHex, CordnCredential.identityOrNull(CordnCredential.of(aliceHex))) + assertContentEquals(aliceHex.encodeToByteArray(), CordnCredential.of(aliceHex).identity) + } + + @Test + fun `both groups run on the same engine at once`() { + // Not a formality: since Stage 1 the profile arrives as a constructor + // argument, so a leaked default or a shared mutable would show up as + // one group adopting the other's rules. + val marmot = marmotGroup() + val cordn = cordnGroup() + + assertEquals(0L, marmot.epoch) + assertEquals(0L, cordn.epoch) + + // Each group reports its own creator under its own encoding. + assertEquals(setOf(aliceHex), CordnCredential.memberIdentities(cordn)) + assertEquals(aliceHex, marmot.memberIdentityHex(0)) + + // And a message in each still opens. + listOf(marmot, cordn).forEach { group -> + val sealed = group.encrypt("hello".encodeToByteArray()) + assertEquals("hello", group.decrypt(sealed).content.decodeToString()) + } + } + + @Test + fun `reading a cordn leaf with the raw-bytes helper gives hex-of-hex, not a pubkey`() { + // The trap, made explicit so nobody "fixes" CordnCredential.membersOf + // back into memberIdentityHex. 64 ASCII bytes hex-encode to 128 chars. + val cordn = cordnGroup() + + assertEquals(128, cordn.memberIdentityHex(0)?.length) + assertNotEquals(aliceHex, cordn.memberIdentityHex(0)) + assertEquals(aliceHex, CordnCredential.identityOrNull(cordn.members().first { it.first == 0 }.second)) + } + + @Test + fun `each profile admits a joiner carrying its own encoding`() { + val cordn = cordnGroup() + val bobsKeyPackage = cordnGroup().createKeyPackage(CordnCredential.of(bobHex).identity, ByteArray(0)) + + cordn.addMember(bobsKeyPackage.keyPackage.toTlsBytes()) + + assertEquals(setOf(aliceHex, bobHex), CordnCredential.memberIdentities(cordn)) + + val marmot = marmotGroup() + val bobsMarmotKeyPackage = marmotGroup().createKeyPackage(bobHex.hexToByteArray(), ByteArray(0)) + + marmot.addMember(bobsMarmotKeyPackage.keyPackage.toTlsBytes()) + + assertEquals(bobHex, marmot.memberIdentityHex(1)) + // ...and the joiner each admitted is invisible to the other binding. + assertNull(CordnCredential.identityOrNull(marmot.members().first { it.first == 1 }.second)) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt new file mode 100644 index 0000000000..9c7d5a4d70 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorMethod +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [CoordinatorClient] against cordn's own wire contracts. + * + * `resources/cordn/coordinator-contracts.json` is generated by + * `quartz/tools/cordn-vector-gen` from **`@cordn/core`** (MIT) — the package + * the reference coordinator and client both import. Every payload in it was + * accepted by their zod schema, and every `rejects` entry was refused by it. + * + * So this suite asks the two questions our own fixture cannot: does the JSON + * we put on the wire match what their coordinator parses, and can we read what + * theirs sends back? Both directions run through the real ContextVM transport, + * so a regression in argument assembly, field naming or result parsing fails + * here rather than against a live coordinator. + * + * What it does NOT cover is the coordinator's behaviour — ordering, cursor + * assignment, admission control. That needs Tier B, which is blocked; see + * `quartz/plans/2026-09-17-cordn-interop.md` §7. + */ +class CoordinatorContractVectorTest { + private val vectors: JsonObject = + Json + .parseToJsonElement( + checkNotNull(CoordinatorContractVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) { + "missing cordn contract vectors" + }.use { it.readBytes() } + .decodeToString(), + ).jsonObject + + private fun contract(method: String): JsonObject = vectors["contracts"]!!.jsonObject[method]!!.jsonObject + + private fun input( + method: String, + key: String = "input", + ): JsonObject = contract(method)[key]!!.jsonObject + + private fun output( + method: String, + key: String = "output", + ): JsonObject = contract(method)[key]!!.jsonObject + + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + private val coordinator = CordnFixtureCoordinator() + + private val client = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stableSigner, ephemeralSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + + private fun serve() = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + + /** Runs [block] while pumping the fixture, since the relay callback cannot suspend. */ + private suspend fun driving(block: suspend () -> T): T = + coroutineScope { + val server = serve() + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + /** The arguments the coordinator saw for [method], which must be its only call. */ + private fun sent(method: String): JsonObject { + val calls = coordinator.calls.filter { it.method == method } + assertEquals(1, calls.size, "expected exactly one $method call, saw ${coordinator.calls.map { it.method }}") + return calls.single().arguments + } + + private suspend fun scripted( + method: String, + result: JsonObject, + block: suspend () -> Unit, + ) { + coordinator.scriptedResults[method] = result + driving(block) + } + + // ---- what we send ---------------------------------------------------- + + @Test + fun `kp_publish arguments match cordn's schema`() = + runTest { + driving { client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") } + assertEquals(input("kp_publish"), sent("kp_publish")) + } + + @Test + fun `kp_remove arguments match cordn's schema`() = + runTest { + driving { client.removeKeyPackages(listOf("1f".repeat(16), "2e".repeat(16))) } + assertEquals(input("kp_remove"), sent("kp_remove")) + } + + @Test + fun `kp_take arguments match cordn's schema`() = + runTest { + scripted("kp_take", output("kp_take", "emptyOutput")) { client.takeKeyPackage("1f".repeat(16)) } + assertEquals(input("kp_take"), sent("kp_take")) + } + + @Test + fun `welcome_store arguments match cordn's schema, with and without a cursor`() = + runTest { + driving { + client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==", after = 12) + } + assertEquals(input("welcome_store"), sent("welcome_store")) + + coordinator.calls.clear() + driving { client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==") } + assertEquals(input("welcome_store", "inputWithoutAfter"), sent("welcome_store")) + } + + @Test + fun `welcome_take omits consumed when there is nothing to retire`() = + runTest { + driving { client.takeWelcomes() } + assertEquals(input("welcome_take"), sent("welcome_take")) + + coordinator.calls.clear() + driving { client.takeWelcomes(listOf(ConsumedWelcomeRef("1f".repeat(16), 1757000000L))) } + assertEquals(input("welcome_take", "inputWithConsumed"), sent("welcome_take")) + } + + @Test + fun `join_request arguments match cordn's schema`() = + runTest { + val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + driving { client.storeJoinRequest(gid, "1f".repeat(16)) } + assertEquals(input("join_request_store"), sent("join_request_store")) + + coordinator.calls.clear() + driving { client.takeJoinRequests(listOf(gid)) } + assertEquals(input("join_request_take_many"), sent("join_request_take_many")) + + coordinator.calls.clear() + driving { + client.takeJoinRequests(listOf(gid), listOf(ConsumedJoinRequestRef(gid, "b".repeat(64), 1757000400L))) + } + assertEquals(input("join_request_take_many", "inputWithConsumed"), sent("join_request_take_many")) + } + + @Test + fun `msg_post arguments match cordn's schema`() = + runTest { + driving { client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") } + assertEquals(input("msg_post"), sent("msg_post")) + } + + @Test + fun `a first fetch omits after entirely rather than sending zero`() = + runTest { + // `after` is typed as a number, so 0 is a real cursor, not "from + // the start" -- sending it would silently skip the first message. + val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + driving { client.fetchMessages(mapOf(gid to null)) } + assertEquals(input("msg_fetch_many"), sent("msg_fetch_many")) + + coordinator.calls.clear() + driving { client.fetchMessages(mapOf(gid to 42L)) } + assertEquals(input("msg_fetch_many", "inputWithCursor"), sent("msg_fetch_many")) + } + + // ---- what we read ---------------------------------------------------- + + @Test + fun `kp_publish result parses`() = + runTest { + lateinit var seen: PublishedKeyPackage + scripted("kp_publish", output("kp_publish")) { + seen = client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") + } + assertEquals("1f".repeat(16), seen.keyPackageRef) + assertEquals(false, seen.lastResort) + assertEquals(1757000000L, seen.at) + } + + @Test + fun `kp_list result parses, last_resort and all`() = + runTest { + lateinit var seen: List + scripted("kp_list", output("kp_list")) { seen = client.listKeyPackages() } + assertEquals(2, seen.size) + assertEquals("a".repeat(64), seen[0].pubKey) + assertEquals(false, seen[0].lastResort) + assertEquals(true, seen[1].lastResort) + assertEquals(1757000100L, seen[1].at) + } + + @Test + fun `kp_take result carries the publication event, and a null one means nothing held`() = + runTest { + lateinit var taken: TakenKeyPackage + scripted("kp_take", output("kp_take")) { taken = client.takeKeyPackage("2e".repeat(16))!! } + assertEquals("b".repeat(64), taken.pubKey) + assertEquals(true, taken.lastResort) + assertEquals(25910, taken.publicationEvent.kind) + // Read back out of the signed payload, not from a sibling field. + assertEquals("AAECAwQFBgc=", taken.keyPackageBase64()) + + coordinator.calls.clear() + coordinator.scriptedResults.clear() + var empty: TakenKeyPackage? = null + scripted("kp_take", output("kp_take", "emptyOutput")) { empty = client.takeKeyPackage("2e".repeat(16)) } + assertNull(empty, "`keyPackage: null` means the coordinator holds nothing, not an error") + } + + @Test + fun `welcome_take result parses, including the optional resume cursor`() = + runTest { + lateinit var seen: List + scripted("welcome_take", output("welcome_take")) { seen = client.takeWelcomes() } + assertEquals(2, seen.size) + assertEquals(12L, seen[0].after) + assertNull(seen[1].after, "`after` is optional; absent must not become 0") + assertEquals("V2VsY29tZQ==", seen[0].welcomeBase64) + } + + @Test + fun `join_request_take_many result parses, gid and all`() = + runTest { + lateinit var seen: List + scripted("join_request_take_many", output("join_request_take_many")) { + seen = client.takeJoinRequests(listOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21")) + } + assertEquals(1, seen.size) + // The single-group variant of this record has no `gid`; the many + // variant does, and we read the many one. + assertEquals("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", seen[0].gid) + assertEquals("b".repeat(64), seen[0].pubKey) + } + + @Test + fun `msg_post and msg_fetch_many results parse`() = + runTest { + lateinit var posted: PostedMessage + scripted("msg_post", output("msg_post")) { + posted = client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") + } + assertEquals(42L, posted.cursor) + + coordinator.calls.clear() + coordinator.scriptedResults.clear() + lateinit var fetched: List + scripted("msg_fetch_many", output("msg_fetch_many")) { + fetched = client.fetchMessages(mapOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" to 42L)) + } + assertEquals(listOf(43L, 44L), fetched.map { it.cursor }) + assertEquals("c2VhbGVkLTE=", fetched[0].sealedBase64) + } + + @Test + fun `every coordinator tool name matches cordn's own table`() = + runTest { + val theirs = + vectors["methods"]!! + .jsonObject.values + .map { it.toString().trim('"') } + .toSet() + val ours = CoordinatorMethod.entries.map { it.wire }.toSet() + assertEquals(theirs, ours, "the tool name set must match cordn's COORDINATOR_METHODS exactly") + assertTrue(theirs.size == 11) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnApplicationMessageTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnApplicationMessageTest.kt new file mode 100644 index 0000000000..b5cba8d981 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnApplicationMessageTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The SEND direction, which the ts-mls fixtures cannot cover. + * + * Those fixtures were written by ts-mls, so they prove we read what cordn + * writes. Nothing in them would notice if we stopped writing the + * `authenticated_data` binding on the way out — and cordn rejects an + * application message that arrives without it + * (`packages/cli/src/groupSync.ts:247`), so the symptom would be every peer + * silently dropping everything we send, with our own client perfectly happy. + */ +class CordnApplicationMessageTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private fun group() = MlsGroup.create(identity = CordnCredential.of(alice).identity, policy = CordnGroupPolicy) + + private fun envelope(content: String = "hello") = CordnEnvelope.build(pubKey = alice, createdAt = 1_700_000_000L, kind = 9, content = content) + + @Test + fun `a sealed message carries the sender in authenticated_data`() { + val group = group() + val sealed = CordnApplicationMessage.seal(group, alice, envelope()) + + // Decrypt through a second view of the same epoch so the ratchet is not + // the thing under test. + val decrypted = + group.decrypt( + com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload + .open( + sealed, + com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload + .applicationKey(group), + ), + ) + + assertContentEquals( + alice.encodeToByteArray(), + decrypted.authenticatedData, + "cordn binds the sender here, and a peer refuses the message without it", + ) + } + + @Test + fun `the authenticated sender is what open reports, not the envelope`() { + val group = group() + val received = CordnApplicationMessage.open(group, CordnApplicationMessage.seal(group, alice, envelope())) + + assertEquals(alice, received.sender) + assertEquals("hello", received.envelope.content) + assertEquals(group.leafIndex, received.senderLeafIndex) + } + + @Test + fun `an envelope claiming a different pubkey is refused at the sender`() { + // Caught before it goes out rather than at every peer, which is the + // difference between an error here and a message nobody accepts. + val group = group() + val forged = CordnEnvelope.build(pubKey = bob, createdAt = 1_700_000_000L, kind = 9, content = "not me") + + assertFailsWith { CordnApplicationMessage.seal(group, alice, forged) } + } + + @Test + fun `a message with no authenticated sender is rejected on receive`() { + // The engine's default is an empty AAD, which is right for Marmot and + // fatal for cordn. Treating empty as "unknown sender" instead of a + // rejection would let anyone omit the field and let the envelope's own + // pubkey fill the gap unchallenged. + val group = group() + val bare = group.encrypt(envelope().encode()) + val decrypted = group.decrypt(bare) + + val error = assertFailsWith { CordnApplicationMessage.open(decrypted) } + assertTrue(error.message?.contains("authenticated sender") == true, "got '${error.message}'") + } + + @Test + fun `a round trip survives non-ASCII content`() { + val group = group() + val text = "reply at epoch 2 ✨ 🪜" + val received = CordnApplicationMessage.open(group, CordnApplicationMessage.seal(group, alice, envelope(text))) + + assertEquals(text, received.envelope.content) + assertEquals(received.envelope.computedId(), received.envelope.id) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt new file mode 100644 index 0000000000..14a5ed3095 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * `cordn1…` refs against cordn's own bech32 codec. + * + * The strings below came out of `@cordn/core`'s `encodeGroupRef`, not out of + * ours (see `quartz/tools/cordn-vector-gen`). A group ref is the one cordn + * artifact a user copies and pastes by hand, so a divergence here is not a + * protocol nuance — it is a link that works in one client and not the other. + * + * Both directions are checked: we must decode what they produce, and produce + * byte-identical output for the same input. The second half is the one that + * catches TLV ordering, since §3 lets a decoder accept any order and would hide + * the difference. + */ +class CordnGroupRefVectorTest { + private val vectors: JsonObject = + Json + .parseToJsonElement( + checkNotNull(CordnGroupRefVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) { + "missing cordn contract vectors" + }.use { it.readBytes() } + .decodeToString(), + ).jsonObject + + private fun JsonObject.text(key: String) = this[key]?.jsonPrimitive?.content + + private fun JsonObject.toRef() = + CordnGroupRef( + gid = text("gid")!!, + coordinatorPubKey = text("coordinatorPubkey"), + relays = this["relays"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty(), + ) + + @Test + fun `we decode every ref cordn encodes`() { + val cases = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject } + assertEquals(6, cases.size, "the vector file lost cases") + + cases.forEach { case -> + val decoded = CordnGroupRef.decode(case.text("encoded")!!) + assertEquals(case.toRef(), decoded, "decoding ${case.text("encoded")}") + } + } + + @Test + fun `we encode byte-identically to cordn`() { + vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.forEach { case -> + assertEquals( + case.text("encoded"), + case.toRef().encode(), + "encoding gid=${case.text("gid")}", + ) + } + } + + @Test + fun `an all-uppercase ref decodes, as it does for cordn`() { + // Bech32 forbids mixed case but permits uppercase, and their + // `decodeGroupRef` accepts it even though their `isGroupRef` screen + // does not. A ref pasted in caps has to keep working. + val case = vectors["groupRefUppercase"]!!.jsonObject + assertEquals(case.toRef(), CordnGroupRef.decode(case.text("encoded")!!)) + } + + @Test + fun `we refuse everything cordn refuses`() { + vectors["groupRefRejects"]!!.jsonArray.forEach { + val bad = it.jsonPrimitive.content + assertFails("must refuse ${bad.ifEmpty { "" }}") { CordnGroupRef.decode(bad) } + } + } + + @Test + fun `a non-ASCII gid survives byte for byte`() { + // §4.1: the coordinator keys its cursor space on these bytes, so any + // normalisation on our side silently splits a group in two. + val case = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.last { it.text("gid")!!.any { c -> c.code > 127 } } + val decoded = CordnGroupRef.decode(case.text("encoded")!!) + assertEquals(case.text("gid"), decoded.gid) + assertEquals(case.text("encoded"), decoded.encode()) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnLifecycleInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnLifecycleInteropTest.kt new file mode 100644 index 0000000000..5915d75df1 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnLifecycleInteropTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A full cordn group lifecycle, against fixtures **ts-mls** produced. + * + * Every other test in this module proves we agree with ourselves. This one + * proves we agree with the implementation cordn's own client runs: Alice + * creates a group in TypeScript, adds Bob, and sends a message, and we join as + * Bob and read it. + * + * Each step is a separate test because each fails for a different reason, and + * a single end-to-end assertion would tell you only that something in a chain + * of six broke. + */ +class CordnLifecycleInteropTest { + private val alice = TsMlsFixtures.text("alice.pk") + private val bob = TsMlsFixtures.text("bob.pk") + + private fun bobBundle() = TsMlsPrivateKeyPackage.decode(TsMlsFixtures.bytes("bob-kp.bin"), TsMlsFixtures.bytes("bob-privkp.bin")) + + /** Bob's group at epoch 1, joined from the ts-mls Welcome. */ + private fun bobJoined(): MlsGroup = MlsGroup.processWelcome(TsMlsFixtures.b64("welcome.b64"), bobBundle(), CordnGroupPolicy) + + // ---- 1. their KeyPackage, our decoder ---------------------------------- + + @Test + fun `we read a ts-mls KeyPackage and compute the same KeyPackageRef`() { + val keyPackage = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-kp.bin"))) + + assertEquals( + bob, + CordnCredential.identityOrNull(keyPackage.leafNode), + "the credential must decode as cordn's 64-ASCII-hex identity", + ) + // kp_ref is the coordinator's primary key for a KeyPackage. Disagreeing + // here means every kp_take and every Welcome addressed to us misses. + assertEquals(TsMlsFixtures.text("bob-kpref.hex"), keyPackage.reference().toHexKey()) + } + + @Test + fun `we recognise a ts-mls last-resort KeyPackage`() { + // cordn marks it with app_data_dictionary component 0x0004; Marmot's + // MIP-era profile uses bare extension 0x000A. Our reader accepts both, + // and this pins the cordn carrier against a real one. + val lastResort = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-lastresort-kp.bin"))) + assertTrue(lastResort.isLastResort()) + + val ordinary = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-kp.bin"))) + assertTrue(!ordinary.isLastResort(), "and must not see it where there is none") + } + + @Test + fun `the ts-mls private key package round-trips through our codec`() { + val bundle = bobBundle() + assertEquals(32, TsMlsPrivateKeyPackage.seedOf(bundle.signaturePrivateKey).size) + assertContentEquals( + TsMlsFixtures.bytes("bob-privkp.bin"), + TsMlsPrivateKeyPackage.encode(bundle), + "re-encoding must reproduce ts-mls's bytes, PKCS#8 wrapper included", + ) + } + + // ---- 2. their seal, our exporter --------------------------------------- + + @Test + fun `we unseal a ts-mls commit with the published epoch-0 exporter`() { + // spec/03.md §5: a Commit is sealed under the epoch it transitions FROM, + // so every member still at that epoch can read it before advancing. + // Sealing it under the new epoch would lock out exactly the members it + // is addressed to. + val opened = SealedPayload.open(TsMlsFixtures.text("commit-add-sealed.b64"), TsMlsFixtures.hex("exporter-e0.hex")) + assertContentEquals(TsMlsFixtures.b64("commit-add.b64"), opened) + } + + @Test + fun `we unseal a ts-mls application message with the epoch-1 exporter`() { + val opened = SealedPayload.open(TsMlsFixtures.text("app-1-sealed.b64"), TsMlsFixtures.hex("exporter-e1.hex")) + assertContentEquals(TsMlsFixtures.b64("app-1.b64"), opened) + } + + // ---- 3. their Welcome, our join --------------------------------------- + + @Test + fun `we join the group from the ts-mls Welcome`() { + val group = bobJoined() + + assertEquals(1L, group.epoch, "the Welcome admits us at epoch 1") + assertEquals(bob, CordnCredential.identityOrNull(group.members()[group.leafIndex].second)) + assertEquals( + setOf(alice, bob), + CordnCredential.memberIdentities(group), + // Not group.currentMemberIdentities(): that hexes the credential + // bytes, which for cordn's already-hex identity gives 128 + // characters of hex-of-hex. The trap is real enough that cordn has + // its own accessor. + ) + } + + @Test + fun `our derived exporter matches theirs byte for byte`() { + // The single strongest assertion available. The exporter sits at the + // end of the whole key schedule, so agreeing on it means the tree, the + // transcript hash, the commit secret and every epoch secret matched. A + // one-bit divergence anywhere upstream produces 32 completely different + // bytes here. + val group = bobJoined() + val exporter = CordnGroupPolicy.PAYLOAD_EXPORTER + + assertContentEquals( + TsMlsFixtures.hex("exporter-e1.hex"), + group.exporterSecret(exporter.label, exporter.context, exporter.length), + ) + } + + @Test + fun `we read the group metadata ts-mls put in the GroupContext`() { + // Also proves the RFC 9420 §12.1.7 fix: the old engine refused any + // extension type outside a hardcoded list, and 0xC04D is not in it. + val metadata = CordnGroupMetadata.fromExtensions(bobJoined().extensions) + + assertEquals("Conformance", metadata?.name) + assertEquals(listOf(alice), metadata?.adminPubkeys) + assertEquals("🪜", metadata?.icon, "the emoji must survive as UTF-8") + assertTrue(!metadata!!.isEgalitarian, "this group names an admin") + } + + // ---- 4. their message, our reader ------------------------------------- + + @Test + fun `we open a ts-mls application message end to end`() { + // The whole path: unseal, MLS-decrypt, read the authenticated sender out + // of AAD, and hold the unsigned envelope to it. + val received = CordnApplicationMessage.open(bobJoined(), TsMlsFixtures.text("app-1-sealed.b64")) + + assertEquals(alice, received.sender, "the sender comes from authenticated_data, not the envelope") + assertEquals("hello from ts-mls", received.envelope.content) + assertEquals(9, received.envelope.kind) + assertEquals(bob, received.envelope.tags.single()[1], "the `p` tag names Bob") + } + + @Test + fun `the envelope id we recompute matches the one ts-mls wrote`() { + // spec/02.md §4 makes the receiver recompute it. If our NIP-01 + // serialization differed by so much as a space, every message would be + // rejected as tampered. + val expected = CordnEnvelope.decode(TsMlsFixtures.bytes("envelope-1.json"), senderIdentity = alice) + val received = CordnApplicationMessage.open(bobJoined(), TsMlsFixtures.text("app-1-sealed.b64")) + + assertEquals(expected.id, received.envelope.id) + assertEquals(expected.id, received.envelope.computedId()) + } + + // ---- 5. their metadata commit, our epoch advance ----------------------- + + /** + * Bob at epoch 2, having processed ts-mls's metadata commit. + * + * The commit arrives PRIVATE-framed, not public. That is a cordn/Marmot + * difference worth noticing: Marmot publishes commits as PublicMessage + * inside a kind-445 event, while cordn seals everything to the coordinator + * and frames handshake traffic privately, so `decrypt` is the entry point + * and `processFramedCommit` is not. + */ + private fun bobAtEpoch2(): MlsGroup { + val group = bobJoined() + val commit = SealedPayload.open(TsMlsFixtures.text("commit-meta-sealed.b64"), TsMlsFixtures.hex("exporter-e1.hex")) + val decrypted = group.decrypt(commit) + assertEquals(ContentType.COMMIT, decrypted.contentType, "cordn frames handshake messages privately") + return group + } + + @Test + fun `we apply a ts-mls GroupContextExtensions commit that renames the group`() { + // The strongest available check on the RFC 9420 §12.1.7 fix. Before it, + // the engine refused any extension type outside a hardcoded list, so + // this real commit -- carrying 0xC04D -- would have been rejected and + // Bob would have sat at epoch 1 forever while everyone else moved on. + val group = bobAtEpoch2() + + assertEquals(2L, group.epoch) + assertEquals("Conformance (renamed)", CordnGroupMetadata.fromExtensions(group.extensions)?.name) + assertEquals( + "https://example.invalid/g.png", + CordnGroupMetadata.fromExtensions(group.extensions)?.imageUrl, + ) + } + + @Test + fun `our exporter still matches theirs after the epoch advance`() { + // Joining agreed at epoch 1; this proves the commit itself -- tree + // mutation, transcript hash, key schedule -- agreed too. + val group = bobAtEpoch2() + val exporter = CordnGroupPolicy.PAYLOAD_EXPORTER + + assertContentEquals( + TsMlsFixtures.hex("exporter-e2.hex"), + group.exporterSecret(exporter.label, exporter.context, exporter.length), + ) + } + + @Test + fun `we read a threaded reply sent at epoch 2`() { + val received = CordnApplicationMessage.open(bobAtEpoch2(), TsMlsFixtures.text("app-2-sealed.b64")) + + assertEquals(alice, received.sender) + assertEquals(1111, received.envelope.kind, "NIP-22 threaded reply, per spec/02.md §6") + assertEquals("reply at epoch 2 \u2728", received.envelope.content) + assertEquals( + CordnEnvelope.decode(TsMlsFixtures.bytes("envelope-2.json"), senderIdentity = alice).id, + received.envelope.id, + ) + } + + // ---- 6. their KeyPackage, our group ------------------------------------ + + @Test + fun `we can add a ts-mls member to a group we created`() { + // The reverse direction, as far as it goes without running their client: + // a group built by our engine under CordnGroupPolicy accepts a real + // ts-mls KeyPackage and produces a Welcome for it. A capability or + // required_capabilities mismatch between the two profiles would fail + // exactly here. + val group = + MlsGroup.create( + identity = CordnCredential.of(alice).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "from Kotlin").toExtension()), + ) + + val result = group.addMember(TsMlsFixtures.bytes("bob2-kp.bin")) + + assertEquals(1L, group.epoch) + assertEquals(setOf(alice, bob), CordnCredential.memberIdentities(group)) + assertTrue(result.welcomeBytes != null, "a Welcome must be produced for the joiner") + } + + // ---- 7. the delivery id ------------------------------------------------ + + @Test + fun `the gid survives a group-ref round trip`() { + // spec/applications/group-ref.md §4.1: byte for byte, no normalisation. + // This gid is a plain string rather than a UUID, which is exactly the + // case a decoder that "tidied" its input would break. + val gid = TsMlsFixtures.text("gid") + assertEquals(gid, CordnGroupRef.decode(CordnGroupRef(gid).encode()).gid) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/KotlinArtifactProducerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/KotlinArtifactProducerTest.kt new file mode 100644 index 0000000000..73c726fe72 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/KotlinArtifactProducerTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import java.io.File +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Produces the artifacts Staircase's `conformance/fixtures-gen/verify.ts` reads, + * so **ts-mls** can check our output rather than the other way round. + * + * [CordnLifecycleInteropTest] proves we can read what ts-mls writes. That is + * only half of interoperating: a client can parse everything correctly and + * still emit something nobody else accepts — and the failure mode is worse, + * because our own tests stay green while every peer silently drops us. + * + * This test writes files; `cordn/interop/verify-with-ts-mls.sh` feeds them to + * ts-mls. The split is deliberate: producing the artifacts needs no toolchain + * beyond Gradle and runs in CI unconditionally, while running ts-mls needs bun + * and a cordn checkout. A green run here means the artifacts exist and our own + * engine round-trips them; it does **not** by itself mean ts-mls accepted them. + * + * Output goes to `-Dcordn.interop.out`, defaulting to `build/interop/kotlin`. + */ +@OptIn(ExperimentalEncodingApi::class) +class KotlinArtifactProducerTest { + private val alice = "cc".repeat(32) + + private val out = + File(System.getProperty("cordn.interop.out") ?: "build/interop/kotlin").also { it.mkdirs() } + + private fun write( + name: String, + value: String, + ) = File(out, name).writeText(value) + + private val metadata = + CordnGroupMetadata( + name = "Kotlin conformance", + description = "quartz MLS via the cordn binding", + adminPubkeys = listOf(alice), + icon = "💎", + ) + + private val updatedMetadata = metadata.copy(name = "Kotlin conformance (renamed)") + + private fun exporterHex(group: MlsGroup): String = CordnGroupPolicy.PAYLOAD_EXPORTER.let { group.exporterSecret(it.label, it.context, it.length) }.toHexKey() + + @Test + fun `produce the artifacts ts-mls verifies`() { + // --- epoch 0: alice creates a cordn group carrying metadata --------- + val group = + MlsGroup.create( + identity = CordnCredential.of(alice).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(metadata.toExtension()), + ) + write("k-alice.pk", alice) + write("k-meta.json", metadataJson(metadata)) + + // --- epoch 1: add THEIR key package --------------------------------- + // bob2 is a real ts-mls KeyPackage. Using ours would make this a test + // of our own encoder talking to itself. + val add = group.addMember(TsMlsFixtures.bytes("bob2-kp.bin")) + val welcome = assertNotNull(add.welcomeBytes, "adding a member must produce a Welcome") + assertEquals(1L, group.epoch) + + write("k-welcome.b64", Base64.encode(welcome)) + write("k-exporter-e1.hex", exporterHex(group)) + + // --- an application message at epoch 1 ------------------------------- + val envelope = + CordnEnvelope.build( + pubKey = alice, + createdAt = 1_700_000_100L, + kind = 9, + content = "hello from quartz 💎", + ) + write("k-app-sealed.b64", CordnApplicationMessage.seal(group, alice, envelope)) + write("k-envelope.json", envelopeJson(envelope)) + + // --- epoch 2: a metadata change ------------------------------------- + // Sealed with the PRE-commit key the commit itself reports. Sealing + // under the post-commit epoch would lock out every member still at + // epoch 1 — which is everyone this commit is addressed to. + group.proposeGroupContextExtensions( + group.extensions.filterNot { it.extensionType == CordnGroupMetadata.EXTENSION_TYPE } + + updatedMetadata.toExtension(), + ) + val commit = group.commit() + assertEquals(2L, group.epoch) + assertTrue(commit.preCommitExporterSecret.isNotEmpty(), "the policy must supply a commit exporter") + + write("k-commit-meta-sealed.b64", SealedPayload.seal(commit.framedCommitBytes, commit.preCommitExporterSecret)) + write("k-meta-2.json", metadataJson(updatedMetadata)) + write("k-exporter-e2.hex", exporterHex(group)) + + // Our own round trip, so a broken artifact fails here rather than only + // in a script someone may not run. + assertEquals( + updatedMetadata.name, + CordnGroupMetadata.fromExtensions(group.extensions)?.name, + ) + assertEquals( + 9, + listOf( + "k-alice.pk", + "k-meta.json", + "k-welcome.b64", + "k-exporter-e1.hex", + "k-app-sealed.b64", + "k-envelope.json", + "k-commit-meta-sealed.b64", + "k-meta-2.json", + "k-exporter-e2.hex", + ).count { File(out, it).exists() }, + "every artifact verify.ts reads must be written", + ) + } + + /** verify.ts compares name, description and adminPubkeys against the extension. */ + private fun metadataJson(meta: CordnGroupMetadata) = + buildString { + append("{\"name\":").append(jsonString(meta.name)) + append(",\"description\":").append(jsonString(meta.description)) + append(",\"adminPubkeys\":[").append(meta.adminPubkeys.joinToString(",") { jsonString(it) }).append("]") + append(",\"icon\":").append(jsonString(meta.icon)) + append("}") + } + + /** verify.ts compares id, content and pubkey. */ + private fun envelopeJson(envelope: CordnEnvelope) = + buildString { + append("{\"id\":").append(jsonString(envelope.id)) + append(",\"pubkey\":").append(jsonString(envelope.pubKey)) + append(",\"created_at\":").append(envelope.createdAt) + append(",\"kind\":").append(envelope.kind) + append(",\"content\":").append(jsonString(envelope.content)) + append("}") + } + + private fun jsonString(value: String) = + buildString { + append('"') + value.forEach { + when { + it == '"' -> append("\\\"") + it == '\\' -> append("\\\\") + it.code < 0x20 -> append("\\u%04x".format(it.code)) + else -> append(it) + } + } + append('"') + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/TsMlsFixtures.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/TsMlsFixtures.kt new file mode 100644 index 0000000000..5628e53cd9 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/TsMlsFixtures.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** Reads the vendored ts-mls fixtures. See `resources/tsmls/README.md`. */ +@OptIn(ExperimentalEncodingApi::class) +object TsMlsFixtures { + private fun resource(name: String): ByteArray = + checkNotNull(TsMlsFixtures::class.java.getResourceAsStream("/tsmls/$name")) { + "missing ts-mls fixture '$name'" + }.use { it.readBytes() } + + fun bytes(name: String): ByteArray = resource(name) + + fun text(name: String): String = resource(name).decodeToString().trim() + + fun b64(name: String): ByteArray = Base64.decode(text(name)) + + fun hex(name: String): ByteArray = text(name).chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} + +/** + * ts-mls's `privateKeyPackageEncoder` layout: + * + * ``` + * opaque init_private_key || opaque hpke_private_key || opaque signature_private_key + * ``` + * + * The two implementations store the Ed25519 signing key differently and neither + * is wrong: ts-mls (noble/WebCrypto) writes a 48-byte PKCS#8 `PrivateKeyInfo` + * wrapping the 32-byte seed; Quartz keeps `seed || public`. The seed is the only + * thing either really holds, so that is what converts here, and the public half + * is re-derived rather than trusted — a pair that disagrees with itself cannot + * be built this way. + * + * Layout documented by Staircase (`cordn-core/…/KeyPackageBundleCodec.kt`, MIT); + * this is our own implementation of it. + */ +object TsMlsPrivateKeyPackage { + /** RFC 8410 PKCS#8 PrivateKeyInfo prefix for Ed25519; the 32-byte seed follows. */ + private val PKCS8_ED25519_PREFIX = + "302e020100300506032b657004220420".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private const val SEED_SIZE = 32 + + /** Accepts a bare seed, ts-mls's PKCS#8 blob, or Quartz's `seed || public`. */ + fun seedOf(privateKey: ByteArray): ByteArray = + when (privateKey.size) { + SEED_SIZE -> privateKey + 48 -> { + require(privateKey.copyOfRange(0, 16).contentEquals(PKCS8_ED25519_PREFIX)) { + "unexpected PKCS#8 Ed25519 header" + } + privateKey.copyOfRange(16, 48) + } + 64 -> privateKey.copyOfRange(0, SEED_SIZE) + else -> throw IllegalArgumentException("unsupported Ed25519 private key length ${privateKey.size}") + } + + fun decode( + keyPackageBytes: ByteArray, + privateBytes: ByteArray, + ): KeyPackageBundle { + val reader = TlsReader(privateBytes) + val initKey = reader.readOpaqueVarInt() + val encryptionKey = reader.readOpaqueVarInt() + val signingSeed = seedOf(reader.readOpaqueVarInt()) + require(!reader.hasRemaining) { "trailing bytes in ts-mls private key package" } + + return KeyPackageBundle( + keyPackage = MlsKeyPackage.decodeTls(TlsReader(keyPackageBytes)), + initPrivateKey = initKey, + encryptionPrivateKey = encryptionKey, + signaturePrivateKey = Ed25519.keyPairFromSeed(signingSeed).privateKey, + ) + } + + /** The inverse, so a round trip can be asserted. */ + fun encode(bundle: KeyPackageBundle): ByteArray { + val writer = TlsWriter() + writer.putOpaqueVarInt(bundle.initPrivateKey) + writer.putOpaqueVarInt(bundle.encryptionPrivateKey) + writer.putOpaqueVarInt(PKCS8_ED25519_PREFIX + seedOf(bundle.signaturePrivateKey)) + return writer.toByteArray() + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClientTest.kt new file mode 100644 index 0000000000..2be514cef7 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClientTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The coordinator client against an in-memory coordinator. + * + * The interesting assertions are not "the call returned" but "the coordinator + * learned only what `spec/00.md` §8 says it learns" — which is checkable only + * by standing on the coordinator's side of the wire, which is what + * [CordnFixtureCoordinator] is for. + */ +class CoordinatorClientTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + + private fun client() = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stableSigner, ephemeralSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + + private fun serve(coordinator: CordnFixtureCoordinator) = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + + /** Runs [block] while pumping the fixture, since the relay callback cannot suspend. */ + private suspend fun driving( + server: CvmFixtureServer, + block: suspend () -> T, + ): T = + coroutineScope { + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + @Test + fun `the message path never touches the stable identity`() = + runTest { + // The privacy claim of spec/00.md §8, checked from the coordinator's + // side. A client that signed msg_post with the account key would + // still work perfectly -- and would tie every message to a real + // npub on a server that keeps ordered history forever. + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + driving(server) { + client.postMessage("group-1", "c2VhbGVk") + client.fetchMessages(mapOf("group-1" to null)) + client.listKeyPackages() + } + + assertTrue(coordinator.calls.isNotEmpty()) + coordinator.calls.forEach { + assertEquals( + ephemeralSigner.pubKey, + it.callerPubKey, + "${it.method} must not be attributable to the account identity", + ) + } + } + + @Test + fun `publishing and joining are attributable, because they name you by design`() = + runTest { + // The other half. These are not leaks to be fixed: a KeyPackage + // publication that did not name its owner would bind nothing, and a + // join request that did not name the asker could not be acted on. + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + driving(server) { + client.publishKeyPackage("ref-1", "a2V5") + client.storeJoinRequest("group-1", "ref-1") + client.takeWelcomes() + } + + coordinator.calls.forEach { + assertEquals(stableSigner.pubKey, it.callerPubKey, "${it.method} must name the account") + } + } + + @Test + fun `a coordinator rejection surfaces as an exception, not an empty result`() = + runTest { + val coordinator = CordnFixtureCoordinator(rejectPublication = true) + val server = serve(coordinator) + val client = client() + + assertFailsWith { + driving(server) { client.publishKeyPackage("ref-1", "a2V5") } + } + } + + @Test + fun `fetch returns a group's messages after its cursor`() = + runTest { + val coordinator = CordnFixtureCoordinator() + coordinator.seed("group-1", "b25l") + val second = coordinator.seed("group-1", "dHdv") + coordinator.seed("group-1", "dGhyZWU=") + coordinator.seed("group-2", "b3RoZXI=") + val server = serve(coordinator) + val client = client() + + val page = driving(server) { client.fetchMessages(mapOf("group-1" to second)) } + + assertEquals(listOf("dGhyZWU="), page.map { it.sealedBase64 }) + assertEquals("group-1", page.single().gid, "another group's stream must not leak into this one") + } + + @Test + fun `a first fetch omits the cursor rather than sending zero`() = + runTest { + // The schema types `after` as a positive int, so 0 is not "from the + // beginning" -- it is out of range, and a coordinator validating its + // own contract would reject the call. + val coordinator = CordnFixtureCoordinator() + coordinator.seed("group-1", "b25l") + val server = serve(coordinator) + val client = client() + + val page = driving(server) { client.fetchMessages(mapOf("group-1" to null)) } + assertEquals(1, page.size) + } + + @Test + fun `taking a key package nobody published returns null`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + assertNull(driving(server) { client.takeKeyPackage("nobody") }) + } + + @Test + fun `posting reports the cursor the coordinator assigned`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + val posted = driving(server) { client.postMessage("group-1", "c2VhbGVk") } + + assertEquals("group-1", posted.gid) + assertTrue(posted.cursor > 0) + assertEquals(listOf("c2VhbGVk"), coordinator.posted("group-1")) + } + + @Test + fun `welcomes carry the resume cursor when the inviter set one`() = + runTest { + // Without it a joiner replays a group's whole history, all of it + // sealed under epochs it has no key for. + val coordinator = CordnFixtureCoordinator() + coordinator.seedWelcome("ref-1", "d2VsY29tZQ==", after = 42, targetPubKey = stableSigner.pubKey) + val server = serve(coordinator) + val client = client() + + val welcomes = driving(server) { client.takeWelcomes() } + assertEquals(42L, welcomes.single().after) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfoTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfoTest.kt new file mode 100644 index 0000000000..fa0cc912a6 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfoTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `initialize` is the one coordinator call whose answer is pure claim: a name, + * a version, a capability object, none of it bound to anything but the key + * that was already signing the response (`spec/00.md` §8.5). So the only + * things worth asserting are that a claim survives the trip intact, and that + * every way a server can decline to make one leaves a null rather than a + * plausible-looking string on a settings screen. + */ +class CoordinatorServerInfoTest { + @Test + fun `reads name version protocol and capabilities`() { + val info = + CoordinatorServerInfo.from( + success( + """ + { + "protocolVersion": "2025-06-18", + "serverInfo": { "name": "cordn", "version": "0.5.7" }, + "capabilities": { "tools": {} } + } + """, + ), + )!! + + assertEquals("cordn", info.name) + assertEquals("0.5.7", info.version) + assertEquals("2025-06-18", info.protocolVersion) + assertEquals(JsonObject(emptyMap()), info.capabilities!!["tools"]) + assertFalse(info.isEmpty) + } + + @Test + fun `a handshake with no serverInfo is still a handshake`() { + // MCP lets a server omit serverInfo. Reporting that as a failed + // initialize would tell a user their coordinator is unreachable when + // it answered perfectly well. + val info = CoordinatorServerInfo.from(success("""{ "protocolVersion": "2025-06-18" }"""))!! + + assertNull(info.name) + assertNull(info.version) + assertEquals("2025-06-18", info.protocolVersion) + assertFalse(info.isEmpty, "a protocol version alone is worth showing") + } + + @Test + fun `an empty result is empty rather than a row of blanks`() { + val info = CoordinatorServerInfo.from(success("{}"))!! + + assertTrue(info.isEmpty) + assertNull(info.capabilities) + } + + @Test + fun `a JSON null name does not become the word null`() { + // jsonPrimitive.content renders JSON null as the four-character string + // "null", which is how a coordinator ends up listed as being called + // "null". Same for an empty string, which renders as a blank row. + val info = + CoordinatorServerInfo.from( + success("""{ "serverInfo": { "name": null, "version": "" }, "protocolVersion": null }"""), + )!! + + assertNull(info.name) + assertNull(info.version) + assertNull(info.protocolVersion) + assertTrue(info.isEmpty) + } + + @Test + fun `a non-success response yields nothing`() { + assertNull( + CoordinatorServerInfo.from( + JsonRpcFailure(JsonRpcId.Num(1), JsonRpcError(JsonRpcError.METHOD_NOT_FOUND, "no initialize here")), + ), + ) + assertNull(CoordinatorServerInfo.from(JsonRpcNotification("notifications/progress"))) + } + + @Test + fun `a result that is not an object yields nothing`() { + // A server answering `"result": "ok"` is not an MCP server; reading a + // name out of it would be inventing one. + assertNull(CoordinatorServerInfo.from(JsonRpcSuccess(JsonRpcId.Num(1), JsonPrimitive("ok")))) + } + + private fun success(result: String) = JsonRpcSuccess(JsonRpcId.Num(1), Json.parseToJsonElement(result.trimIndent())) +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSyncTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSyncTest.kt new file mode 100644 index 0000000000..429fb157ab --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSyncTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** Catch-up against a coordinator holding real history. */ +class CordnGroupSyncTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + + private fun sync(coordinator: CordnFixtureCoordinator): Pair { + val signer = NostrSignerInternal(KeyPair()) + val client = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(signer, signer), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + val server = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + return CordnGroupSync(client) to server + } + + private suspend fun driving( + server: CvmFixtureServer, + block: suspend () -> T, + ): T = + coroutineScope { + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + @Test + fun `catch-up drains a group and stops when the page comes back empty`() = + runTest { + val coordinator = CordnFixtureCoordinator() + repeat(5) { coordinator.seed("g1", "bXNnJGl0") } + val (sync, server) = sync(coordinator) + + val seen = mutableListOf() + val drained = driving(server) { sync.catchUp(listOf("g1")) { _, i -> seen += i } } + + assertEquals(5, drained) + assertEquals(5, seen.size) + assertEquals(5L, sync.inbox("g1").cursor.lastCursor) + } + + @Test + fun `catch-up resumes from a persisted cursor`() = + runTest { + val coordinator = CordnFixtureCoordinator() + coordinator.seed("g1", "b25l") + val second = coordinator.seed("g1", "dHdv") + coordinator.seed("g1", "dGhyZWU=") + val (sync, server) = sync(coordinator) + sync.restore("g1", GroupCursor(fetchCursor = second, lastCursor = second)) + + val drained = driving(server) { sync.catchUp(listOf("g1")) { _, _ -> } } + + assertEquals(1, drained, "only the message after the persisted cursor") + } + + @Test + fun `catch-up drains several groups independently`() = + runTest { + // spec/00.md §4: cursors are per group. One shared cursor would + // skip a quiet group's history the moment a busy one moved past it. + val coordinator = CordnFixtureCoordinator() + coordinator.seed("g1", "YQ==") + coordinator.seed("g2", "Yg==") + coordinator.seed("g1", "Yw==") + val (sync, server) = sync(coordinator) + + val perGroup = mutableMapOf() + driving(server) { + sync.catchUp(listOf("g1", "g2")) { gid, _ -> perGroup[gid] = (perGroup[gid] ?: 0) + 1 } + } + + assertEquals(2, perGroup["g1"]) + assertEquals(1, perGroup["g2"]) + } + + @Test + fun `a posted commit is recognised as our own when it comes back`() = + runTest { + // The whole point of the pending-operation table, end to end: the + // Commit we posted must come back as confirmation, never as work. + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + val seen = mutableListOf() + driving(server) { + sync.postCommit("g1", "Y29tbWl0") + sync.catchUp(listOf("g1")) { _, i -> seen += i } + } + + assertIs(seen.single()) + assertTrue(sync.unconfirmed().isEmpty(), "the pending operation is retired once confirmed") + } + + @Test + fun `a posted application message is not re-ingested as someone else's`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + val seen = mutableListOf() + driving(server) { + sync.postMessage("g1", "aGVsbG8=") + sync.catchUp(listOf("g1")) { _, i -> seen += i } + } + + assertIs(seen.single()) + } + + @Test + fun `a skipped message does not come back on the next catch-up`() = + runTest { + // The stall this prevents is invisible in a single pass: every + // outcome looks right, and only the SECOND catch-up reveals that + // the cursor never moved past the messages we chose not to process. + // In production that is a group that re-delivers its own echoes + // forever and never reaches the traffic behind them. + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + driving(server) { + sync.postCommit("g1", "Y29tbWl0") + sync.postMessage("g1", "aGVsbG8=") + sync.catchUp(listOf("g1")) { _, _ -> } + } + + val second = driving(server) { sync.catchUp(listOf("g1")) { _, _ -> } } + assertEquals(0, second, "both skipped messages must be behind the cursor") + } + + @Test + fun `an unconfirmed commit stays listed until its echo arrives`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + driving(server) { sync.postCommit("g1", "Y29tbWl0") } + + assertEquals(listOf("Y29tbWl0"), sync.unconfirmed()["g1"]?.map { it.sealedBase64 }) + } + + @Test + fun `cursors survive a round trip through persistence`() = + runTest { + val coordinator = CordnFixtureCoordinator() + repeat(3) { coordinator.seed("g1", "eA==") } + val (sync, server) = sync(coordinator) + driving(server) { sync.catchUp(listOf("g1")) { _, _ -> } } + + val saved = sync.cursors() + val (resumed, server2) = sync(coordinator) + saved.forEach { (gid, cursor) -> resumed.restore(gid, cursor) } + + val drained = driving(server2) { resumed.catchUp(listOf("g1")) { _, _ -> } } + assertEquals(0, drained, "a resumed client re-reads nothing") + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt index 8aafe8e10e..8565bc6982 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.protocolCore.ConvergencePolicy import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceStatus import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt index 9b35b3a6a8..711852c67e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt @@ -20,13 +20,15 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -57,7 +59,7 @@ class MarmotMipBehaviorTest { private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore()) private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle { - val tempGroup = MlsGroup.create(identity.hexToByteArray()) + val tempGroup = MlsGroup.create(identity.hexToByteArray(), policy = MarmotGroupPolicy) return tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0)) } @@ -67,7 +69,7 @@ class MarmotMipBehaviorTest { @Test fun create_installsRequiredCapabilitiesExtension() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) // RFC 9420 §13.3: required_capabilities is extension type 0x0003. val reqCaps = alice.extensions.find { it.extensionType == 0x0003 } @@ -405,10 +407,10 @@ class MarmotMipBehaviorTest { // must reject because Remove is admin-only. val proposals = listOf( - com.vitorpamplona.quartz.marmot.mls.group + com.vitorpamplona.quartz.mls.group .PendingProposal( proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Remove(removedLeafIndex = 0), senderLeafIndex = 1, @@ -416,7 +418,7 @@ class MarmotMipBehaviorTest { ) val ex = assertFailsWith { - alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 1) + MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 1) } assertTrue( ex.message!!.contains("non-admin members may only commit"), @@ -444,17 +446,17 @@ class MarmotMipBehaviorTest { // proposal list passes. val proposals = listOf( - com.vitorpamplona.quartz.marmot.mls.group + com.vitorpamplona.quartz.mls.group .PendingProposal( proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .SelfRemove(), senderLeafIndex = 99, ), ) // Should not throw. - alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 0) + MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 0) } @Test @@ -470,10 +472,10 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val proposals = listOf( - com.vitorpamplona.quartz.marmot.mls.group + com.vitorpamplona.quartz.mls.group .PendingProposal( proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .GroupContextExtensions( extensions = @@ -488,7 +490,7 @@ class MarmotMipBehaviorTest { ), ) assertFailsWith { - alice.enforceNoAdminDepletion(proposals) + MarmotGroupPolicy.enforceNoAdminDepletion(alice.view(), proposals) } } @@ -517,7 +519,7 @@ class MarmotMipBehaviorTest { val staged = alice.pendingProposalsSnapshot() assertEquals(1, staged.size, "buildSelfRemoveProposalMessage must also stage to pending pool") val entry = staged.single() - assertIs(entry.proposal) + assertIs(entry.proposal) assertEquals(alice.leafIndex, entry.senderLeafIndex) // The captured AC bytes are what RFC 9420 §5.2's MakeProposalRef // hashes — must be present so a peer's commit referencing this @@ -574,7 +576,7 @@ class MarmotMipBehaviorTest { val (alice, bob) = build2MemberGroupWithBobJoined() val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .SelfRemove() val before = alice.pendingProposalsSnapshot().size @@ -582,14 +584,14 @@ class MarmotMipBehaviorTest { val result = alice.decrypt(wireBytes) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.ContentType.PROPOSAL, + com.vitorpamplona.quartz.mls.framing.ContentType.PROPOSAL, result.contentType, ) assertEquals(bob.leafIndex, result.senderLeafIndex) val after = alice.pendingProposalsSnapshot() assertEquals(before + 1, after.size, "decrypt must stage the proposal in pending pool") val staged = after.last() - assertIs(staged.proposal) + assertIs(staged.proposal) assertEquals(bob.leafIndex, staged.senderLeafIndex) assertNotNull( staged.authenticatedContentBytes, @@ -610,7 +612,7 @@ class MarmotMipBehaviorTest { val (alice, bob) = build2MemberGroupWithBobJoined() val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = ByteArray(16) { 0xAB.toByte() }, pskNonce = ByteArray(16)) val wireBytes = bob.encryptProposalAsPrivateMessage(proposal) @@ -639,7 +641,7 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val welcomeBytes = requireNotNull(commitResult.welcomeBytes) { "addMember must produce a Welcome" } - val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle) + val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle, policy = MarmotGroupPolicy) return alice to bob } @@ -658,7 +660,7 @@ class MarmotMipBehaviorTest { @Test fun secretTree_rejectsRatchetJumpsBeyondCap() { val st = - com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree( + com.vitorpamplona.quartz.mls.schedule.SecretTree( encryptionSecret = ByteArray(32), leafCount = 1, ) @@ -682,7 +684,7 @@ class MarmotMipBehaviorTest { @Test fun privateMessage_rejectsOversizedAuthenticatedData() { val w = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() w.putOpaqueVarInt(ByteArray(32)) // group_id w.putUint64(0L) // epoch @@ -692,9 +694,9 @@ class MarmotMipBehaviorTest { w.putOpaqueVarInt(ByteArray(64)) // ciphertext (small) val ex = assertFailsWith { - com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage + com.vitorpamplona.quartz.mls.framing.PrivateMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(w.toByteArray()), ) } @@ -715,11 +717,11 @@ class MarmotMipBehaviorTest { */ @Test fun verifyTreeParentHashesForJoin_acceptsSingleMemberTree() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val tree = - com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree + com.vitorpamplona.quartz.mls.tree.RatchetTree .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(alice.exportTreeBytes()), ) assertNull(MlsGroup.verifyTreeParentHashesForJoin(tree)) @@ -749,9 +751,9 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val originalTree = - com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree + com.vitorpamplona.quartz.mls.tree.RatchetTree .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(alice.exportTreeBytes()), ) @@ -763,7 +765,7 @@ class MarmotMipBehaviorTest { for (i in 0 until originalTree.leafCount) { val leaf = originalTree.getLeaf(i) ?: continue if (leaf.leafNodeSource == - com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource.COMMIT + com.vitorpamplona.quartz.mls.tree.LeafNodeSource.COMMIT ) { val tampered = leaf.copy(parentHash = ByteArray(32) { 0x99.toByte() }) originalTree.setLeaf(i, tampered) @@ -798,7 +800,7 @@ class MarmotMipBehaviorTest { */ @Test fun findRequiredCapabilities_decodesMarmotExtensionInstalledByCreate() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val req = MlsGroup.findRequiredCapabilities(alice.extensions) ?: error("required_capabilities must be present after create()") @@ -823,7 +825,7 @@ class MarmotMipBehaviorTest { ) // Missing 0xF2EE. val caps = - com.vitorpamplona.quartz.marmot.mls.tree.Capabilities( + com.vitorpamplona.quartz.mls.tree.Capabilities( extensions = emptyList(), proposals = listOf(0x000A), credentials = listOf(0x0001), @@ -847,7 +849,7 @@ class MarmotMipBehaviorTest { credentials = emptyList(), ) val caps = - com.vitorpamplona.quartz.marmot.mls.tree.Capabilities( + com.vitorpamplona.quartz.mls.tree.Capabilities( extensions = emptyList(), proposals = emptyList(), credentials = listOf(0x0001), @@ -866,7 +868,7 @@ class MarmotMipBehaviorTest { credentials = listOf(0x0001), ) val caps = - com.vitorpamplona.quartz.marmot.mls.tree.Capabilities( + com.vitorpamplona.quartz.mls.tree.Capabilities( extensions = listOf(0xF2EE, 0x1234), proposals = listOf(0x000A, 0x000B), credentials = listOf(0x0001, 0x0002), @@ -905,8 +907,8 @@ class MarmotMipBehaviorTest { * (SelfRemove), then re-sign so the KP's outer signature still * validates. Useful for testing the §7.2 gate in isolation. */ - private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage { - val tempGroup = MlsGroup.create(identity.hexToByteArray()) + private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.mls.messages.MlsKeyPackage { + val tempGroup = MlsGroup.create(identity.hexToByteArray(), policy = MarmotGroupPolicy) val bundle = tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0)) val original = bundle.keyPackage val originalLeaf = original.leafNode @@ -923,7 +925,7 @@ class MarmotMipBehaviorTest { originalLeaf.copy(capabilities = tamperedCaps).let { lf -> val tbs = lf.encodeTbs() val sig = - com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider + com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider .signWithLabel(bundle.signaturePrivateKey, "LeafNodeTBS", tbs) lf.copy(signature = sig) } @@ -931,7 +933,7 @@ class MarmotMipBehaviorTest { val unsigned = original.copy(leafNode = tamperedLeaf, signature = ByteArray(0)) return unsigned.copy( signature = - com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider + com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider .signWithLabel(bundle.signaturePrivateKey, "KeyPackageTBS", unsigned.encodeTbs()), ) } @@ -947,7 +949,7 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_emptyListReturnsAllZeros() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val out = alice.computePskSecret(emptyList()) assertEquals(32, out.size, "psk_secret length must be Nh = 32 for SHA-256") assertTrue(out.all { it == 0.toByte() }, "default_psk_secret is all zeros") @@ -970,14 +972,14 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_singleExternalPsk_matchesSpecDerivation() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val pskId = ByteArray(16) { (it + 1).toByte() } val pskNonce = ByteArray(16) { (0x80 or it).toByte() } val pskValue = ByteArray(32) { (0xA0 or (it and 0x0F)).toByte() } alice.registerPsk(pskId, pskValue) val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = pskId, pskNonce = pskNonce) @@ -985,11 +987,11 @@ class MarmotMipBehaviorTest { // Reference computation per §5.3 (PSKType=1, no usage/group/epoch). val zero = ByteArray(32) - val crypto = com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider + val crypto = com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider val pskExtracted = crypto.hkdfExtract(salt = zero, ikm = pskValue) val labelWriter = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() labelWriter.putUint8(1) // PSKType external labelWriter.putOpaqueVarInt(pskId) @@ -1017,12 +1019,12 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_resumptionPskRejectsUntilProposalWidened() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val pskId = ByteArray(16) { it.toByte() } alice.registerPsk(pskId, ByteArray(32)) val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 2, pskId = pskId, pskNonce = ByteArray(16)) @@ -1038,18 +1040,18 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_orderingChangesOutput() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val idA = ByteArray(16) { 0x11 } val idB = ByteArray(16) { 0x22 } alice.registerPsk(idA, ByteArray(32) { 0x33 }) alice.registerPsk(idB, ByteArray(32) { 0x44 }) val pskA = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = idA, pskNonce = ByteArray(8)) val pskB = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = idB, pskNonce = ByteArray(8)) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index bb5fd83ec0..73eaf4d668 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceStatus import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -380,28 +380,28 @@ class MarmotPipelineTest { // The framedCommitBytes must decode as an MlsMessage(PublicMessage(commit)) val framed = commitResult.framedCommitBytes val mlsMessage = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(framed), ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + com.vitorpamplona.quartz.mls.framing.WireFormat.PUBLIC_MESSAGE, mlsMessage.wireFormat, ) val publicMessage = - com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage + com.vitorpamplona.quartz.mls.framing.PublicMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(mlsMessage.payload), ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT, + com.vitorpamplona.quartz.mls.framing.ContentType.COMMIT, publicMessage.contentType, ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.SenderType.MEMBER, + com.vitorpamplona.quartz.mls.framing.SenderType.MEMBER, publicMessage.sender.senderType, ) assertNotNull(publicMessage.confirmationTag, "confirmation_tag must be present on a commit") @@ -431,13 +431,13 @@ class MarmotPipelineTest { val exporterKey = manager.exporterSecret(groupId) val mlsBytes = GroupEventEncryption.decrypt(event.content, exporterKey) val mlsMessage = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(mlsBytes), ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + com.vitorpamplona.quartz.mls.framing.WireFormat.PUBLIC_MESSAGE, mlsMessage.wireFormat, ) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CommitPreservesLeafIdentityTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt similarity index 96% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CommitPreservesLeafIdentityTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt index 101d0048eb..13938086c2 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CommitPreservesLeafIdentityTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt index 5dc2df88e2..20fe82cef6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt @@ -20,8 +20,13 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.test.Test @@ -47,7 +52,7 @@ class CurrentProfileAuthorizationTest { creator: ByteArray, admins: List, ): MlsGroup { - val group = MlsGroup.create(creator) + val group = MlsGroup.create(creator, policy = MarmotGroupPolicy) val dictionary = MarmotGroupState.buildDictionary( adminPolicy = AdminPolicyV1.of(admins), @@ -79,7 +84,7 @@ class CurrentProfileAuthorizationTest { val alice = currentProfileGroup(aliceAccount, listOf(aliceAccount)) val bobBundle = alice.createKeyPackage(bobAccount, ByteArray(0)) val add = alice.addMember(bobBundle.keyPackage.toTlsBytes()) - val bob = MlsGroup.processWelcome(add.welcomeBytes!!, bobBundle) + val bob = MlsGroup.processWelcome(add.welcomeBytes!!, bobBundle, policy = MarmotGroupPolicy) assertTrue(!bob.isLocalAdmin()) assertEquals(setOf(aliceAccount.toHexKey()), bob.currentAdminIdentities()) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt index 6c138d319b..6746a556db 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt @@ -23,14 +23,17 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt similarity index 92% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt index 5fb12b322d..07f9b46bbf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt @@ -18,16 +18,23 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519KeyPair -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData +import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -87,7 +94,7 @@ class CurrentProfileWelcomeTest { val commit = group.commit() val welcome = assertNotNull(commit.welcomeBytes, "adding a member must produce a Welcome") - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) assertEquals(group.currentGroupState().profile?.name, joined.currentGroupState().profile?.name) } @@ -122,7 +129,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) } assertTrue( failure.message.orEmpty().contains("agent text stream roles"), "expected a role-capability refusal, got: ${failure.message}", @@ -151,7 +158,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } @@ -180,7 +187,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } @@ -215,7 +222,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) } assertTrue( failure.message.orEmpty().contains("agent text stream roles"), "expected a role-capability refusal, got: ${failure.message}", @@ -253,7 +260,7 @@ class CurrentProfileWelcomeTest { ): KeyPackageBundle { val full = CurrentProfileGroupFactory.createKeyPackage(signer) val reduced = - MlsGroup.currentProfileLeafCapabilities().let { + MarmotCapabilities.currentProfileLeaf().let { Capabilities( extensions = it.extensions + roles, proposals = it.proposals, @@ -289,7 +296,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals( AgentTextStreamQuicPolicyV1.userToAgentDefault(), joined.currentGroupState().agentTextStream, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt index dab9c56995..c79419f47a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsWriter import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt index f385caa7cf..1299edbbf7 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentData import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt new file mode 100644 index 0000000000..23b932237b --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 +import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState +import com.vitorpamplona.quartz.marmot.appComponents.NostrRoutingV1 +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * That MIP-03 enforcement now travels with [MarmotGroupPolicy] rather than + * with the engine. + * + * Every other authorization test asserts that Marmot refuses. This one asserts + * the other half, which is what the extraction actually changed: the same + * group, at the same state, accepts the same commit once the policy is gone. + * If someone re-hardcodes the rules into `MlsGroup` these tests fail, and a + * cordn or plain RFC 9420 group would be back to inheriting Marmot's rules + * without asking. + */ +class MarmotPolicySeamTest { + private val alice = "11".repeat(32).hexToByteArray() + private val bob = "22".repeat(32).hexToByteArray() + + /** A group created by [creator] whose admin policy names only [admins]. */ + private fun groupAdminedBy( + creator: ByteArray, + admins: List, + ): MlsGroup { + val group = MlsGroup.create(creator, policy = MarmotGroupPolicy) + val dictionary = + MarmotGroupState.buildDictionary( + adminPolicy = AdminPolicyV1.of(admins), + routing = NostrRoutingV1.of(ByteArray(32) { 0x5a }, listOf("wss://relay.example")), + profile = GroupProfileV1("Seam", ""), + ) + // Bootstrap: installed before any admin is named, which MIP-01 allows. + group.proposeGroupContextExtensions(listOf(dictionary.toExtension())) + group.commit() + return group + } + + @Test + fun marmotsPolicyRefusesANonAdminExtensionChange() { + val group = groupAdminedBy(creator = alice, admins = listOf(bob)) + assertTrue(!group.isLocalAdmin(), "alice must not be an admin for this to test anything") + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith("a non-admin must not be able to rewrite group state") { + group.commit() + } + } + + @Test + fun theSameCommitIsAcceptedOnceTheGroupCarriesNoPolicy() { + val marmot = groupAdminedBy(creator = alice, admins = listOf(bob)) + val epochBefore = marmot.epoch + + // Same state, same proposal, no binding AUTHORIZATION rules — but the + // same extension types still declared. A Marmot group carries types its + // older leaves do not advertise, and RFC 9420 §13.4 is enforced from + // leaf capabilities, so dropping the declaration too would fail this + // commit for a reason that has nothing to do with who may commit. + val noAuthorizationRules = + object : MlsGroupPolicy { + override val knownExtensionTypes = MarmotGroupPolicy.knownExtensionTypes + } + val plain = MlsGroup.restore(marmot.saveState(), noAuthorizationRules) + plain.proposeGroupContextExtensions(plain.extensions) + plain.commit() + + assertEquals( + epochBefore + 1, + plain.epoch, + "RFC 9420 places no limit on who may commit; only the binding does", + ) + } + + @Test + fun marmotsProfileTravelsWithItsPolicy() { + val plain = MlsGroup.create(alice) + val marmot = MlsGroup.create(alice, policy = MarmotGroupPolicy) + + assertTrue( + plain.extensions.none { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "the engine's own default must require nothing", + ) + assertTrue( + marmot.extensions.any { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "naming MarmotGroupPolicy must still install required_capabilities", + ) + assertEquals( + listOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE), + MarmotGroupPolicy.defaultLeafCapabilities.extensions, + "and the MIP-era leaf set, which used to be MlsGroup.create's hardcoded default", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt index f516a80421..7226a5fbb6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.marmot.groups +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.mls.group.MlsGroup import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertContentEquals @@ -147,7 +148,7 @@ class MlsGroupManagerTest { // group is enough to observe the ratchet behavior.) val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val addResult = alice.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) - val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle, policy = MarmotGroupPolicy) // Alice sends generation 0 (no commit); Bob consumes it. val ct0 = alice.encrypt(groupId, "msg0".encodeToByteArray()) @@ -314,7 +315,7 @@ class MlsGroupManagerTest { // production before a Welcome has ever been seen). val bobBundle1 = MlsGroup - .create("bob".encodeToByteArray()) + .create("bob".encodeToByteArray(), policy = MarmotGroupPolicy) .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes()) val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome") @@ -371,7 +372,7 @@ class MlsGroupManagerTest { // --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. - val bobBundle2 = MlsGroup - .create("bob".encodeToByteArray()) + .create("bob".encodeToByteArray(), policy = MarmotGroupPolicy) .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes()) val secondWelcome = diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt index 26619fe6f9..5509b2a4ef 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.quartz.marmot.mip00KeyPackages -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt index 403aa6e217..ed80e026f0 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt index 2d18dbd279..b0a31075ff 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/AmethystAuthoredVectorGen.kt similarity index 94% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/AmethystAuthoredVectorGen.kt index 1bf2935a0f..865968db72 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/AmethystAuthoredVectorGen.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Welcome import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -44,7 +44,7 @@ import kotlin.test.Test * JOINER_HANDOFF_JSON=/tmp/joiner-handoff.json \ * AMETHYST_FIXTURE_JSON=/tmp/amethyst-fixture.json \ * ./gradlew :quartz:jvmTest \ - * --tests com.vitorpamplona.quartz.marmot.mls.AmethystAuthoredVectorGen + * --tests com.vitorpamplona.quartz.mls.AmethystAuthoredVectorGen * * The handoff file comes from the foreign backend's * `emit-joiner-kp` helper (it holds Bob's public KP bytes and Bob's diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/Ed25519Test.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/Ed25519Test.kt index 1c8822924f..1acb27e465 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/Ed25519Test.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/HpkeTest.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/HpkeTest.kt index e740eb7a50..75cba09cf8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/HpkeTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.Hpke +import com.vitorpamplona.quartz.mls.crypto.X25519 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MdkWelcomeInteropTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MdkWelcomeInteropTest.kt index 38eb366374..ce9b52f194 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MdkWelcomeInteropTest.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsConformanceTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsConformanceTest.kt index 86459eb11e..05cf503773 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsConformanceTest.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Hpke +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.GroupInfo +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.tree.LeafNode import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -185,10 +185,10 @@ class MlsConformanceTest { // Deserialize the MlsMessage wrapping the Welcome val mlsMsg = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls(TlsReader(welcomeBytes)) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.WELCOME, + com.vitorpamplona.quartz.mls.framing.WireFormat.WELCOME, mlsMsg.wireFormat, "Wire format must be WELCOME (3)", ) @@ -415,7 +415,7 @@ class MlsConformanceTest { // Commit should be deserializable val commit = - com.vitorpamplona.quartz.marmot.mls.messages.Commit + com.vitorpamplona.quartz.mls.messages.Commit .decodeTls(TlsReader(result.commitBytes)) assertTrue(commit.proposals.isNotEmpty(), "Commit must contain proposals") assertTrue(commit.updatePath != null, "Add commit should have UpdatePath") @@ -431,7 +431,7 @@ class MlsConformanceTest { assertTrue(result.commitBytes.isNotEmpty()) val commit = - com.vitorpamplona.quartz.marmot.mls.messages.Commit + com.vitorpamplona.quartz.mls.messages.Commit .decodeTls(TlsReader(result.commitBytes)) assertTrue(commit.proposals.isNotEmpty(), "Remove commit must contain proposals") } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupEdgeCaseTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupEdgeCaseTest.kt index a9750d7b5f..b1cd2df3a8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupEdgeCaseTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupLifecycleTest.kt similarity index 99% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupLifecycleTest.kt index 6609561c90..d8d82eaff0 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupLifecycleTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupStateTest.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupStateTest.kt index 81cba67a8a..d9b2f0e138 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupStateTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState -import com.vitorpamplona.quartz.marmot.mls.group.RetainedEpochSecrets +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.group.RetainedEpochSecrets import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -123,14 +123,14 @@ class MlsGroupStateTest { // Serialize and deserialize val writer = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() retained.encodeTls(writer) val bytes = writer.toByteArray() val restored = RetainedEpochSecrets.decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(bytes), ) @@ -177,7 +177,7 @@ class MlsGroupStateTest { // staged-proposal pool; older blobs still decode, so the version only // ever moves forward when the layout gains a field. val reader = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(bytes) val version = reader.readUint16() assertEquals(4, version) @@ -347,7 +347,7 @@ class MlsGroupStateTest { */ private fun encodeAsV1(state: MlsGroupState): ByteArray { val writer = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() writer.putUint16(1) state.groupContext.encodeTls(writer) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupTest.kt index e01677c711..74893e3e99 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroup import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -48,7 +48,7 @@ class MlsGroupTest { fun testCreateGroupWithSigningKey() { val identity = "alice@nostr".encodeToByteArray() val sigKp = - com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 + com.vitorpamplona.quartz.mls.crypto.Ed25519 .generateKeyPair() val group = MlsGroup.create(identity, sigKp.privateKey) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/TsMlsWelcomeInteropTest.kt similarity index 94% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/TsMlsWelcomeInteropTest.kt index 244b6c0c00..2324d980ae 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/TsMlsWelcomeInteropTest.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/X25519Test.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/X25519Test.kt index f323e8d342..38bc2a755d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/X25519Test.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.X25519 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionaryInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionaryInteropTest.kt similarity index 96% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionaryInteropTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionaryInteropTest.kt index 6362457ad0..a558d8c37e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionaryInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionaryInteropTest.kt @@ -18,18 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataUpdateProposalTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataUpdateProposalTest.kt similarity index 93% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataUpdateProposalTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataUpdateProposalTest.kt index f5e69864d3..da09c97ff1 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataUpdateProposalTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataUpdateProposalTest.kt @@ -18,17 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.messages.ProposalType +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.messages.ProposalType +import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.test.Test @@ -181,7 +182,9 @@ class AppDataUpdateProposalTest { // GroupContextExtensions proposal already produced, regardless of the // order the two appear in the proposal list. Propose them in the // "wrong" order to prove we do not simply follow list order. - val group = MlsGroup.create(creator) + // Advertised, because installing the dictionary as a GroupContext + // extension puts it under RFC 9420 §13.4: every member must support it. + val group = MlsGroup.create(creator, capabilities = Capabilities(extensions = listOf(AppDataDictionary.EXTENSION_TYPE))) group.proposeAppDataUpdate(AppComponentIds.GROUP_PROFILE_V1, profileA) group.proposeGroupContextExtensions( listOf(AppDataDictionary(listOf(ComponentData(AppComponentIds.ADMIN_POLICY_V1, adminPolicy))).toExtension()), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/AuthenticatedDataTest.kt similarity index 92% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/AuthenticatedDataTest.kt index 7a3385da12..a30d1e0810 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/AuthenticatedDataTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt new file mode 100644 index 0000000000..773d08ca1d --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * RFC 9420 §12.1.7, which the engine used to get wrong in both directions. + * + * It rejected any GroupContextExtensions proposal carrying an extension type + * outside a hardcoded list — a rule the RFC does not have, and one that made + * a whole class of valid group un-joinable. Meanwhile the rule the RFC DOES + * state, that the resulting group must not require capabilities some member + * lacks, was not checked at all. + * + * Found while building the cordn binding: cordn's `0xC04D` metadata extension + * is exactly the kind of application extension the old check refused. + */ +class GroupContextExtensionsRuleTest { + private val alice = "alice".encodeToByteArray() + + /** An arbitrary application extension type, in the private-use range. */ + private val appExtension = 0xC04D + + private fun requiredCapabilities(extensions: List): Extension { + val writer = TlsWriter() + val exts = TlsWriter() + extensions.forEach { exts.putUint16(it) } + writer.putOpaqueVarInt(exts.toByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } + + @Test + fun anUnknownExtensionTypeIsInstalledWhenEveryMemberAdvertisesIt() { + // "We recognise the type" is not the rule. RFC 9420 §13.4 makes the + // test membership: "an extension in use by the group MUST be supported + // by all members of the group", read off leaf capabilities. So a type + // this code has never heard of installs fine, provided the leaves say + // they support it. + // + // An earlier version of this test asserted the opposite - that an + // unknown type installs unconditionally - on the reading that §12.1.7 + // states the only rule. §12.1.7 does state the only rule *it* has; + // §13.4 is where the membership requirement lives. + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension))) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1, 2, 3)))) + group.commit() + + assertTrue( + group.extensions.any { it.extensionType == appExtension }, + "the extension must be installed, not rejected", + ) + } + + @Test + fun anExtensionThisMemberDoesNotAdvertiseIsRejected() { + // The same proposal, from a leaf that never claimed the capability. + // Accepting it would put the group in a state §13.4 forbids. + val group = MlsGroup.create(alice) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1, 2, 3)))) + + val error = assertFailsWith { group.commit() } + assertTrue( + error.message?.contains("Unsupported extension type") == true, + "unexpected message: ${error.message}", + ) + } + + @Test + fun aRequiredCapabilityNoMemberAdvertisesIsRejected() { + // The rule the RFC actually states. Accepting this splits the group: + // every peer that checks refuses the commit, every peer that does not + // applies it, and the two halves diverge at the next epoch. + val group = MlsGroup.create(alice, capabilities = Capabilities()) + group.proposeGroupContextExtensions(listOf(requiredCapabilities(listOf(appExtension)))) + + val error = assertFailsWith { group.commit() } + assertTrue( + error.message?.contains("required_capabilities") == true, + "must fail on the capability rule specifically: got '${error.message}'", + ) + } + + @Test + fun aRequiredCapabilityEveryMemberAdvertisesIsAccepted() { + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension))) + val epochBefore = group.epoch + + group.proposeGroupContextExtensions(listOf(requiredCapabilities(listOf(appExtension)))) + group.commit() + + assertEquals(epochBefore + 1, group.epoch) + } + + @Test + fun theReplacementIsWholesaleNotAMerge() { + // §12.1.7: "This is a wholesale replacement, not a merge. An extension + // is only carried over if the sender of the proposal includes it." + // + // Both types are advertised so that §13.4 is satisfied throughout and + // this test fails only on the replacement rule it is about. + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension, 0xC04E))) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1)))) + group.commit() + + group.proposeGroupContextExtensions(listOf(Extension(0xC04E, byteArrayOf(2)))) + group.commit() + + assertTrue(group.extensions.none { it.extensionType == appExtension }, "the old extension is gone") + assertTrue(group.extensions.any { it.extensionType == 0xC04E }) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsSupportTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsSupportTest.kt index ada9e0375c..b2c0be0f7a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsSupportTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupCreateOptionsTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupCreateOptionsTest.kt index 4e71e5f0c4..d95a39a1e3 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupCreateOptionsTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/KeyPackageLifetimeTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/KeyPackageLifetimeTest.kt index 89472471a3..30d78c0b02 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/KeyPackageLifetimeTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.tree.Lifetime import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt new file mode 100644 index 0000000000..34e9ac85d4 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.tree.Capabilities +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The [MlsGroupPolicy] seam itself, with no binding involved. + * + * The engine used to carry Marmot's authorization rules and capability + * defaults inline, so a plain RFC 9420 group could not be created at all — + * every group came out requiring `marmot_group_data`. These tests pin the two + * halves of the fix: the default really is RFC 9420 as written, and a policy + * that refuses really is consulted rather than advisory. + */ +class MlsGroupPolicySeamTest { + private val alice = "alice".encodeToByteArray() + + /** Records what the engine asked, and refuses on demand. */ + private class RecordingPolicy( + val refuseCommit: Boolean = false, + val refuseSelfRemove: Boolean = false, + override val commitExporter: MlsExporterLabel? = null, + ) : MlsGroupPolicy { + var commitsSeen = 0 + var selfRemovesSeen = 0 + var lastCommitter: Int? = null + + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + commitsSeen++ + lastCommitter = committerLeafIndex + if (refuseCommit) throw IllegalStateException("refused by policy") + } + + override fun authorizeSelfRemove(group: GroupView) { + selfRemovesSeen++ + if (refuseSelfRemove) throw IllegalStateException("no leaving") + } + } + + @Test + fun aDefaultGroupRequiresNothingBeyondRfc9420() { + val group = MlsGroup.create(alice) + + assertFalse( + group.extensions.any { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "the default profile must not install required_capabilities — that was Marmot's, not RFC 9420's", + ) + assertEquals( + Capabilities(), + MlsGroupPolicy.Permissive.defaultLeafCapabilities, + "RFC 9420 §7.2 forbids advertising DEFAULT types, so the neutral leaf advertises nothing", + ) + } + + @Test + fun authorizeCommitIsConsultedWithTheLocalCommitter() { + val policy = RecordingPolicy() + val group = MlsGroup.create(alice, policy = policy) + + group.proposeGroupContextExtensions(group.extensions) + group.commit() + + assertEquals(1, policy.commitsSeen) + assertEquals(group.leafIndex, policy.lastCommitter) + } + + @Test + fun aRefusedCommitDoesNotAdvanceTheEpoch() { + val policy = RecordingPolicy(refuseCommit = true) + val group = MlsGroup.create(alice, policy = policy) + val epochBefore = group.epoch + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith { group.commit() } + + assertEquals( + epochBefore, + group.epoch, + "a policy refusal must abort before any mutation, or the group diverges from every peer", + ) + } + + @Test + fun authorizeSelfRemoveGatesTheProposalAtItsSender() { + val allowed = RecordingPolicy() + MlsGroup.create(alice, policy = allowed).proposeSelfRemove() + assertEquals(1, allowed.selfRemovesSeen) + + val refused = RecordingPolicy(refuseSelfRemove = true) + val group = MlsGroup.create(alice, policy = refused) + assertFailsWith { group.proposeSelfRemove() } + assertTrue(group.pendingProposalsSnapshot().isEmpty(), "a refused proposal must not be staged") + } + + @Test + fun theCommitExporterSecretComesFromThePolicy() { + val none = MlsGroup.create(alice, policy = RecordingPolicy()) + none.proposeGroupContextExtensions(none.extensions) + assertEquals( + 0, + none.commit().preCommitExporterSecret.size, + "a binding that seals nothing outside MLS gets no secret", + ) + + val label = MlsExporterLabel("myapp", "group-event".encodeToByteArray(), 32) + val bound = MlsGroup.create(alice, policy = RecordingPolicy(commitExporter = label)) + val expected = bound.exporterSecret(label.label, label.context, label.length) + bound.proposeGroupContextExtensions(bound.extensions) + + assertContentEquals( + expected, + bound.commit().preCommitExporterSecret, + "the engine must derive it under the policy's label, not one of its own", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/UpdatePathAncestorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/UpdatePathAncestorTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/UpdatePathAncestorTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/UpdatePathAncestorTest.kt index 8aa92dabbc..90fc240276 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/UpdatePathAncestorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/UpdatePathAncestorTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/BindingIsolationTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/BindingIsolationTest.kt new file mode 100644 index 0000000000..5aff11f31d --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/BindingIsolationTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Marmot and cordn must be able to walk away from each other. + * + * They are two bindings of RFC 9420 onto two different delivery models, they do + * not interoperate, and neither is a layer of the other. So a change to one must + * never force a change to the other — which holds only while neither imports the + * other, and while the engine under both imports neither. + * + * `mls/README.md` has stated the engine half of this as a grep a human is + * supposed to run. A grep in a README is a wish. This is the same invariant as a + * test, so the build says no instead of a reviewer noticing. + * + * ## Scope: shipped code only + * + * Main source sets, never tests, and deliberately: an interop test that drives + * both profiles through one engine — `cordn/interop/BothCredentialProfilesTest` + * is exactly that — is how we *demonstrate* the two cannot collide, and the + * `mls/components` tests decode real Marmot payloads as fixtures because the + * point of an interop test is to run against bytes that exist. A fixture is + * data; an import in shipped code is a dependency. + */ +class BindingIsolationTest { + private val quartzRoot: File by lazy { + // Gradle runs tests with the module directory as the working directory, + // but resolve it rather than trust it: a wrong root would make every + // assertion below vacuously pass, which is the one outcome an + // architecture test must never have. + generateSequence(File(".").absoluteFile) { it.parentFile } + .map { if (it.name == "quartz") it else File(it, "quartz") } + .firstOrNull { File(it, "src/commonMain/kotlin/com/vitorpamplona/quartz").isDirectory } + ?: fail("cannot locate the quartz module from ${File(".").absolutePath}") + } + + /** Main source sets only — see the class KDoc. */ + private fun sourceSets(pkg: String): List = + File(quartzRoot, "src") + .listFiles() + .orEmpty() + .filter { it.isDirectory && !it.name.endsWith("Test") } + .map { File(it, "kotlin/com/vitorpamplona/quartz/$pkg") } + .filter { it.isDirectory } + + /** Every `import ` under [pkg], as `path:line import…`. */ + private fun offendingImports( + pkg: String, + forbidden: String, + ): List = + sourceSets(pkg).flatMap { dir -> + dir + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (i, line) -> + if (line.trimStart().startsWith("import $forbidden")) { + "${file.relativeTo(quartzRoot)}:${i + 1} ${line.trim()}" + } else { + null + } + } + } + } + + private fun assertNoImports( + pkg: String, + forbidden: String, + why: String, + ) { + val offences = offendingImports(pkg, forbidden) + assertTrue( + offences.isEmpty(), + "$pkg must not import $forbidden — $why\n " + offences.joinToString("\n "), + ) + } + + @Test + fun `the engine knows nothing about either binding`() { + // The reason Stage 1 of the cordn interop plan existed at all. An engine + // that imports a binding is an engine only that binding can use, and + // :quic was already reaching into it for X25519 before the split. + assertNoImports("mls", "com.vitorpamplona.quartz.marmot", "it is RFC 9420, not Marmot") + assertNoImports("mls", "com.vitorpamplona.quartz.cordn", "it is RFC 9420, not cordn") + assertNoImports("mls", "com.vitorpamplona.quartz.nip01Core", "it is RFC 9420, not Nostr") + } + + @Test + fun `cordn does not depend on Marmot`() { + // Everything named Marmot*, Mip* or mip0* is the wrong layer for cordn: + // a different delivery service, a different credential encoding, a + // different capability profile. Reuse there would couple two protocols + // that have no reason to move together. + assertNoImports("cordn", "com.vitorpamplona.quartz.marmot", "they are independent bindings") + } + + @Test + fun `Marmot does not depend on cordn`() { + // The direction that matters most in practice: Marmot ships to users + // today, and cordn is the newer, less settled of the two. Marmot must + // not acquire a reason to care when cordn changes. + assertNoImports("marmot", "com.vitorpamplona.quartz.cordn", "they are independent bindings") + } + + @Test + fun `the check is actually looking at files`() { + // Guards the failure mode that would make every assertion above pass + // for the wrong reason: a bad root, a renamed package, an empty walk. + listOf("mls", "marmot", "cordn").forEach { pkg -> + val files = + sourceSets(pkg).flatMap { + it.walkTopDown().filter { f -> f.isFile && f.extension == "kt" }.toList() + } + assertTrue(files.size > 5, "found only ${files.size} Kotlin files under $pkg — the scan is broken, not the code") + } + // And that it can see an import at all, using one every binding has. + assertTrue( + offendingImports("cordn", "com.vitorpamplona.quartz.mls").isNotEmpty(), + "the import scanner found no mls imports in cordn, so it would not find a marmot one either", + ) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncementLiveVectorTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncementLiveVectorTest.kt new file mode 100644 index 0000000000..e1251516f4 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncementLiveVectorTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * CEP-6 announcements captured off the public relays on 2026-09-22, from + * coordinators that serve the eleven cordn tools. + * + * These are real, signature-valid events, so they pin the parser to what other + * implementations actually emit rather than to what we would emit ourselves. + */ +class ServerAnnouncementLiveVectorTest { + /** A named, hosted coordinator — the only one on the network carrying a website. */ + private val dojopop = + """{"id":"1a65d58ffbaae66cd14d15e275d57e08dea50bbd06ac4a606f2a7312d1cb29f8","pubkey":"d969813a5c0e3e65dad03fc9e1d2db5933dda8b307ddce474e8da60b4e288259","created_at":1786946186,"kind":11316,"tags":[["name","dojopop-cordn"],["about","DojoPop MLS group messaging coordinator"],["website","https://dojopop.live"],["support_encryption"],["support_encryption_ephemeral"],["support_oversized_transfer"],["support_open_stream"]],"content":"{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{\"listChanged\":true}},\"serverInfo\":{\"name\":\"cordn-server\",\"version\":\"0.1.0\"}}","sig":"33ad89ba030b935bbcd08e98ed7b952dfe3d072e165ab96b9f98635cb81a2987e66d7d75a391aa904f824e9dab090b3edd16f0002f9522f5dfb9a3fbc15db88e"}""" + + /** A browser-tab coordinator — same shape, no website tag. */ + private val browserTab = + """{"id":"acead902457220f407bcb7857020c034089d0c35e4c29d18066ac17fab055aa7","pubkey":"35e2a4020dd6b9f5f45cf27d3e65f4a286a8e89d75c8ae141d9a794c1236dc26","created_at":1786113384,"kind":11316,"tags":[["name","My coordinator"],["about","Cordn coordinator running in a browser tab; key package quota 33 per identity"],["support_encryption"],["support_encryption_ephemeral"],["support_oversized_transfer"],["support_open_stream"]],"content":"{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{\"listChanged\":true}},\"serverInfo\":{\"name\":\"My coordinator\",\"version\":\"0.1.0\"}}","sig":"a0f29d13955636d407635eaa59ee307fa9b39d54466189e247f0ff9c61c17070084d0bd604efd29287a19c4e841a21c7c69dd1a500b245c98561dacc09b550a7"}""" + + private fun parse(json: String): ServerAnnouncement { + val event: Event = JacksonMapper.fromJson(json) + assertTrue("captured vector must still verify", event.verify()) + return ServerAnnouncement.parseOrNull(event)!! + } + + @Test + fun readsANamedCoordinatorAnnouncement() { + val ann = parse(dojopop) + + assertEquals(11316, ann.kind) + assertEquals("d969813a5c0e3e65dad03fc9e1d2db5933dda8b307ddce474e8da60b4e288259", ann.pubKey) + assertEquals("dojopop-cordn", ann.discovery.name) + assertEquals("DojoPop MLS group messaging coordinator", ann.discovery.about) + assertEquals("https://dojopop.live", ann.discovery.website) + assertNull(ann.discovery.picture) + assertTrue(ann.discovery.supportsEncryption) + assertTrue(ann.discovery.supportsEphemeralEncryption) + assertTrue(ann.discovery.supportsOversizedTransfer) + assertTrue(ann.discovery.supportsOpenStream) + } + + @Test + fun readsACoordinatorThatOmitsTheOptionalTags() { + val ann = parse(browserTab) + + assertEquals("My coordinator", ann.discovery.name) + assertNull(ann.discovery.website) + assertNull(ann.discovery.picture) + assertTrue(ann.discovery.supportsEncryption) + } + + /** + * Nothing in a CEP-6 announcement says which relays reach the coordinator. + * + * A client that learns a coordinator this way only has its pubkey, so it has + * to keep talking on the relay it heard the announcement on. Asserted so the + * day a routing tag appears in the wild, this test is what flags it. + */ + @Test + fun carriesNoRelayHint() { + listOf(dojopop, browserTab).forEach { + assertEquals(emptyList(), parse(it).discovery.unknownTags) + } + } + + /** Replaceable kinds: a lagging relay's older copy must not win. */ + @Test + fun keepsTheNewestAnnouncementPerKind() { + val events = listOf(JacksonMapper.fromJson(browserTab), JacksonMapper.fromJson(dojopop)) + + val latest = ServerAnnouncement.latestPerKind(events) + + assertEquals(1, latest.size) + assertEquals("dojopop-cordn", latest[11316]!!.discovery.name) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocumentTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocumentTest.kt new file mode 100644 index 0000000000..8ad4339a3c --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocumentTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The §4/§5/§6/§7 document layer, on its own. + * + * These codecs decide what survives a phone swap, so the cases worth pinning + * are the ones that fail quietly: a field dropped in the round trip is data the + * user loses, and an address check that passes when it should not is a blob + * nobody authorised being handed to the MLS engine. + */ +class CordnDeviceDocumentTest { + private val group = + CordnGroupDocument( + gid = "gid-1", + coordinator = "aa".repeat(32), + clientState = "c3RhdGU=", + cursor = 42, + issuedAt = 1_700_000_000_000, + roomState = "cm9vbQ==", + echoState = "ZWNobw==", + joinedViaRequest = true, + ) + + @Test + fun `a group document round-trips every field`() { + val decoded = CordnDeviceDocument.decode(CordnDeviceDocument.encode(group)) as CordnGroupDocument + + assertEquals(group, decoded) + } + + @Test + fun `a meta document round-trips every field`() { + val meta = + CordnMetaDocument( + removed = listOf(CordnTombstone("gone", 7)), + lastResortKeyPackage = CordnLastResortKeyPackage("a2s=", "cHJpdg=="), + issuedAt = 99, + ) + + assertEquals(meta, CordnDeviceDocument.decode(CordnDeviceDocument.encode(meta))) + } + + @Test + fun `an empty meta document round-trips`() { + // The normal migration case for an account with no tombstones and no + // published last-resort key package. + assertEquals(CordnMetaDocument(), CordnDeviceDocument.decode(CordnDeviceDocument.encode(CordnMetaDocument()))) + } + + @Test + fun `the draft, the read position and the via-request flag survive`() { + // CordnBackup's Archive carries none of these three, so a restore there + // silently returns every room unread with the half-typed message gone. + // Migration must not repeat that, which is why they are on the document. + val decoded = CordnDeviceDocument.decode(CordnDeviceDocument.encode(group)) as CordnGroupDocument + + assertEquals("cm9vbQ==", decoded.roomState) + assertEquals("ZWNobw==", decoded.echoState) + assertTrue(decoded.joinedViaRequest) + } + + @Test + fun `an unknown schema version is rejected`() { + val bumped = CordnDeviceDocument.encode(group).replace("\"schemaVersion\":1", "\"schemaVersion\":2") + + val thrown = assertThrows(CordnDocumentException::class.java) { CordnDeviceDocument.decode(bumped) } + assertTrue(thrown.message!!.contains("schemaVersion")) + } + + @Test + fun `an unknown document type is rejected`() { + val retyped = CordnDeviceDocument.encode(group).replace("\"type\":\"group\"", "\"type\":\"ledger\"") + + assertThrows(CordnDocumentException::class.java) { CordnDeviceDocument.decode(retyped) } + } + + @Test + fun `a half lastResortKeyPackage is rejected rather than half-loaded`() { + // Both halves are needed at join time. Keeping the public half alone + // would resolve an incoming Welcome and then fail to open it. + val half = """{"schemaVersion":1,"type":"meta","issuedAt":0,"lastResortKeyPackage":{"keyPackage":"a2s="}}""" + + assertThrows(CordnDocumentException::class.java) { CordnDeviceDocument.decode(half) } + } + + @Test + fun `unknown fields are ignored, so a newer writer does not break this reader`() { + val extended = CordnDeviceDocument.encode(group).dropLast(1) + ""","somethingNewer":{"a":1}}""" + + assertEquals(group, CordnDeviceDocument.decode(extended)) + } + + @Test + fun `a foreign clientState is flagged rather than handed to the engine`() { + // The spec leaves clientState library-private on purpose, so a ts-mls + // document is unreadable here. Without the marker that surfaces as a + // crash inside MLS; with it, a caller can refuse with a reason. + val theirs = CordnDeviceDocument.encode(group).replace(CordnDeviceDocument.CLIENT_STATE_FORMAT, "ts-mls") + + val decoded = CordnDeviceDocument.decode(theirs) as CordnGroupDocument + assertFalse(decoded.isReadableHere) + assertTrue((CordnDeviceDocument.decode(CordnDeviceDocument.encode(group)) as CordnGroupDocument).isReadableHere) + } + + @Test + fun `a document that predates the marker is not assumed to be ours`() { + val unmarked = CordnDeviceDocument.encode(group.copy(clientStateFormat = null)) + + val decoded = CordnDeviceDocument.decode(unmarked) as CordnGroupDocument + assertNull(decoded.clientStateFormat) + assertFalse(decoded.isReadableHere) + } + + @Test + fun `a sealed document round-trips and the address is over the ciphertext`() { + val dek = KeyPair() + + val blob = CordnDocumentSeal.seal(group, dek) + + assertEquals(group, CordnDocumentSeal.open(blob, dek)) + assertTrue(CordnDocumentSeal.verifyAddress(blob, CordnDocumentSeal.address(blob))) + } + + @Test + fun `the same document seals to a different address every time`() { + // NIP-44 salts randomly, so the address cannot be used for dedup — §5 + // says so, and a caller that assumed otherwise would skip republishing + // a document whose content had genuinely changed. + val dek = KeyPair() + + val first = CordnDocumentSeal.seal(group, dek) + val second = CordnDocumentSeal.seal(group, dek) + + assertNotEquals(CordnDocumentSeal.address(first), CordnDocumentSeal.address(second)) + assertEquals(CordnDocumentSeal.open(first, dek), CordnDocumentSeal.open(second, dek)) + } + + @Test + fun `another DEK does not open the document`() { + val blob = CordnDocumentSeal.seal(group, KeyPair()) + + assertThrows(CordnDocumentException::class.java) { CordnDocumentSeal.open(blob, KeyPair()) } + } + + @Test + fun `a blob that does not match its advertised address is refused`() { + // The §6 check. It is what stops a content store from substituting a + // blob the tip never named. + val dek = KeyPair() + val blob = CordnDocumentSeal.seal(group, dek) + val other = CordnDocumentSeal.seal(group.copy(gid = "gid-2"), dek) + + assertFalse(CordnDocumentSeal.verifyAddress(other, CordnDocumentSeal.address(blob))) + } + + @Test + fun `the plaintext never contains the state in the clear`() { + val dek = KeyPair() + + val blob = CordnDocumentSeal.seal(group, dek).decodeToString() + + assertFalse(blob.contains("c3RhdGU=")) + assertFalse(blob.contains("gid-1")) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTipTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTipTest.kt new file mode 100644 index 0000000000..bf1f7f3bab --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTipTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Tag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The §6 tip inventory. + * + * The tip is the only authenticity root in this design — documents carry no + * signature — so the cases here are about refusing an inventory a device + * cannot act on, rather than salvaging part of it. + */ +class CordnDeviceTipTest { + private val dek = "ab".repeat(32) + + private val inventory = + CordnTipInventory( + groups = + listOf( + CordnTipEntry(address = "11".repeat(32), gid = "gid-1"), + CordnTipEntry(address = "22".repeat(32), gid = "gid-2"), + ), + meta = "33".repeat(32), + dekPrivateKey = dek, + servers = listOf("https://first.example", "https://second.example"), + ) + + @Test + fun `an inventory round-trips through the inner event tags`() { + assertEquals(inventory, CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(inventory)))) + } + + @Test + fun `server order is preserved, because a reader tries them in order`() { + val parsed = CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(inventory))) + + assertEquals(listOf("https://first.example", "https://second.example"), parsed.servers) + } + + @Test + fun `an inventory with no groups is legal`() { + // An account that holds no cordn groups still has a meta document and + // a DEK, and a migration of it should succeed with nothing to seed. + val empty = inventory.copy(groups = emptyList()) + + assertEquals(empty, CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(empty)))) + } + + @Test + fun `a missing dek is refused`() { + // Without it every listed document is unreadable, so there is no + // partial success to fall back to. + val tags = CordnDeviceTip.tags(inventory).filterNot { it[0] == CordnDeviceTip.TAG_DEK }.toTypedArray() + + assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(innerEvent(tags)) } + } + + @Test + fun `a malformed dek is refused`() { + val tags = arrayOf(arrayOf(CordnDeviceTip.TAG_DEK, "nothex")) + + val thrown = assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(innerEvent(tags)) } + assertTrue(thrown.message!!.contains("64 hex")) + } + + @Test + fun `a group entry with no gid is refused rather than skipped`() { + // Skipping it would silently drop a group from the migration, which + // the user discovers as a missing conversation on the new phone. + val tags = + arrayOf( + arrayOf(CordnDeviceTip.TAG_X, "11".repeat(32), CordnDeviceTip.KIND_GROUP), + arrayOf(CordnDeviceTip.TAG_DEK, dek), + ) + + assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(innerEvent(tags)) } + } + + @Test + fun `the wrong inner kind is refused`() { + val wrong = innerEvent(CordnDeviceTip.tags(inventory)).let { Event(it.id, it.pubKey, it.createdAt, 1, it.tags, it.content, it.sig) } + + assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(wrong) } + } + + @Test + fun `unknown tags are ignored`() { + val tags = CordnDeviceTip.tags(inventory) + arrayOf(arrayOf("future", "value")) + + assertEquals(inventory, CordnDeviceTip.parse(innerEvent(tags))) + } + + @Test + fun `a second meta entry does not displace the first`() { + val tags = + CordnDeviceTip.tags(inventory) + arrayOf(arrayOf(CordnDeviceTip.TAG_X, "99".repeat(32), CordnDeviceTip.KIND_META)) + + assertEquals("33".repeat(32), CordnDeviceTip.parse(innerEvent(tags)).meta) + } + + @Test + fun `an inventory with no meta entry parses`() { + val noMeta = inventory.copy(meta = null) + + assertNull(CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(noMeta))).meta) + } + + @Test + fun `the dek is normalised to lowercase`() { + val tags = arrayOf(arrayOf(CordnDeviceTip.TAG_DEK, dek.uppercase())) + + assertEquals(dek, CordnDeviceTip.parse(innerEvent(tags)).dekPrivateKey) + } + + private fun innerEvent(tags: Array) = + Event( + id = "00".repeat(32), + pubKey = "cc".repeat(32), + createdAt = 1, + kind = CordnDeviceTip.INNER_KIND, + tags = tags, + content = "", + sig = "00".repeat(32), + ) +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCodeTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCodeTest.kt new file mode 100644 index 0000000000..bf5c30d05a --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCodeTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The scannable code that points a new phone at the old phone's tip. + * + * This is the one artefact a user physically handles, so the cases worth + * pinning are about what a photographed or mis-scanned code can do: it must + * carry no owner key material, it must not silently lose a relay to + * truncation, and by default it must not grant the ability to move the tip. + */ +class CordnHandoffCodeTest { + private fun tlv( + type: Byte, + value: ByteArray, + ) = byteArrayOf(type, value.size.toByte()) + value + + private fun bech32(payload: ByteArray) = Bech32.encodeBytes(CordnHandoffCode.HRP, payload, Bech32.Encoding.Bech32) + + private val code = + CordnHandoffCode( + ephemeralPubKey = "aa".repeat(32), + dTag = "opaque-d", + relays = listOf("wss://one.example", "wss://two.example"), + ) + + @Test + fun `a code round-trips`() { + assertEquals(code, CordnHandoffCode.decode(code.encode())) + } + + @Test + fun `a code is a cordndev string`() { + assertTrue(code.encode().startsWith("cordndev1")) + } + + @Test + fun `relay order survives`() { + assertEquals(listOf("wss://one.example", "wss://two.example"), CordnHandoffCode.decode(code.encode()).relays) + } + + @Test + fun `a code grants no write by default`() { + // A photographed QR should be worth nothing. Without the write key the + // code names an event anyone could already fetch and only the owner can + // decrypt, so a leak reveals nothing and cannot move the tip. + assertFalse(code.grantsWrite) + assertNull(CordnHandoffCode.decode(code.encode()).writeKey) + } + + @Test + fun `a write-granting code round-trips and says so`() { + val writable = code.copy(writeKey = "bb".repeat(32)) + + val decoded = CordnHandoffCode.decode(writable.encode()) + assertTrue(decoded.grantsWrite) + assertEquals("bb".repeat(32), decoded.writeKey) + } + + @Test + fun `readOnly strips the write key and keeps everything else`() { + val writable = code.copy(writeKey = "bb".repeat(32)) + + assertEquals(code, writable.readOnly()) + assertFalse(writable.readOnly().grantsWrite) + } + + @Test + fun `readOnly on an already read-only code is the same code`() { + assertEquals(code, code.readOnly()) + } + + @Test + fun `the kind travels, so changing it later cannot strand old codes`() { + val odd = code.copy(kind = 31078) + + assertEquals(31078, CordnHandoffCode.decode(odd.encode()).kind) + } + + @Test + fun `a code with no relays is refused at construction`() { + assertThrows(IllegalArgumentException::class.java) { code.copy(relays = emptyList()) } + } + + @Test + fun `a truncated trailing relay is refused, not silently dropped`() { + // The failure this strictness exists to prevent. The payload is hand + // built so the truncation lands on an OPTIONAL tuple: chopping the + // encoder's own output instead removes the pubkey, which both a strict + // and a lenient parser reject, so it discriminates nothing. (The first + // two versions of this test made exactly that mistake and passed + // against a deliberately lenient parser.) + val relay = "wss://two.example".encodeToByteArray() + val full = + tlv(CordnHandoffCode.TLV_PUBKEY, ByteArray(32) { 0xAA.toByte() }) + + tlv(CordnHandoffCode.TLV_D, "opaque-d".encodeToByteArray()) + + tlv(CordnHandoffCode.TLV_RELAY, "wss://one.example".encodeToByteArray()) + + tlv(CordnHandoffCode.TLV_RELAY, relay) + + // Sanity: intact, it decodes with both relays. + assertEquals(2, CordnHandoffCode.decode(bech32(full)).relays.size) + + val truncated = full.copyOfRange(0, full.size - 4) + + assertNull(CordnHandoffCode.decodeOrNull(bech32(truncated))) + } + + @Test + fun `a relay whose declared length overruns the payload is refused`() { + val full = + tlv(CordnHandoffCode.TLV_PUBKEY, ByteArray(32) { 0xAA.toByte() }) + + tlv(CordnHandoffCode.TLV_D, "opaque-d".encodeToByteArray()) + + tlv(CordnHandoffCode.TLV_RELAY, "wss://one.example".encodeToByteArray()) + // Inflate the relay tuple's declared length past the end of the buffer. + val lying = full.copyOf().also { it[full.size - "wss://one.example".length - 1] = 0xFF.toByte() } + + assertNull(CordnHandoffCode.decodeOrNull(bech32(lying))) + } + + @Test + fun `a mixed-case code is refused`() { + val mixed = code.encode().replaceFirst("cordndev1", "CordnDev1") + + assertThrows(IllegalArgumentException::class.java) { CordnHandoffCode.decode(mixed) } + } + + @Test + fun `an uppercase code is accepted, because bech32 allows it`() { + assertEquals(code, CordnHandoffCode.decode(code.encode().uppercase())) + } + + @Test + fun `a cordn group ref is not a handoff code`() { + assertNull(CordnHandoffCode.decodeOrNull("cordn1qqqqqq")) + } + + @Test + fun `a bad pubkey length is refused at construction`() { + assertThrows(IllegalArgumentException::class.java) { code.copy(ephemeralPubKey = "aa") } + } + + @Test + fun `an empty d tag is refused`() { + assertThrows(IllegalArgumentException::class.java) { code.copy(dTag = "") } + } + + @Test + fun `the code carries nothing that could be an owner key`() { + // §4.3: the documents do not provision identity, and neither does this. + // The user signs in on the new phone by their usual means first. + val writable = code.copy(writeKey = "bb".repeat(32)) + + val fields = listOf(writable.ephemeralPubKey, writable.dTag, writable.writeKey!!) + writable.relays + assertEquals(setOf("aa".repeat(32), "opaque-d", "bb".repeat(32), "wss://one.example", "wss://two.example"), fields.toSet()) + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt similarity index 98% rename from quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt rename to quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt index efc7e36298..bf9061c8f6 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance import io.github.andreypfau.kotlinx.crypto.Sha512 @@ -115,7 +115,7 @@ actual object Ed25519 { } actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { - require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } val publicKey = derivePublicKey(seed) return Ed25519KeyPair(seed + publicKey, publicKey) } diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.linux.kt similarity index 99% rename from quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.linux.kt rename to quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.linux.kt index 413eb64faa..a8a4188344 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.linux.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/tools/cordn-vector-gen/.gitignore b/quartz/tools/cordn-vector-gen/.gitignore new file mode 100644 index 0000000000..504afef81f --- /dev/null +++ b/quartz/tools/cordn-vector-gen/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/quartz/tools/cordn-vector-gen/README.md b/quartz/tools/cordn-vector-gen/README.md new file mode 100644 index 0000000000..7ff28db5d3 --- /dev/null +++ b/quartz/tools/cordn-vector-gen/README.md @@ -0,0 +1,50 @@ +# cordn-vector-gen + +Emits `quartz/src/commonTest/resources/cordn/coordinator-contracts.json` from +**`@cordn/core`** — the package cordn's own reference coordinator and client +both import. Consumed by `CoordinatorContractVectorTest` and +`CordnGroupRefVectorTest`. + +## Why this exists + +Our ts-mls fixtures (`resources/tsmls/`) cover the *crypto* layer: KeyPackages, +Welcomes, exporters, sealed payloads. They say nothing about the layer above — +the eleven coordinator tools, their argument names, which fields are optional, +and the `cordn1…` group ref. That layer had been verified only against our +reading of the spec, which is exactly the kind of agreement that holds right up +until someone runs a real coordinator. + +So this generator goes the other way: it takes the payloads **we** put on the +wire and hands each one to **their** zod schema. A field we named wrong fails at +generation time. Each method also carries `rejects` — payloads their schema must +refuse — because a positive result only means something if the schema is strict +where we assume it is. + +Group refs are round-tripped through their bech32 codec, and the vectors pin +both directions: we must decode what they encode, *and* encode byte-identically. +Only the second half catches TLV ordering, since the spec lets decoders accept +any order. + +## Regenerating + +``` +cd quartz/tools/cordn-vector-gen +npm install +node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json +``` + +Output is deterministic — fixed actors, fixed timestamps, no randomness — so a +regeneration that changes the file means cordn changed something. Commit the +result with the reason. + +## Licensing + +`@cordn/core` and `@cordn/cli` are **MIT** and each ship a LICENSE file; this +generator is a dev-only dependency on the first and ships in nothing. + +The rest of the `Cordn-msg/cordn` repository — `packages/coordinator`, +`packages/server`, `packages/test-utils`, and the `ghcr.io/cordn-msg/cordn` +image built from them — carries **no license file and no `license` field**, so +default copyright applies. That is why Tier B (a live coordinator round-trip) +is not wired up here and why these vectors are generated from the two licensed +packages instead. See `quartz/plans/2026-09-17-cordn-interop.md` §7. diff --git a/quartz/tools/cordn-vector-gen/generate.mjs b/quartz/tools/cordn-vector-gen/generate.mjs new file mode 100644 index 0000000000..959a17ca14 --- /dev/null +++ b/quartz/tools/cordn-vector-gen/generate.mjs @@ -0,0 +1,334 @@ +// Emits cordn coordinator-contract + group-ref vectors, validated by cordn's +// OWN schemas and codecs (`@cordn/core`, MIT). +// +// The point is direction: every sample below is what OUR Kotlin client puts on +// the wire, handed to THEIR zod schema. A shape we invented that their +// coordinator would reject fails here, at generation time, instead of silently +// in production. The negative samples do the converse — they prove the schema +// is actually strict where we assume it is, so a passing positive means +// something. +// +// Usage: npm install && node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json + +import { + COORDINATOR_METHODS, + publishKeyPackageInputSchema, + publishKeyPackageOutputSchema, + listAvailableKeyPackagesInputSchema, + listAvailableKeyPackagesOutputSchema, + consumeKeyPackageInputSchema, + consumeKeyPackageOutputSchema, + removeKeyPackagesInputSchema, + removeKeyPackagesOutputSchema, + fetchPendingWelcomesInputSchema, + fetchPendingWelcomesOutputSchema, + storeWelcomeInputSchema, + storeWelcomeOutputSchema, + storeJoinRequestInputSchema, + storeJoinRequestOutputSchema, + fetchManyPendingJoinRequestsInputSchema, + fetchManyPendingJoinRequestsOutputSchema, + postGroupMessageInputSchema, + postGroupMessageOutputSchema, + fetchManyGroupMessagesInputSchema, + fetchManyGroupMessagesOutputSchema, + subscribeManyGroupMessagesInputSchema, + subscribeManyGroupMessagesOutputSchema, + groupMessageSchema, + encodeGroupRef, + decodeGroupRef, + isGroupRef, +} from "@cordn/core"; +import { readFileSync } from "node:fs"; + +const coreVersion = JSON.parse( + readFileSync(new URL("./node_modules/@cordn/core/package.json", import.meta.url), "utf8"), +).version; + +// Deterministic actors. 64 lowercase hex, because that is what a cordn +// credential carries as 64 ASCII bytes (spec/00.md §13). +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const COORDINATOR = "c".repeat(64); +const GID = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"; +const KP_REF = "1f".repeat(16); +const KP_REF_2 = "2e".repeat(16); + +const failures = []; + +/** Validates `value` against `schema`, recording (not throwing) on mismatch. */ +function accepts(label, schema, value) { + const parsed = schema.safeParse(value); + if (!parsed.success) { + failures.push(`${label}: cordn's schema REJECTED a payload we send/expect — ${JSON.stringify(parsed.error.issues)}`); + } + return value; +} + +/** The converse: proves the schema really is strict about `label`. */ +function rejects(label, schema, value) { + const parsed = schema.safeParse(value); + if (parsed.success) { + failures.push(`${label}: cordn's schema ACCEPTED a payload we assume it rejects`); + } + return value; +} + +const contracts = { + [COORDINATOR_METHODS.publishKeyPackage]: { + input: accepts("kp_publish.input", publishKeyPackageInputSchema, { + kp_ref: KP_REF, + kp_64: "AAECAwQFBgc=", + }), + output: accepts("kp_publish.output", publishKeyPackageOutputSchema, { + kp_ref: KP_REF, + last_resort: false, + at: 1757000000, + }), + rejects: [ + rejects("kp_publish.input/no-ref", publishKeyPackageInputSchema, { kp_64: "AAECAwQFBgc=" }), + rejects("kp_publish.input/legacy-field", publishKeyPackageInputSchema, { + kp_ref: KP_REF, + keyPackageBase64: "AAECAwQFBgc=", + }), + ], + }, + [COORDINATOR_METHODS.listAvailableKeyPackages]: { + input: accepts("kp_list.input", listAvailableKeyPackagesInputSchema, {}), + output: accepts("kp_list.output", listAvailableKeyPackagesOutputSchema, { + keyPackages: [ + { pk: ALICE, kp_ref: KP_REF, last_resort: false, at: 1757000000 }, + { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 }, + ], + }), + rejects: [ + rejects("kp_list.output/missing-last_resort", listAvailableKeyPackagesOutputSchema, { + keyPackages: [{ pk: ALICE, kp_ref: KP_REF, at: 1757000000 }], + }), + ], + }, + [COORDINATOR_METHODS.consumeKeyPackage]: { + input: accepts("kp_take.input", consumeKeyPackageInputSchema, { id: KP_REF }), + output: accepts("kp_take.output", consumeKeyPackageOutputSchema, { + keyPackage: { + pk: BOB, + kp_ref: KP_REF_2, + last_resort: true, + at: 1757000100, + event: { + id: "0".repeat(64), + pubkey: BOB, + created_at: 1757000100, + kind: 25910, + tags: [["p", COORDINATOR]], + content: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "kp_publish", arguments: { kp_ref: KP_REF_2, kp_64: "AAECAwQFBgc=" } }, + }), + sig: "0".repeat(128), + }, + }, + }), + // `keyPackage: null` is how the coordinator says "nothing matching" — our + // client returns null rather than raising, so pin that it is legal. + emptyOutput: accepts("kp_take.output/null", consumeKeyPackageOutputSchema, { keyPackage: null }), + rejects: [ + rejects("kp_take.output/no-event", consumeKeyPackageOutputSchema, { + keyPackage: { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 }, + }), + ], + }, + [COORDINATOR_METHODS.removeKeyPackages]: { + input: accepts("kp_remove.input", removeKeyPackagesInputSchema, { kp_refs: [KP_REF, KP_REF_2] }), + output: accepts("kp_remove.output", removeKeyPackagesOutputSchema, { kp_refs: [KP_REF] }), + rejects: [rejects("kp_remove.input/scalar", removeKeyPackagesInputSchema, { kp_refs: KP_REF })], + }, + [COORDINATOR_METHODS.fetchPendingWelcomes]: { + // Our client omits `consumed` entirely when it has nothing to retire, + // rather than sending an empty array. + input: accepts("welcome_take.input/empty", fetchPendingWelcomesInputSchema, {}), + inputWithConsumed: accepts("welcome_take.input/consumed", fetchPendingWelcomesInputSchema, { + consumed: [{ kp_ref: KP_REF, at: 1757000000 }], + }), + output: accepts("welcome_take.output", fetchPendingWelcomesOutputSchema, { + welcomes: [ + { kp_ref: KP_REF, welcome_64: "V2VsY29tZQ==", at: 1757000200, after: 12 }, + { kp_ref: KP_REF_2, welcome_64: "V2VsY29tZTI=", at: 1757000300 }, + ], + }), + rejects: [ + rejects("welcome_take.input/consumed-without-at", fetchPendingWelcomesInputSchema, { + consumed: [{ kp_ref: KP_REF }], + }), + ], + }, + [COORDINATOR_METHODS.storeWelcome]: { + input: accepts("welcome_store.input", storeWelcomeInputSchema, { + target_pk: BOB, + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + after: 12, + }), + inputWithoutAfter: accepts("welcome_store.input/no-after", storeWelcomeInputSchema, { + target_pk: BOB, + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + }), + output: accepts("welcome_store.output", storeWelcomeOutputSchema, { at: 1757000200 }), + rejects: [ + rejects("welcome_store.input/no-target", storeWelcomeInputSchema, { + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + }), + ], + }, + [COORDINATOR_METHODS.storeJoinRequest]: { + input: accepts("join_request_store.input", storeJoinRequestInputSchema, { gid: GID, kp_ref: KP_REF }), + output: accepts("join_request_store.output", storeJoinRequestOutputSchema, { at: 1757000400 }), + rejects: [rejects("join_request_store.input/no-gid", storeJoinRequestInputSchema, { kp_ref: KP_REF })], + }, + [COORDINATOR_METHODS.fetchManyPendingJoinRequests]: { + input: accepts("join_request_take_many.input", fetchManyPendingJoinRequestsInputSchema, { + groups: [{ gid: GID }], + }), + inputWithConsumed: accepts("join_request_take_many.input/consumed", fetchManyPendingJoinRequestsInputSchema, { + groups: [{ gid: GID }], + consumed: [{ gid: GID, pk: BOB, at: 1757000400 }], + }), + output: accepts("join_request_take_many.output", fetchManyPendingJoinRequestsOutputSchema, { + requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400, gid: GID }], + }), + rejects: [ + // The many-variant carries `gid` on every request; the single-group + // shape does not, and mixing them is the easy mistake. + rejects("join_request_take_many.output/no-gid", fetchManyPendingJoinRequestsOutputSchema, { + requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400 }], + }), + rejects("join_request_take_many.input/bare-gids", fetchManyPendingJoinRequestsInputSchema, { groups: [GID] }), + ], + }, + [COORDINATOR_METHODS.postGroupMessage]: { + input: accepts("msg_post.input", postGroupMessageInputSchema, { gid: GID, msg_64: "c2VhbGVk" }), + output: accepts("msg_post.output", postGroupMessageOutputSchema, { gid: GID, cursor: 42, at: 1757000500 }), + rejects: [rejects("msg_post.input/no-msg", postGroupMessageInputSchema, { gid: GID })], + }, + [COORDINATOR_METHODS.fetchManyGroupMessages]: { + // A first-ever fetch omits `after` rather than sending 0 — the schema + // types it as a number, and 0 would be a real cursor value. + input: accepts("msg_fetch_many.input/first", fetchManyGroupMessagesInputSchema, { groups: [{ gid: GID }] }), + inputWithCursor: accepts("msg_fetch_many.input/resume", fetchManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: 42 }], + }), + output: accepts("msg_fetch_many.output", fetchManyGroupMessagesOutputSchema, { + messages: [ + { gid: GID, cursor: 43, msg_64: "c2VhbGVkLTE=", at: 1757000600 }, + { gid: GID, cursor: 44, msg_64: "c2VhbGVkLTI=", at: 1757000700 }, + ], + }), + rejects: [ + rejects("msg_fetch_many.input/string-cursor", fetchManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: "42" }], + }), + ], + }, + [COORDINATOR_METHODS.subscribeManyGroupMessages]: { + input: accepts("msg_sub_many.input", subscribeManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: 42 }], + }), + output: accepts("msg_sub_many.output", subscribeManyGroupMessagesOutputSchema, { + subscribed: true, + groups: [GID], + }), + // Each CEP-41 stream fragment is one of these, NOT a {messages:[…]} page. + streamFragment: accepts("msg_sub_many.fragment", groupMessageSchema, { + gid: GID, + cursor: 45, + msg_64: "c2VhbGVkLTM=", + at: 1757000800, + }), + rejects: [ + rejects("msg_sub_many.output/subscribed-false", subscribeManyGroupMessagesOutputSchema, { + subscribed: false, + groups: [GID], + }), + ], + }, +}; + +// Group refs, round-tripped through THEIR bech32 codec. +const groupRefCases = [ + { gid: GID }, + { gid: GID, coordinatorPubkey: COORDINATOR }, + { gid: GID, coordinatorPubkey: COORDINATOR, relays: ["wss://relay.example.com/"] }, + { + gid: GID, + coordinatorPubkey: COORDINATOR, + relays: ["wss://relay.example.com/", "wss://relay2.example.com/"], + }, + { gid: "a" }, + { gid: "grupo-café-éàü" }, +]; + +const groupRefs = groupRefCases.map((ref) => { + const encoded = encodeGroupRef(ref); + const decoded = decodeGroupRef(encoded); + if (JSON.stringify(decoded) !== JSON.stringify(ref)) { + failures.push(`groupRef: their own round-trip changed ${JSON.stringify(ref)} into ${JSON.stringify(decoded)}`); + } + if (!isGroupRef(encoded)) failures.push(`groupRef: isGroupRef rejected ${encoded}`); + return { ...ref, encoded }; +}); + +// Bech32 forbids mixed case but allows an all-uppercase form. Their +// `isGroupRef` screen rejects uppercase while `decodeGroupRef` accepts it, so +// a ref pasted in caps decodes on both sides — pin that, it is easy to get +// wrong in either direction. +const uppercaseRef = encodeGroupRef({ gid: GID, coordinatorPubkey: COORDINATOR }).toUpperCase(); +if (JSON.stringify(decodeGroupRef(uppercaseRef)) !== JSON.stringify({ gid: GID, coordinatorPubkey: COORDINATOR })) { + failures.push("groupRef: their decoder did not round-trip the uppercase form"); +} +const groupRefUppercase = { gid: GID, coordinatorPubkey: COORDINATOR, encoded: uppercaseRef }; + +// Strings their decoder must refuse. Ours has to refuse them too, or we accept +// group coordinates nobody else would. +const groupRefRejects = [ + "cordn1qqqqq", + "nostr1qqqqq", + "CORDN1QQQQQ", + "cordn", + "", +]; +for (const bad of groupRefRejects) { + let threw = false; + try { + decodeGroupRef(bad); + } catch { + threw = true; + } + if (!threw) failures.push(`groupRef: their decoder ACCEPTED ${JSON.stringify(bad)}, which we treat as invalid`); +} + +if (failures.length > 0) { + console.error("cordn-vector-gen: refusing to emit vectors\n " + failures.join("\n ")); + process.exit(1); +} + +process.stdout.write( + JSON.stringify( + { + _comment: + "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). " + + "Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.", + generator: { cordnCore: coreVersion }, + methods: COORDINATOR_METHODS, + contracts, + groupRefs, + groupRefUppercase, + groupRefRejects, + }, + null, + 2, + ) + "\n", +); diff --git a/quartz/tools/cordn-vector-gen/package.json b/quartz/tools/cordn-vector-gen/package.json new file mode 100644 index 0000000000..73145858fa --- /dev/null +++ b/quartz/tools/cordn-vector-gen/package.json @@ -0,0 +1,9 @@ +{ + "name": "cordn-vector-gen", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@cordn/core": "0.5.5" + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index 5aeba8fc6f..0ded197347 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quic.tls -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.X25519KeyPair import com.vitorpamplona.quic.QuicCodecException import com.vitorpamplona.quic.QuicReader import com.vitorpamplona.quic.QuicWriter diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt index 356b8b2527..488f090c75 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quic.tls -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.X25519KeyPair import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quic.QuicReader import com.vitorpamplona.quic.QuicWriter