diff --git a/PLAN.md b/PLAN.md index 375fcf5..98defe2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -209,11 +209,12 @@ Decision: no test files are maintained for this feature. M1 and M2 were verified The consequence to plan around is in §14 and risk 13. -### 5.5 Modified (6 files, all small) +### 5.5 Modified (7 files, all small) | File | Change | |---|---| -| `shared/.../nostr/Nostr.kt` | add `val concord`, 2 routing branches | +| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches | +| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` | | `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params | | `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector | | `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` | @@ -702,13 +703,45 @@ Each milestone ends with something runnable. ### M3 — Join + read -`ConcordStore.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordManager` read path, `Nostr.kt` routing. +Delivered. `ConcordModels.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordStore.kt`, the `ConcordManager` read path, and the two routing branches in `Nostr.kt`. **Done when:** pasting a real invite link stores the membership, connects the community's relays, folds metadata + channel list, and renders channel messages from a real community. +What shipped: + +- `ConcordInvite.kt` — `parseInviteLink`, `decodeInviteFragment` (CORD-05 §3), the stock relay dictionary, `inviteBundleKey`, `decryptInviteBundle`, `problems()` validation, `relaySet`, `toMembership` +- `ConcordControl.kt` — `editionOf` + `fold`, with the `ep` chain walk, highest-version-wins and the lower-rumor-id tie-break +- `ConcordStore.kt` — memberships through `AppStorage.setSecret`, plane rumors through LMDB indexed on `d`/`r`/`k` +- `ConcordManager.kt` — plane index, `restore()`/`sync()`, `isPlaneAddress`, `handlePlaneEvent`, `onInboxRumor`, `previewInvite`, `join`, `channelMessages` +- `Nostr.kt` — the plane-author routing branch, the Concord queue, the Direct Invite interception, and `concord.attach(storage)` from `init` + +**Verified** with a throwaway check on the iOS target (20 cases, all passing), then deleted: +- the fragment decodes for the stock flag, explicit dictionary ids, `wss://`-implied hosts and verbatim URLs; unknown ids, short tokens and a wrong version are refused; explicit entries cap at 3 while the stock flag yields the whole dictionary ✅ +- a link round-trips through a real `naddr` (kind `33301`, empty identifier) ✅ +- a valid bundle passes `problems()`; a tampered owner, a malformed salt and 257 channels are refused ✅ +- `bundle_key` round-trips a bundle through NIP-44, and a different token cannot open it ✅ +- `expires_at` converts ms→s and an absent expiry never expires ✅ +- an intact Control chain folds to its highest version; a broken link truncates to the last good one; a missing predecessor disqualifies that version alone; same-version ties break on the lower rumor id; entities fold independently; metadata for another `community_id` is ignored; `deleted` is projected ✅ +- `editionOf` parses a real rumor and refuses `vsk 10` and a missing `ev` ✅ + +**One bug the check caught:** `inviteBundleKey` first used the 32-byte hex guard, but the token is 16 bytes — every link would have failed to open. + +**Not covered by that check:** anything needing the network — fetching a bundle, the subscription, and publishing the Join. Those are the interop smoke test below. + +### M3 interop smoke test — the acceptance gate + +Against a real Community, with a real invite link: + +1. paste the link → the preview shows the right name and channel count, and no `problems` +2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list +3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently +4. a message sent from Coop appears there too (once M4 lands) + +If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1). + ### M4 — Write -`sendChannelMessage`, Guestbook `join` (`3306`, `content: "join"`), `unreadCount`. +`sendChannelMessage` and `unreadCount`. (The Guestbook `join` originally listed here shipped early, with M3's `join`.) **Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop. @@ -741,6 +774,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI | 1 | **`community_id` owner proof.** CORD-05 §1 writes `sha256(owner ‖ salt)`. `examples.md` §6.1 writes `sha256("concord/community" ‖ owner ‖ salt)`. CORD-02 A.4 (marked *frozen*, normative) writes `sha256(utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32])`. | **Implement A.4.** It is a hard-fail interop check, so validate against a reference implementation at the first opportunity. | | 2 | **`expires_at` units differ in three places** — bundle = unix **ms**, Invite List entry = unix **s**, NIP-40 tag = **s**. | Keep all conversions in one function. | | 3 | **`vac` on `3303`.** CORD-06 §3 says a rotation cites its Grant, but neither its §1 JSONC nor `examples.md` shows the tag. | Deferring rekeys sidesteps this. | +| 4 | **What `concord/invite-key` yields.** CORD-05 §2 writes `bundle_key = hkdf(token, "concord/invite-key")` and then `nip44_encrypt(bundle_key, …)`, which reads as a raw conversation key. A.6 lists the label in the *derivation* registry, where every row is fed through `group_key` → `scalar_normalize` → keypair, and elsewhere CORD-01 always writes `conv_key` for the self-ECDH value it feeds `nip44_encrypt`. | **Modelled as `group_key("concord/invite-key", token, 0…0)`.** This is the only reading the existing SDK can implement — it exposes NIP-44 by keypair only, never by conversation key — and it is consistent with every other row in A.6. It is a hard-fail interop check: verify at the smoke test, and if a bundle refuses to open, the raw-conversation-key reading is the alternative and would need NIP-44 hand-rolled. | ### 13.2 Design risks @@ -756,6 +790,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI | 11 | **Spec is young** (71 commits, no reference implementation in-repo, examples explicitly non-normative). | Keep every frozen constant in `ConcordKind.kt` so a spec revision is a one-file change. | | 12 | **`scalar_normalize`'s retry branch is ~2⁻¹²⁸ rare.** It will never fire in practice, so a bug there would never surface either. | Split the pure `groupSeed` out of `groupKey` so the counter path is reachable through its `isValid` seam rather than buried behind a crypto call. | | 13 | **The nostr SDK cannot run in a host JVM unit test.** Its uniffi bindings are JNA-backed and the Android artifact carries Android-ABI `.so` files, so `testDebugUnitTest` on macOS fails with `UnsatisfiedLinkError: libjnidispatch.jnilib`. Discovered in M1. | Not a problem while tests are not kept, but it does mean **there is no automated regression net** for anything that touches `SecretKey`, `Keys`, `nip44*` or `EventBuilder`. Verification is manual: `:shared:iosSimulatorArm64Test` is the only executable target here that can load the SDK, so a throwaway check in `shared/src/iosTest` is the cheapest way to exercise wire-format code, and the M3 interop smoke test is the real acceptance gate. Keep the SDK-free half in pure functions so it at least *could* be covered without a device. | +| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. | ### 13.3 Open decision @@ -765,10 +800,10 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI ## 14. Verification plan -**No test files are kept** (decision, M2). The checks below were written and run during M1 and M2, +**No test files are kept** (decision, M2). The checks below were written and run during M1–M3, then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are -recorded here because they are what establishes the wire format is right, and because whoever -next touches the crypto should re-run the same checks. +recorded here (and per-milestone in §12) because they are what establishes the wire format is +right, and because whoever next touches this code should re-run the same checks. The problem this leaves is real and worth stating plainly: this is frozen-by-spec crypto with **no spec test vectors** ("Examples are illustrative, not verifiable test vectors"), where a diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 347727d..0f392f9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -63,7 +63,7 @@ class NostrForegroundService : Service() { // Initialize Nostr client try { - nostr.init(dbDir.absolutePath) + nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService)) } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr Client", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt new file mode 100644 index 0000000..0120050 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordControl.kt @@ -0,0 +1,138 @@ +package su.reya.coop.concord + +import kotlinx.serialization.decodeFromString +import rust.nostr.sdk.UnsignedEvent +import kotlin.time.Instant + +/** + * Folding the Control Plane (CORD-04 §1). + * + * The Control Plane carries the Community's authoritative state as **editions**: each is a + * `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this + * edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole + * chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so. + * + * v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`). + * + * ## What v1 does not do + * + * **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the + * owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could + * publish a forged metadata or Channel edition and v1 would display it. + * + * That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2), + * and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the + * reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and + * `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly + * outranks the entity it edits. + */ +object ConcordControl { + + /** + * Parses one Control rumor into an edition, or null when it is not a foldable edition. + * + * A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1 + * does not implement dissolution, so treating it as an ordinary edition would misread it. + */ + fun editionOf(rumor: UnsignedEvent): ControlEdition? { + if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null + + val tags = rumor.tags().toVec() + val vsk = tags.firstOrNull { it.kind() == ConcordTag.VSK }?.content()?.toIntOrNull() ?: return null + if (vsk == ConcordVsk.DISSOLVED) return null + + val eidHex = tags.firstOrNull { it.kind() == ConcordTag.EID }?.content() ?: return null + if (eidHex.hex32() == null) return null + + val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null + // CORD-04: versions climb from 1. A zero version has no place in the chain. + if (version == 0uL) return null + + val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content() + if (prev != null && prev.hex32() == null) return null + + return ControlEdition( + vsk = vsk, + eidHex = eidHex.lowercase(), + version = version, + prevHashHex = prev?.lowercase(), + content = rumor.content(), + actorHex = rumor.author().toHex(), + rumorIdHex = rumor.id()?.toHex() ?: return null, + createdAt = Instant.fromEpochSeconds(rumor.createdAt().asSecs().toLong()), + ) + } + + /** + * Projects the editions into the metadata and Channel list v1 renders. + * + * Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a + * broken chain on one Channel never takes the Community's metadata down with it. + */ + fun fold(editions: List, communityIdHex: String): ControlFold { + var meta: CommunityMeta? = null + val channels = mutableMapOf() + + editions.groupBy { it.vsk to it.eidHex }.forEach { (entity, group) -> + val (vsk, eidHex) = entity + if (vsk != ConcordVsk.METADATA && vsk != ConcordVsk.CHANNEL) return@forEach + // The metadata entity *is* the Community, so its coordinate must be the community_id; + // an edition naming anything else is not this Community's metadata. + if (vsk == ConcordVsk.METADATA && eidHex != communityIdHex) return@forEach + + val winner = winner(group) ?: return@forEach + when (vsk) { + ConcordVsk.METADATA -> meta = decode(winner.content) + else -> decode(winner.content)?.let { channels[eidHex] = it } + } + } + + return ControlFold(meta = meta, channels = channels) + } + + /** + * Picks the highest version whose `ep` chain reaches all the way back to version 1. + * + * "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down + * from the top and take the first version we can actually verify. A missing predecessor or a + * broken link disqualifies that version, not the entity. + * + * Ties on the same version break on the **lower rumor id**, never on `created_at`, so two + * clients folding the same two candidates always agree. + */ + private fun winner(group: List): ControlEdition? { + val byVersion = group + .groupBy { it.version } + .mapValues { (_, candidates) -> candidates.minByOrNull { it.rumorIdHex } ?: return null } + + val eid = byVersion.values.firstOrNull()?.eidHex?.hex32() ?: return null + + for (top in byVersion.keys.sortedDescending()) { + var prev: ByteArray? = null + var candidate: ControlEdition? = null + var intact = true + + for (version in 1uL..top) { + val edition = byVersion[version] + if (edition == null) { + intact = false + break + } + // The first edition has no predecessor; every later one must chain to the last. + val expected = if (version == 1uL) null else prev?.toHex() + if (edition.prevHashHex != expected) { + intact = false + break + } + prev = editionHash(eid, version, prev, edition.content.encodeToByteArray()) + candidate = edition + } + + if (intact) return candidate + } + return null + } + + private inline fun decode(content: String): T? = + runCatching { concordJson.decodeFromString(content) }.getOrNull() +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt index 46a6b22..549b49c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt @@ -237,3 +237,14 @@ internal fun ByteArray.toHex(): String = toByteString().hex() /** Inverse of [toHex]. Throws on odd length or a non-hex character. */ internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray() + +/** Inverse of [toHex], returning null instead of throwing — for reading untrusted input. */ +internal fun String.hexToBytesOrNull(): ByteArray? = runCatching { hexToBytes() }.getOrNull() + +/** + * A 32-byte value read from hex, or null when it is not exactly 32 bytes. + * + * The length check is the point: most of Concord's ids and keys are 32 bytes, and a short or long + * value must be rejected before it reaches a derivation that assumes a fixed width. + */ +internal fun String.hex32(): ByteArray? = if (length == 64) hexToBytesOrNull() else null diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt new file mode 100644 index 0000000..43831e8 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordInvite.kt @@ -0,0 +1,253 @@ +package su.reya.coop.concord + +import kotlin.io.encoding.Base64 +import rust.nostr.sdk.Nip19Coordinate +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.nip44Decrypt + +/** + * CORD-05: redeeming an invite. + * + * Two ways to be handed the keys, one bundle: + * + * - **Public link** — `$BASE/invite/#`. The naddr names where the encrypted + * bundle sits on relays; the fragment carries an off-network unlock token and never reaches a + * server. The bundle is fetched, then decrypted with a key derived from the token. + * - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That + * path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`. + * + * Minting is out of v1's scope, so this file only decodes. + * + * A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the + * strength of what the bundle claims (CORD-05 §1). + */ + +/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */ +internal object ConcordRelayDictionary { + val STOCK = listOf( + "wss://jskitty.com/nostr", + "wss://asia.vectorapp.io/nostr", + "wss://relay.ditto.pub", + "wss://relay.dreamith.to", + ) +} + +/** The link's secret half: `[version][flags][relays?][token:16]`, base64url without padding. */ +data class InviteFragment( + val version: Int, + val relays: List, + /** The 16-byte unlock token, hex. Derives exactly one thing: the bundle key. */ + val tokenHex: String, +) + +/** A parsed invite link: the public locator plus the secret fragment. */ +data class ParsedInvite( + val signerHex: String, + val identifier: String, + /** Relays the naddr itself names. Usually empty; the compact form travels in the fragment. */ + val naddrRelays: List, + val fragment: InviteFragment, +) + +private const val FRAGMENT_VERSION = 4 + +/** The one flag v1 knows: the stock set is in use, so zero relay bytes follow. */ +private const val FLAG_STOCK_RELAYS = 1 +private const val TOKEN_BYTES = 16 + +/** CORD-05 §3: the fragment only needs to *find* the bundle; the bundle carries the real set. */ +private const val MAX_BOOTSTRAP_RELAYS = 3 + +/** CORD-05 §1: the spec's reference ceiling on a bundle's Channel list. */ +private const val MAX_INVITE_CHANNELS = 256 + +/** CORD-02 §6: five stable relays is the recommendation, not a rule; a client may truncate. */ +private const val MAX_COMMUNITY_RELAYS = 5 + +/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */ +private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL) + +/** + * Decodes a link into its locator and fragment, or null when it is not a Concord invite. + * + * The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any + * client recognizing an invite must respect the naddr and fragment verbatim — so only the last path + * segment and the `#` fragment are read. + */ +fun parseInviteLink(text: String): ParsedInvite? { + val trimmed = text.trim() + val hash = trimmed.indexOf('#') + if (hash <= 0 || hash == trimmed.lastIndex) return null + + val naddr = trimmed.substring(0, hash).substringAfterLast('/') + val fragment = decodeInviteFragment(trimmed.substring(hash + 1)) ?: return null + + val coordinate = runCatching { Nip19Coordinate.fromBech32(naddr) }.getOrNull() ?: return null + if (coordinate.coordinate().kind().asU16() != ConcordKind.INVITE_BUNDLE.toUShort()) return null + + return ParsedInvite( + signerHex = coordinate.coordinate().publicKey().toHex(), + identifier = coordinate.coordinate().identifier(), + naddrRelays = coordinate.relays(), + fragment = fragment, + ) +} + +/** + * CORD-05 §3's fragment layout: + * + * ``` + * [version=4][flags][relays?][token:16] + * ``` + * + * With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries, + * each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL. + */ +fun decodeInviteFragment(fragment: String): InviteFragment? { + val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null + // Two header bytes plus the token at minimum. + if (bytes.size < 2 + TOKEN_BYTES) return null + + val reader = FragmentReader(bytes) + if (reader.byte() != FRAGMENT_VERSION) return null + + val flags = reader.byte() ?: return null + // The stock flag selects the whole dictionary, so nothing extra is carried and the cap on + // explicit entries does not apply to it. + val relays = if (flags and FLAG_STOCK_RELAYS != 0) { + ConcordRelayDictionary.STOCK + } else { + val count = reader.byte() ?: return null + buildList { + repeat(count) { + val lead = reader.byte() ?: return null + val relay = when (lead) { + 0 -> reader.slice(reader.byte() ?: return null)?.let { "wss://$it" } + 255 -> reader.slice(reader.byte() ?: return null) + else -> ConcordRelayDictionary.STOCK.getOrNull(lead - 1) + } ?: return null + add(relay) + } + }.take(MAX_BOOTSTRAP_RELAYS) + } + + // Whatever is left must be exactly the token: a short one silently weakens the link. + val token = bytes.copyOfRange(reader.index, bytes.size) + if (token.size != TOKEN_BYTES) return null + + return InviteFragment( + version = FRAGMENT_VERSION, + relays = relays, + tokenHex = token.toHex(), + ) +} + +/** + * `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one + * thing — decrypting the bundle — and nothing else. + * + * Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's + * `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord + * `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key + * is what lets the existing SDK do the encryption instead of hand-rolling NIP-44. + */ +fun inviteBundleKey(tokenHex: String): GroupKey? = + tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) } + +/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */ +fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? { + val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull() + ?: return null + return runCatching { concordJson.decodeFromString(plaintext) }.getOrNull() +} + +/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */ +fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString() + +/** + * CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so + * the preview can explain itself rather than silently refusing. + * + * The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖ + * owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community. + */ +fun CommunityInvite.problems(): List { + val problems = mutableListOf() + + val ownerBytes = owner.hex32() + val saltBytes = ownerSalt.hex32() + val rootBytes = communityRoot.hex32() + if (ownerBytes == null) problems += "The invite's owner key is malformed" + if (saltBytes == null) problems += "The invite's owner salt is malformed" + if (rootBytes == null) problems += "The invite's community key is malformed" + + if (ownerBytes != null && saltBytes != null) { + if (communityId(ownerBytes, saltBytes).toHex() != communityId.lowercase()) { + problems += "The invite does not prove its community id" + } + } + if (communityId.hex32() == null) problems += "The invite's community id is malformed" + if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed" + + if (channels.size > MAX_INVITE_CHANNELS) { + problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)" + } + channels.forEachIndexed { index, channel -> + if (channel.id.hex32() == null) problems += "Channel $index has a malformed id" + if (channel.key.hex32() == null) problems += "Channel $index has a malformed key" + } + + return problems +} + +/** CORD-05 §1: an expired bundle still previews, but joining refuses. */ +fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs + +/** + * The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays, + * used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the + * bundle's copy is the join-time snapshot of the real set (CORD-02 §6). + */ +fun CommunityInvite.relaySet(fallback: List = emptyList()): List { + val source = relays.ifEmpty { fallback } + return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS) +} + +/** Turns a validated bundle into the membership we persist. */ +fun CommunityInvite.toMembership(relays: List): Membership = Membership( + communityId = communityId.lowercase(), + owner = owner.lowercase(), + ownerSalt = ownerSalt.lowercase(), + communityRoot = communityRoot.lowercase(), + rootEpoch = rootEpoch, + controlPk = controlPk?.lowercase(), + controlRoot = null, + relays = relays, + name = name, + channels = channels.map { channel -> + StoredChannelKey( + id = channel.id.lowercase(), + key = channel.key.lowercase(), + epoch = channel.epoch, + name = channel.name, + // A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the + // only reading the bundle supports. The Control fold overrides this for display. + private = !channel.key.equals(communityRoot, ignoreCase = true), + ) + }, +) + +/** Reads the fragment's length-prefixed fields, refusing to run past the end. */ +private class FragmentReader(private val bytes: ByteArray) { + var index = 0 + private set + + fun byte(): Int? = if (index < bytes.size) bytes[index++].toInt() and 0xff else null + + fun slice(length: Int): String? { + if (length < 0 || index + length > bytes.size) return null + val value = bytes.decodeToString(index, index + length) + index += length + return value + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt new file mode 100644 index 0000000..64803ba --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt @@ -0,0 +1,475 @@ +package su.reya.coop.concord + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.decodeFromString +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Event +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Kind +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.AppStorage +import su.reya.coop.nostr.Nostr +import kotlin.concurrent.Volatile +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +/** + * Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite + * turns into membership. + * + * ## Why this lives on [Nostr] + * + * The client has exactly **one** notification pump. `client.notifications()` is called once, inside + * [Nostr.handleNotifications], and a second consumer would silently split the stream — so Concord + * routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]). + * + * ## The two phases + * + * [restore] is local and fast: it reads memberships from storage and folds whatever the Control + * plane has already cached, so routing is meaningful before anything hits the network. [sync] is + * the network half: connect the Community's relays and subscribe to its plane addresses. The pump + * calls them in that order, so the first event that arrives already knows where it belongs. + */ +class ConcordManager(private val nostr: Nostr) { + + /** + * Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a + * Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17 + * path, which assumes an ephemeral author and a `p`-tagged recipient. + * + * Replaced wholesale rather than mutated, and read from the notification pump's thread while a + * worker coroutine rewrites it, so the reference is volatile. + */ + @Volatile + private var planes: Map = emptyMap() + + /** Latest Control fold per community id. Replaced wholesale; see [planes]. */ + @Volatile + private var folds: Map = emptyMap() + + private val _memberships = MutableStateFlow>(emptyList()) + val memberships: StateFlow> = _memberships.asStateFlow() + + private val _communities = MutableStateFlow>(emptyList()) + val communities: StateFlow> = _communities.asStateFlow() + + /** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */ + private val _directInvites = MutableStateFlow>(emptyList()) + val directInvites: StateFlow> = _directInvites.asStateFlow() + + private var store: ConcordStore? = null + + /** + * Hands over the encrypted storage [Nostr] cannot reach on its own. + * + * [Nostr] is a Context-free singleton, so the Android layer passes [AppStorage] in through + * [Nostr.init]. Until this is called nothing can be persisted or restored, and the manager + * simply routes nothing. + */ + internal fun attach(storage: AppStorage) { + store = ConcordStore(storage, nostr) + } + + // ----------------------------------------------------------------------------------------- + // Routing + // ----------------------------------------------------------------------------------------- + + /** True when a wrap's author is one of our planes' stream keys. */ + fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex) + + /** + * Handles one stream wrap from a plane we hold a key for. + * + * [PlaneKey.unwrap] runs every check a reader must make and returns null on any failure, so a + * forged, unverifiable, mis-bound or undecryptable wrap is dropped here without ever being + * rendered. What survives is stored under its logical scope and, for Control, re-folded. + */ + suspend fun handlePlaneEvent(event: Event) { + val plane = planes[event.author().toHex()] ?: return + val rumor = plane.key.unwrap(event) ?: return + val store = store ?: return + + store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor) + + // Control carries consensus state, so every edition changes what we can read: a new + // Channel means a new plane address, and therefore a new subscription. + if (plane.role == PlaneRole.Control) refreshControl(plane.communityIdHex) + } + + /** + * Intercepts rumors that arrive through the ordinary NIP-17 giftwrap pipeline and belong to + * Concord instead. + * + * Returns true when Concord consumed the rumor. A Direct Invite (kind `3313`) rides a *standard* + * NIP-59 wrap, so it unwraps fine — but it is a key handoff, not a message, and letting it + * through would have the chat layer build a room out of it. Returning true here keeps all + * Concord kind knowledge inside this package and leaves the chat code untouched. + */ + fun onInboxRumor(rumor: UnsignedEvent): Boolean { + if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false + + val invite = runCatching { concordJson.decodeFromString(rumor.content()) }.getOrNull() + if (invite == null) { + println("Concord: a direct invite arrived but its bundle could not be read") + return true + } + if (invite.problems().isNotEmpty()) { + println("Concord: refusing a direct invite that does not prove its community id") + return true + } + + _directInvites.update { current -> + if (current.any { it.communityId.equals(invite.communityId, ignoreCase = true) }) current + else current + invite + } + return true + } + + // ----------------------------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------------------------- + + /** Local half of startup: load memberships, fold the cached Control plane, index the planes. */ + suspend fun restore() { + val store = store ?: return + val memberships = store.loadMemberships() + _memberships.value = memberships + + // Fold what is already on disk so Channels and metadata render immediately. Live editions + // arrive later through handlePlaneEvent and re-fold. + folds = memberships.associate { membership -> + membership.communityId to ConcordControl.fold( + editions = store.cachedRumors(membership.communityId) + .mapNotNull { ConcordControl.editionOf(it) }, + communityIdHex = membership.communityId, + ) + } + refreshPlanes() + } + + /** Network half of startup: connect every Community's relays and subscribe to its planes. */ + suspend fun sync() { + for (membership in _memberships.value) { + try { + subscribeCommunity(membership) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: could not subscribe to ${membership.communityId}: ${e.message}") + } + } + } + + // ----------------------------------------------------------------------------------------- + // Invites + // ----------------------------------------------------------------------------------------- + + /** + * Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining + * would mean. Nothing is joined, nothing is subscribed and no presence is announced here + * (CORD-05 §1: a bundle is passive until the user accepts). + */ + suspend fun previewInvite(link: String): InvitePreview { + val parsed = parseInviteLink(link) ?: throw IllegalArgumentException("That is not a Concord invite link") + val bundleKey = inviteBundleKey(parsed.fragment.tokenHex) + ?: throw IllegalArgumentException("The invite link's token is malformed") + val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready") + + // The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries + // the Community's real relay set (CORD-05 §3). + val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays) + .mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } + .distinct() + if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from") + + relays.forEach { relay -> + client.addRelay(relay) + client.connectRelay(relay) + } + + val filter = Filter() + .kind(Kind(ConcordKind.INVITE_BUNDLE.toUShort())) + .author(PublicKey.fromBytes(parsed.signerHex.hexToBytes())) + .identifier(parsed.identifier) + + val bundle = client + .fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds) + .toVec() + .firstOrNull() + ?: throw IllegalStateException("No invite bundle was found at that link") + + if (isInviteTombstone(bundle.tagValue(ConcordTag.VSK))) { + throw IllegalStateException("This invite link has been revoked") + } + + val invite = decryptInviteBundle(bundle.content(), bundleKey) + ?: throw IllegalStateException("The invite bundle could not be opened") + + return preview(invite, link, parsed.fragment.relays) + } + + /** The same preview for a Direct Invite, which arrived with no link to fetch. */ + fun previewDirectInvite(invite: CommunityInvite): InvitePreview = preview(invite, link = null, fallbackRelays = emptyList()) + + /** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */ + suspend fun join(preview: InvitePreview): CommunityState { + preview.problems.firstOrNull()?.let { throw IllegalArgumentException(it) } + if (preview.expired) throw IllegalArgumentException("That invite has expired") + + val store = store ?: throw IllegalStateException("Concord storage is not ready") + val membership = preview.invite.toMembership(preview.relays) + + val updated = _memberships.value.filterNot { it.communityId == membership.communityId } + membership + store.saveMemberships(updated) + _memberships.value = updated + + refreshPlanes() + subscribeCommunity(membership) + + // CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no + // Guestbook fold in v1 — the Control plane is what drives the UI. + if (!preview.alreadyJoined) publishJoin(membership, preview.invite) + + _directInvites.update { invites -> + invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) } + } + return _communities.value.firstOrNull { it.membership.communityId == membership.communityId } + ?: CommunityState(membership, null, emptyList()) + } + + // ----------------------------------------------------------------------------------------- + // Reading + // ----------------------------------------------------------------------------------------- + + /** + * A Channel's messages, oldest first. Reads the local cache; the live subscription is what + * keeps it current. + */ + suspend fun channelMessages(channelIdHex: String): List = + store?.cachedRumors(channelIdHex).orEmpty() + .mapNotNull { it.toConcordMessage() } + .sortedBy { it.timestampMs } + + // ----------------------------------------------------------------------------------------- + // Planes + // ----------------------------------------------------------------------------------------- + + /** + * Rebuilds the plane index and the Community read model from the memberships and folds we hold. + * + * Returns the communities whose set of plane addresses changed, because those are exactly the + * ones whose subscription is now stale — a Control edition that adds a Channel adds an address. + */ + private fun refreshPlanes(): Set { + val previous = planes + val next = mutableMapOf() + val states = mutableListOf() + + for (membership in _memberships.value) { + val communityId = membership.communityId.hex32() + val root = membership.communityRoot.hex32() + val fold = folds[membership.communityId] + val granted = membership.channels.associateBy { it.id } + + if (communityId != null && root != null) { + // Control is write-restricted: we hold the read key plus the writers' pubkey, which + // is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a + // legacy, pre-split Community, whose plane was addressed by the `concord/control` + // derivation itself; v1 does not read those, so such a Community shows no metadata. + // CORD-02 §5 requires that legacy reading and the first base rotation upgrades it. + membership.controlPk?.let { controlPkHex -> + if (controlPkHex.hex32() != null) { + val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex) + next[key.streamPublicKeyHex] = Plane( + communityIdHex = membership.communityId, + role = PlaneRole.Control, + key = key, + scopeIdHex = membership.communityId, + ) + } + } + + val guestbook = guestbookPlaneKey(root, communityId, membership.rootEpoch) + next[guestbook.streamPublicKeyHex] = Plane( + communityIdHex = membership.communityId, + role = PlaneRole.Guestbook, + key = guestbook, + scopeIdHex = membership.communityId, + ) + } + + val channelIds = (granted.keys + fold?.channels?.keys.orEmpty()).distinct() + val channels = channelIds.mapNotNull { channelIdHex -> + val meta = fold?.channels?.get(channelIdHex) + if (meta?.deleted == true) return@mapNotNull null + + val stored = granted[channelIdHex] + val channelId = channelIdHex.hex32() + // A Public Channel's key *is* the community_root, so one we were never granted is + // still derivable; a Private one is not (CORD-03 §1). + val secret = stored?.key?.hex32() + ?: if (meta?.isPrivate != true) root else null + val epoch = stored?.epoch ?: membership.rootEpoch + + var plane: PlaneKey? = null + if (channelId != null && secret != null) { + plane = channelPlaneKey(secret, channelId, epoch) + next[plane.streamPublicKeyHex] = Plane( + communityIdHex = membership.communityId, + role = PlaneRole.Channel, + key = plane, + scopeIdHex = channelIdHex, + ) + } + + ConcordChannel( + idHex = channelIdHex, + name = meta?.name ?: stored?.name ?: channelIdHex.take(8), + private = meta?.isPrivate ?: stored?.private ?: false, + epoch = epoch, + hasKey = plane != null, + ) + } + + states += CommunityState( + membership = membership, + meta = fold?.meta, + channels = channels.sortedBy { it.name.lowercase() }, + ) + } + + planes = next + _communities.value = states + + return _memberships.value + .filter { membership -> + previous.planeKeys(membership.communityId) != next.planeKeys(membership.communityId) + } + .map { it.communityId } + .toSet() + } + + /** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */ + private suspend fun refreshControl(communityIdHex: String) { + val store = store ?: return + val editions = store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) } + folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex)) + + if (communityIdHex in refreshPlanes()) { + _memberships.value.firstOrNull { it.communityId == communityIdHex }?.let { subscribeCommunity(it) } + } + } + + /** + * Subscribes to one Community: connect its relays, then ask for every plane's address. + * + * `channel` and `epoch` are multi-letter tags, and Nostr filters only accept single-letter ones, + * so plane addresses are the finest granularity available. That is the right level anyway — one + * key per plane — and the `channel`/`epoch` binding is a strict check after decryption. + */ + private suspend fun subscribeCommunity(membership: Membership) { + val client = nostr.client ?: return + val id = subscriptionId(membership.communityId) + client.unsubscribe(id) + + val relays = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }.distinct() + if (relays.isEmpty()) return + + // Always the Community's own relays, never the app's defaults: Concord reverses NIP-59 + // (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops + // these wraps, and the bootstrap set is tuned for NIP-17. + relays.forEach { relay -> + client.addRelay(relay) + client.connectRelay(relay) + } + + val filters = planes.values + .filter { it.communityIdHex == membership.communityId } + .mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() } + .distinct() + .map { Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) } + if (filters.isEmpty()) return + + client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id) + } + + /** Publishes a still-fetchable Guestbook Join (CORD-02 §5) — each member's own word. */ + private suspend fun publishJoin(membership: Membership, invite: CommunityInvite?) { + val client = nostr.client ?: return + val communityId = membership.communityId.hex32() ?: return + val root = membership.communityRoot.hex32() ?: return + val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch) + // CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what + // makes per-link usage counters possible at all. + val extraTags = invite?.creatorNpub?.let { creator -> + listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty()))) + }.orEmpty() + + val rumor = plane.rumor( + author = author, + kind = ConcordKind.JOIN_LEAVE.toUShort(), + content = "join", + createdAt = Clock.System.now(), + extraTags = extraTags, + ) + val wrap = plane.wrap(rumor, nostr.signer) + + val targets = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() } + client.sendEvent(event = wrap, target = SendEventTarget.to(targets), ackPolicy = AckPolicy.none()) + } + + private fun subscriptionId(communityIdHex: String): String = "$SUBSCRIPTION_PREFIX$communityIdHex" + + private fun preview( + invite: CommunityInvite, + link: String?, + fallbackRelays: List, + ): InvitePreview { + val relays = invite.relaySet(fallbackRelays) + val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList() + return InvitePreview( + link = link, + invite = invite, + relays = relays, + problems = problems, + expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()), + alreadyJoined = _memberships.value.any { it.communityId.equals(invite.communityId, ignoreCase = true) }, + ) + } + + private companion object { + const val SUBSCRIPTION_PREFIX = "concord:" + } +} + +/** What a plane is for — the Community-wide Control and Guestbook planes, or one Channel. */ +private enum class PlaneRole { Control, Guestbook, Channel } + +/** + * One plane address we hold: the key to decrypt it, the role that says how to fold it, and the + * [scopeIdHex] it caches under. + */ +private class Plane( + val communityIdHex: String, + val role: PlaneRole, + val key: PlaneKey, + val scopeIdHex: String, +) + +private fun Map.planeKeys(communityIdHex: String): Set = + filterValues { it.communityIdHex == communityIdHex }.keys + +/** The first content value of a tag, or null. Used for tags read without their companion fields. */ +private fun Event.tagValue(kind: String): String? = + tags().toVec().firstOrNull { it.kind() == kind }?.content() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt new file mode 100644 index 0000000..1a179db --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordModels.kt @@ -0,0 +1,254 @@ +package su.reya.coop.concord + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import rust.nostr.sdk.UnsignedEvent +import kotlin.time.Instant + +/** + * Concord's data model: what we persist about a Community, what an invite carries, and what the + * Control fold projects. + * + * Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon]) + * keep the spec's snake_case field names because those keys are normative. Types that are ours + * alone ([Membership], [StoredChannelKey]) keep Kotlin naming. + * + * Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to + * round-trip fields they do not understand. v1 only ever *reads* these documents, so the models + * below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing + * them back must preserve what it did not parse. + */ +internal val concordJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +// --------------------------------------------------------------------------------------------- +// Persisted +// --------------------------------------------------------------------------------------------- + +/** + * A Community this device has joined, as stored locally. Holding these keys *is* membership + * (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store. + * + * There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key + * cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise. + */ +@Serializable +data class Membership( + /** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */ + val communityId: String, + /** Owner x-only pubkey hex, the other half of the `community_id` commitment. */ + val owner: String, + /** 32-byte hex salt; not secret, travels in invites so anyone can recompute the commitment. */ + val ownerSalt: String, + /** 32-byte hex `community_root`. The base access key — never leaves the encrypted store. */ + val communityRoot: String, + /** Epoch the root was issued at (CORD-02 §3). */ + val rootEpoch: ULong, + /** Control Plane signer pubkey (CORD-02 §5), used to *address* the plane. Trusted on trust. */ + val controlPk: String? = null, + /** Staff-only write key. v1 never writes to the Control Plane, so this stays null. */ + val controlRoot: String? = null, + /** The Community's relays — always from the invite, never the app's defaults (see PLAN.md 13.2). */ + val relays: List = emptyList(), + /** Last known name, cached so a community renders before its Control fold lands. */ + val name: String? = null, + /** Channels this invite actually granted a key for. */ + val channels: List = emptyList(), +) + +/** + * A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry + * it here, which is why [private] can only be a hint — the Control fold is the authority. + */ +@Serializable +data class StoredChannelKey( + val id: String, + val key: String, + val epoch: ULong, + val name: String? = null, + val private: Boolean = false, +) + +// --------------------------------------------------------------------------------------------- +// Invite bundle (CORD-05 §1) +// --------------------------------------------------------------------------------------------- + +/** + * The `CommunityInvite` bundle: the same document whether it arrives inside a public link's + * encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6). + * + * The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real + * Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will + * never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it. + */ +@Serializable +data class CommunityInvite( + @SerialName("community_id") val communityId: String = "", + val owner: String = "", + @SerialName("owner_salt") val ownerSalt: String = "", + @SerialName("community_root") val communityRoot: String = "", + @SerialName("root_epoch") val rootEpoch: ULong = 0u, + @SerialName("control_pk") val controlPk: String? = null, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String? = null, + val icon: ConcordIcon? = null, + /** Unix **milliseconds** — see [expiryToEpochSeconds]. Optional. */ + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName("creator_npub") val creatorNpub: String? = null, + val label: String? = null, +) + +/** One granted Channel inside a bundle. `key` is `community_root` for a Public one (CORD-03 §1). */ +@Serializable +data class InviteChannel( + val id: String = "", + val key: String = "", + val epoch: ULong = 0u, + val name: String? = null, +) + +/** + * A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext + * only; a member fetches, decrypts and verifies [hash]. v1 never fetches these. + */ +@Serializable +data class ConcordIcon( + val url: String? = null, + val key: String? = null, + val nonce: String? = null, + val hash: String? = null, +) + +// --------------------------------------------------------------------------------------------- +// Control fold +// --------------------------------------------------------------------------------------------- + +/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */ +@Serializable +data class CommunityMeta( + val name: String? = null, + val description: String? = null, + /** The Community's own relay set. This is the authority; the invite's copy is a snapshot. */ + val relays: List = emptyList(), + val icon: ConcordIcon? = null, + val banner: ConcordIcon? = null, + /** Disappearing-messages timer in seconds (CORD-08). Read only; v1 does not enforce it. */ + @SerialName("message_expiration") val messageExpiration: Long? = null, +) + +/** Channel metadata — the `vsk 2` entity's content (CORD-03 §2). */ +@Serializable +data class ChannelMeta( + val name: String? = null, + @SerialName("private") val isPrivate: Boolean = false, + /** Terminal: the id is never reused and clients drop the Channel (CORD-03 §2). */ + val deleted: Boolean = false, +) + +/** + * One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B). + * + * The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate, + * `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as + * a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization. + */ +data class ControlEdition( + val vsk: Int, + val eidHex: String, + val version: ULong, + val prevHashHex: String?, + val content: String, + val actorHex: String, + val rumorIdHex: String, + val createdAt: Instant, +) + +/** The projected Control state v1 uses: the Community's metadata and its Channel list. */ +data class ControlFold( + val meta: CommunityMeta?, + val channels: Map, +) + +// --------------------------------------------------------------------------------------------- +// Read surface +// --------------------------------------------------------------------------------------------- + +/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */ +data class ConcordChannel( + val idHex: String, + val name: String, + val private: Boolean, + val epoch: ULong, + /** False for a Private Channel we were never granted — listable, but not readable. */ + val hasKey: Boolean, +) + +/** A Community with its Control fold applied. */ +data class CommunityState( + val membership: Membership, + /** Null until a `vsk 0` edition has been folded; the invite's name fills in before that. */ + val meta: CommunityMeta?, + val channels: List, +) + +/** A Chat-plane message, already unwrapped and signature-checked. */ +data class ConcordMessage( + val idHex: String, + val author: String, + val content: String, + val createdAt: Instant, + /** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */ + val timestampMs: Long, +) + +/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */ +data class InvitePreview( + /** Null for a Direct Invite, which arrives with no link. */ + val link: String?, + val invite: CommunityInvite, + /** Resolved relay set: the bundle's, falling back to the link's bootstrap relays. */ + val relays: List, + /** Non-empty means the bundle is refused — these are the reasons, in plain English. */ + val problems: List, + val expired: Boolean, + val alreadyJoined: Boolean, +) { + val joinable: Boolean get() = problems.isEmpty() && !expired +} + +// --------------------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------------------- + +/** + * CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix + * **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so + * every conversion lives here. Returns null when the bundle carries no expiry at all. + */ +fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 } + +/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */ +internal fun UnsignedEvent.concordTimestampMs(): Long { + val ms = tags().toVec() + .firstOrNull { it.kind() == ConcordTag.MS } + ?.content() + ?.toIntOrNull() + ?.takeIf { it in 0..999 } + return createdAt().asSecs().toLong() * 1000 + (ms ?: 0) +} + +/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */ +internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? { + if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null + return ConcordMessage( + idHex = id()?.toHex().orEmpty(), + author = author().toHex(), + content = content(), + createdAt = Instant.fromEpochSeconds(createdAt().asSecs().toLong()), + timestampMs = concordTimestampMs(), + ) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt new file mode 100644 index 0000000..c265b02 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordStore.kt @@ -0,0 +1,113 @@ +package su.reya.coop.concord + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.AppStorage +import su.reya.coop.nostr.Nostr + +/** + * Concord's local persistence, in the two places the rest of the app already keeps things. + * + * **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2), + * so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go + * through plaintext storage, and they never touch LMDB. + * + * **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way + * [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the + * Control fold has something to fold before the network answers. + */ +class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) { + + suspend fun loadMemberships(): List { + val raw = try { + storage.getSecret(MEMBERSHIPS_KEY) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: could not read memberships: ${e.message}") + return emptyList() + } ?: return emptyList() + + return try { + concordJson.decodeFromString>(raw) + } catch (e: Exception) { + println("Concord: stored memberships are unreadable: ${e.message}") + emptyList() + } + } + + suspend fun saveMemberships(memberships: List) { + try { + storage.setSecret(MEMBERSHIPS_KEY, concordJson.encodeToString(memberships)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw IllegalStateException("Concord: could not store memberships: ${e.message}", e) + } + } + + /** + * Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the + * community id for Control and Guestbook. + * + * The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable* + * kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every + * message would replace the one before it and history would vanish silently. The wrap id is + * that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it — + * and it doubles as the dedupe key when a wrap arrives from several relays. + */ + suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) { + try { + val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson()) + .tags( + listOf( + Tag.identifier(wrapIdHex), + Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)), + Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())), + ) + ) + .finalizeAsync(Keys.generate()) + + nostr.client?.database()?.saveEvent(event) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: failed to cache rumor: ${e.message}") + } + } + + /** Every cached rumor for one scope, oldest first. */ + suspend fun cachedRumors(scopeIdHex: String): List { + val events = try { + val filter = Filter() + .kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)) + .reference(scopeIdHex) + nostr.client?.database()?.query(filter)?.toVec() ?: return emptyList() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: failed to read cached rumors: ${e.message}") + return emptyList() + } + + return events + .mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() } + .sortedBy { it.createdAt().asSecs() } + } + + private companion object { + const val MEMBERSHIPS_KEY = "concord_memberships" + + /** Single-letter index tags; `r` is what `Filter.reference` queries. */ + const val INDEX_SCOPE_TAG = "r" + const val INDEX_KIND_TAG = "k" + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index ce06e65..ea82606 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -32,6 +32,8 @@ import rust.nostr.sdk.SleepWhenIdle import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.initLogger +import su.reya.coop.AppStorage +import su.reya.coop.concord.ConcordManager import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -50,6 +52,7 @@ class Nostr( val messages = MessageManager(this) val profiles = ProfileManager(this) val relays = RelayManager(this) + val concord = ConcordManager(this) private val isInitialized = MutableStateFlow(false) @@ -64,10 +67,18 @@ class Nostr( _newEvents.tryEmit(event) } - suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) { + suspend fun init( + dbPath: String, + storage: AppStorage, + logLevel: LogLevel = LogLevel.WARN, + ) { try { if (isInitialized.value) return + // Concord's keys live in the encrypted store, which only the Android layer can build. + // Handing it over here keeps it out of the Context-free singleton's constructor. + concord.attach(storage) + // Initialize the logger for nostr client initLogger(logLevel) @@ -144,16 +155,35 @@ class Nostr( val notifications = client?.notifications() ?: return@supervisorScope val giftWrapQueue = Channel(1024) + val concordQueue = Channel(1024) + var processedCount = 0 var eoseReceived = false + try { + concord.restore() + } catch (e: Exception) { + println("Failed to restore Concord state: ${e.message}") + } + + launch(defaultDispatcher) { concord.sync() } + + launch(defaultDispatcher) { + for (event in concordQueue) { + try { + concord.handlePlaneEvent(event) + } catch (e: Exception) { + println("Failed to handle Concord plane event: ${e.message}") + } + } + } + launch(defaultDispatcher) { for (event in giftWrapQueue) { val rumor = messages.extractRumor(event) processedCount++ - // Trigger new message notification - if (rumor != null) { + if (rumor != null && !concord.onInboxRumor(rumor)) { val isSelfMessage = rumor.author() == signer.publicKeyFlow.value val isNew = rumor.createdAt().asSecs() >= now.asSecs() @@ -214,7 +244,11 @@ class Nostr( } KindStandard.GIFT_WRAP -> { - giftWrapQueue.send(event) + if (concord.isPlaneAddress(event.author().toHex())) { + concordQueue.send(event) + } else { + giftWrapQueue.send(event) + } } else -> {}