add concord plan
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.Nip44Version
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
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(
|
||||
read = key,
|
||||
stream = key,
|
||||
streamPublicKeyHex = key.publicKey.toHex(),
|
||||
form = SealForm.Encrypted,
|
||||
chat = ChatBinding(channelId.toHex(), epoch),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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(
|
||||
read = key,
|
||||
stream = key,
|
||||
streamPublicKeyHex = key.publicKey.toHex(),
|
||||
form = SealForm.Encrypted,
|
||||
chat = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
epoch: ULong,
|
||||
streamPublicKeyHex: String,
|
||||
): PlaneKey {
|
||||
val key = groupKey(ConcordLabel.CONTROL, communityRoot, communityId, epoch)
|
||||
return PlaneKey(
|
||||
read = key,
|
||||
stream = null,
|
||||
streamPublicKeyHex = streamPublicKeyHex,
|
||||
form = SealForm.Plaintext,
|
||||
chat = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
content: String,
|
||||
createdAt: Instant,
|
||||
extraTags: List<Tag> = emptyList(),
|
||||
): UnsignedEvent {
|
||||
val tags = buildList {
|
||||
chat?.let {
|
||||
add(Tag.custom(ConcordTag.CHANNEL, listOf(it.channelIdHex)))
|
||||
add(Tag.custom(ConcordTag.EPOCH, listOf(it.epoch.toString())))
|
||||
}
|
||||
add(Tag.custom(ConcordTag.MS, listOf(msOf(createdAt).toString())))
|
||||
addAll(extraTags)
|
||||
}
|
||||
return EventBuilder(Kind(kind), content)
|
||||
.tags(tags)
|
||||
.customCreatedAt(Timestamp.fromSecs(createdAt.epochSeconds.toULong()))
|
||||
.finalizeUnsigned(author)
|
||||
.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 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)
|
||||
} 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()}"
|
||||
}
|
||||
|
||||
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()))
|
||||
.tags(listOf(ephemeral))
|
||||
.customCreatedAt(createdAt)
|
||||
.finalize(keys)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Concord: failed to wrap seal: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
val seal = runCatching {
|
||||
Event.fromJson(nip44Decrypt(read.secretKey, read.publicKey, event.content()))
|
||||
}.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 {
|
||||
nip44Decrypt(read.secretKey, read.publicKey, seal.content())
|
||||
}.getOrNull() ?: return null
|
||||
// Plaintext seal: the rumor's bytes are already the content, never re-parsed as an event.
|
||||
sealKind == SealForm.Plaintext.kind && form == SealForm.Plaintext -> seal.content()
|
||||
else -> 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()
|
||||
val channel = tags.firstOrNull { it.kind() == ConcordTag.CHANNEL }?.content()
|
||||
val epoch = tags.firstOrNull { it.kind() == ConcordTag.EPOCH }?.content()
|
||||
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 nip44Seal(key: GroupKey, plaintext: String): String {
|
||||
val size = plaintext.encodeToByteArray().size
|
||||
require(size <= MAX_PLAINTEXT_BYTES) {
|
||||
"Concord: NIP-44 plaintext is $size bytes, over the $MAX_PLAINTEXT_BYTES cap"
|
||||
}
|
||||
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()
|
||||
Reference in New Issue
Block a user