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)
|
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 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 ✅
|
- 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()
|
||||||
Reference in New Issue
Block a user