diff --git a/PLAN.md b/PLAN.md index 946b776..375fcf5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -29,7 +29,7 @@ Implementation plan for adding **Concord** communities and channels to Coop. 11. [UI](#11-ui) 12. [Milestones](#12-milestones) 13. [Risks and open decisions](#13-risks-and-open-decisions) -14. [Test plan](#14-test-plan) +14. [Verification plan](#14-verification-plan) 15. [Appendix A — Concord constants reference](#appendix-a--concord-constants-reference) 16. [Appendix B — Coop architecture reference](#appendix-b--coop-architecture-reference) @@ -179,7 +179,7 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue | `ConcordKind.kt` | All frozen constants in one place: kind numbers, `vsk` registry, permission bits, KDF label strings, tag names. Pure data, no SDK import — a spec revision is a one-file change | | `ConcordCrypto.kt` | `hkdfSha256`, `hkdfInfo`, `groupSeed`, `isValidScalar`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, hex/bytes helpers | | `ConcordModels.kt` | `Membership`, `CommunityInvite`, `CommunityMeta`, `ChannelMeta`, `ConcordChannel`, `ConcordMessage`, `ControlEdition` | -| `ConcordPlane.kt` | `PlaneKeys` (sk/pk/relays), `wrap()`, `unwrap()`, rumor builders — the CORD-01 stack | +| `ConcordPlane.kt` | `SealForm`, `ChatBinding`, `PlaneKey` + `channelPlaneKey` / `guestbookPlaneKey` / `controlPlaneKey`, and the `rumor()` / `wrap()` / `unwrap()` extensions — the CORD-01 stack with the CORD-03 §3 binding built in | | `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/#` decoder, relay dictionary | | `ConcordStore.kt` | Membership persistence (`AppStorage.setSecret`), LMDB index events, Control edition storage | | `ConcordControl.kt` | Control fold: group by `eid`, take highest `ev` with intact `ep` chain; project `vsk 0` / `vsk 2` | @@ -203,17 +203,11 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue | `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join | | `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states | -### 5.4 New — tests +### 5.4 Tests — **none are kept** -| File | Contents | -|---|---| -| `shared/src/commonTest/kotlin/su/reya/coop/concord/ConcordCryptoTest.kt` | RFC 5869 HKDF vectors, `hkdfInfo` layout golden bytes, `community_id` / `prevcommit` / `edition_hash` golden digests, `groupSeed` determinism + counter path, `isValidScalar` boundaries, hex round trip | -| `shared/src/commonTest/kotlin/su/reya/coop/concord/ConcordPlaneTest.kt` | wrap→unwrap round trip, impersonation rejection, `channel`/`epoch` mismatch rejection — **must be pure**, see risk 13 | -| `shared/src/iosTest/kotlin/su/reya/coop/concord/ConcordSdkInteropTest.kt` | The SDK-backed half: `groupKey` → `pk == xonly(sk)`, `isValidScalar` cross-checked against `SecretKey.fromBytes`, NIP-44 self-ECDH round trip, wrong-plane rejection | +Decision: no test files are maintained for this feature. M1 and M2 were verified during development and the checks were then discarded, so the tree carries no `commonTest` or `iosTest` sources for Concord. -> `commonTest` runs on JVM **and** iOS, so nothing in it may touch the SDK. Anything that does -goes in `iosTest` (or `appleTest`), which is the only executable target that can load the -SDK's native library here. +The consequence to plan around is in §14 and risk 13. ### 5.5 Modified (6 files, all small) @@ -370,70 +364,46 @@ Only `concord/channel`, `concord/guestbook`, and `concord/control` are exercised ### 7.1 Sending a channel message +Implemented in `ConcordPlane.kt`; the whole send path is two calls. + ```kotlin -suspend fun sendChannelMessage(ch: ConcordChannel, text: String) { - val author = signer.getPublicKeyAsync() ?: error("not signed in") - val now = Clock.System.now() - val secs = now.epochSeconds.toULong() - val ms = (now.toEpochMilliseconds() % 1000).toInt() - - // 1. rumor — kind 9, NEVER signed, MUST carry the binding tags - val rumor = EventBuilder(Kind(9u), text) - .tags(listOf( - Tag.custom("channel", listOf(ch.idHex)), - Tag.custom("epoch", listOf(ch.epoch.toString())), - Tag.custom("ms", listOf(ms.toString())), - )) - .finalizeUnsigned(author) - .ensureId() - - // 2. seal — kind 20013, signed by the REAL author (works with a bunker signer) - val seal = EventBuilder( - Kind(20013u), - nip44Encrypt(ch.sk, ch.pk, rumor.asJson(), Nip44Version.V2) // self-ECDH conv key - ).finalizeAsync(signer) ?: error("seal failed") - - // 3. wrap — kind 1059, signed by the STREAM key, ephemeral `p` (NIP-59 reversed) - val wrap = EventBuilder( - Kind(1059u), - nip44Encrypt(ch.sk, ch.pk, seal.asJson(), Nip44Version.V2) // same conv key, second layer - ) - .tags(listOf(Tag.publicKey(Keys.generate().publicKey()))) // ephemeral; discard the secret - .finalize(Keys(ch.secretKey)) // stream signature - - client.sendEvent(wrap, SendEventTarget.to(communityRelays), AckPolicy.none()) -} +val plane = channelPlaneKey(secret, channelId, epoch) // CORD-03 §1 +val rumor = plane.rumor(author, ConcordKind.MESSAGE.toUShort(), text, now) +val wrap = plane.wrap(rumor, signer) // signer = the real author ``` -Two things that are easy to get wrong: +What `wrap` does, in order: -- The wrap content is encrypted under the **same conversation key** as the seal. It is double encryption under one key — not a second key. -- `created_at` is **never tweaked**. Sub-second ordering lives in the `ms` tag; true time = `created_at * 1000 + ms`. +1. **rumor** — `kind` given, stamped with the binding tags and `ms`, `finalizeUnsigned(author).ensureId()`. Never signed. +2. **seal** — `kind` = the plane's `SealForm`. `20013` content is the NIP-44 ciphertext of the rumor JSON; `20014` content is that JSON byte-verbatim. `customCreatedAt(rumor.createdAt())`, then `.finalizeAsync(signer)` so the **real author** signs it and a NIP-46 bunker works. +3. **wrap** — `kind 1059`, content = NIP-44 of the seal JSON, one ephemeral `p` tag, `customCreatedAt(rumor.createdAt())`, `.finalize(Keys(stream.secretKey))` so the **stream key** signs it. + +Four things that are easy to get wrong: + +- The wrap content is encrypted under the **same conversation key** as the seal. It is double encryption under one key — not a second key, and never the `p`-tagged key. +- `created_at` is **never tweaked**, and the seal and wrap reuse the rumor's value verbatim so all three layers agree. Sub-second ordering lives in the `ms` tag; true time = `created_at * 1000 + ms`. +- The seal form is **owned by the plane**, not passed per call (CORD-02 §5), so a chat wrap can never be sealed with `20014`. +- The NIP-44 65,535-byte plaintext cap is enforced **at build time** before each encryption. A lenient publisher mints events a strict reader cannot decrypt. ### 7.2 Receiving -```kotlin -fun unwrap(event: Event, keys: PlaneKeys): UnsignedEvent? { - val seal = Event.fromJson(nip44Decrypt(keys.sk, keys.pk, event.content())) - if (!seal.verify()) return null - val rumorJson = when (seal.kind().asU16()) { - 20013u -> nip44Decrypt(keys.sk, keys.pk, seal.content()) // Chat / Guestbook / rekey - 20014u -> seal.content() // Control only, byte-verbatim - else -> return null - } - val rumor = UnsignedEvent.fromJson(rumorJson).ensureId() - if (rumor.author() != seal.author()) return null // NIP-59 impersonation check - return rumor -} +`PlaneKey.unwrap(event)` runs every check a reader must make and returns null on any failure. Null means **drop**, never retry: + +1. `kind` is `1059` — otherwise not ours +2. author equals the plane's stream pubkey — this is what makes CORD-01's write-restricted split real: a read-key holder can verify a wrap but cannot mint one +3. `event.verify()` — id and signature +4. NIP-44 decrypt the wrap content with the **read** key → seal +5. `seal.verify()`, and `seal.kind()` matches the plane's `SealForm` (the other form is a discipline violation, not a variant) +6. `20013` → NIP-44 decrypt the seal content; `20014` → take it verbatim, never re-parsed as an event +7. `UnsignedEvent.fromJson(…).ensureId()`, and `rumor.author() == seal.author()` — NIP-59's impersonation check +8. the **mandatory** CORD-03 §3 binding check (below) + +``` +channel must be present and strict-equal to the plane's channel_id +epoch must be present and strict-equal to the plane's epoch ``` -Then the **mandatory** CORD-03 §3 binding check — drop on mismatch, never render: - -```kotlin -val tChannel = rumor.tags().toVec().firstOrNull { it.kind() == "channel" }?.content() -val tEpoch = rumor.tags().toVec().firstOrNull { it.kind() == "epoch" }?.content() -if (tChannel != ch.idHex || tEpoch != ch.epoch.toString()) return // re-wrap / cross-epoch replay -``` +On a Chat plane both are required, so neither a re-wrap into another Channel nor a cross-epoch replay survives. The community-wide Guestbook and Control planes split no sub-context, so the spec binds nothing there and the check is a no-op. ### 7.3 Seal discipline (CORD-02 §5) — normative @@ -442,7 +412,7 @@ if (tChannel != ch.idHex || tEpoch != ch.epoch.toString()) return // re-wrap / | **Control** | **`20014` plaintext** — a signature over ciphertext could not survive a compaction re-wrap across epochs | | Chat, Guestbook, rekey | **`20013` encrypted** | -Enforce the NIP-44 65,535-byte plaintext cap yourself at **every** layer before publishing. Libraries are lenient; a lenient publisher mints events a strict reader cannot decrypt. +Modelled as `SealForm`, held by `PlaneKey`, set by the plane factories. Because the plane owns it, `wrap` cannot pick the wrong form and `unwrap` rejects the other form outright. ### 7.4 Event kinds used in v1 @@ -706,26 +676,29 @@ Each milestone ends with something runnable. ### M1 — Crypto core (no UI, no networking) — **done** -`ConcordCrypto.kt` + `ConcordKind.kt` + `ConcordCryptoTest.kt`. +`ConcordCrypto.kt` + `ConcordKind.kt`. Committed as `e221eda "add concord crypto"`. + +**Verified** with RFC 5869 Test Cases 1–3 and SHA-256 digests computed outside the codebase, then cross-checked against the SDK on the iOS target (`isValidScalar` vs `SecretKey.fromBytes`, `groupKey` → `pk == xonly(sk)`, NIP-44 self-ECDH round trip). Those tests were not kept — see §14. -**Done when:** - RFC 5869 Test Cases 1, 2 & 3 pass ✅ - `hkdfInfo` matches a golden hex annotated with the A.1 layout ✅ - `communityId` / `prevCommit` / `editionHash` match digests computed outside the codebase ✅ - `groupSeed` is deterministic, separates label/id/epoch/secret, and retries with the A.3 counter ✅ - `isValidScalar` agrees with the secp256k1 range at 0, 1, `n-1`, `n`, above `n` ✅ -- `groupKey` → `pk == xonly(sk)` ✅ (in `shared/src/iosTest`, the only target that can load the SDK) -- `isValidScalar` accepts/rejects exactly what `SecretKey.fromBytes` does ✅ +- `groupKey` → `pk == xonly(sk)` ✅ -### M2 — Plane stack (no networking) +### M2 — Plane stack (no networking) — **done** -`ConcordPlane.kt` + `ConcordPlaneTest.kt`. +`ConcordPlane.kt`. Delivers `SealForm`, `ChatBinding`, `PlaneKey` + the three plane factories, `PlaneKey.rumor()`, `PlaneKey.wrap()` and `PlaneKey.unwrap()`. -**Done when:** -- build a `kind 9` message for a test channel → unwrap → identical rumor JSON -- a tampered seal fails `verify()` -- a `channel` / `epoch` mismatch is rejected -- a `20013` seal is rejected where `20014` is required +**Verified** with a throwaway end-to-end check run on the iOS target, then deleted (no test files are kept): +- build a `kind 9` message for a channel → wrap → unwrap → **byte-identical** rumor JSON, with `channel` / `epoch` / `ms` intact and `created_at` shared by all three layers ✅ +- the wrap carries exactly one ephemeral `p` tag ✅ +- a different channel id, a different epoch, or the Guestbook plane cannot decrypt it ✅ +- a `channel` or `epoch` mismatch, and a rumor with no binding tags at all, are dropped ✅ +- publishing with a signer that is not the rumor's author is refused, and a hand-built wrap whose seal author differs is dropped on read ✅ +- `20014` is rejected on a Chat plane, and `20013` is rejected on the Control plane ✅ +- the Control plane is readable from the **read** key plus the writers' pubkey alone, refuses to wrap, and drops a wrap signed by anyone but staff ✅ ### M3 — Join + read @@ -766,7 +739,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI | # | Conflict | Resolution | |---|---|---| | 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; unit-test it. | +| 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. | ### 13.2 Design risks @@ -781,8 +754,8 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI | 9 | **LMDB replaces addressable events by `d`.** Would silently eat messages. | Always set `Tag.identifier(wrapId)`. | | 10 | **No succession ↔ no honest rollback.** Dissolution and refoundings are one-way. | Do not build UI implying otherwise. | | 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` and unit-test the counter path through its `isValid` seam. | -| 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 while running M1. | Keep every SDK-free derivation in pure functions so `commonTest` (which runs on both JVM and iOS) covers the byte-exact half — `groupSeed`, `isValidScalar`, all the hashes. Put anything touching `SecretKey`, `Keys`, `nip44*` or `EventBuilder` in `shared/src/iosTest`, which runs via `:shared:iosSimulatorArm64Test` and *can* load the SDK. Design M2's plane stack the same way: a pure core plus a thin SDK shell. | +| 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. | ### 13.3 Open decision @@ -790,37 +763,50 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI --- -## 14. Test plan +## 14. Verification plan -`shared/src/commonTest` currently holds only the template stub, so this introduces the convention. Worth it: this is frozen-by-spec crypto with **no spec test vectors**, and a byte-layout mistake would silently break interop rather than fail loudly. +**No test files are kept** (decision, M2). The checks below were written and run during M1 and M2, +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. -**M1 status: 25 tests, all passing** — 20 on the host JVM (`:shared:testDebugUnitTest`, which runs -`commonTest` only) and 25 on the iOS simulator (`:shared:iosSimulatorArm64Test`, which runs -`commonTest` **plus** `iosTest`). Both commands are offline-capable. +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 +byte-layout mistake breaks interop *silently* rather than failing loudly. With no kept suite +there is no regression net, so: -The "Runs on" column matters: risk 13 means the SDK half of every milestone is untestable on a host JVM, so it needs a device or the iOS test target. +- **The M3 interop smoke test is the acceptance gate.** Nothing else catches a label typo. +- **`:shared:iosSimulatorArm64Test` is the only target here that can load the SDK** (risk 13), so + a throwaway check in `shared/src/iosTest` — written, run, deleted — is the cheapest way to + exercise anything touching `SecretKey`, `Keys`, `nip44*` or `EventBuilder` while developing. +- Anything SDK-free is deliberately kept in a pure function so it *could* be covered from + `commonTest` (JVM and iOS, no device needed) if this decision is ever revisited. -| Test | Type | Runs on | +### Checks that were run + +| Check | Type | Ran on | |---|---|---| -| RFC 5869 TC1 / TC2 / TC3 | known-answer | host JVM + iOS ✅ | -| `hkdfSha256` rejects L > 255·32 | boundary | host JVM + iOS ✅ | -| `hkdfInfo(channel, id32, 4u)` / epoch-omitted length | golden hex, annotated with the A.1 layout | host JVM + iOS ✅ | -| `communityId`, `prevCommit`, `editionHash` | golden SHA-256 computed outside the codebase | host JVM + iOS ✅ | -| `editionHash` absent-vs-zero-`prev` | negative | host JVM + iOS ✅ | -| `groupSeed` determinism + label/id/epoch/secret separation | property | host JVM + iOS ✅ | -| `groupSeed` counter path (counter appended to `info`, starts at 0) | injected failing seed | host JVM + iOS ✅ | -| `isValidScalar` at 0, 1, `n-1`, `n`, above `n`, short | boundary | host JVM + iOS ✅ | -| `groupKey` → `pk == xonly(sk)` | property | `iosTest` ✅ | -| `isValidScalar` vs `SecretKey.fromBytes` on the same seeds | cross-check | `iosTest` ✅ | -| `groupKey` → `nip44` self-ECDH round trip | smoke | `iosTest` ✅ | -| a different plane cannot read the payload | negative | `iosTest` ✅ | -| hex round trip, lowercase, 64 chars | unit | host JVM + iOS ✅ | -| wrap → unwrap round trip | integration, no network | `iosTest` ⏳ M2 | -| impersonation rejection | negative | `iosTest` ⏳ M2 | -| `channel` / `epoch` tag mismatch rejection | negative | `iosTest` ⏳ M2 | -| `20013` vs `20014` plane discipline | negative | `iosTest` ⏳ M2 | -| 32 bytes → base64url = 43 chars, unpadded | unit | host JVM ⏳ M3 | -| `expires_at` ms/s conversion | unit | host JVM ⏳ M3 | +| RFC 5869 TC1 / TC2 / TC3 | known-answer | host JVM + iOS ✅ M1 | +| `hkdfSha256` rejects L > 255·32 | boundary | host JVM + iOS ✅ M1 | +| `hkdfInfo(channel, id32, 4u)` / epoch-omitted length | golden hex, annotated with the A.1 layout | host JVM + iOS ✅ M1 | +| `communityId`, `prevCommit`, `editionHash` | golden SHA-256 computed outside the codebase | host JVM + iOS ✅ M1 | +| `editionHash` absent-vs-zero-`prev` | negative | host JVM + iOS ✅ M1 | +| `groupSeed` determinism + label/id/epoch/secret separation | property | host JVM + iOS ✅ M1 | +| `groupSeed` counter path (counter appended to `info`, starts at 0) | injected failing seed | host JVM + iOS ✅ M1 | +| `isValidScalar` at 0, 1, `n-1`, `n`, above `n`, short | boundary | host JVM + iOS ✅ M1 | +| `groupKey` → `pk == xonly(sk)` | property | iOS ✅ M1 | +| `isValidScalar` vs `SecretKey.fromBytes` on the same seeds | cross-check | iOS ✅ M1 | +| `groupKey` → `nip44` self-ECDH round trip, and a different plane cannot read it | smoke | iOS ✅ M1 | +| hex round trip, lowercase, 64 chars | unit | host JVM + iOS ✅ M1 | +| wrap → unwrap round trip is **byte-identical** | integration, no network | iOS ✅ M2 | +| wrap carries exactly one ephemeral `p` tag; `created_at` shared by all three layers | property | iOS ✅ M2 | +| wrong channel id / wrong epoch / wrong plane cannot decrypt | negative | iOS ✅ M2 | +| impersonation, refused on publish **and** dropped on read | negative | iOS ✅ M2 | +| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 | +| `20013` vs `20014` discipline, both directions | negative | iOS ✅ M2 | +| Control readable from the read key + writers' pubkey alone; refuses to wrap; drops a non-staff wrap | integration | iOS ✅ M2 | +| 32 bytes → base64url = 43 chars, unpadded | unit | ⏳ M3 | +| `expires_at` ms/s conversion | unit | ⏳ M3 | **RFC 5869 Test Case 1** (for reference): @@ -844,8 +830,8 @@ PRK = 0x19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04 OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8 ``` -**Test Case 2** (80-octet inputs, L = 82) is transcribed in `ConcordCryptoTest` too. It is worth -keeping even though Concord never asks for more than 32 bytes: it is the only vector that drives +**Test Case 2** (80-octet inputs, L = 82) is likewise a known-answer vector here. It is worth +using even though Concord never asks for more than 32 bytes: it is the only vector that drives Expand across three blocks, and it is what caught the empty-salt handling. Additionally, run one **interop smoke test** against a real community before declaring M3 done. Nothing catches a label typo faster. diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt new file mode 100644 index 0000000..85d0f02 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordPlane.kt @@ -0,0 +1,308 @@ +package su.reya.coop.concord + +import kotlinx.coroutines.CancellationException +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Event +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.Nip44Version +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Tag +import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent +import rust.nostr.sdk.nip44Decrypt +import rust.nostr.sdk.nip44Encrypt +import kotlin.time.Instant + +/** + * The CORD-01 wire stack: `wrap → seal → rumor`. + * + * A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends + * is three nested layers: + * + * ``` + * wrap kind 1059, signed by the stream key, one ephemeral `p` tag + * └ nip44(conv_key) of + * seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author + * └ nip44(conv_key) of | byte-verbatim + * rumor unsigned, its authority is the seal's signature around it + * ``` + * + * Both encrypted layers use the *same* conversation key — that is double encryption under one + * key, not two, and it is what makes the wrap readable by anyone holding the plane key. + * + * Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral, + * which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these + * and why routing happens by author before that code is reached. + */ + +/** + * Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the + * plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong + * one and [unwrap] can reject a seal of the other form instead of quietly accommodating it. + */ +enum class SealForm(private val kindValue: Int) { + /** + * Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap, + * so no relay — honest or malicious — can retain and display the rumor as a public event. + */ + Encrypted(ConcordKind.SEAL), + + /** + * Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**, + * because a signature over ciphertext is bound to the key that encrypted it and would break + * if re-wrapped under another key across an epoch change. + */ + Plaintext(ConcordKind.PLAINTEXT_SEAL); + + val kind: UShort get() = kindValue.toUShort() +} + +/** + * CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message + * into a different Channel or replay it across an epoch. + * + * The tags live *inside* the author-signed rumor, so the author's signature covers them; the + * reader checks them strict-equal against the coordinate whose key opened the wrap. + */ +data class ChatBinding(val channelIdHex: String, val epoch: ULong) + +/** + * One plane's keys and, for a Chat plane, the coordinate its rumors are bound to. + * + * [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream] + * is the address that signs wraps. They are the same key on a normal stream, and differ only on + * a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) — where a reader + * holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one. + * + * Deliberately not a `data class`: there is no value equality worth having, and the default + * `toString` keeps key material out of logs. + */ +class PlaneKey( + val read: GroupKey, + val stream: GroupKey?, + /** The x-only hex of the key every wrap on this plane must be signed by. */ + val streamPublicKeyHex: String, + /** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */ + val form: SealForm, + val chat: ChatBinding?, +) + +/** + * A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret, + * Private ones their own independent `channel_key`; the `channel_id` in the derivation is what + * gives each Channel a distinct address either way. + */ +fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey { + val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch) + return PlaneKey( + read = key, + stream = key, + streamPublicKeyHex = key.publicKey.toHex(), + form = SealForm.Encrypted, + chat = ChatBinding(channelId.toHex(), epoch), + ) +} + +/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */ +fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey { + val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch) + return PlaneKey( + read = key, + stream = key, + streamPublicKeyHex = key.publicKey.toHex(), + form = SealForm.Encrypted, + chat = null, + ) +} + +/** + * The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held + * `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is + * whatever the invite claimed — nothing in an invite can prove it, so build nothing + * security-relevant on it beyond the subscription address. + */ +fun controlPlaneKey( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: ULong, + streamPublicKeyHex: String, +): PlaneKey { + val key = groupKey(ConcordLabel.CONTROL, communityRoot, communityId, epoch) + return PlaneKey( + read = key, + stream = null, + streamPublicKeyHex = streamPublicKeyHex, + form = SealForm.Plaintext, + chat = null, + ) +} + +/** + * Builds an unsigned rumor for this plane (CORD-01). + * + * The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the + * very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not + * match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is + * never tweaked — true time is `created_at * 1000 + ms`. + * + * A rumor is never signed and never a standalone artifact: its authority is the seal's + * signature around it. + */ +fun PlaneKey.rumor( + author: PublicKey, + kind: UShort, + content: String, + createdAt: Instant, + extraTags: List = emptyList(), +): UnsignedEvent { + val tags = buildList { + chat?.let { + add(Tag.custom(ConcordTag.CHANNEL, listOf(it.channelIdHex))) + add(Tag.custom(ConcordTag.EPOCH, listOf(it.epoch.toString()))) + } + add(Tag.custom(ConcordTag.MS, listOf(msOf(createdAt).toString()))) + addAll(extraTags) + } + return EventBuilder(Kind(kind), content) + .tags(tags) + .customCreatedAt(Timestamp.fromSecs(createdAt.epochSeconds.toULong())) + .finalizeUnsigned(author) + .ensureId() +} + +/** + * Wraps [rumor] into a publishable stream event. + * + * [signer] signs the *seal*, so it must be the real author's signer — the user's own key, which + * also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead, + * and the seal form comes from the plane itself (CORD-02 §5). + * + * Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three + * layers agree and pagination by wrap timestamp lines up with message ordering. + */ +suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event { + val stream = stream ?: error("Concord: this plane is read-only and cannot wrap") + val createdAt = rumor.createdAt() + + val rumorJson = rumor.asJson() + val seal = try { + EventBuilder( + Kind(form.kind), + // CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact + // signed bytes forward instead of re-serializing them. + if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson, + ) + .customCreatedAt(createdAt) + .finalizeAsync(signer) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e) + } + + // The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch + // would produce a message nobody can read. Fail here instead, where the cause is visible. + check(seal.author() == rumor.author()) { + "Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}" + } + + return try { + // The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only + // its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the + // keypair right away is safe. v1 has no giftwrap deletion, which is the one thing + // CORD-01 §Deletions would want the secret for. + val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) } + Keys(stream.secretKey).use { keys -> + EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson())) + .tags(listOf(ephemeral)) + .customCreatedAt(createdAt) + .finalize(keys) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw IllegalStateException("Concord: failed to wrap seal: ${e.message}", e) + } +} + +/** + * Unwraps a stream event and enforces every check a reader must make, returning null when any + * of them fails. + * + * Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an + * impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch` + * mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is + * rendered before they pass. + */ +fun PlaneKey.unwrap(event: Event): UnsignedEvent? { + if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null + // The wrap is signed by the stream key. Verifying this is what makes CORD-01's + // write-restricted split real: a read-key holder can verify a wrap but cannot mint one. + if (event.author().toHex() != streamPublicKeyHex) return null + if (!event.verify()) return null + + val seal = runCatching { + Event.fromJson(nip44Decrypt(read.secretKey, read.publicKey, event.content())) + }.getOrNull() ?: return null + if (!seal.verify()) return null + + // CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form + // is a discipline violation, not a variant to accommodate: only the matching pair is accepted. + val sealKind = seal.kind().asU16() + val rumorJson = when { + sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching { + nip44Decrypt(read.secretKey, read.publicKey, seal.content()) + }.getOrNull() ?: return null + // Plaintext seal: the rumor's bytes are already the content, never re-parsed as an event. + sealKind == SealForm.Plaintext.kind && form == SealForm.Plaintext -> seal.content() + else -> return null + } + + val rumor = runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null + // NIP-59's impersonation check: the seal proves who wrote the rumor inside it. + if (rumor.author() != seal.author()) return null + if (!bindsToThisPlane(rumor)) return null + return rumor +} + +/** + * CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match + * this plane's coordinate, so neither a re-wrap into another Channel nor a cross-epoch replay + * survives. The community-wide Guestbook and Control planes split no sub-context, so the spec + * binds nothing there and this is a no-op. + */ +private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean { + val expected = chat ?: return true + val tags = rumor.tags().toVec() + val channel = tags.firstOrNull { it.kind() == ConcordTag.CHANNEL }?.content() + val epoch = tags.firstOrNull { it.kind() == ConcordTag.EPOCH }?.content() + return channel == expected.channelIdHex && epoch == expected.epoch.toString() +} + +/** + * CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms` + * falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a + * negative value. + */ +private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt() + +/** + * NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding). + * + * Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so + * this fails loudly at build time instead of producing an undecryptable message. + */ +private const val MAX_PLAINTEXT_BYTES = 65_535 + +private fun nip44Seal(key: GroupKey, plaintext: String): String { + val size = plaintext.encodeToByteArray().size + require(size <= MAX_PLAINTEXT_BYTES) { + "Concord: NIP-44 plaintext is $size bytes, over the $MAX_PLAINTEXT_BYTES cap" + } + return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2) +} + +/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */ +private fun concordKind(kind: Int): UShort = kind.toUShort()