add concord plan
This commit is contained in:
@@ -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 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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user