add concord crypto
This commit is contained in:
@@ -176,8 +176,8 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
|
|||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ConcordKind.kt` | All frozen constants in one place: kind numbers, `vsk` registry, permission bits, KDF label strings, tag names |
|
| `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`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, `scalarNormalize`, 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` | `PlaneKeys` (sk/pk/relays), `wrap()`, `unwrap()`, rumor builders — the CORD-01 stack |
|
||||||
| `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/<naddr>#<fragment>` decoder, relay dictionary |
|
| `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 |
|
| `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 — `shared/src/commonTest/kotlin/su/reya/coop/`
|
### 5.4 New — tests
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ConcordCryptoTest.kt` | RFC 5869 HKDF vectors, info-layout golden bytes, `community_id` self-certification, `group_key` determinism |
|
| `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 |
|
||||||
| `ConcordPlaneTest.kt` | wrap→unwrap round trip, impersonation rejection, `channel`/`epoch` mismatch rejection |
|
| `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)
|
### 5.5 Modified (6 files, all small)
|
||||||
|
|
||||||
@@ -248,9 +253,17 @@ fun hkdfInfo(label: String, id: ByteArray, epoch: ULong?): ByteArray {
|
|||||||
}.readByteArray()
|
}.readByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HKDF-SHA256, salt = ∅. Okio gives us HMAC-SHA256 on every target. */
|
/** HKDF-SHA256. Okio gives us HMAC-SHA256 on every target. */
|
||||||
fun hkdfSha256(ikm: ByteArray, info: ByteArray, length: Int = 32): ByteArray {
|
fun hkdfSha256(
|
||||||
val prk = ikm.toByteString().hmacSha256(ByteString.EMPTY) // Extract(salt = ∅, ikm)
|
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()
|
val out = Buffer()
|
||||||
var t = ByteString.EMPTY
|
var t = ByteString.EMPTY
|
||||||
var counter = 1
|
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)
|
### 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)
|
### 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
|
```kotlin
|
||||||
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
|
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)
|
val base = hkdfInfo(label, id, epoch)
|
||||||
var counter = -1 // -1 = no counter byte (first attempt)
|
var counter = -1 // -1 = no counter byte (first attempt)
|
||||||
while (counter <= 255) {
|
while (counter <= 255) {
|
||||||
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
|
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
|
||||||
val seed = hkdfSha256(secret, info)
|
val seed = hkdfSha256(secret, info)
|
||||||
val sk = runCatching { SecretKey.fromBytes(seed) }.getOrNull() // the reject branch
|
if (isValid(seed)) return seed
|
||||||
if (sk != null) return GroupKey(sk, Keys(sk).publicKey())
|
|
||||||
counter++ // A.3: counter starts at 0 on first retry
|
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.
|
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`.
|
`ConcordCrypto.kt` + `ConcordKind.kt` + `ConcordCryptoTest.kt`.
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
- RFC 5869 Test Cases 1 & 3 pass
|
- RFC 5869 Test Cases 1, 2 & 3 pass ✅
|
||||||
- `hkdfInfo` matches a hand-computed golden hex, annotated with the A.1 layout
|
- `hkdfInfo` matches a golden hex annotated with the A.1 layout ✅
|
||||||
- `communityId` self-certifies (recompute from `owner` + `salt`, compare)
|
- `communityId` / `prevCommit` / `editionHash` match digests computed outside the codebase ✅
|
||||||
- `groupKey` is deterministic and `pk == xonly(sk)`
|
- `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)
|
### 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)`. |
|
| 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 | **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
|
### 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.
|
`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 |
|
**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
|
||||||
| RFC 5869 TC1 / TC2 / TC3 | known-answer |
|
`commonTest` **plus** `iosTest`). Both commands are offline-capable.
|
||||||
| `hkdfInfo("concord/channel", id32, 4u)` | golden hex, annotated with the A.1 layout |
|
|
||||||
| `communityId` self-certification | round trip |
|
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.
|
||||||
| `groupKey` determinism + `pk == xonly(sk)` | property |
|
|
||||||
| `scalarNormalize` counter path | injected failing seed |
|
| Test | Type | Runs on |
|
||||||
| 32 bytes → base64url = 43 chars, unpadded | unit |
|
|---|---|---|
|
||||||
| wrap → unwrap round trip | integration, no network |
|
| RFC 5869 TC1 / TC2 / TC3 | known-answer | host JVM + iOS ✅ |
|
||||||
| impersonation rejection | negative |
|
| `hkdfSha256` rejects L > 255·32 | boundary | host JVM + iOS ✅ |
|
||||||
| `channel` / `epoch` tag mismatch rejection | negative |
|
| `hkdfInfo(channel, id32, 4u)` / epoch-omitted length | golden hex, annotated with the A.1 layout | host JVM + iOS ✅ |
|
||||||
| `20013` vs `20014` plane discipline | negative |
|
| `communityId`, `prevCommit`, `editionHash` | golden SHA-256 computed outside the codebase | host JVM + iOS ✅ |
|
||||||
| `expires_at` ms/s conversion | unit |
|
| `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):
|
**RFC 5869 Test Case 1** (for reference):
|
||||||
|
|
||||||
@@ -786,6 +844,10 @@ PRK = 0x19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04
|
|||||||
OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8
|
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.
|
Additionally, run one **interop smoke test** against a real community before declaring M3 done. Nothing catches a label typo faster.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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", "<ephemeral pubkey>"]` — the one tag a stream wrap carries (NIP-59 reversed). */
|
||||||
|
const val P = "p"
|
||||||
|
|
||||||
|
/** `["channel", "<channel_id>"]` — MUST also be committed inside the author-signed rumor. */
|
||||||
|
const val CHANNEL = "channel"
|
||||||
|
|
||||||
|
/** `["epoch", "<n>"]` — MUST also be committed inside the author-signed rumor. */
|
||||||
|
const val EPOCH = "epoch"
|
||||||
|
|
||||||
|
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
|
||||||
|
const val QUOTE = "q"
|
||||||
|
|
||||||
|
/** `["vsk", "<n>"]` — Control edition entity type. */
|
||||||
|
const val VSK = "vsk"
|
||||||
|
|
||||||
|
/** `["eid", "<hex32>"]` — stable coordinate of a Control edition's entity. */
|
||||||
|
const val EID = "eid"
|
||||||
|
|
||||||
|
/** `["ev", "<n>"]` — this edition's version, climbing from 1. */
|
||||||
|
const val EV = "ev"
|
||||||
|
|
||||||
|
/** `["ep", "<hash hex>"]` — previous edition hash. Absent on the first edition. */
|
||||||
|
const val EP = "ep"
|
||||||
|
|
||||||
|
/** `["vac", "<grant eid>", "<version>", "<hash>"]` — authority citation. Absent when the owner acts. */
|
||||||
|
const val VAC = "vac"
|
||||||
|
|
||||||
|
/** `["expiration", "<secs>"]` — NIP-40. MUST be on both wrap and rumor, same value. */
|
||||||
|
const val EXPIRATION = "expiration"
|
||||||
|
|
||||||
|
/** `["d", ""]` / `["d", "<index>"]` — addressable coordinate. */
|
||||||
|
const val D = "d"
|
||||||
|
|
||||||
|
/** `["snap", "<id>", "<i>", "<n>"]` — Guestbook snapshot chunk. */
|
||||||
|
const val SNAP = "snap"
|
||||||
|
|
||||||
|
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
|
||||||
|
const val INVITE = "invite"
|
||||||
|
|
||||||
|
/** `["k", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */
|
||||||
|
const val K = "k"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user