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