add concord crypto

This commit is contained in:
2026-09-15 07:19:58 +07:00
parent e49168851e
commit e221eda546
3 changed files with 615 additions and 32 deletions
+94 -32
View File
@@ -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/<naddr>#<fragment>` 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.
---