update concord backend
This commit is contained in:
@@ -12,10 +12,10 @@ import rust.nostr.sdk.SecretKey
|
||||
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
|
||||
*
|
||||
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
|
||||
* rather than loudly — so each function quotes the CORD section that governs it, and
|
||||
* `ConcordCryptoTest` pins the output against RFC 5869 vectors and independently computed
|
||||
* digests. Concord ships no test vectors of its own ("Examples are illustrative, not
|
||||
* verifiable test vectors").
|
||||
* 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
|
||||
@@ -27,7 +27,7 @@ import rust.nostr.sdk.SecretKey
|
||||
*
|
||||
* Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32
|
||||
* zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer
|
||||
* vectors — which do use a salt — can be used as tests; Concord publishes none.
|
||||
* 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.
|
||||
*/
|
||||
@@ -43,7 +43,7 @@ fun hkdfSha256(
|
||||
// Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen
|
||||
// (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the
|
||||
// two are literally the same key. Substituting is exact, not a workaround; the RFC's own
|
||||
// zero-length-salt vector is asserted against it in ConcordCryptoTest.
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -56,6 +56,21 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
@Volatile
|
||||
private var folds: Map<String, ControlFold> = emptyMap()
|
||||
|
||||
/**
|
||||
* Unread badges by Channel id. Replaced wholesale; see [planes]. In memory only, mirroring
|
||||
* `Room.unreadCount` in the chat layer, so a restart begins with every badge cleared.
|
||||
*/
|
||||
@Volatile
|
||||
private var unread: Map<String, Int> = emptyMap()
|
||||
|
||||
/**
|
||||
* Bumped whenever a plane rumor is cached or a message is sent, so a screen showing a Channel
|
||||
* has something to re-query on. A counter rather than a set of changed scopes, because two
|
||||
* messages to the same Channel must both be observable.
|
||||
*/
|
||||
private val _revision = MutableStateFlow(0L)
|
||||
val revision: StateFlow<Long> = _revision.asStateFlow()
|
||||
|
||||
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
|
||||
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
|
||||
|
||||
@@ -99,6 +114,12 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val store = store ?: return
|
||||
|
||||
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
|
||||
_revision.update { it + 1 }
|
||||
|
||||
// A message from someone else is the only thing a badge counts. Our own wraps come back
|
||||
// through the same subscription and so does a re-fetch, so the check is on the rumor's
|
||||
// author rather than on the arrival.
|
||||
if (plane.role == PlaneRole.Channel) countUnread(plane.scopeIdHex, rumor)
|
||||
|
||||
// Control carries consensus state, so every edition changes what we can read: a new
|
||||
// Channel means a new plane address, and therefore a new subscription.
|
||||
@@ -259,6 +280,91 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
.mapNotNull { it.toConcordMessage() }
|
||||
.sortedBy { it.timestampMs }
|
||||
|
||||
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
|
||||
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
|
||||
|
||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
||||
fun markChannelRead(channelIdHex: String) {
|
||||
if ((unread[channelIdHex] ?: 0) == 0) return
|
||||
unread = unread - channelIdHex
|
||||
publishUnread(channelIdHex, 0)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sends a message to a Channel (CORD-03 §3).
|
||||
*
|
||||
* The send *is* [PlaneKey.rumor] plus [PlaneKey.wrap], with nothing in between: the rumor carries
|
||||
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that
|
||||
* key's stream half, so a message cannot be built for a coordinate it will not verify at.
|
||||
*
|
||||
* The message is cached locally *before* it is published, so it is visible even if every relay is
|
||||
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
|
||||
* duplicating it.
|
||||
*
|
||||
* Returns the message as a reader will see it. Throws when the Channel is hidden behind a key we
|
||||
* were never granted — a Private Channel is listable without being writable.
|
||||
*/
|
||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
|
||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||
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,
|
||||
kind = ConcordKind.MESSAGE.toUShort(),
|
||||
content = content,
|
||||
createdAt = Clock.System.now(),
|
||||
)
|
||||
val wrap = plane.key.wrap(rumor, nostr.signer)
|
||||
|
||||
store?.cacheRumor(plane.scopeIdHex, wrap.id().toHex(), rumor)
|
||||
_revision.update { it + 1 }
|
||||
|
||||
// Always the Community's own relays, never the app's defaults (see [subscribeCommunity]).
|
||||
val relays = _memberships.value
|
||||
.firstOrNull { it.communityId == plane.communityIdHex }
|
||||
?.relays
|
||||
.orEmpty()
|
||||
.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") }
|
||||
|
||||
return rumor.toConcordMessage()
|
||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||
}
|
||||
|
||||
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
|
||||
private fun publishUnread(channelIdHex: String, count: Int) {
|
||||
_communities.update { states ->
|
||||
if (states.none { state -> state.channels.any { it.idHex == channelIdHex } }) return@update states
|
||||
states.map { state ->
|
||||
state.copy(
|
||||
channels = state.channels.map {
|
||||
if (it.idHex == channelIdHex) it.copy(unreadCount = count) else it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts one incoming message against its Channel, ignoring our own and anything but a message. */
|
||||
private fun countUnread(channelIdHex: String, rumor: UnsignedEvent) {
|
||||
if (rumor.kind().asU16() != ConcordKind.MESSAGE.toUShort()) return
|
||||
if (rumor.author() == nostr.signer.publicKeyFlow.value) return
|
||||
|
||||
val count = (unread[channelIdHex] ?: 0) + 1
|
||||
unread = unread + (channelIdHex to count)
|
||||
publishUnread(channelIdHex, count)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Planes
|
||||
// -----------------------------------------------------------------------------------------
|
||||
@@ -337,6 +443,9 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
private = meta?.isPrivate ?: stored?.private ?: false,
|
||||
epoch = epoch,
|
||||
hasKey = plane != null,
|
||||
// Carried across the re-index rather than recomputed: a Control edition must not
|
||||
// silently clear every badge, the same way ChatRepository preserves its counters.
|
||||
unreadCount = unread[channelIdHex] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,11 @@ data class ConcordChannel(
|
||||
val epoch: ULong,
|
||||
/** False for a Private Channel we were never granted — listable, but not readable. */
|
||||
val hasKey: Boolean,
|
||||
/**
|
||||
* Messages from others since this Channel was last opened. In memory only, exactly like
|
||||
* `Room.unreadCount`: it survives a Control re-fold, but not a restart.
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
)
|
||||
|
||||
/** A Community with its Control fold applied. */
|
||||
|
||||
Reference in New Issue
Block a user