From e221eda5464732593a4cde51a5d745324d01e7fe Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 15 Sep 2026 07:19:58 +0700 Subject: [PATCH] add concord crypto --- PLAN.md | 126 ++++++-- .../su/reya/coop/concord/ConcordCrypto.kt | 239 +++++++++++++++ .../su/reya/coop/concord/ConcordKind.kt | 282 ++++++++++++++++++ 3 files changed, 615 insertions(+), 32 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt diff --git a/PLAN.md b/PLAN.md index fdf9f39..946b776 100644 --- a/PLAN.md +++ b/PLAN.md @@ -176,8 +176,8 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue | File | Contents | |---|---| -| `ConcordKind.kt` | All frozen constants in one place: kind numbers, `vsk` registry, permission bits, KDF label strings, tag names | -| `ConcordCrypto.kt` | `hkdfSha256`, `hkdfInfo`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, `scalarNormalize`, hex/bytes helpers | +| `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 | | `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/#` decoder, relay dictionary | @@ -203,12 +203,17 @@ 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 — `shared/src/commonTest/kotlin/su/reya/coop/` +### 5.4 New — tests | File | Contents | |---|---| -| `ConcordCryptoTest.kt` | RFC 5869 HKDF vectors, info-layout golden bytes, `community_id` self-certification, `group_key` determinism | -| `ConcordPlaneTest.kt` | wrap→unwrap round trip, impersonation rejection, `channel`/`epoch` mismatch rejection | +| `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 +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) @@ -248,9 +253,17 @@ fun hkdfInfo(label: String, id: ByteArray, epoch: ULong?): ByteArray { }.readByteArray() } -/** HKDF-SHA256, salt = ∅. Okio gives us HMAC-SHA256 on every target. */ -fun hkdfSha256(ikm: ByteArray, info: ByteArray, length: Int = 32): ByteArray { - val prk = ikm.toByteString().hmacSha256(ByteString.EMPTY) // Extract(salt = ∅, ikm) +/** HKDF-SHA256. Okio gives us HMAC-SHA256 on every target. */ +fun hkdfSha256( + ikm: ByteArray, + info: ByteArray, + salt: ByteArray = ByteArray(0), // Concord always omits it; see note below + length: Int = 32, +): ByteArray { + // Okio refuses a zero-length HMAC key, so substitute HashLen zero octets — the value RFC + // 5869 defines for an absent salt, and one HMAC's block padding makes identical anyway. + val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString() + val prk = ikm.toByteString().hmacSha256(saltKey) // Extract(salt, ikm) val out = Buffer() var t = ByteString.EMPTY var counter = 1 @@ -264,7 +277,12 @@ fun hkdfSha256(ikm: ByteArray, info: ByteArray, length: Int = 32): ByteArray { } ``` -Implementing the general `length` loop (rather than hardcoding 32) costs three lines and buys RFC 5869's L=42 test vectors — the only real verification available, since Concord ships no test vectors ("Examples are illustrative, not verifiable test vectors"). +Two deliberate widenings of the Concord-specialised form, both bought for verification: + +1. The general `length` loop (rather than hardcoding 32) unlocks RFC 5869's L=42 and L=82 vectors. +2. The optional `salt` unlocks Cases 1 and 2; Concord always takes the default. Concord ships + no test vectors of its own ("Examples are illustrative, not verifiable test vectors"), so the + RFC is the only ground truth available. ### 6.2 `scalar_normalize` (CORD-02 A.3) @@ -274,20 +292,42 @@ The counter is appended to the **info**, after whatever fields are present. ### 6.3 `group_key` (CORD-02 A.2) +Split in two on purpose: `groupSeed` is pure byte manipulation and therefore unit-testable, +while `groupKey`'s secp256k1 half needs the SDK, which **cannot load under a host JVM unit +test** (see risk 13). `isValidScalar` replaces the `SecretKey.fromBytes` probe with a plain +big-endian range check against the secp256k1 order, so A.3's retry needs no crypto library. + ```kotlin data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey) -fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong?): GroupKey { +/** group_key up to and including scalar_normalize: returns the normalized seed. */ +fun groupSeed( + label: String, + secret: ByteArray, + id: ByteArray, + epoch: ULong? = null, + isValid: (ByteArray) -> Boolean = ::isValidScalar, // seam for A.3's ~2⁻¹²⁸ branch +): ByteArray { val base = hkdfInfo(label, id, epoch) var counter = -1 // -1 = no counter byte (first attempt) while (counter <= 255) { val info = if (counter < 0) base else base + byteArrayOf(counter.toByte()) val seed = hkdfSha256(secret, info) - val sk = runCatching { SecretKey.fromBytes(seed) }.getOrNull() // the reject branch - if (sk != null) return GroupKey(sk, Keys(sk).publicKey()) + if (isValid(seed)) return seed counter++ // A.3: counter starts at 0 on first retry } - error("scalar_normalize exhausted") + error("Concord group_key: scalar_normalize exhausted for label $label") +} + +/** A secp256k1 secret key is any integer in [1, n-1]. */ +fun isValidScalar(seed: ByteArray): Boolean { + if (seed.size != 32) return false + // reject all-zeroes, then big-endian compare against SECP256K1_ORDER +} + +fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey { + val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch)) + return GroupKey(secretKey, Keys(secretKey).publicKey()) } ``` @@ -664,15 +704,18 @@ And one entry in `BottomMenuList` (`HomeScreen.kt:785-791`) plus `ic_communities Each milestone ends with something runnable. -### M1 — Crypto core (no UI, no networking) +### M1 — Crypto core (no UI, no networking) — **done** `ConcordCrypto.kt` + `ConcordKind.kt` + `ConcordCryptoTest.kt`. **Done when:** -- RFC 5869 Test Cases 1 & 3 pass -- `hkdfInfo` matches a hand-computed golden hex, annotated with the A.1 layout -- `communityId` self-certifies (recompute from `owner` + `salt`, compare) -- `groupKey` is deterministic and `pk == xonly(sk)` +- 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 ✅ ### M2 — Plane stack (no networking) @@ -738,7 +781,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 | **Parity and `scalar_normalize`.** The retry branch is ~2⁻¹²⁸ rare, so it will never fire in practice — meaning a bug there would never surface either. | Unit-test the counter path with an injected failing seed. | +| 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. | ### 13.3 Open decision @@ -750,19 +794,33 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI `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. -| Test | Type | -|---|---| -| RFC 5869 TC1 / TC2 / TC3 | known-answer | -| `hkdfInfo("concord/channel", id32, 4u)` | golden hex, annotated with the A.1 layout | -| `communityId` self-certification | round trip | -| `groupKey` determinism + `pk == xonly(sk)` | property | -| `scalarNormalize` counter path | injected failing seed | -| 32 bytes → base64url = 43 chars, unpadded | unit | -| wrap → unwrap round trip | integration, no network | -| impersonation rejection | negative | -| `channel` / `epoch` tag mismatch rejection | negative | -| `20013` vs `20014` plane discipline | negative | -| `expires_at` ms/s conversion | unit | +**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 "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. + +| Test | Type | Runs 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 Test Case 1** (for reference): @@ -786,6 +844,10 @@ 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 +Expand across three blocks, and it is what caught the empty-salt handling. + Additionally, run one **interop smoke test** against a real community before declaring M3 done. Nothing catches a label typo faster. --- diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt new file mode 100644 index 0000000..46a6b22 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCrypto.kt @@ -0,0 +1,239 @@ +package su.reya.coop.concord + +import okio.Buffer +import okio.ByteString +import okio.ByteString.Companion.decodeHex +import okio.ByteString.Companion.toByteString +import rust.nostr.sdk.Keys +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.SecretKey + +/** + * Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A). + * + * Everything here is frozen by the spec, and a single wrong byte breaks interop silently + * rather than loudly — so each function quotes the CORD section that governs it, and + * `ConcordCryptoTest` pins the output against RFC 5869 vectors and independently computed + * digests. Concord ships no test vectors of its own ("Examples are illustrative, not + * verifiable test vectors"). + * + * Only HMAC-SHA256 and SHA-256 come from outside: both are Okio `ByteString` members that + * are available on every target this module builds for, so no new crypto dependency is + * needed. Everything else is composition of the existing nostr SDK. + */ + +/** + * HKDF-SHA256 (RFC 5869), Extract then Expand. + * + * Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32 + * zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer + * vectors — which do use a salt — can be used as tests; Concord publishes none. + * + * @param length output length in octets, `1..255 * 32` per RFC 5869. + */ +fun hkdfSha256( + ikm: ByteArray, + info: ByteArray, + salt: ByteArray = ByteArray(0), + length: Int = 32, +): ByteArray { + require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" } + + // Extract: PRK = HMAC-SHA256(salt, IKM). Note IKM is the HMAC *message*, not the key. + // Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen + // (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the + // two are literally the same key. Substituting is exact, not a workaround; the RFC's own + // zero-length-salt vector is asserted against it in ConcordCryptoTest. + val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString() + val prk = ikm.toByteString().hmacSha256(saltKey) + + // Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet. + val out = Buffer() + var t = ByteString.EMPTY + var counter = 1 + while (out.size < length) { + t = Buffer() + .write(t) + .write(info) + .writeByte(counter) + .readByteString() + .hmacSha256(prk) + out.write(t) + counter++ + } + return out.readByteArray(length.toLong()) +} + +/** + * Builds the HKDF `info` for a Concord label (CORD-02 A.1): + * + * ``` + * info = utf8(label) | 0x00 | id[32] | epoch_be[8] // epoch omitted for labels marked "—" + * ``` + * + * [id] is always present and always 32 bytes, all-zeroes where a label has no meaningful + * id. The epoch is the only omittable field. + */ +fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray { + require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" } + return Buffer().apply { + writeUtf8(label) + writeByte(0) + write(id) + if (epoch != null) writeLong(epoch.toLong()) + }.readByteArray() +} + +/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */ +data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey) + +/** + * The secret-key material of a plane's group key: CORD-02 A.2's `group_key` up to and + * including A.3's `scalar_normalize`. + * + * ``` + * info = hkdfInfo(label, id, epoch) + * seed = hkdf(secret, info) + * while (!isValidScalar(seed)) { info = info | counter++; seed = hkdf(secret, info) } + * ``` + * + * A.3 only bites when the HKDF output is not a secp256k1 scalar, which is ~2⁻¹²⁸ rare, so + * [isValid] exists as a seam for tests; production callers pass the default. + * + * This is deliberately split from [groupKey]: it is pure byte manipulation and so is + * unit-testable, whereas the secp256k1 half needs the nostr SDK, whose native library + * cannot be loaded by a host JVM unit test (see PLAN.md §13.2). + */ +fun groupSeed( + label: String, + secret: ByteArray, + id: ByteArray, + epoch: ULong? = null, + isValid: (ByteArray) -> Boolean = ::isValidScalar, +): ByteArray { + val base = hkdfInfo(label, id, epoch) + var counter = -1 // -1 means "no counter byte", i.e. the first attempt + while (counter <= 255) { + val info = if (counter < 0) base else base + byteArrayOf(counter.toByte()) + val seed = hkdfSha256(secret, info) + if (isValid(seed)) return seed + counter++ // A.3: the counter starts at 0 on the first retry + } + error("Concord group_key: scalar_normalize exhausted for label $label") +} + +/** + * A secp256k1 secret key is any integer in `[1, n-1]`, so CORD-02 A.3's validity test rejects + * exactly the all-zeroes seed and any seed not below the group order. + */ +fun isValidScalar(seed: ByteArray): Boolean { + if (seed.size != 32) return false + var anyNonZero = false + for (byte in seed) { + if (byte != 0.toByte()) { + anyNonZero = true + break + } + } + if (!anyNonZero) return false + for (i in 0 until 32) { + val candidate = seed[i].toInt() and 0xff + val order = SECP256K1_ORDER[i].toInt() and 0xff + if (candidate != order) return candidate < order + } + return false // exactly n, also out of range +} + +private val SECP256K1_ORDER = + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes() + +/** + * `group_key` (CORD-02 A.2): a plane's keypair, `(scalar_normalize(seed), xonly_pubkey(sk))`. + * + * The `conv_key` of A.2 needs no implementation of its own — it *is* the NIP-44 conversation + * key, which the SDK derives from the pair returned here, so nothing here hand-rolls ECDH. + */ +fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey { + val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch)) + return GroupKey(secretKey, Keys(secretKey).publicKey()) +} + +/** + * `community_id` (CORD-02 A.4), which is also the community's self-certification: + * + * ``` + * community_id = sha256( utf8("concord/community") | owner_xonly[32] | owner_salt[32] ) + * ``` + * + * Note this is a plain SHA-256 commitment with **no** `0x00` separator and no length + * prefix — it is deliberately *not* the HKDF construction of A.1, despite looking like it. + */ +fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray { + require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" } + require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" } + return Buffer().apply { + writeUtf8(ConcordLabel.COMMUNITY) + write(ownerXonly) + write(ownerSalt) + }.readByteString().sha256().toByteArray() +} + +/** + * `prevcommit` (CORD-02 A.8) — the commitment to the previous epoch's key, published when + * an epoch rolls so that members can verify the rotation chained from what they held: + * + * ``` + * prevcommit = sha256( utf8("concord/epoch-key-commitment") | prev_epoch_be[8] | prev_key[32] ) + * ``` + */ +fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray { + require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" } + return Buffer().apply { + writeUtf8(ConcordLabel.EPOCH_KEY_COMMITMENT) + writeLong(prevEpoch.toLong()) + write(prevKey) + }.readByteString().sha256().toByteArray() +} + +/** + * `edition_hash` (CORD-02 A.8) — links a Control edition to its predecessor, so a client + * folding the Control plane can detect a rewritten chain: + * + * ``` + * edition_hash = sha256( + * len64(label) | label // label = ConcordLabel.EDITION_HASH + * | entity_id[32] + * | version_be[8] + * | (prev ? 0x01 | prev[32] : 0x00 | zero[32]) + * | len64(content) | content ) // content bytes verbatim, never re-serialized + * ``` + * + * [content] must be the exact bytes that were signed — re-serializing the JSON would change + * the hash. + */ +fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray { + require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" } + require(prev == null || prev.size == 32) { "prev must be 32 bytes" } + val label = ConcordLabel.EDITION_HASH.encodeToByteArray() + return Buffer().apply { + writeLong(label.size.toLong()) + write(label) + write(entityId) + writeLong(version.toLong()) + if (prev != null) { + writeByte(1) + write(prev) + } else { + writeByte(0) + write(ByteArray(32)) + } + writeLong(content.size.toLong()) + write(content) + }.readByteString().sha256().toByteArray() +} + +/** 64 lowercase hex chars, the encoding CORD-01 mandates for every 32-byte value on the wire. */ +internal fun ByteArray.toHex(): String = toByteString().hex() + +/** Inverse of [toHex]. Throws on odd length or a non-hex character. */ +internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt new file mode 100644 index 0000000..69c7d6e --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt @@ -0,0 +1,282 @@ +package su.reya.coop.concord + +/** + * Frozen constants from the Concord specification. + * + * Concord is defined by the CORD documents (github.com/concord-protocol/concord); the + * numbers, labels and permission bits below are normative and must match byte for byte + * or nothing interoperates. They are collected in this one file so that a spec revision + * is a one-file change and so that no literal kind number ever appears at a call site. + * + * References are to the CORD section that defines each value. See `PLAN.md` Appendix A + * for the same tables with prose. + */ + +/** + * Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5, + * CORD-02 Appendix B). + */ +object ConcordKind { + /** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */ + const val WRAP = 1059 + + /** Ephemeral wrap for kinds that must never be stored (typing, voice presence). */ + const val EPHEMERAL_WRAP = 21059 + + /** + * Sealed content for everything that must stay secret: Chat, Guestbook, rekey blobs + * and Direct Invites. CORD-02 §5 makes this choice normative, never stylistic. + */ + const val SEAL = 20013 + + /** + * Plaintext seal. **Control plane only** (CORD-02 §5) — the roster is public by design + * so that membership, and therefore authority, is verifiable by anyone. + */ + const val PLAINTEXT_SEAL = 20014 + + /** Channel message (NIP-C7 shape). Chat plane. */ + const val MESSAGE = 9 + + /** Threaded reply (NIP-22 shape). Chat plane. */ + const val REPLY = 1111 + + /** Reaction (NIP-25 shape). Chat plane. */ + const val REACTION = 7 + + /** Delete (NIP-09 shape). Chat plane. */ + const val DELETE = 5 + + /** Disappearing-message timer notice (CORD-08 §4). Chat plane. */ + const val TIMER_NOTICE = 1740 + + /** Edit. Chat plane. */ + const val EDIT = 3302 + + /** Rekey blobs (CORD-06). Rekey addresses. */ + const val REKEY = 3303 + + /** Join / Leave. Guestbook plane. */ + const val JOIN_LEAVE = 3306 + + /** Control edition — sub-kinded by [ConcordVsk]. Control plane. */ + const val CONTROL_EDITION = 3308 + + /** Kick. Guestbook plane. */ + const val KICK = 3309 + + /** WebXDC peer signal. Chat plane. */ + const val WEBXDC_SIGNAL = 3310 + + /** Guestbook snapshot, chunked, refounder-signed. Guestbook plane. */ + const val GUESTBOOK_SNAPSHOT = 3312 + + /** Typing indicator. Chat plane, ephemeral. */ + const val TYPING = 23311 + + /** Voice presence (CORD-07). Chat plane, ephemeral. */ + const val VOICE_PRESENCE = 23313 + + /** Direct Invite — giftwrapped straight to an npub. Rides a standard wrap, not a stream. */ + const val DIRECT_INVITE = 3313 + + /** Public invite bundle. Addressable, signed by its per-link keypair at an empty `d`. */ + const val INVITE_BUNDLE = 33301 + + /** Community List — one addressable event per fragment, NIP-44 to self. */ + const val COMMUNITY_LIST = 33302 + + /** Invite List — replaceable, NIP-44 to self. */ + const val INVITE_LIST = 13303 +} + +/** Control edition sub-kinds, the `vsk` tag on [ConcordKind.CONTROL_EDITION] (CORD-02 Appendix A). */ +object ConcordVsk { + /** Community metadata. */ + const val METADATA = 0 + + /** Role. */ + const val ROLE = 1 + + /** Channel metadata — the only other edition v1 folds. */ + const val CHANNEL = 2 + + /** Grant. */ + const val GRANT = 3 + + /** Banlist. */ + const val BANLIST = 4 + + /** Reserved for role ordering. */ + const val RESERVED_ROLE_ORDER = 5 + + /** Claimed by the invite bundle's live marker. */ + const val INVITE_LIVE = 6 + + /** Retired (was the v1 owner attestation). */ + const val RETIRED_OWNER_ATTESTATION = 7 + + /** Invite-link registry. */ + const val INVITE_REGISTRY = 8 + + /** Claimed by the invite bundle's revocation tombstone. */ + const val INVITE_TOMBSTONE = 9 + + /** Dissolved tombstone — chainless, exempt from version discipline. */ + const val DISSOLVED = 10 + + /** Pin List. */ + const val PIN_LIST = 11 +} + +/** + * Permission bits (CORD-04 §3). Rank ordering is separate: `position` orders authority and + * **lower is higher**, with the owner at position 0. + */ +object ConcordPermission { + const val MANAGE_ROLES = 1 shl 0 + const val MANAGE_CHANNELS = 1 shl 1 + const val MANAGE_METADATA = 1 shl 2 + const val KICK = 1 shl 3 + const val BAN = 1 shl 4 + const val MANAGE_MESSAGES = 1 shl 5 + const val CREATE_INVITE = 1 shl 6 + + /** Retired (was `MANAGE_INVITES`). Never grant it. */ + const val RETIRED_MANAGE_INVITES = 1 shl 7 + + const val VIEW_AUDIT_LOG = 1 shl 8 + const val MENTION_EVERYONE = 1 shl 9 + + // 1 shl 10 is reserved. + + const val PIN_MESSAGES = 1 shl 11 + + // 1 shl 12 is reserved. + + /** + * Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold + * `control_root` and can therefore write to the Control plane. + */ + const val STAFF = MANAGE_ROLES or MANAGE_CHANNELS or MANAGE_METADATA or + BAN or CREATE_INVITE or PIN_MESSAGES +} + +/** + * HKDF label registry (CORD-02 Appendix A.6). The label is the *first* field of the HKDF + * `info` (see `hkdfInfo`), so these strings are on the wire and must not be edited. + */ +object ConcordLabel { + /** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */ + const val CHANNEL = "concord/channel" + + /** Control Plane **read** key. `secret` = `community_root`. */ + const val CONTROL = "concord/control" + + /** Control Plane signer, held by staff only. `secret` = `control_root`. */ + const val CONTROL_SIGNER = "concord/control-signer" + + /** Guestbook Plane group key. `secret` = `community_root`. */ + const val GUESTBOOK = "concord/guestbook" + + /** Dissolution tombstone address. `secret` = `community_id`, id = 32 zero bytes, no epoch. */ + const val DISSOLVED = "concord/dissolved" + + /** A channel rekey address. `secret` = the prior `community_root`. */ + const val REKEY_PSEUDONYM = "concord/rekey-pseudonym" + + /** A base (community-wide) rekey address. `secret` = the prior `community_root`. */ + const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym" + + /** A rekey blob locator. `secret` = `rotator_xonly ‖ recipient_xonly`. */ + const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym" + + /** SFU room keypair (CORD-07). */ + const val VOICE_SIGNER = "concord/voice-signer" + + /** 32-byte call media key (CORD-07). */ + const val VOICE_MEDIA = "concord/voice-media" + + /** Per-sender frame key (CORD-07). id = `sha256(identity)`, no epoch. */ + const val VOICE_SENDER = "concord/voice-sender" + + /** A member's Grant coordinate. no epoch. */ + const val GRANT = "concord/grant" + + /** Banlist coordinate. no epoch. */ + const val BANLIST = "concord/banlist" + + /** A Channel's Pin List coordinate. no epoch. */ + const val PINS = "concord/pins" + + /** A creator's invite-link registry coordinate. no epoch. */ + const val INVITE_LINKS = "concord/invite-links" + + /** Public-invite decrypt key. `secret` = the link's unlock token, no epoch. */ + const val INVITE_KEY = "concord/invite-key" + + /** + * Prefix for the `community_id` commitment (CORD-02 A.4). Not an HKDF label — it is + * hashed directly, with no `0x00` separator and no length prefix. + */ + const val COMMUNITY = "concord/community" + + /** Prefix for `prevcommit` (CORD-02 A.8). Also hashed directly, not through HKDF. */ + const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment" + + /** + * Label for `edition_hash` (CORD-02 A.8). + * + * Deliberately **not** under the `concord/` prefix: the spec pins the reference + * implementation's own `vector` label here. Reproduce it exactly. + */ + const val EDITION_HASH = "vector-community/v1/edition" +} + +/** Tag names (CORD-01, CORD-02 §5 and the per-kind sections). */ +object ConcordTag { + /** `["ms", "<0..999>"]` — sub-second ordering. True time = `created_at * 1000 + ms`. */ + const val MS = "ms" + + /** `["p", ""]` — the one tag a stream wrap carries (NIP-59 reversed). */ + const val P = "p" + + /** `["channel", ""]` — MUST also be committed inside the author-signed rumor. */ + const val CHANNEL = "channel" + + /** `["epoch", ""]` — MUST also be committed inside the author-signed rumor. */ + const val EPOCH = "epoch" + + /** `["q", "", "", ""]` — NIP-C7 inline quote. */ + const val QUOTE = "q" + + /** `["vsk", ""]` — Control edition entity type. */ + const val VSK = "vsk" + + /** `["eid", ""]` — stable coordinate of a Control edition's entity. */ + const val EID = "eid" + + /** `["ev", ""]` — this edition's version, climbing from 1. */ + const val EV = "ev" + + /** `["ep", ""]` — previous edition hash. Absent on the first edition. */ + const val EP = "ep" + + /** `["vac", "", "", ""]` — authority citation. Absent when the owner acts. */ + const val VAC = "vac" + + /** `["expiration", ""]` — NIP-40. MUST be on both wrap and rumor, same value. */ + const val EXPIRATION = "expiration" + + /** `["d", ""]` / `["d", ""]` — addressable coordinate. */ + const val D = "d" + + /** `["snap", "", "", ""]` — Guestbook snapshot chunk. */ + const val SNAP = "snap" + + /** `["invite", "", "