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)
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/<naddr>#<fragment>` 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 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 ✅
- `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.