add concord plan

This commit is contained in:
2026-09-15 07:35:49 +07:00
parent e221eda546
commit 8b0cbd294e
2 changed files with 403 additions and 109 deletions
+95 -109
View File
@@ -29,7 +29,7 @@ Implementation plan for adding **Concord** communities and channels to Coop.
11. [UI](#11-ui) 11. [UI](#11-ui)
12. [Milestones](#12-milestones) 12. [Milestones](#12-milestones)
13. [Risks and open decisions](#13-risks-and-open-decisions) 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) 15. [Appendix A — Concord constants reference](#appendix-a--concord-constants-reference)
16. [Appendix B — Coop architecture reference](#appendix-b--coop-architecture-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 | | `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 | | `ConcordCrypto.kt` | `hkdfSha256`, `hkdfInfo`, `groupSeed`, `isValidScalar`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, hex/bytes helpers |
| `ConcordModels.kt` | `Membership`, `CommunityInvite`, `CommunityMeta`, `ChannelMeta`, `ConcordChannel`, `ConcordMessage`, `ControlEdition` | | `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/<naddr>#<fragment>` decoder, relay dictionary | | `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/<naddr>#<fragment>` decoder, relay dictionary |
| `ConcordStore.kt` | Membership persistence (`AppStorage.setSecret`), LMDB index events, Control edition storage | | `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` | | `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 | | `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join |
| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states | | `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states |
### 5.4 New — tests ### 5.4 Tests — **none are kept**
| File | Contents | 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.
|---|---|
| `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 |
> `commonTest` runs on JVM **and** iOS, so nothing in it may touch the SDK. Anything that does The consequence to plan around is in §14 and risk 13.
goes in `iosTest` (or `appleTest`), which is the only executable target that can load the
SDK's native library here.
### 5.5 Modified (6 files, all small) ### 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 ### 7.1 Sending a channel message
Implemented in `ConcordPlane.kt`; the whole send path is two calls.
```kotlin ```kotlin
suspend fun sendChannelMessage(ch: ConcordChannel, text: String) { val plane = channelPlaneKey(secret, channelId, epoch) // CORD-03 §1
val author = signer.getPublicKeyAsync() ?: error("not signed in") val rumor = plane.rumor(author, ConcordKind.MESSAGE.toUShort(), text, now)
val now = Clock.System.now() val wrap = plane.wrap(rumor, signer) // signer = the real author
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())
}
``` ```
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. 1. **rumor**`kind` given, stamped with the binding tags and `ms`, `finalizeUnsigned(author).ensureId()`. Never signed.
- `created_at` is **never tweaked**. Sub-second ordering lives in the `ms` tag; true time = `created_at * 1000 + ms`. 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 ### 7.2 Receiving
```kotlin `PlaneKey.unwrap(event)` runs every check a reader must make and returns null on any failure. Null means **drop**, never retry:
fun unwrap(event: Event, keys: PlaneKeys): UnsignedEvent? {
val seal = Event.fromJson(nip44Decrypt(keys.sk, keys.pk, event.content())) 1. `kind` is `1059` — otherwise not ours
if (!seal.verify()) return null 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
val rumorJson = when (seal.kind().asU16()) { 3. `event.verify()` — id and signature
20013u -> nip44Decrypt(keys.sk, keys.pk, seal.content()) // Chat / Guestbook / rekey 4. NIP-44 decrypt the wrap content with the **read** key → seal
20014u -> seal.content() // Control only, byte-verbatim 5. `seal.verify()`, and `seal.kind()` matches the plane's `SealForm` (the other form is a discipline violation, not a variant)
else -> return null 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
val rumor = UnsignedEvent.fromJson(rumorJson).ensureId() 8. the **mandatory** CORD-03 §3 binding check (below)
if (rumor.author() != seal.author()) return null // NIP-59 impersonation check
return rumor ```
} 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: 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.
```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
```
### 7.3 Seal discipline (CORD-02 §5) — normative ### 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 | | **Control** | **`20014` plaintext** — a signature over ciphertext could not survive a compaction re-wrap across epochs |
| Chat, Guestbook, rekey | **`20013` encrypted** | | 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 ### 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** ### 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 13 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 ✅ - RFC 5869 Test Cases 1, 2 & 3 pass ✅
- `hkdfInfo` matches a golden hex annotated with the A.1 layout ✅ - `hkdfInfo` matches a golden hex annotated with the A.1 layout ✅
- `communityId` / `prevCommit` / `editionHash` match digests computed outside the codebase ✅ - `communityId` / `prevCommit` / `editionHash` match digests computed outside the codebase ✅
- `groupSeed` is deterministic, separates label/id/epoch/secret, and retries with the A.3 counter ✅ - `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` - `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) - `groupKey``pk == xonly(sk)`
- `isValidScalar` accepts/rejects exactly what `SecretKey.fromBytes` does ✅
### 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:** **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 test channel → unwrap → identical rumor JSON - 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 ✅
- a tampered seal fails `verify()` - the wrap carries exactly one ephemeral `p` tag ✅
- a `channel` / `epoch` mismatch is rejected - a different channel id, a different epoch, or the Guestbook plane cannot decrypt it ✅
- a `20013` seal is rejected where `20014` is required - 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 ### M3 — Join + read
@@ -766,7 +739,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
| # | Conflict | Resolution | | # | 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. | | 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. | | 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 ### 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)`. | | 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. | | 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. | | 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. | | 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 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. | | 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 ### 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 The problem this leaves is real and worth stating plainly: this is frozen-by-spec crypto with
`commonTest` only) and 25 on the iOS simulator (`:shared:iosSimulatorArm64Test`, which runs **no spec test vectors** ("Examples are illustrative, not verifiable test vectors"), where a
`commonTest` **plus** `iosTest`). Both commands are offline-capable. 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 ✅ | | RFC 5869 TC1 / TC2 / TC3 | known-answer | host JVM + iOS ✅ M1 |
| `hkdfSha256` rejects L > 255·32 | boundary | host JVM + iOS ✅ | | `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 ✅ | | `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 ✅ | | `communityId`, `prevCommit`, `editionHash` | golden SHA-256 computed outside the codebase | host JVM + iOS ✅ M1 |
| `editionHash` absent-vs-zero-`prev` | negative | host JVM + iOS ✅ | | `editionHash` absent-vs-zero-`prev` | negative | host JVM + iOS ✅ M1 |
| `groupSeed` determinism + label/id/epoch/secret separation | property | host JVM + iOS ✅ | | `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 ✅ | | `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 ✅ | | `isValidScalar` at 0, 1, `n-1`, `n`, above `n`, short | boundary | host JVM + iOS ✅ M1 |
| `groupKey``pk == xonly(sk)` | property | `iosTest` | | `groupKey``pk == xonly(sk)` | property | iOS ✅ M1 |
| `isValidScalar` vs `SecretKey.fromBytes` on the same seeds | cross-check | `iosTest` | | `isValidScalar` vs `SecretKey.fromBytes` on the same seeds | cross-check | iOS ✅ M1 |
| `groupKey``nip44` self-ECDH round trip | smoke | `iosTest` | | `groupKey``nip44` self-ECDH round trip, and a different plane cannot read it | smoke | iOS ✅ M1 |
| a different plane cannot read the payload | negative | `iosTest` | | hex round trip, lowercase, 64 chars | unit | host JVM + iOS ✅ M1 |
| hex round trip, lowercase, 64 chars | unit | host JVM + iOS ✅ | | wrap → unwrap round trip is **byte-identical** | integration, no network | iOS ✅ M2 |
| wrap → unwrap round trip | integration, no network | `iosTest` M2 | | wrap carries exactly one ephemeral `p` tag; `created_at` shared by all three layers | property | iOS ✅ M2 |
| impersonation rejection | negative | `iosTest` M2 | | wrong channel id / wrong epoch / wrong plane cannot decrypt | negative | iOS ✅ M2 |
| `channel` / `epoch` tag mismatch rejection | negative | `iosTest` M2 | | impersonation, refused on publish **and** dropped on read | negative | iOS ✅ M2 |
| `20013` vs `20014` plane discipline | negative | `iosTest` M2 | | `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
| 32 bytes → base64url = 43 chars, unpadded | unit | host JVM ⏳ M3 | | `20013` vs `20014` discipline, both directions | negative | iOS ✅ M2 |
| `expires_at` ms/s conversion | unit | host JVM ⏳ M3 | | 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): **RFC 5869 Test Case 1** (for reference):
@@ -844,8 +830,8 @@ PRK = 0x19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04
OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8 OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8
``` ```
**Test Case 2** (80-octet inputs, L = 82) is transcribed in `ConcordCryptoTest` too. It is worth **Test Case 2** (80-octet inputs, L = 82) is likewise a known-answer vector here. It is worth
keeping even though Concord never asks for more than 32 bytes: it is the only vector that drives 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. 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. Additionally, run one **interop smoke test** against a real community before declaring M3 done. Nothing catches a label typo faster.
@@ -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<Tag> = 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()