48 KiB
Concord Protocol Implementation Plan
Implementation plan for adding Concord communities and channels to Coop.
- Protocol repo: https://github.com/concord-protocol/concord
- Protocol docs: https://concordprotocol.org/learn/what-is-concord
- SDK:
su.reya:nostr-sdk-kmp:0.3.2
Requirements this plan honors
- No over-engineering, no over-complication.
- Reuse existing APIs and types from the nostr SDK; only create new code where the SDK has nothing.
- Follow the existing Coop architecture (no new patterns, no new DI framework, no new persistence layer).
Table of contents
- Verdict
- Capability check — exists vs. must be written
- Scope — v1 vs. deferred
- Architecture
- Files
- Crypto layer — exact algorithm
- Wire format — exact stack
- Subscriptions
- Persistence
- Invites
- UI
- Milestones
- Risks and open decisions
- Test plan
- Appendix A — Concord constants reference
- Appendix B — Coop architecture reference
1. Verdict
Feasible without touching the Rust SDK. Every cryptographic primitive Concord needs is reachable through nostr-sdk-kmp except HKDF-SHA256, which Okio already gives us the pieces for (ByteString.hmacSha256) — and Okio is already a commonMain dependency.
The hard part is not crypto. It's that Concord's wrap is NIP-59 inverted (fixed author, ephemeral p), so it cannot ride the existing MessageManager giftwrap pipeline. It needs a parallel plane.
2. Capability check — exists vs. must be written
Verified against the published artifact su.reya:nostr-sdk-kmp:0.3.2 (sources jar), not just documentation.
2.1 Exists — use it, don't wrap it
| Concord need | Existing API | Notes |
|---|---|---|
| NIP-44 with an arbitrary keypair | nip44Encrypt(sk, pk, content, Nip44Version.V2)nip44Decrypt(sk, pk, payload) |
Concord's conv_key = nip44_conversation_key(sk, pk) is NIP-44's conversation key. Pass the plane's own keypair → self-ECDH happens for free. Do not hand-roll this. |
| x-only pubkey from a secret | Keys(secretKey).publicKey() |
secp256k1 x-only, matches Concord's byte-exact rule |
| Arbitrary kind numbers | Kind(1059u), Kind(20013u), Kind(20014u), Kind(3308u), Kind(9u)… |
Kind(kind: UShort) is a public constructor |
| Sign a wrap with a derived key | EventBuilder(kind, content).tags(…).finalize(Keys(sk)) |
Keys implements NostrSigner |
| Sign a seal with the user's key (incl. NIP-46 bunker) | .finalizeAsync(nostr.signer) |
UniversalSigner already proxies this |
| Decode events | Event.fromJson, UnsignedEvent.fromJson(…).ensureId(), Event.verify() |
Already used in Messaging.extractRumor |
| Multi-letter tags | Tag.custom("channel", listOf("…")), Tag.parse(List<String>), tag.kind(), tag.asVec() |
Filters only support single-letter tags |
| Subscribe / publish to specific relays | client.subscribe(ReqTarget.manual(mapOf(relay to filters)), id)client.addRelay(url, RelayCapabilities.read())SendEventTarget.to(relays) |
Same idiom as MessageManager.getUserMessages |
| Local persistence | client.database().saveEvent/query (LMDB) |
Mirror MessageManager.setCachedRumor |
| Secret storage | AppStorage.setSecret (AES-GCM via Android Keystore) |
Room storage does not; roots must use this |
| NIP-40 expiry | Tag.expiration(Timestamp) |
For deferred CORD-08 |
| SHA-256 | Okio ByteString.sha256() |
okio already in shared/commonMain |
| HMAC-SHA256 | Okio ByteString.hmacSha256(key) |
In commonMain → works on iOS targets too |
| base64url unpadded | kotlin.io.encoding.Base64.UrlSafe (Kotlin 2.4) |
Base64 already imported in BlossomClient |
| scalar validity test | SecretKey.fromBytes(bytes) throws for invalid scalars |
This is scalar_normalize's reject branch |
| hex ↔ bytes | Okio ByteString.decodeHex(), ByteString.hex() |
2.2 Missing — must be written (small, pure Kotlin)
| Need | Size | Where |
|---|---|---|
| HKDF-SHA256 (Extract + Expand) | ~15 lines on top of Okio | concord/ConcordCrypto.kt |
hkdf info layout (label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]) |
~10 lines | same |
scalar_normalize retry loop |
~10 lines | same |
community_id, edition_hash, prevcommit |
~15 lines | same |
Everything else is composition of existing SDK calls.
3. Scope — v1 vs. deferred
Concord is 8 CORDs. Building all at once contradicts requirement 1.
3.1 v1 — ships
| CORD | What ships |
|---|---|
| 01 Private Streams | Full wrap/seal/rumor stack: 1059 / 20013 / 20014 + ephemeral p |
| 02 Communities | community_id, community_root, control_root (held, not used), epochs, Control/Chat/Guestbook planes. Read-only Control fold for vsk 0 (metadata) + vsk 2 (channels) only. Guestbook: publish join on join, do not fold. |
| 03 Channels | Public + Private, key derivation, channel/epoch binding checks, send/receive kind 9 |
| 05 Invites | Redeem only: Direct Invite (3313) + public link bundle (33301) + fragment decoder. No minting. |
3.2 Deferred — explicitly out of v1
| CORD / feature | Why deferred |
|---|---|
| 04 Roles | Roster fold + vac citation + outranking rules. Large. Needed for moderation, not for chat. |
| 06 Rekeys | Epoch rotation. v1 reads the epoch it was invited to and holds old keys read-only. |
| 05 minting | Invite List (13303), Registry (vsk 8), revocation tombstones. |
| 07 A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
| 08 Disappearing | Cheap to add later; Tag.expiration exists. |
| Pins / Edits / Threads / WebXDC | Not needed for a first cut. |
Community List (33302) |
Cross-device sync; single-device + AppStorage covers v1. |
| Dissolution | Rare path. |
Consequence to be honest about: the v1 Control fold does not enforce authorization. A
control_rootholder could publish forged metadata or channels and v1 would display it. This is bounded (only staff holdcontrol_root, and the spec itself calls it "a spam gate, never authority") but it is a real gap. Document it in code and label the feature beta in the UI.
4. Architecture
4.1 Where it plugs in
graph TD
NF[Notifications stream] --> N[Nostr.handleNotifications]
N --> R{route by kind and author}
R -->|kind 1059, known plane pk| CM[ConcordManager]
R -->|kind 1059, otherwise| MW[MessageManager - unchanged]
R -->|rumor kind 3313| CM
CM --> CP[ConcordCrypto]
CM --> PL[ConcordPlane wrap and unwrap]
CM --> ST[ConcordStore]
ST --> AS[AppStorage - secrets]
ST --> DB[LMDB index events]
CM --> CR[ConcordRepository]
CR --> CV[ConcordViewModel]
CV --> CS[Screens]
4.2 The critical integration point
Nostr.handleNotifications is a single notification pump (client.notifications()). Calling client.notifications() a second time would race two consumers over the same stream. Concord must therefore be routed through the existing loop, with two new branches.
// shared/.../nostr/Nostr.kt
class Nostr(...) {
val messages = MessageManager(this)
val profiles = ProfileManager(this)
val relays = RelayManager(this)
val concord = ConcordManager(this) // NEW
Branch A — plane wraps. Concord wraps are kind 1059 whose author is the plane's stream pubkey. The existing extractRumor would call signer.nip44DecryptAsync(event.author(), …) on them and fail. Route by author instead:
KindStandard.GIFT_WRAP -> {
if (concord.isPlaneAddress(event.author())) {
concord.handlePlaneEvent(event) // own decrypt path
} else {
giftWrapQueue.send(event) // existing NIP-17 path, untouched
}
}
Branch B — Direct Invites. A kind 3313 invite rides a standard NIP-59 wrap (ephemeral author, p = recipient, k = 3313), so it flows through the existing extractRumor fine — but ChatRepository.updateRoomState would then build a garbage Room from it. Intercept it before the app-level callback:
// inside the giftWrapQueue consumer, after extractRumor
if (rumor != null && concord.onInboxRumor(rumor)) continue
ConcordManager.onInboxRumor returns true when it consumed the rumor (i.e. it was a 3313). This keeps all Concord kind knowledge inside the concord package, and requires no changes to ChatRepository.
Add a comment on
handleNotificationsrecording that it must remain the onlyclient.notifications()consumer.
5. Files
5.1 New — shared/src/commonMain/kotlin/su/reya/coop/concord/
| 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 |
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 |
ConcordStore.kt |
Membership persistence (AppStorage.setSecret), LMDB index events, Control edition storage |
ConcordControl.kt |
Control fold: group by eid, take highest ev with intact ep chain; project vsk 0 / vsk 2 |
ConcordManager.kt |
Subscriptions, relay connect, notification routing, send-message, join/leave |
5.2 New — shared/src/commonMain/kotlin/su/reya/coop/
| File | Contents |
|---|---|
Community.kt |
UI-facing models + derived flows, mirroring Room.kt / RoomUiState |
repository/ConcordRepository.kt |
ErrorHost by createErrorHost(), MutableStateFlow, stateIn(scope, WhileSubscribed(5000), …) |
viewmodel/ConcordViewModel.kt |
Façade over the repository, mirrors ChatViewModel |
viewmodel/ChannelScreenViewModel.kt |
Entry-scoped, mirrors ChatScreenViewModel (mutableStateListOf<UnsignedEvent>) |
5.3 New — composeApp/src/androidMain/kotlin/su/reya/coop/screens/
| File | Contents |
|---|---|
CommunitiesScreen.kt |
Joined communities list + FAB → Join |
CommunityScreen.kt |
Channel list for one community |
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/
| 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 |
5.5 Modified (6 files, all small)
| File | Change |
|---|---|
shared/.../nostr/Nostr.kt |
add val concord, 2 routing branches |
composeApp/.../MainActivity.kt |
private val concordRepository by lazy { … }, add to App(...) params |
composeApp/.../App.kt |
factory branch, viewModel(...), 3 × entry<…>, snackbar collector |
composeApp/.../Navigation.kt |
Screen.Communities, Screen.Community(id), Screen.Channel(communityId, channelId), Screen.JoinCommunity |
composeApp/.../screens/HomeScreen.kt |
one entry in BottomMenuList |
composeApp/src/androidMain/composeResources/drawable/ic_communities.xml |
icon |
6. Crypto layer — exact algorithm
This is the part that must be byte-exact or nothing interoperates. Freeze it in one file, with the governing spec section quoted above each function.
6.1 HKDF info layout (CORD-02 A.1)
info = utf8(label) ‖ 0x00 ‖ id[32] ‖ epoch_be[8] // epoch omitted for labels marked "—"
salt = ∅ (zero-length, NOT 32 zero bytes)
len = 32
id is always present, 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?): ByteArray {
require(id.size == 32)
return Buffer().apply {
writeUtf8(label) // 1. label bytes
writeByte(0) // 2. separator
write(id) // 3. id, always present
if (epoch != null) writeLong(epoch.toLong()) // 4. BE u64, only when the label has an epoch
}.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)
val out = Buffer()
var t = ByteString.EMPTY
var counter = 1
while (out.size < length) { // Expand
t = Buffer().write(t).write(info).writeByte(counter).readByteString()
.hmacSha256(prk)
out.write(t)
counter++
}
return out.readByteArray(length.toLong())
}
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").
6.2 scalar_normalize (CORD-02 A.3)
Must yield a valid secp256k1 secret key: if
seedis not a valid scalar, append one incrementing counter byte to the hkdfinfoand retry, the counter starting at 0. The reject branch is ~2⁻¹²⁸ rare; the counter keeps it deterministic across implementations.
The counter is appended to the info, after whatever fields are present.
6.3 group_key (CORD-02 A.2)
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong?): GroupKey {
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())
counter++ // A.3: counter starts at 0 on first retry
}
error("scalar_normalize exhausted")
}
The conv_key from A.2 needs no separate implementation: nip44Encrypt(groupSk, groupPk, …) derives exactly it.
6.4 community_id (CORD-02 A.4) — note: no 0x00 separator
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray =
sha256("concord/community".encodeToByteArray(), ownerXonly, ownerSalt)
This is a plain SHA-256 commitment, not the HKDF construction.
6.5 Labels required in v1
| Label | secret (ikm) | id | epoch |
|---|---|---|---|
concord/control |
community_root |
community_id |
yes |
concord/control-signer |
control_root |
community_id |
yes |
concord/channel |
community_root (public) or channel_key (private) |
channel_id |
yes |
concord/guestbook |
community_root |
community_id |
yes |
concord/dissolved |
community_id |
0…0 |
— |
Only concord/channel, concord/guestbook, and concord/control are exercised in v1. concord/control-signer is needed only if community creation is added (M4.5).
6.6 Encoding rules (CORD-01, normative)
| Rule | |
|---|---|
| Hex is lowercase | Every 32-byte value in fields and tags is 64 lowercase hex chars |
| Pubkeys are x-only hex, never bech32 | Not npub, not 33-byte compressed |
| Tag values are strings | "4", never 4 |
Empty content is "" |
Never null, never omitted |
created_at is unix seconds, untweaked |
Sub-second ordering rides the ms tag |
7. Wire format — exact stack
7.1 Sending a channel message
suspend fun sendChannelMessage(ch: ConcordChannel, text: String) {
val author = signer.getPublicKeyAsync() ?: error("not signed in")
val now = Clock.System.now()
val secs = now.epochSeconds.toULong()
val ms = (now.toEpochMilliseconds() % 1000).toInt()
// 1. rumor — kind 9, NEVER signed, MUST carry the binding tags
val rumor = EventBuilder(Kind(9u), text)
.tags(listOf(
Tag.custom("channel", listOf(ch.idHex)),
Tag.custom("epoch", listOf(ch.epoch.toString())),
Tag.custom("ms", listOf(ms.toString())),
))
.finalizeUnsigned(author)
.ensureId()
// 2. seal — kind 20013, signed by the REAL author (works with a bunker signer)
val seal = EventBuilder(
Kind(20013u),
nip44Encrypt(ch.sk, ch.pk, rumor.asJson(), Nip44Version.V2) // self-ECDH conv key
).finalizeAsync(signer) ?: error("seal failed")
// 3. wrap — kind 1059, signed by the STREAM key, ephemeral `p` (NIP-59 reversed)
val wrap = EventBuilder(
Kind(1059u),
nip44Encrypt(ch.sk, ch.pk, seal.asJson(), Nip44Version.V2) // same conv key, second layer
)
.tags(listOf(Tag.publicKey(Keys.generate().publicKey()))) // ephemeral; discard the secret
.finalize(Keys(ch.secretKey)) // stream signature
client.sendEvent(wrap, SendEventTarget.to(communityRelays), AckPolicy.none())
}
Two things that are easy to get wrong:
- The wrap content is encrypted under the same conversation key as the seal. It is double encryption under one key — not a second key.
created_atis never tweaked. Sub-second ordering lives in themstag; true time =created_at * 1000 + ms.
7.2 Receiving
fun unwrap(event: Event, keys: PlaneKeys): UnsignedEvent? {
val seal = Event.fromJson(nip44Decrypt(keys.sk, keys.pk, event.content()))
if (!seal.verify()) return null
val rumorJson = when (seal.kind().asU16()) {
20013u -> nip44Decrypt(keys.sk, keys.pk, seal.content()) // Chat / Guestbook / rekey
20014u -> seal.content() // Control only, byte-verbatim
else -> return null
}
val rumor = UnsignedEvent.fromJson(rumorJson).ensureId()
if (rumor.author() != seal.author()) return null // NIP-59 impersonation check
return rumor
}
Then the mandatory CORD-03 §3 binding check — drop on mismatch, never render:
val tChannel = rumor.tags().toVec().firstOrNull { it.kind() == "channel" }?.content()
val tEpoch = rumor.tags().toVec().firstOrNull { it.kind() == "epoch" }?.content()
if (tChannel != ch.idHex || tEpoch != ch.epoch.toString()) return // re-wrap / cross-epoch replay
7.3 Seal discipline (CORD-02 §5) — normative
| Plane | Seal kind |
|---|---|
| Control | 20014 plaintext — a signature over ciphertext could not survive a compaction re-wrap across epochs |
| Chat, Guestbook, rekey | 20013 encrypted |
Enforce the NIP-44 65,535-byte plaintext cap yourself at every layer before publishing. Libraries are lenient; a lenient publisher mints events a strict reader cannot decrypt.
7.4 Event kinds used in v1
| Kind | Function | Plane |
|---|---|---|
1059 |
Stream wrap (durable envelope) | all |
21059 |
Ephemeral gift wrap — relays MUST NOT store | all (deferred) |
20013 |
Encrypted seal | Chat, Guestbook, rekey |
20014 |
Plaintext seal | Control |
9 |
Message | Chat |
3308 |
Control edition, sub-kinded by vsk |
Control |
3306 |
Join / Leave | Guestbook |
3313 |
Direct invite (standard NIP-59 wrap, k-tagged) |
person-to-person |
33301 |
Public invite bundle | outside the wrap |
30078 |
(Coop-internal) LMDB index bucket for cached rumors | local only |
Full registry: Appendix A.
8. Subscriptions
One subscription id per community, refreshed whenever the relay set or channel set changes:
val id = "concord:${community.idHex}"
client.unsubscribe(id)
// 1. connect the community's relays (from the invite, then the Control fold)
community.relays.forEach {
client.addRelay(RelayUrl.parse(it), RelayCapabilities.read())
client.connectRelay(RelayUrl.parse(it))
}
// 2. one filter per plane; group them so each relay gets the ones it should
val control = Filter().kind(Kind(1059u)).author(controlPk) // signer pk, from the invite
val guestb = Filter().kind(Kind(1059u)).author(guestbook.pk)
val channels = channels.map { Filter().kind(Kind(1059u)).author(it.pk) }
val targets = mutableMapOf<RelayUrl, List<Filter>>()
community.relays.forEach { url ->
targets[RelayUrl.parse(url)] = listOf(control, guestb) + channels
}
client.subscribe(ReqTarget.manual(targets), id = id)
You cannot filter on channel / epoch — they are multi-letter tags and Filter.customTags only accepts SingleLetterTag. Plane-level filtering by author is the correct granularity anyway (one key per plane), and the channel / epoch binding is checked post-decrypt.
For paginated history, mirror MessageManager.getUserMessages: Filter().kind(Kind(1059u)).author(planePk).limit(n) with until cursors.
Relay compatibility. Concord wraps reverse NIP-59 (fixed author, ephemeral
p). Relays enforcing the optional NIP-59p-tag guard will drop them. The bootstrap set (relay.ditto.pub,relay.primal.net, …) is tuned for NIP-17 — always use the community's relay set from the invite, never the app defaults.
9. Persistence
Two mechanisms, both already established in the codebase.
9.1 Roots and keys → AppStorage.setSecret
Android Keystore-backed AES-GCM. One JSON blob, mirroring SettingsRepository's single-key pattern exactly.
@Serializable
data class Membership(
val communityId: String, // hex
val owner: String, // hex — proves the community_id
val ownerSalt: String, // hex
val communityRoot: String, // hex 32B — never leave setSecret
val rootEpoch: ULong,
val controlPk: String?, // hex, from invite; trusted-on-trust until a rotation
val controlRoot: String?, // hex — staff only, usually null
val relays: List<String>,
val name: String?,
val channels: List<StoredChannelKey>, // id, key(hex), epoch, name, private
)
@Serializable
data class StoredChannelKey(
val id: String, // hex
val key: String, // hex 32B
val epoch: ULong,
val name: String?,
val private: Boolean,
)
Storage keys: concord_memberships via getSecret / setSecret.
Never use
set()for these — it is plaintext DataStore.
9.2 Community state → LMDB index events
Mirror MessageManager.setCachedRumor precisely. Kind 30078 (APPLICATION_SPECIFIC_DATA) is addressable, so the d tag is what makes each row a unique slot.
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
.tags(listOf(
Tag.identifier(wrapId.toHex()), // `d` — unique slot, and dedupe key
Tag.custom("r", listOf(scopeIdHex)), // index: channel_id or community_id
Tag.custom("k", listOf(rumor.kind().asU16().toString())), // rumor kind
))
.finalizeAsync(Keys.generate()) ?: return // throwaway key, as MessageManager does
client.database().saveEvent(event)
Reading a channel:
Filter()
.kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
.reference(channelIdHex)
then UnsignedEvent.fromJson(it.content()). The Control plane uses the same mechanism with r = communityIdHex.
Do not skip
Tag.identifier. Without a uniqued, LMDB replaces every message with the previous one sharing the coordinate, silently eating the history.MessageManager.setCachedRumoruses the same trick for the same reason.
9.3 Control fold (v1, non-gating)
- Query all stored editions for the community (
r = communityIdHex). - Parse to
ControlEdition { vsk, eid, ev, ep, content, actor }. - Group by
(vsk, eid); per group take the highestev(refuse to downgrade) whoseepchain is intact. Same-version ties break on the lower rumor id — nevercreated_at. - Project:
vsk 0→CommunityMeta(name, description, relays, icon, banner,message_expiration,custom)vsk 2→ChannelMeta(name,private,deleted,custom)
- Ignore every other
vskin v1.
Not implemented in v1: authority resolution against the Roster (vsk 1 / vsk 3), vac citation validation, and vsk 4 banlist filtering. See Risks.
10. Invites
10.1 Direct Invite — kind 3313
Rides a standard NIP-59 wrap, so it arrives through the existing giftwrap pipeline and only needs an interception in handleNotifications:
| Layer | Kind | Details |
|---|---|---|
| Wrap | 1059 |
ephemeral single-use author, ["p", "<recipient>"], ["k", "3313"], optional NIP-40 ["expiration", "<secs>"] |
| Seal | 13 |
standard NIP-59 — not the reversed stream wrap |
| Rumor | 3313 |
content = the CommunityInvite JSON, tags: [] |
Indexed fetch, if ever needed: {"kinds":[1059], "#p":["<me>"], "#k":["3313"]}.
The
ktag is unsigned relay-visible bytes — a hint, never authority. An invite is whatever unwraps to a kind3313rumor, so accept an untagged one too.
10.2 Public link — $BASE/invite/<naddr>#<fragment>
| Part | Contents |
|---|---|
naddr |
NIP-19 addressable pointer for (kind 33301, link_signer, "") — a locator, not a secret |
fragment |
base64url (unpadded) of [version][flags][relays?][token:16] — never sent to any server |
version |
must be 4; reject lower as legacy |
flags |
stock-set bit → zero relay bytes follow |
| relay encoding | 1..254 = dictionary id, 0 = [len][host] wss-implied, 255 = [len][full URL] |
| max bootstrap relays | 3 |
Stock dictionary: 1 = wss://jskitty.com/nostr, 2 = wss://asia.vectorapp.io/nostr, 3 = wss://relay.ditto.pub, 4 = wss://relay.dreamith.to.
bundle_key = hkdf(token, "concord/invite-key") — the token derives exactly this one thing.
10.3 Bundle (CommunityInvite) and its validation
{
"community_id": "<hex>",
"owner": "<hex>",
"owner_salt": "<hex>", // verify: community_id == sha256("concord/community" ‖ owner ‖ salt)
"community_root": "<hex>",
"root_epoch": 0,
"control_pk": "<hex>", // taken on trust — nothing in the bundle can prove it
"channels": [ { "id": "<hex>", "key": "<hex>", "epoch": 1, "name": "testers" } ],
"relays": ["wss://…"],
"name": "Vector",
"icon": { "url": "…", "key": "…", "nonce": "…", "hash": "…" },
"expires_at": 1735689600000, // optional, unix MILLISECONDS
"creator_npub": "<hex>", // optional attribution
"label": "Reddit" // optional attribution
}
Required validation, in order:
community_id == sha256("concord/community" ‖ owner ‖ owner_salt)— refuse the bundle otherwise.- Reject more than a sane channel count (spec's reference ceiling is 256).
- Truncate
relaysto the Community's cap (5 recommended). - If
expires_atis in the past: preview still renders, joining refuses.
10.4 Join flow
- Parse link → decode fragment → require
version == 4. - Derive
bundle_key. - Fetch coordinate
(33301, link_signer, "")from the bootstrap relays. - Check liveness — a
["vsk","9"]tombstone at the coordinate replaces the bundle. - Decrypt content →
CommunityInvite. - Validate per §10.3.
- Preview only. No join, no subscribe, no icon fetch, no presence.
- On explicit accept: persist
Membership→ connect relays → subscribe → publish Guestbookjoin.
// kind 3306
{ "kind": 3306, "pubkey": "<member>", "content": "join",
"tags": [ ["ms", "128"], ["invite", "<creator hex>", "<label>"] ] } // invite tag optional
expires_atunit trap. The bundle uses unix milliseconds; the Invite List entry (13303) uses unix seconds; the NIP-40 tag uses seconds. Three representations of the same instant. Easy silent bug — keep the conversions in one function.
10.5 Deep links
Concord invite links are https://<base>/invite/<naddr>#<fragment>. The fragment never reaches a server, and Android intent filters strip fragments unreliably — so paste-text and QR scan are the sanctioned paths. Do not build an https deep-link filter.
If a deep link is wanted later, add coop://invite?url=<urlencoded> and let Coop's own handler carry the fragment.
11. UI
Mirrors the DM screens structurally so it feels native to Coop.
| Screen | Mirrors | Notes |
|---|---|---|
CommunitiesScreen |
HomeScreen list + FAB |
Community avatar (Avatar), name, channel count. Empty state per ContactListScreen convention. |
CommunityScreen |
HomeScreen |
Channel rows split public / private, lock icon on private. #general comes from genesis. |
ChannelScreen |
ChatScreen |
Reuse DateSeparator, ChatInput, Avatar. Discord-style (author shown per message) rather than Coop's DM style. |
JoinCommunityScreen |
NewChatScreen |
Paste link or QR scan (reuse LocalScanResult / Screen.Scan), preview card, Join button. |
11.1 Wiring, following existing conventions
// MainActivity.kt — lazy field next to chatRepository
private val concordRepository by lazy {
ConcordRepository(NostrManager.instance, AppStore(this@MainActivity), scope)
}
// App.kt — factory branch
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(concordRepository)
Plus, in App.kt:
val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)(activity-scoped)entry<Screen.Communities> { CommunitiesScreen(concordViewModel) }entry<Screen.Community> { key -> … }(entry-scopedCommunityScreenViewModel)entry<Screen.Channel> { key -> … }(entry-scopedChannelScreenViewModel, keyed likeScreen.Chat)- a fourth
launch { concordViewModel.errorEvents.collect { snackbarHostState.showSnackbar(it) } }in the existingLaunchedEffectatApp.kt:162-178
And one entry in BottomMenuList (HomeScreen.kt:785-791) plus ic_communities.xml in composeApp/src/androidMain/composeResources/drawable/.
11.2 UI honesty
- Label the feature beta and surface the "no authority enforcement yet" limitation in a community settings/info row.
- Warn on community creation (if implemented) that losing the owner key kills the community — there is no succession by design.
12. Milestones
Each milestone ends with something runnable.
M1 — Crypto core (no UI, no networking)
ConcordCrypto.kt + ConcordKind.kt + ConcordCryptoTest.kt.
Done when:
- RFC 5869 Test Cases 1 & 3 pass
hkdfInfomatches a hand-computed golden hex, annotated with the A.1 layoutcommunityIdself-certifies (recompute fromowner+salt, compare)groupKeyis deterministic andpk == xonly(sk)
M2 — Plane stack (no networking)
ConcordPlane.kt + ConcordPlaneTest.kt.
Done when:
- build a
kind 9message for a test channel → unwrap → identical rumor JSON - a tampered seal fails
verify() - a
channel/epochmismatch is rejected - a
20013seal is rejected where20014is required
M3 — Join + read
ConcordStore.kt, ConcordInvite.kt, ConcordControl.kt, ConcordManager read path, Nostr.kt routing.
Done when: pasting a real invite link stores the membership, connects the community's relays, folds metadata + channel list, and renders channel messages from a real community.
M4 — Write
sendChannelMessage, Guestbook join (3306, content: "join"), unreadCount.
Done when: a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
M4.5 — Community creation (optional)
Owner mints community_id, community_root, control_root, genesis metadata (vsk 0) + #general (vsk 2); writes to the Control plane via the 20014 plaintext-seal path signed by concord/control-signer.
Why consider it: ~150 lines on top of M3, makes the feature demoable without depending on an existing Concord client, and exercises the Control-plane write path that join-only never touches.
Why it might wait: requires control_root handling and careful 20014 discipline.
M5 — UI polish
ConcordRepository / view models / 4 screens / BottomMenuList entry / icon / error snackbars.
Done when: the whole flow works without adb logcat.
M6 — Cheap wins (optional)
Reactions (kind 7) and edits (kind 3302) reusing the existing DM reaction UI. Threads (kind 1111) are more work — separate milestone.
13. Risks and open decisions
13.1 Spec conflicts — resolve before writing byte-exact code
| # | Conflict | Resolution |
|---|---|---|
| 1 | community_id owner proof. CORD-05 §1 writes sha256(owner ‖ salt). examples.md §6.1 writes sha256("concord/community" ‖ owner ‖ salt). CORD-02 A.4 (marked frozen, normative) writes sha256(utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32]). |
Implement A.4. It is a hard-fail interop check, so validate against a reference implementation at the first opportunity. |
| 2 | expires_at units differ in three places — bundle = unix ms, Invite List entry = unix s, NIP-40 tag = s. |
Keep all conversions in one function; unit-test it. |
| 3 | vac on 3303. CORD-06 §3 says a rotation cites its Grant, but neither its §1 JSONC nor examples.md shows the tag. |
Deferring rekeys sidesteps this. |
13.2 Design risks
| # | Risk | Mitigation |
|---|---|---|
| 4 | Relay compatibility. Reversed NIP-59 wraps may be dropped by relays enforcing the p-tag guard. |
Always use the community's relay set from the invite. Never the app defaults. |
| 5 | No owner recovery, by design. community_id commits to the owner's key; lose it and the community is dead. |
Surface a backup warning on community creation. |
| 6 | control_pk is taken on trust at join — nothing in the invite can prove it. |
Build nothing security-relevant on it beyond the subscription address. |
| 7 | v1 has no authority enforcement. Staff hold control_root, so blast radius is limited, but a hostile staffer can forge metadata/channels. |
Document in code; label beta in UI; close with a CORD-04 milestone. |
| 8 | One notification pump. A second client.notifications() consumer would silently split subscriptions. |
Comment on handleNotifications stating it must stay the only consumer. |
| 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. |
13.3 Open decision
Community creation in v1? See M4.5. Recommendation: yes, after M4 — it makes the feature demoable standalone.
14. Test plan
shared/src/commonTest currently holds only the template stub, so this introduces the convention. Worth it: this is frozen-by-spec crypto with no spec test vectors, and a byte-layout mistake would silently break interop rather than fail loudly.
| 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 |
RFC 5869 Test Case 1 (for reference):
IKM = 0x0b × 22
salt = 0x000102030405060708090a0b0c
info = 0xf0f1f2f3f4f5f6f7f8f9
L = 42
PRK = 0x077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5
OKM = 0x3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
RFC 5869 Test Case 3 (zero-length salt — closest to Concord's usage):
IKM = 0x0b × 22
salt = (empty)
info = (empty)
L = 42
PRK = 0x19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04
OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8
Additionally, run one interop smoke test against a real community before declaring M3 done. Nothing catches a label typo faster.
Appendix A — Concord constants reference
A.1 Durable plane kinds (inner rumor kinds)
| Kind | Function | Plane |
|---|---|---|
9 |
Message (NIP-C7 shape) | Chat |
1111 |
Threaded reply (NIP-22 shape) | Chat |
7 |
Reaction (NIP-25 shape) | Chat |
5 |
Delete (NIP-09 shape) | Chat |
1740 |
Timer notice (CORD-08 §4) | Chat |
3302 |
Edit | Chat |
3303 |
Rekey blobs (CORD-06) | rekey addresses |
3306 |
Join / Leave | Guestbook |
3308 |
Control edition, sub-kinded below | Control |
3309 |
Kick | Guestbook |
3310 |
WebXDC peer signal | Chat |
3312 |
Guestbook snapshot, chunked, refounder-signed | Guestbook |
A.2 Ephemeral kinds (outer wrap 21059)
| Kind | Function | Plane |
|---|---|---|
23311 |
Typing indicator | Chat |
23313 |
Voice presence (CORD-07) | Chat |
A.3 Kinds outside the wrap
| Kind | Function |
|---|---|
33301 |
Public invite bundle — addressable, signed by its per-link keypair at an empty d, vsk 6 (live) / 9 (revocation tombstone) |
33302 |
Community List — addressable, one event per fragment at d = fragment index, NIP-44 to self |
13303 |
Invite List — replaceable, NIP-44 to self |
A.4 Control edition sub-kinds (vsk)
| vsk | Entity |
|---|---|
0 |
Community metadata |
1 |
Role |
2 |
Channel metadata |
3 |
Grant |
4 |
Banlist |
5 |
reserved (role ordering) |
6, 9 |
claimed by the addressable invite marker |
7 |
retired (v1 owner attestation) |
8 |
Invite-link registry |
10 |
Dissolved tombstone — chainless, exempt from version discipline |
11 |
Pin List |
A.5 Tags
| Tag | Where | Semantics |
|---|---|---|
["ms", "<0..999>"] |
every rumor | true time = created_at * 1000 + ms; outside 0..999 is malformed → drop |
["p", "<ephemeral pubkey>"] |
outer stream wrap | NIP-59 reversed: fixed author, ephemeral p |
["channel", "<channel_id>"] |
every Chat rumor | MUST be committed inside the author-signed rumor; receiver MUST strict-equal check |
["epoch", "<n>"] |
every Chat rumor | same strict-equal check |
["q", "<rumor id>", "", "<author>"] |
kind 9 inline quote |
NIP-C7 |
K/E/P, k/e/p |
kind 1111 reply |
uppercase = thread root, lowercase = immediate parent (all rumor ids) |
["vsk", "<n>"] |
kind 3308 |
entity type |
["eid", "<hex32>"] |
kind 3308 |
stable coordinate |
["ev", "<n>"] |
kind 3308 |
this edition's version, climbs from 1 |
["ep", "<hash hex>"] |
kind 3308 |
previous edition hash; absent on the first |
["vac", "<grant eid>", "<version>", "<hash>"] |
kind 3308, 3309 |
authority citation; absent when the owner acts |
["expiration", "<secs>"] |
Chat wrap + rumor | NIP-40; MUST be on both, same value; never on kind 5 or 1740 |
["d", ""] |
kind 33301 |
empty identifier — the per-link pubkey makes the coordinate unique |
["d", "<index>"] |
kind 33302 |
fragment index in decimal |
["snap", "<id>", "<i>", "<n>"] |
kind 3312 |
snapshot chunk; one id + one created_at across all n |
["invite", "<creator hex>", "<label>"] |
kind 3306 join |
optional invite attribution |
["k", "3313"] |
outer Direct Invite wrap | the one deliberate outer-tag exception besides expiration |
A.6 Permission bits (CORD-04 §3)
| Bit | Permission |
|---|---|
1<<0 |
MANAGE_ROLES |
1<<1 |
MANAGE_CHANNELS |
1<<2 |
MANAGE_METADATA |
1<<3 |
KICK |
1<<4 |
BAN |
1<<5 |
MANAGE_MESSAGES |
1<<6 |
CREATE_INVITE |
1<<7 |
retired (was MANAGE_INVITES) |
1<<8 |
VIEW_AUDIT_LOG |
1<<9 |
MENTION_EVERYONE |
1<<10, 1<<12 |
reserved |
1<<11 |
PIN_MESSAGES |
Staff = any of MANAGE_ROLES, MANAGE_CHANNELS, MANAGE_METADATA, BAN, CREATE_INVITE, PIN_MESSAGES, plus always the owner. Staff hold control_root.
position orders authority, lower is higher; owner is position 0 and never a Role; a member's rank is the lowest position among their Roles; an actor must strictly outrank its target.
A.7 KDF label registry (CORD-02 A.6)
| Label | secret (ikm) | id | epoch | Yields |
|---|---|---|---|---|
concord/channel |
channel key or community_root |
channel_id |
yes | a Channel's group key |
concord/control |
community_root |
community_id |
yes | Control Plane read key |
concord/control-signer |
control_root |
community_id |
yes | Control Plane signer (staff only) |
concord/rekey-pseudonym |
prior community_root |
channel_id |
new_epoch | a channel rekey address |
concord/base-rekey-pseudonym |
prior community_root |
community_id |
new_epoch | a base rekey address |
concord/recipient-pseudonym |
rotator_xonly ‖ recipient_xonly |
scope_id |
new_epoch | a rekey blob locator |
concord/guestbook |
community_root |
community_id |
yes | Guestbook Plane group key |
concord/voice-signer |
channel key or community_root |
channel_id |
yes | SFU room keypair |
concord/voice-media |
channel key or community_root |
channel_id |
yes | 32-byte call media key |
concord/voice-sender |
voice_media_key |
sha256(identity) |
— | per-sender frame key |
concord/dissolved |
community_id |
0…0 |
— | dissolution tombstone address |
concord/grant |
community_id |
member_xonly |
— | a member's Grant coordinate |
concord/banlist |
community_id |
0…0 |
— | Banlist coordinate |
concord/pins |
community_id |
channel_id |
— | a Channel's Pin List coordinate |
concord/invite-links |
community_id |
creator_xonly |
— | a creator's Registry coordinate |
concord/invite-key |
token |
0…0 |
— | public-invite decrypt key |
concord/invite-locator, concord/invite-signer |
— | — | — | retired |
A.8 Other frozen derivations
community_id = sha256( utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32] )
prevcommit = sha256( utf8("concord/epoch-key-commitment") ‖ prev_epoch_be[8] ‖ prev_key[32] )
dissolved_pk = group_key("concord/dissolved", community_id, 0…0).pk
edition_hash = sha256(
len64(label) ‖ label // label = utf8 "vector-community/v1/edition"
‖ entity_id[32]
‖ version_be[8]
‖ (prev ? 0x01 ‖ prev[32] : 0x00 ‖ zero[32])
‖ len64(content) ‖ content ) // content bytes verbatim, never re-serialized
edition_hash's label string is literallyvector-community/v1/editionin the spec — the reference implementation'svectorprefix, not theconcord/prefix used by every KDF label. Reproduce it exactly.
Appendix B — Coop architecture reference
Points that constrain the implementation. Verified against the current tree (feat/group-chat, clean).
B.1 Module layout
| Module | Targets | Notes |
|---|---|---|
:shared |
androidTarget, iosArm64, iosSimulatorArm64 |
All domain/data code in commonMain |
:composeApp |
androidTarget only |
androidMain only — no commonMain UI, no working iOS app |
shared has Compose runtime but not UI/foundation/material3, so it can use mutableStateOf but cannot declare composables. All new screens go in composeApp/src/androidMain.
B.2 Dependency wiring
No DI framework. The graph is hand-built as by lazy fields on MainActivity, passed positionally into App(...), then:
- global values via
staticCompositionLocalOf(LocalNavigator,LocalProfileCache,LocalSettings,LocalConnectivity,LocalSnackbarHostState,LocalScanResult) - view models via an ad-hoc
ViewModelProvider.Factorywith awhenonmodelClass - Activity-scoped VMs created above
NavDisplay; parameterized VMs created insideentry<…>with akey
Repositories receive the Activity's MainScope() and hop to Dispatchers.Default per call via a defaultDispatcher constructor param. They never create their own scope.
B.3 Navigation
JetBrains Navigation 3. Routes are a @Serializable sealed interface Screen : NavKey, a rememberNavBackStack(Screen.Home), a NavDisplay with entryProvider { entry<Screen.X> { … } }, and a tiny Navigator wrapper exposing navigate(route) / goBack().
Adding a screen = declare the route, create the file, register the entry, wire the VM (if any), wire the repository (if any), add a nav entry point and an icon.
B.4 View models
androidx.lifecycle.ViewModel subclasses in shared/commonMain. Two shapes:
- Façade (
ChatViewModel, 21 lines): re-exposes repositoryStateFlows 1:1, forwards calls,ErrorHost by repository. - Stateful screen VM (
ChatScreenViewModel):mutableStateListOf/mutableStateOf+viewModelScope, stillErrorHost by repository.
UI collects with collectAsStateWithLifecycle().
B.5 Errors
ErrorHost (errorEvents: SharedFlow<String> + showError(message)) with createErrorHost().
Chain: repository : ErrorHost by createErrorHost() → view model : ErrorHost by repository → one collector per VM in App.kt:162-178 pushing into snackbarHostState.
A new feature's errors are only visible if a collector is added there.
B.6 Persistence
| Mechanism | Use |
|---|---|
AppStorage.get/set |
Plaintext DataStore preferences |
AppStorage.getSecret/setSecret |
AES-GCM via Android Keystore (<key>_encrypted + <key>_iv) |
LMDB (client.database()) |
Nostr events, at filesDir/nostr; wiped by nostr.prune() on logout |
Current keys: app_settings (plain), user_signer / app_keys (secret), notification_banner_dismissed (plain).
The only JSON persistence pattern is SettingsRepository: one key, Json { ignoreUnknownKeys = true; encodeDefaults = true }, a MutableStateFlow, and update(transform).
B.7 Established idioms to mirror
| Concern | Mirror |
|---|---|
| Synthetic LMDB index rows | MessageManager.setCachedRumor / getCachedRumor |
| Relay connect + subscribe | MessageManager.getUserMessages |
| Secret-backed single-blob repository | SettingsRepository |
| Repository shape | ChatRepository (ErrorHost, MutableStateFlow, stateIn(scope, WhileSubscribed(5000), …), catch (e: CancellationException) { throw e }) |
| List row / empty state / dialogs | HomeScreen, ContactListScreen, SettingsScreen |
B.8 Known constraints
Nostr.handleNotificationsmust remain the onlyclient.notifications()consumer.sharedcannot declare composables.- No
@Previewcomposables exist anywhere — don't introduce one just for this feature. - All user-facing text is hardcoded English literals; there is no
Res.stringconvention. Matching existing convention means inline literals. client.database().saveEventreplaces addressable events bydtag.concord's repository must be constructed inMainActivity, not inshared.