add simple ui
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */
|
||||
fun CommunityState.displayName(): String {
|
||||
val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
return name ?: "Untitled community"
|
||||
}
|
||||
|
||||
/** Every Channel's badge added up, for the Community row. */
|
||||
fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount }
|
||||
|
||||
/** `HH:mm`, local time. */
|
||||
fun ConcordMessage.timeLabel(): String {
|
||||
val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val hour = time.hour.toString().padStart(2, '0')
|
||||
val minute = time.minute.toString().padStart(2, '0')
|
||||
return "$hour:$minute"
|
||||
}
|
||||
|
||||
/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */
|
||||
fun ConcordMessage.dayLabel(): String {
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val date = createdAt.toLocalDateTime(zone).date
|
||||
val today = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
return when (date) {
|
||||
today -> "Today"
|
||||
today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
|
||||
else -> {
|
||||
val day = date.day.toString().padStart(2, '0')
|
||||
val month = date.month.number.toString().padStart(2, '0')
|
||||
val year = date.year.toString().takeLast(2)
|
||||
"$day/$month/$year"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,36 +4,7 @@ import kotlinx.serialization.decodeFromString
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Folding the Control Plane (CORD-04 §1).
|
||||
*
|
||||
* The Control Plane carries the Community's authoritative state as **editions**: each is a
|
||||
* `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this
|
||||
* edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole
|
||||
* chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so.
|
||||
*
|
||||
* v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`).
|
||||
*
|
||||
* ## What v1 does not do
|
||||
*
|
||||
* **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the
|
||||
* owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could
|
||||
* publish a forged metadata or Channel edition and v1 would display it.
|
||||
*
|
||||
* That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2),
|
||||
* and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the
|
||||
* reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and
|
||||
* `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly
|
||||
* outranks the entity it edits.
|
||||
*/
|
||||
object ConcordControl {
|
||||
|
||||
/**
|
||||
* Parses one Control rumor into an edition, or null when it is not a foldable edition.
|
||||
*
|
||||
* A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1
|
||||
* does not implement dissolution, so treating it as an ordinary edition would misread it.
|
||||
*/
|
||||
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
|
||||
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
|
||||
|
||||
@@ -45,7 +16,6 @@ object ConcordControl {
|
||||
if (eidHex.hex32() == null) return null
|
||||
|
||||
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
|
||||
// CORD-04: versions climb from 1. A zero version has no place in the chain.
|
||||
if (version == 0uL) return null
|
||||
|
||||
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
|
||||
@@ -63,12 +33,6 @@ object ConcordControl {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the editions into the metadata and Channel list v1 renders.
|
||||
*
|
||||
* Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a
|
||||
* broken chain on one Channel never takes the Community's metadata down with it.
|
||||
*/
|
||||
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
|
||||
var meta: CommunityMeta? = null
|
||||
val channels = mutableMapOf<String, ChannelMeta>()
|
||||
@@ -90,16 +54,6 @@ object ConcordControl {
|
||||
return ControlFold(meta = meta, channels = channels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the highest version whose `ep` chain reaches all the way back to version 1.
|
||||
*
|
||||
* "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down
|
||||
* from the top and take the first version we can actually verify. A missing predecessor or a
|
||||
* broken link disqualifies that version, not the entity.
|
||||
*
|
||||
* Ties on the same version break on the **lower rumor id**, never on `created_at`, so two
|
||||
* clients folding the same two candidates always agree.
|
||||
*/
|
||||
private fun winner(group: List<ControlEdition>): ControlEdition? {
|
||||
val byVersion = group
|
||||
.groupBy { it.version }
|
||||
|
||||
@@ -8,29 +8,6 @@ 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 each was
|
||||
* checked against RFC 5869 vectors and independently computed digests before it was written.
|
||||
* Concord ships no test vectors of its own ("Examples are illustrative, not verifiable test
|
||||
* vectors"), and no test file is kept — see PLAN.md §14.
|
||||
*
|
||||
* 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 re-checked by hand; Concord publishes none.
|
||||
*
|
||||
* @param length output length in octets, `1..255 * 32` per RFC 5869.
|
||||
*/
|
||||
fun hkdfSha256(
|
||||
ikm: ByteArray,
|
||||
info: ByteArray,
|
||||
@@ -39,18 +16,13 @@ fun hkdfSha256(
|
||||
): 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 was checked against it.
|
||||
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)
|
||||
@@ -61,19 +33,10 @@ fun hkdfSha256(
|
||||
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 {
|
||||
@@ -87,23 +50,6 @@ fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
|
||||
/** 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,
|
||||
@@ -112,62 +58,47 @@ fun groupSeed(
|
||||
isValid: (ByteArray) -> Boolean = ::isValidScalar,
|
||||
): ByteArray {
|
||||
val base = hkdfInfo(label, id, epoch)
|
||||
var counter = -1 // -1 means "no counter byte", i.e. the first attempt
|
||||
var counter = -1
|
||||
|
||||
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
|
||||
counter++
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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}" }
|
||||
@@ -178,14 +109,6 @@ fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
|
||||
}.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 {
|
||||
@@ -195,22 +118,6 @@ fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
|
||||
}.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" }
|
||||
|
||||
@@ -5,23 +5,6 @@ import rust.nostr.sdk.Nip19Coordinate
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.nip44Decrypt
|
||||
|
||||
/**
|
||||
* CORD-05: redeeming an invite.
|
||||
*
|
||||
* Two ways to be handed the keys, one bundle:
|
||||
*
|
||||
* - **Public link** — `$BASE/invite/<naddr>#<fragment>`. The naddr names where the encrypted
|
||||
* bundle sits on relays; the fragment carries an off-network unlock token and never reaches a
|
||||
* server. The bundle is fetched, then decrypted with a key derived from the token.
|
||||
* - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That
|
||||
* path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`.
|
||||
*
|
||||
* Minting is out of v1's scope, so this file only decodes.
|
||||
*
|
||||
* A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the
|
||||
* strength of what the bundle claims (CORD-05 §1).
|
||||
*/
|
||||
|
||||
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
|
||||
internal object ConcordRelayDictionary {
|
||||
val STOCK = listOf(
|
||||
@@ -67,13 +50,6 @@ private const val MAX_COMMUNITY_RELAYS = 5
|
||||
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
|
||||
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
|
||||
|
||||
/**
|
||||
* Decodes a link into its locator and fragment, or null when it is not a Concord invite.
|
||||
*
|
||||
* The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any
|
||||
* client recognizing an invite must respect the naddr and fragment verbatim — so only the last path
|
||||
* segment and the `#` fragment are read.
|
||||
*/
|
||||
fun parseInviteLink(text: String): ParsedInvite? {
|
||||
val trimmed = text.trim()
|
||||
val hash = trimmed.indexOf('#')
|
||||
@@ -93,16 +69,6 @@ fun parseInviteLink(text: String): ParsedInvite? {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-05 §3's fragment layout:
|
||||
*
|
||||
* ```
|
||||
* [version=4][flags][relays?][token:16]
|
||||
* ```
|
||||
*
|
||||
* With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries,
|
||||
* each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL.
|
||||
*/
|
||||
fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
|
||||
// Two header bytes plus the token at minimum.
|
||||
@@ -112,8 +78,7 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
if (reader.byte() != FRAGMENT_VERSION) return null
|
||||
|
||||
val flags = reader.byte() ?: return null
|
||||
// The stock flag selects the whole dictionary, so nothing extra is carried and the cap on
|
||||
// explicit entries does not apply to it.
|
||||
|
||||
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
|
||||
ConcordRelayDictionary.STOCK
|
||||
} else {
|
||||
@@ -142,41 +107,24 @@ fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one
|
||||
* thing — decrypting the bundle — and nothing else.
|
||||
*
|
||||
* Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's
|
||||
* `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord
|
||||
* `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key
|
||||
* is what lets the existing SDK do the encryption instead of hand-rolling NIP-44.
|
||||
*/
|
||||
fun inviteBundleKey(tokenHex: String): GroupKey? =
|
||||
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
|
||||
|
||||
/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */
|
||||
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
|
||||
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
|
||||
?: return null
|
||||
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
|
||||
}
|
||||
|
||||
/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */
|
||||
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
|
||||
|
||||
/**
|
||||
* CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so
|
||||
* the preview can explain itself rather than silently refusing.
|
||||
*
|
||||
* The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖
|
||||
* owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community.
|
||||
*/
|
||||
fun CommunityInvite.problems(): List<String> {
|
||||
val problems = mutableListOf<String>()
|
||||
|
||||
val ownerBytes = owner.hex32()
|
||||
val saltBytes = ownerSalt.hex32()
|
||||
val rootBytes = communityRoot.hex32()
|
||||
|
||||
if (ownerBytes == null) problems += "The invite's owner key is malformed"
|
||||
if (saltBytes == null) problems += "The invite's owner salt is malformed"
|
||||
if (rootBytes == null) problems += "The invite's community key is malformed"
|
||||
@@ -186,12 +134,14 @@ fun CommunityInvite.problems(): List<String> {
|
||||
problems += "The invite does not prove its community id"
|
||||
}
|
||||
}
|
||||
|
||||
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
|
||||
if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
|
||||
|
||||
if (channels.size > MAX_INVITE_CHANNELS) {
|
||||
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
|
||||
}
|
||||
|
||||
channels.forEachIndexed { index, channel ->
|
||||
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
|
||||
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
|
||||
@@ -200,20 +150,13 @@ fun CommunityInvite.problems(): List<String> {
|
||||
return problems
|
||||
}
|
||||
|
||||
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
|
||||
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
|
||||
|
||||
/**
|
||||
* The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays,
|
||||
* used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the
|
||||
* bundle's copy is the join-time snapshot of the real set (CORD-02 §6).
|
||||
*/
|
||||
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
|
||||
val source = relays.ifEmpty { fallback }
|
||||
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
|
||||
}
|
||||
|
||||
/** Turns a validated bundle into the membership we persist. */
|
||||
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
||||
communityId = communityId.lowercase(),
|
||||
owner = owner.lowercase(),
|
||||
@@ -230,14 +173,11 @@ fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
||||
key = channel.key.lowercase(),
|
||||
epoch = channel.epoch,
|
||||
name = channel.name,
|
||||
// A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the
|
||||
// only reading the bundle supports. The Control fold overrides this for display.
|
||||
private = !channel.key.equals(communityRoot, ignoreCase = true),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Reads the fragment's length-prefixed fields, refusing to run past the end. */
|
||||
private class FragmentReader(private val bytes: ByteArray) {
|
||||
var index = 0
|
||||
private set
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
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
|
||||
@@ -129,10 +113,6 @@ object ConcordVsk {
|
||||
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
|
||||
@@ -148,12 +128,8 @@ object ConcordPermission {
|
||||
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.
|
||||
@@ -162,10 +138,6 @@ object ConcordPermission {
|
||||
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"
|
||||
|
||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.Filter
|
||||
@@ -22,33 +21,7 @@ import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite
|
||||
* turns into membership.
|
||||
*
|
||||
* ## Why this lives on [Nostr]
|
||||
*
|
||||
* The client has exactly **one** notification pump. `client.notifications()` is called once, inside
|
||||
* [Nostr.handleNotifications], and a second consumer would silently split the stream — so Concord
|
||||
* routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]).
|
||||
*
|
||||
* ## The two phases
|
||||
*
|
||||
* [restore] is local and fast: it reads memberships from storage and folds whatever the Control
|
||||
* plane has already cached, so routing is meaningful before anything hits the network. [sync] is
|
||||
* the network half: connect the Community's relays and subscribe to its plane addresses. The pump
|
||||
* calls them in that order, so the first event that arrives already knows where it belongs.
|
||||
*/
|
||||
class ConcordManager(private val nostr: Nostr) {
|
||||
|
||||
/**
|
||||
* Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a
|
||||
* Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17
|
||||
* path, which assumes an ephemeral author and a `p`-tagged recipient.
|
||||
*
|
||||
* Replaced wholesale rather than mutated, and read from the notification pump's thread while a
|
||||
* worker coroutine rewrites it, so the reference is volatile.
|
||||
*/
|
||||
@Volatile
|
||||
private var planes: Map<String, Plane> = emptyMap()
|
||||
|
||||
@@ -81,6 +54,13 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
|
||||
|
||||
/**
|
||||
* True once [restore] has run. An empty [memberships] means the same thing before and after it,
|
||||
* so a screen needs this to tell "joined nothing" from "not loaded yet".
|
||||
*/
|
||||
private val _restored = MutableStateFlow(false)
|
||||
val restored: StateFlow<Boolean> = _restored.asStateFlow()
|
||||
|
||||
private var store: ConcordStore? = null
|
||||
|
||||
/**
|
||||
@@ -94,10 +74,6 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
store = ConcordStore(storage, nostr)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Routing
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/** True when a wrap's author is one of our planes' stream keys. */
|
||||
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
|
||||
|
||||
@@ -138,7 +114,8 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
|
||||
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
|
||||
|
||||
val invite = runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
|
||||
val invite =
|
||||
runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
|
||||
if (invite == null) {
|
||||
println("Concord: a direct invite arrived but its bundle could not be read")
|
||||
return true
|
||||
@@ -149,16 +126,17 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
_directInvites.update { current ->
|
||||
if (current.any { it.communityId.equals(invite.communityId, ignoreCase = true) }) current
|
||||
if (current.any {
|
||||
it.communityId.equals(
|
||||
invite.communityId,
|
||||
ignoreCase = true
|
||||
)
|
||||
}) current
|
||||
else current + invite
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
|
||||
suspend fun restore() {
|
||||
val store = store ?: return
|
||||
@@ -175,6 +153,31 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
)
|
||||
}
|
||||
refreshPlanes()
|
||||
_restored.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every membership and everything derived from it.
|
||||
*
|
||||
* Membership keys are identity-scoped — holding them *is* being in the Community (CORD-02 §2) —
|
||||
* so signing out has to take them with it, or the next identity on this device holds a seat it
|
||||
* never took. The cached rumors go with `Nostr.prune()`; the subscriptions are dropped here,
|
||||
* since nothing else would.
|
||||
*/
|
||||
suspend fun reset() {
|
||||
_memberships.value.forEach { membership ->
|
||||
nostr.client?.unsubscribe(subscriptionId(membership.communityId))
|
||||
}
|
||||
|
||||
store?.clearMemberships()
|
||||
|
||||
_memberships.value = emptyList()
|
||||
_directInvites.value = emptyList()
|
||||
|
||||
folds = emptyMap()
|
||||
unread = emptyMap()
|
||||
|
||||
refreshPlanes()
|
||||
}
|
||||
|
||||
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
|
||||
@@ -190,26 +193,25 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Invites
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining
|
||||
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
|
||||
* (CORD-05 §1: a bundle is passive until the user accepts).
|
||||
*/
|
||||
suspend fun previewInvite(link: String): InvitePreview {
|
||||
val parsed = parseInviteLink(link) ?: throw IllegalArgumentException("That is not a Concord invite link")
|
||||
val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
|
||||
?: throw IllegalArgumentException("The invite link's token is malformed")
|
||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||
|
||||
// The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries
|
||||
// the Community's real relay set (CORD-05 §3).
|
||||
val parsed = parseInviteLink(link)
|
||||
?: throw IllegalArgumentException("That is not a Concord invite link")
|
||||
|
||||
val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
|
||||
?: throw IllegalArgumentException("The invite link's token is malformed")
|
||||
|
||||
|
||||
val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
|
||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
.distinct()
|
||||
|
||||
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
|
||||
|
||||
relays.forEach { relay ->
|
||||
@@ -223,7 +225,10 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
.identifier(parsed.identifier)
|
||||
|
||||
val bundle = client
|
||||
.fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds)
|
||||
.fetchEvents(
|
||||
ReqTarget.manual(relays.associateWith { listOf(filter) }),
|
||||
timeout = 8.seconds
|
||||
)
|
||||
.toVec()
|
||||
.firstOrNull()
|
||||
?: throw IllegalStateException("No invite bundle was found at that link")
|
||||
@@ -239,7 +244,18 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
/** The same preview for a Direct Invite, which arrived with no link to fetch. */
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview = preview(invite, link = null, fallbackRelays = emptyList())
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
||||
preview(invite, link = null, fallbackRelays = emptyList())
|
||||
|
||||
/**
|
||||
* Forgets a Direct Invite the user declined. It was never persisted, so this only clears the
|
||||
* in-memory list — there is nothing on a relay to take back.
|
||||
*/
|
||||
fun dismissDirectInvite(communityIdHex: String) {
|
||||
_directInvites.update { invites ->
|
||||
invites.filterNot { it.communityId.equals(communityIdHex, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
|
||||
suspend fun join(preview: InvitePreview): CommunityState {
|
||||
@@ -249,28 +265,24 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val store = store ?: throw IllegalStateException("Concord storage is not ready")
|
||||
val membership = preview.invite.toMembership(preview.relays)
|
||||
|
||||
val updated = _memberships.value.filterNot { it.communityId == membership.communityId } + membership
|
||||
val updated =
|
||||
_memberships.value.filterNot { it.communityId == membership.communityId } + membership
|
||||
store.saveMemberships(updated)
|
||||
_memberships.value = updated
|
||||
|
||||
refreshPlanes()
|
||||
subscribeCommunity(membership)
|
||||
|
||||
// CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no
|
||||
// Guestbook fold in v1 — the Control plane is what drives the UI.
|
||||
if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
|
||||
|
||||
_directInvites.update { invites ->
|
||||
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
|
||||
}
|
||||
|
||||
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
|
||||
?: CommunityState(membership, null, emptyList())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Reading
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
||||
* keeps it current.
|
||||
@@ -290,10 +302,6 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
publishUnread(channelIdHex, 0)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sends a message to a Channel (CORD-03 §3).
|
||||
*
|
||||
@@ -310,10 +318,13 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
*/
|
||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
|
||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||
val author =
|
||||
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val plane = planes.values.firstOrNull {
|
||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
||||
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
|
||||
val rumor = plane.key.rumor(
|
||||
author = author,
|
||||
@@ -334,8 +345,11 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
|
||||
|
||||
client.sendEvent(event = wrap, target = SendEventTarget.to(relays), ackPolicy = AckPolicy.none())
|
||||
.failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
|
||||
client.sendEvent(
|
||||
event = wrap,
|
||||
target = SendEventTarget.to(relays),
|
||||
ackPolicy = AckPolicy.none()
|
||||
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
|
||||
|
||||
return rumor.toConcordMessage()
|
||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||
@@ -365,10 +379,6 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
publishUnread(channelIdHex, count)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Planes
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rebuilds the plane index and the Community read model from the memberships and folds we hold.
|
||||
*
|
||||
@@ -387,14 +397,10 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val granted = membership.channels.associateBy { it.id }
|
||||
|
||||
if (communityId != null && root != null) {
|
||||
// Control is write-restricted: we hold the read key plus the writers' pubkey, which
|
||||
// is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a
|
||||
// legacy, pre-split Community, whose plane was addressed by the `concord/control`
|
||||
// derivation itself; v1 does not read those, so such a Community shows no metadata.
|
||||
// CORD-02 §5 requires that legacy reading and the first base rotation upgrades it.
|
||||
membership.controlPk?.let { controlPkHex ->
|
||||
if (controlPkHex.hex32() != null) {
|
||||
val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
|
||||
val key =
|
||||
controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
|
||||
next[key.streamPublicKeyHex] = Plane(
|
||||
communityIdHex = membership.communityId,
|
||||
role = PlaneRole.Control,
|
||||
@@ -420,8 +426,6 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
|
||||
val stored = granted[channelIdHex]
|
||||
val channelId = channelIdHex.hex32()
|
||||
// A Public Channel's key *is* the community_root, so one we were never granted is
|
||||
// still derivable; a Private one is not (CORD-03 §1).
|
||||
val secret = stored?.key?.hex32()
|
||||
?: if (meta?.isPrivate != true) root else null
|
||||
val epoch = stored?.epoch ?: membership.rootEpoch
|
||||
@@ -470,11 +474,13 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
/** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
|
||||
private suspend fun refreshControl(communityIdHex: String) {
|
||||
val store = store ?: return
|
||||
val editions = store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
|
||||
val editions =
|
||||
store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
|
||||
folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
|
||||
|
||||
if (communityIdHex in refreshPlanes()) {
|
||||
_memberships.value.firstOrNull { it.communityId == communityIdHex }?.let { subscribeCommunity(it) }
|
||||
_memberships.value.firstOrNull { it.communityId == communityIdHex }
|
||||
?.let { subscribeCommunity(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,12 +496,12 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val id = subscriptionId(membership.communityId)
|
||||
client.unsubscribe(id)
|
||||
|
||||
val relays = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }.distinct()
|
||||
val relays = membership.relays
|
||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
.distinct()
|
||||
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
// Always the Community's own relays, never the app's defaults: Concord reverses NIP-59
|
||||
// (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops
|
||||
// these wraps, and the bootstrap set is tuned for NIP-17.
|
||||
relays.forEach { relay ->
|
||||
client.addRelay(relay)
|
||||
client.connectRelay(relay)
|
||||
@@ -505,7 +511,10 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
.filter { it.communityIdHex == membership.communityId }
|
||||
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
|
||||
.distinct()
|
||||
.map { Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) }
|
||||
.map {
|
||||
Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it))
|
||||
}
|
||||
|
||||
if (filters.isEmpty()) return
|
||||
|
||||
client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
|
||||
@@ -516,14 +525,15 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val client = nostr.client ?: return
|
||||
val communityId = membership.communityId.hex32() ?: return
|
||||
val root = membership.communityRoot.hex32() ?: return
|
||||
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
val author =
|
||||
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
|
||||
// CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what
|
||||
// makes per-link usage counters possible at all.
|
||||
val extraTags = invite?.creatorNpub?.let { creator ->
|
||||
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
|
||||
}.orEmpty()
|
||||
|
||||
val extraTags = invite?.creatorNpub
|
||||
?.let { creator ->
|
||||
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
|
||||
}.orEmpty()
|
||||
|
||||
val rumor = plane.rumor(
|
||||
author = author,
|
||||
@@ -534,11 +544,18 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
)
|
||||
val wrap = plane.wrap(rumor, nostr.signer)
|
||||
|
||||
val targets = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
client.sendEvent(event = wrap, target = SendEventTarget.to(targets), ackPolicy = AckPolicy.none())
|
||||
val targets =
|
||||
membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
|
||||
client.sendEvent(
|
||||
event = wrap,
|
||||
target = SendEventTarget.to(targets),
|
||||
ackPolicy = AckPolicy.none()
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscriptionId(communityIdHex: String): String = "$SUBSCRIPTION_PREFIX$communityIdHex"
|
||||
private fun subscriptionId(communityIdHex: String): String =
|
||||
"$SUBSCRIPTION_PREFIX$communityIdHex"
|
||||
|
||||
private fun preview(
|
||||
invite: CommunityInvite,
|
||||
@@ -546,14 +563,21 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
fallbackRelays: List<String>,
|
||||
): InvitePreview {
|
||||
val relays = invite.relaySet(fallbackRelays)
|
||||
val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
val problems =
|
||||
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
|
||||
return InvitePreview(
|
||||
link = link,
|
||||
invite = invite,
|
||||
relays = relays,
|
||||
problems = problems,
|
||||
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
|
||||
alreadyJoined = _memberships.value.any { it.communityId.equals(invite.communityId, ignoreCase = true) },
|
||||
alreadyJoined = _memberships.value.any {
|
||||
it.communityId.equals(
|
||||
invite.communityId,
|
||||
ignoreCase = true
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,35 +6,11 @@ import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Concord's data model: what we persist about a Community, what an invite carries, and what the
|
||||
* Control fold projects.
|
||||
*
|
||||
* Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon])
|
||||
* keep the spec's snake_case field names because those keys are normative. Types that are ours
|
||||
* alone ([Membership], [StoredChannelKey]) keep Kotlin naming.
|
||||
*
|
||||
* Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to
|
||||
* round-trip fields they do not understand. v1 only ever *reads* these documents, so the models
|
||||
* below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing
|
||||
* them back must preserve what it did not parse.
|
||||
*/
|
||||
internal val concordJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Persisted
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A Community this device has joined, as stored locally. Holding these keys *is* membership
|
||||
* (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store.
|
||||
*
|
||||
* There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key
|
||||
* cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise.
|
||||
*/
|
||||
@Serializable
|
||||
data class Membership(
|
||||
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
|
||||
@@ -59,10 +35,6 @@ data class Membership(
|
||||
val channels: List<StoredChannelKey> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry
|
||||
* it here, which is why [private] can only be a hint — the Control fold is the authority.
|
||||
*/
|
||||
@Serializable
|
||||
data class StoredChannelKey(
|
||||
val id: String,
|
||||
@@ -72,18 +44,6 @@ data class StoredChannelKey(
|
||||
val private: Boolean = false,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Invite bundle (CORD-05 §1)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The `CommunityInvite` bundle: the same document whether it arrives inside a public link's
|
||||
* encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6).
|
||||
*
|
||||
* The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real
|
||||
* Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will
|
||||
* never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it.
|
||||
*/
|
||||
@Serializable
|
||||
data class CommunityInvite(
|
||||
@SerialName("community_id") val communityId: String = "",
|
||||
@@ -111,10 +71,6 @@ data class InviteChannel(
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext
|
||||
* only; a member fetches, decrypts and verifies [hash]. v1 never fetches these.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConcordIcon(
|
||||
val url: String? = null,
|
||||
@@ -123,10 +79,6 @@ data class ConcordIcon(
|
||||
val hash: String? = null,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Control fold
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
|
||||
@Serializable
|
||||
data class CommunityMeta(
|
||||
@@ -149,13 +101,6 @@ data class ChannelMeta(
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B).
|
||||
*
|
||||
* The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate,
|
||||
* `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as
|
||||
* a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization.
|
||||
*/
|
||||
data class ControlEdition(
|
||||
val vsk: Int,
|
||||
val eidHex: String,
|
||||
@@ -173,10 +118,6 @@ data class ControlFold(
|
||||
val channels: Map<String, ChannelMeta>,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Read surface
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
|
||||
data class ConcordChannel(
|
||||
val idHex: String,
|
||||
@@ -225,18 +166,8 @@ data class InvitePreview(
|
||||
val joinable: Boolean get() = problems.isEmpty() && !expired
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix
|
||||
* **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so
|
||||
* every conversion lives here. Returns null when the bundle carries no expiry at all.
|
||||
*/
|
||||
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
|
||||
|
||||
/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */
|
||||
internal fun UnsignedEvent.concordTimestampMs(): Long {
|
||||
val ms = tags().toVec()
|
||||
.firstOrNull { it.kind() == ConcordTag.MS }
|
||||
@@ -246,7 +177,6 @@ internal fun UnsignedEvent.concordTimestampMs(): Long {
|
||||
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
|
||||
}
|
||||
|
||||
/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */
|
||||
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
||||
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
|
||||
return ConcordMessage(
|
||||
|
||||
@@ -15,85 +15,23 @@ import rust.nostr.sdk.nip44Decrypt
|
||||
import rust.nostr.sdk.nip44Encrypt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The CORD-01 wire stack: `wrap → seal → rumor`.
|
||||
*
|
||||
* A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends
|
||||
* is three nested layers:
|
||||
*
|
||||
* ```
|
||||
* wrap kind 1059, signed by the stream key, one ephemeral `p` tag
|
||||
* └ nip44(conv_key) of
|
||||
* seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author
|
||||
* └ nip44(conv_key) of | byte-verbatim
|
||||
* rumor unsigned, its authority is the seal's signature around it
|
||||
* ```
|
||||
*
|
||||
* Both encrypted layers use the *same* conversation key — that is double encryption under one
|
||||
* key, not two, and it is what makes the wrap readable by anyone holding the plane key.
|
||||
*
|
||||
* Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral,
|
||||
* which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these
|
||||
* and why routing happens by author before that code is reached.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the
|
||||
* plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong
|
||||
* one and [unwrap] can reject a seal of the other form instead of quietly accommodating it.
|
||||
*/
|
||||
enum class SealForm(private val kindValue: Int) {
|
||||
/**
|
||||
* Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap,
|
||||
* so no relay — honest or malicious — can retain and display the rumor as a public event.
|
||||
*/
|
||||
Encrypted(ConcordKind.SEAL),
|
||||
|
||||
/**
|
||||
* Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**,
|
||||
* because a signature over ciphertext is bound to the key that encrypted it and would break
|
||||
* if re-wrapped under another key across an epoch change.
|
||||
*/
|
||||
Plaintext(ConcordKind.PLAINTEXT_SEAL);
|
||||
|
||||
val kind: UShort get() = kindValue.toUShort()
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message
|
||||
* into a different Channel or replay it across an epoch.
|
||||
*
|
||||
* The tags live *inside* the author-signed rumor, so the author's signature covers them; the
|
||||
* reader checks them strict-equal against the coordinate whose key opened the wrap.
|
||||
*/
|
||||
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
|
||||
|
||||
/**
|
||||
* One plane's keys and, for a Chat plane, the coordinate its rumors are bound to.
|
||||
*
|
||||
* [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream]
|
||||
* is the address that signs wraps. They are the same key on a normal stream, and differ only on
|
||||
* a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) — where a reader
|
||||
* holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one.
|
||||
*
|
||||
* Deliberately not a `data class`: there is no value equality worth having, and the default
|
||||
* `toString` keeps key material out of logs.
|
||||
*/
|
||||
class PlaneKey(
|
||||
val read: GroupKey,
|
||||
val stream: GroupKey?,
|
||||
/** The x-only hex of the key every wrap on this plane must be signed by. */
|
||||
val streamPublicKeyHex: String,
|
||||
/** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */
|
||||
val form: SealForm,
|
||||
val chat: ChatBinding?,
|
||||
)
|
||||
|
||||
/**
|
||||
* A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret,
|
||||
* Private ones their own independent `channel_key`; the `channel_id` in the derivation is what
|
||||
* gives each Channel a distinct address either way.
|
||||
*/
|
||||
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
|
||||
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
|
||||
return PlaneKey(
|
||||
@@ -105,7 +43,6 @@ fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong
|
||||
)
|
||||
}
|
||||
|
||||
/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */
|
||||
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
|
||||
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
|
||||
return PlaneKey(
|
||||
@@ -117,12 +54,6 @@ fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: U
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held
|
||||
* `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is
|
||||
* whatever the invite claimed — nothing in an invite can prove it, so build nothing
|
||||
* security-relevant on it beyond the subscription address.
|
||||
*/
|
||||
fun controlPlaneKey(
|
||||
communityRoot: ByteArray,
|
||||
communityId: ByteArray,
|
||||
@@ -139,17 +70,6 @@ fun controlPlaneKey(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an unsigned rumor for this plane (CORD-01).
|
||||
*
|
||||
* The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the
|
||||
* very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not
|
||||
* match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is
|
||||
* never tweaked — true time is `created_at * 1000 + ms`.
|
||||
*
|
||||
* A rumor is never signed and never a standalone artifact: its authority is the seal's
|
||||
* signature around it.
|
||||
*/
|
||||
fun PlaneKey.rumor(
|
||||
author: PublicKey,
|
||||
kind: UShort,
|
||||
@@ -172,47 +92,28 @@ fun PlaneKey.rumor(
|
||||
.ensureId()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps [rumor] into a publishable stream event.
|
||||
*
|
||||
* [signer] signs the *seal*, so it must be the real author's signer — the user's own key, which
|
||||
* also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead,
|
||||
* and the seal form comes from the plane itself (CORD-02 §5).
|
||||
*
|
||||
* Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three
|
||||
* layers agree and pagination by wrap timestamp lines up with message ordering.
|
||||
*/
|
||||
suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
|
||||
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
|
||||
val createdAt = rumor.createdAt()
|
||||
|
||||
val rumorJson = rumor.asJson()
|
||||
val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson
|
||||
|
||||
val seal = try {
|
||||
EventBuilder(
|
||||
Kind(form.kind),
|
||||
// CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact
|
||||
// signed bytes forward instead of re-serializing them.
|
||||
if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson,
|
||||
)
|
||||
.customCreatedAt(createdAt)
|
||||
.finalizeAsync(signer)
|
||||
EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
|
||||
}
|
||||
|
||||
// The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch
|
||||
// would produce a message nobody can read. Fail here instead, where the cause is visible.
|
||||
check(seal.author() == rumor.author()) {
|
||||
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}"
|
||||
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${
|
||||
rumor.author().toHex()
|
||||
}"
|
||||
}
|
||||
|
||||
return try {
|
||||
// The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only
|
||||
// its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the
|
||||
// keypair right away is safe. v1 has no giftwrap deletion, which is the one thing
|
||||
// CORD-01 §Deletions would want the secret for.
|
||||
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
|
||||
Keys(stream.secretKey).use { keys ->
|
||||
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
|
||||
@@ -227,19 +128,8 @@ suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a stream event and enforces every check a reader must make, returning null when any
|
||||
* of them fails.
|
||||
*
|
||||
* Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an
|
||||
* impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch`
|
||||
* mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is
|
||||
* rendered before they pass.
|
||||
*/
|
||||
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
|
||||
// The wrap is signed by the stream key. Verifying this is what makes CORD-01's
|
||||
// write-restricted split real: a read-key holder can verify a wrap but cannot mint one.
|
||||
if (event.author().toHex() != streamPublicKeyHex) return null
|
||||
if (!event.verify()) return null
|
||||
|
||||
@@ -248,8 +138,6 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||
}.getOrNull() ?: return null
|
||||
if (!seal.verify()) return null
|
||||
|
||||
// CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form
|
||||
// is a discipline violation, not a variant to accommodate: only the matching pair is accepted.
|
||||
val sealKind = seal.kind().asU16()
|
||||
val rumorJson = when {
|
||||
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
|
||||
@@ -260,19 +148,14 @@ fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val rumor = runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
|
||||
val rumor =
|
||||
runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
|
||||
// NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
|
||||
if (rumor.author() != seal.author()) return null
|
||||
if (!bindsToThisPlane(rumor)) return null
|
||||
return rumor
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match
|
||||
* this plane's coordinate, so neither a re-wrap into another Channel nor a cross-epoch replay
|
||||
* survives. The community-wide Guestbook and Control planes split no sub-context, so the spec
|
||||
* binds nothing there and this is a no-op.
|
||||
*/
|
||||
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
||||
val expected = chat ?: return true
|
||||
val tags = rumor.tags().toVec()
|
||||
@@ -281,20 +164,9 @@ private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
||||
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms`
|
||||
* falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a
|
||||
* negative value.
|
||||
*/
|
||||
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
|
||||
|
||||
/**
|
||||
* NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding).
|
||||
*
|
||||
* Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so
|
||||
* this fails loudly at build time instead of producing an undecryptable message.
|
||||
*/
|
||||
private const val MAX_PLAINTEXT_BYTES = 65_535
|
||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
||||
|
||||
private fun nip44Seal(key: GroupKey, plaintext: String): String {
|
||||
val size = plaintext.encodeToByteArray().size
|
||||
@@ -304,5 +176,4 @@ private fun nip44Seal(key: GroupKey, plaintext: String): String {
|
||||
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
|
||||
}
|
||||
|
||||
/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */
|
||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
||||
private const val MAX_PLAINTEXT_BYTES = 65_535
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Keys
|
||||
@@ -13,19 +11,7 @@ import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.nostr.Nostr
|
||||
|
||||
/**
|
||||
* Concord's local persistence, in the two places the rest of the app already keeps things.
|
||||
*
|
||||
* **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2),
|
||||
* so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go
|
||||
* through plaintext storage, and they never touch LMDB.
|
||||
*
|
||||
* **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way
|
||||
* [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the
|
||||
* Control fold has something to fold before the network answers.
|
||||
*/
|
||||
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
|
||||
suspend fun loadMemberships(): List<Membership> {
|
||||
val raw = try {
|
||||
storage.getSecret(MEMBERSHIPS_KEY)
|
||||
@@ -54,27 +40,28 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the
|
||||
* community id for Control and Guestbook.
|
||||
*
|
||||
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
|
||||
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
|
||||
* message would replace the one before it and history would vanish silently. The wrap id is
|
||||
* that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it —
|
||||
* and it doubles as the dedupe key when a wrap arrives from several relays.
|
||||
*/
|
||||
suspend fun clearMemberships() {
|
||||
try {
|
||||
storage.clear(MEMBERSHIPS_KEY)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Concord: could not clear memberships: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
|
||||
try {
|
||||
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||
.tags(
|
||||
listOf(
|
||||
Tag.identifier(wrapIdHex),
|
||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
||||
val event =
|
||||
EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||
.tags(
|
||||
listOf(
|
||||
Tag.identifier(wrapIdHex),
|
||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
||||
)
|
||||
)
|
||||
)
|
||||
.finalizeAsync(Keys.generate())
|
||||
.finalizeAsync(Keys.generate())
|
||||
|
||||
nostr.client?.database()?.saveEvent(event)
|
||||
} catch (e: CancellationException) {
|
||||
@@ -99,14 +86,16 @@ class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return events
|
||||
.mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() }
|
||||
.mapNotNull {
|
||||
runCatching {
|
||||
UnsignedEvent.fromJson(it.content()).ensureId()
|
||||
}.getOrNull()
|
||||
}
|
||||
.sortedBy { it.createdAt().asSecs() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MEMBERSHIPS_KEY = "concord_memberships"
|
||||
|
||||
/** Single-letter index tags; `r` is what `Filter.reference` queries. */
|
||||
const val INDEX_SCOPE_TAG = "r"
|
||||
const val INDEX_KIND_TAG = "k"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.viewmodel.ErrorHost
|
||||
import su.reya.coop.viewmodel.createErrorHost
|
||||
|
||||
class ConcordRepository(
|
||||
private val nostr: Nostr,
|
||||
private val scope: CoroutineScope,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val concord = nostr.concord
|
||||
|
||||
val communities: StateFlow<List<CommunityState>> = concord.communities
|
||||
|
||||
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = concord.directInvites
|
||||
|
||||
/** True once memberships have been loaded, so an empty list means "joined nothing". */
|
||||
val isReady: StateFlow<Boolean> = concord.restored
|
||||
|
||||
/** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */
|
||||
val revision: StateFlow<Long> = concord.revision
|
||||
|
||||
/** Fetches and decrypts the bundle behind a public invite link. */
|
||||
suspend fun previewInvite(link: String): InvitePreview? = attempt { concord.previewInvite(link) }
|
||||
|
||||
/** The same preview for a Direct Invite, which already arrived with its bundle in hand. */
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
||||
concord.previewDirectInvite(invite)
|
||||
|
||||
/** Accepts an invite: persist the keys, connect, subscribe, announce the join. */
|
||||
suspend fun join(preview: InvitePreview): CommunityState? = attempt { concord.join(preview) }
|
||||
|
||||
/** A Channel's history, oldest first, from the local cache the live subscription keeps current. */
|
||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
||||
attempt { concord.channelMessages(channelIdHex) }.orEmpty()
|
||||
|
||||
suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
|
||||
attempt { concord.sendChannelMessage(channelIdHex, content) }
|
||||
|
||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
||||
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
|
||||
|
||||
fun dismissDirectInvite(communityIdHex: String) = concord.dismissDirectInvite(communityIdHex)
|
||||
|
||||
fun resetInternalState() {
|
||||
scope.launch(defaultDispatcher) { attempt { concord.reset() } }
|
||||
}
|
||||
|
||||
/** Runs one Concord call off the main thread, reporting any failure instead of throwing. */
|
||||
private suspend fun <T> attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) {
|
||||
try {
|
||||
block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.concord.ConcordChannel
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
|
||||
class ChannelScreenViewModel(
|
||||
val communityId: String,
|
||||
val channelId: String,
|
||||
accountRepository: AccountRepository,
|
||||
private val concordRepository: ConcordRepository,
|
||||
) : ViewModel(), ErrorHost by concordRepository {
|
||||
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
|
||||
|
||||
val channel: StateFlow<ConcordChannel?> = concordRepository.communities
|
||||
.map { communities ->
|
||||
communities
|
||||
.firstOrNull { it.membership.communityId == communityId }
|
||||
?.channels
|
||||
?.firstOrNull { it.idHex == channelId }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
val messages = mutableStateListOf<ConcordMessage>()
|
||||
|
||||
private var reloadJob: Job? = null
|
||||
|
||||
init {
|
||||
reload()
|
||||
|
||||
viewModelScope.launch {
|
||||
concordRepository.revision.drop(1).collect { reload() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
reloadJob?.cancel()
|
||||
reloadJob = viewModelScope.launch {
|
||||
val loaded = concordRepository.channelMessages(channelId)
|
||||
if (messages.map { it.idHex } != loaded.map { it.idHex }) {
|
||||
messages.clear()
|
||||
messages.addAll(loaded)
|
||||
}
|
||||
loading = false
|
||||
concordRepository.markChannelRead(channelId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
|
||||
class ConcordViewModel(
|
||||
private val repository: ConcordRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
val communities: StateFlow<List<CommunityState>> = repository.communities
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = repository.directInvites
|
||||
val isReady: StateFlow<Boolean> = repository.isReady
|
||||
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
||||
repository.previewDirectInvite(invite)
|
||||
|
||||
suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link)
|
||||
|
||||
suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview)
|
||||
|
||||
fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex)
|
||||
|
||||
fun resetInternalState() = repository.resetInternalState()
|
||||
}
|
||||
Reference in New Issue
Block a user