add concord crypto

This commit is contained in:
2026-09-15 07:19:58 +07:00
parent e49168851e
commit e221eda546
3 changed files with 615 additions and 32 deletions
@@ -0,0 +1,239 @@
package su.reya.coop.concord
import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ByteString.Companion.toByteString
import rust.nostr.sdk.Keys
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.SecretKey
/**
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
*
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
* rather than loudly — so each function quotes the CORD section that governs it, and
* `ConcordCryptoTest` pins the output against RFC 5869 vectors and independently computed
* digests. Concord ships no test vectors of its own ("Examples are illustrative, not
* verifiable test vectors").
*
* Only HMAC-SHA256 and SHA-256 come from outside: both are Okio `ByteString` members that
* are available on every target this module builds for, so no new crypto dependency is
* needed. Everything else is composition of the existing nostr SDK.
*/
/**
* HKDF-SHA256 (RFC 5869), Extract then Expand.
*
* Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32
* zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer
* vectors — which do use a salt — can be used as tests; Concord publishes none.
*
* @param length output length in octets, `1..255 * 32` per RFC 5869.
*/
fun hkdfSha256(
ikm: ByteArray,
info: ByteArray,
salt: ByteArray = ByteArray(0),
length: Int = 32,
): ByteArray {
require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
// Extract: PRK = HMAC-SHA256(salt, IKM). Note IKM is the HMAC *message*, not the key.
// Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen
// (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the
// two are literally the same key. Substituting is exact, not a workaround; the RFC's own
// zero-length-salt vector is asserted against it in ConcordCryptoTest.
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
val prk = ikm.toByteString().hmacSha256(saltKey)
// Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet.
val out = Buffer()
var t = ByteString.EMPTY
var counter = 1
while (out.size < length) {
t = Buffer()
.write(t)
.write(info)
.writeByte(counter)
.readByteString()
.hmacSha256(prk)
out.write(t)
counter++
}
return out.readByteArray(length.toLong())
}
/**
* Builds the HKDF `info` for a Concord label (CORD-02 A.1):
*
* ```
* info = utf8(label) | 0x00 | id[32] | epoch_be[8] // epoch omitted for labels marked "—"
* ```
*
* [id] is always present and always 32 bytes, all-zeroes where a label has no meaningful
* id. The epoch is the only omittable field.
*/
fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
return Buffer().apply {
writeUtf8(label)
writeByte(0)
write(id)
if (epoch != null) writeLong(epoch.toLong())
}.readByteArray()
}
/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
/**
* The secret-key material of a plane's group key: CORD-02 A.2's `group_key` up to and
* including A.3's `scalar_normalize`.
*
* ```
* info = hkdfInfo(label, id, epoch)
* seed = hkdf(secret, info)
* while (!isValidScalar(seed)) { info = info | counter++; seed = hkdf(secret, info) }
* ```
*
* A.3 only bites when the HKDF output is not a secp256k1 scalar, which is ~2⁻¹²⁸ rare, so
* [isValid] exists as a seam for tests; production callers pass the default.
*
* This is deliberately split from [groupKey]: it is pure byte manipulation and so is
* unit-testable, whereas the secp256k1 half needs the nostr SDK, whose native library
* cannot be loaded by a host JVM unit test (see PLAN.md §13.2).
*/
fun groupSeed(
label: String,
secret: ByteArray,
id: ByteArray,
epoch: ULong? = null,
isValid: (ByteArray) -> Boolean = ::isValidScalar,
): ByteArray {
val base = hkdfInfo(label, id, epoch)
var counter = -1 // -1 means "no counter byte", i.e. the first attempt
while (counter <= 255) {
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
val seed = hkdfSha256(secret, info)
if (isValid(seed)) return seed
counter++ // A.3: the counter starts at 0 on the first retry
}
error("Concord group_key: scalar_normalize exhausted for label $label")
}
/**
* A secp256k1 secret key is any integer in `[1, n-1]`, so CORD-02 A.3's validity test rejects
* exactly the all-zeroes seed and any seed not below the group order.
*/
fun isValidScalar(seed: ByteArray): Boolean {
if (seed.size != 32) return false
var anyNonZero = false
for (byte in seed) {
if (byte != 0.toByte()) {
anyNonZero = true
break
}
}
if (!anyNonZero) return false
for (i in 0 until 32) {
val candidate = seed[i].toInt() and 0xff
val order = SECP256K1_ORDER[i].toInt() and 0xff
if (candidate != order) return candidate < order
}
return false // exactly n, also out of range
}
private val SECP256K1_ORDER =
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
/**
* `group_key` (CORD-02 A.2): a plane's keypair, `(scalar_normalize(seed), xonly_pubkey(sk))`.
*
* The `conv_key` of A.2 needs no implementation of its own — it *is* the NIP-44 conversation
* key, which the SDK derives from the pair returned here, so nothing here hand-rolls ECDH.
*/
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
return GroupKey(secretKey, Keys(secretKey).publicKey())
}
/**
* `community_id` (CORD-02 A.4), which is also the community's self-certification:
*
* ```
* community_id = sha256( utf8("concord/community") | owner_xonly[32] | owner_salt[32] )
* ```
*
* Note this is a plain SHA-256 commitment with **no** `0x00` separator and no length
* prefix — it is deliberately *not* the HKDF construction of A.1, despite looking like it.
*/
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" }
require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" }
return Buffer().apply {
writeUtf8(ConcordLabel.COMMUNITY)
write(ownerXonly)
write(ownerSalt)
}.readByteString().sha256().toByteArray()
}
/**
* `prevcommit` (CORD-02 A.8) — the commitment to the previous epoch's key, published when
* an epoch rolls so that members can verify the rotation chained from what they held:
*
* ```
* prevcommit = sha256( utf8("concord/epoch-key-commitment") | prev_epoch_be[8] | prev_key[32] )
* ```
*/
fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
return Buffer().apply {
writeUtf8(ConcordLabel.EPOCH_KEY_COMMITMENT)
writeLong(prevEpoch.toLong())
write(prevKey)
}.readByteString().sha256().toByteArray()
}
/**
* `edition_hash` (CORD-02 A.8) — links a Control edition to its predecessor, so a client
* folding the Control plane can detect a rewritten chain:
*
* ```
* edition_hash = sha256(
* len64(label) | label // label = ConcordLabel.EDITION_HASH
* | entity_id[32]
* | version_be[8]
* | (prev ? 0x01 | prev[32] : 0x00 | zero[32])
* | len64(content) | content ) // content bytes verbatim, never re-serialized
* ```
*
* [content] must be the exact bytes that were signed — re-serializing the JSON would change
* the hash.
*/
fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray {
require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" }
require(prev == null || prev.size == 32) { "prev must be 32 bytes" }
val label = ConcordLabel.EDITION_HASH.encodeToByteArray()
return Buffer().apply {
writeLong(label.size.toLong())
write(label)
write(entityId)
writeLong(version.toLong())
if (prev != null) {
writeByte(1)
write(prev)
} else {
writeByte(0)
write(ByteArray(32))
}
writeLong(content.size.toLong())
write(content)
}.readByteString().sha256().toByteArray()
}
/** 64 lowercase hex chars, the encoding CORD-01 mandates for every 32-byte value on the wire. */
internal fun ByteArray.toHex(): String = toByteString().hex()
/** Inverse of [toHex]. Throws on odd length or a non-hex character. */
internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray()
@@ -0,0 +1,282 @@
package su.reya.coop.concord
/**
* Frozen constants from the Concord specification.
*
* Concord is defined by the CORD documents (github.com/concord-protocol/concord); the
* numbers, labels and permission bits below are normative and must match byte for byte
* or nothing interoperates. They are collected in this one file so that a spec revision
* is a one-file change and so that no literal kind number ever appears at a call site.
*
* References are to the CORD section that defines each value. See `PLAN.md` Appendix A
* for the same tables with prose.
*/
/**
* Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5,
* CORD-02 Appendix B).
*/
object ConcordKind {
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
const val WRAP = 1059
/** Ephemeral wrap for kinds that must never be stored (typing, voice presence). */
const val EPHEMERAL_WRAP = 21059
/**
* Sealed content for everything that must stay secret: Chat, Guestbook, rekey blobs
* and Direct Invites. CORD-02 §5 makes this choice normative, never stylistic.
*/
const val SEAL = 20013
/**
* Plaintext seal. **Control plane only** (CORD-02 §5) — the roster is public by design
* so that membership, and therefore authority, is verifiable by anyone.
*/
const val PLAINTEXT_SEAL = 20014
/** Channel message (NIP-C7 shape). Chat plane. */
const val MESSAGE = 9
/** Threaded reply (NIP-22 shape). Chat plane. */
const val REPLY = 1111
/** Reaction (NIP-25 shape). Chat plane. */
const val REACTION = 7
/** Delete (NIP-09 shape). Chat plane. */
const val DELETE = 5
/** Disappearing-message timer notice (CORD-08 §4). Chat plane. */
const val TIMER_NOTICE = 1740
/** Edit. Chat plane. */
const val EDIT = 3302
/** Rekey blobs (CORD-06). Rekey addresses. */
const val REKEY = 3303
/** Join / Leave. Guestbook plane. */
const val JOIN_LEAVE = 3306
/** Control edition — sub-kinded by [ConcordVsk]. Control plane. */
const val CONTROL_EDITION = 3308
/** Kick. Guestbook plane. */
const val KICK = 3309
/** WebXDC peer signal. Chat plane. */
const val WEBXDC_SIGNAL = 3310
/** Guestbook snapshot, chunked, refounder-signed. Guestbook plane. */
const val GUESTBOOK_SNAPSHOT = 3312
/** Typing indicator. Chat plane, ephemeral. */
const val TYPING = 23311
/** Voice presence (CORD-07). Chat plane, ephemeral. */
const val VOICE_PRESENCE = 23313
/** Direct Invite — giftwrapped straight to an npub. Rides a standard wrap, not a stream. */
const val DIRECT_INVITE = 3313
/** Public invite bundle. Addressable, signed by its per-link keypair at an empty `d`. */
const val INVITE_BUNDLE = 33301
/** Community List — one addressable event per fragment, NIP-44 to self. */
const val COMMUNITY_LIST = 33302
/** Invite List — replaceable, NIP-44 to self. */
const val INVITE_LIST = 13303
}
/** Control edition sub-kinds, the `vsk` tag on [ConcordKind.CONTROL_EDITION] (CORD-02 Appendix A). */
object ConcordVsk {
/** Community metadata. */
const val METADATA = 0
/** Role. */
const val ROLE = 1
/** Channel metadata — the only other edition v1 folds. */
const val CHANNEL = 2
/** Grant. */
const val GRANT = 3
/** Banlist. */
const val BANLIST = 4
/** Reserved for role ordering. */
const val RESERVED_ROLE_ORDER = 5
/** Claimed by the invite bundle's live marker. */
const val INVITE_LIVE = 6
/** Retired (was the v1 owner attestation). */
const val RETIRED_OWNER_ATTESTATION = 7
/** Invite-link registry. */
const val INVITE_REGISTRY = 8
/** Claimed by the invite bundle's revocation tombstone. */
const val INVITE_TOMBSTONE = 9
/** Dissolved tombstone — chainless, exempt from version discipline. */
const val DISSOLVED = 10
/** Pin List. */
const val PIN_LIST = 11
}
/**
* Permission bits (CORD-04 §3). Rank ordering is separate: `position` orders authority and
* **lower is higher**, with the owner at position 0.
*/
object ConcordPermission {
const val MANAGE_ROLES = 1 shl 0
const val MANAGE_CHANNELS = 1 shl 1
const val MANAGE_METADATA = 1 shl 2
const val KICK = 1 shl 3
const val BAN = 1 shl 4
const val MANAGE_MESSAGES = 1 shl 5
const val CREATE_INVITE = 1 shl 6
/** Retired (was `MANAGE_INVITES`). Never grant it. */
const val RETIRED_MANAGE_INVITES = 1 shl 7
const val VIEW_AUDIT_LOG = 1 shl 8
const val MENTION_EVERYONE = 1 shl 9
// 1 shl 10 is reserved.
const val PIN_MESSAGES = 1 shl 11
// 1 shl 12 is reserved.
/**
* Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold
* `control_root` and can therefore write to the Control plane.
*/
const val STAFF = MANAGE_ROLES or MANAGE_CHANNELS or MANAGE_METADATA or
BAN or CREATE_INVITE or PIN_MESSAGES
}
/**
* HKDF label registry (CORD-02 Appendix A.6). The label is the *first* field of the HKDF
* `info` (see `hkdfInfo`), so these strings are on the wire and must not be edited.
*/
object ConcordLabel {
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
const val CHANNEL = "concord/channel"
/** Control Plane **read** key. `secret` = `community_root`. */
const val CONTROL = "concord/control"
/** Control Plane signer, held by staff only. `secret` = `control_root`. */
const val CONTROL_SIGNER = "concord/control-signer"
/** Guestbook Plane group key. `secret` = `community_root`. */
const val GUESTBOOK = "concord/guestbook"
/** Dissolution tombstone address. `secret` = `community_id`, id = 32 zero bytes, no epoch. */
const val DISSOLVED = "concord/dissolved"
/** A channel rekey address. `secret` = the prior `community_root`. */
const val REKEY_PSEUDONYM = "concord/rekey-pseudonym"
/** A base (community-wide) rekey address. `secret` = the prior `community_root`. */
const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym"
/** A rekey blob locator. `secret` = `rotator_xonly ‖ recipient_xonly`. */
const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym"
/** SFU room keypair (CORD-07). */
const val VOICE_SIGNER = "concord/voice-signer"
/** 32-byte call media key (CORD-07). */
const val VOICE_MEDIA = "concord/voice-media"
/** Per-sender frame key (CORD-07). id = `sha256(identity)`, no epoch. */
const val VOICE_SENDER = "concord/voice-sender"
/** A member's Grant coordinate. no epoch. */
const val GRANT = "concord/grant"
/** Banlist coordinate. no epoch. */
const val BANLIST = "concord/banlist"
/** A Channel's Pin List coordinate. no epoch. */
const val PINS = "concord/pins"
/** A creator's invite-link registry coordinate. no epoch. */
const val INVITE_LINKS = "concord/invite-links"
/** Public-invite decrypt key. `secret` = the link's unlock token, no epoch. */
const val INVITE_KEY = "concord/invite-key"
/**
* Prefix for the `community_id` commitment (CORD-02 A.4). Not an HKDF label — it is
* hashed directly, with no `0x00` separator and no length prefix.
*/
const val COMMUNITY = "concord/community"
/** Prefix for `prevcommit` (CORD-02 A.8). Also hashed directly, not through HKDF. */
const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment"
/**
* Label for `edition_hash` (CORD-02 A.8).
*
* Deliberately **not** under the `concord/` prefix: the spec pins the reference
* implementation's own `vector` label here. Reproduce it exactly.
*/
const val EDITION_HASH = "vector-community/v1/edition"
}
/** Tag names (CORD-01, CORD-02 §5 and the per-kind sections). */
object ConcordTag {
/** `["ms", "<0..999>"]` — sub-second ordering. True time = `created_at * 1000 + ms`. */
const val MS = "ms"
/** `["p", "<ephemeral pubkey>"]` — the one tag a stream wrap carries (NIP-59 reversed). */
const val P = "p"
/** `["channel", "<channel_id>"]` — MUST also be committed inside the author-signed rumor. */
const val CHANNEL = "channel"
/** `["epoch", "<n>"]` — MUST also be committed inside the author-signed rumor. */
const val EPOCH = "epoch"
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
const val QUOTE = "q"
/** `["vsk", "<n>"]` — Control edition entity type. */
const val VSK = "vsk"
/** `["eid", "<hex32>"]` — stable coordinate of a Control edition's entity. */
const val EID = "eid"
/** `["ev", "<n>"]` — this edition's version, climbing from 1. */
const val EV = "ev"
/** `["ep", "<hash hex>"]` — previous edition hash. Absent on the first edition. */
const val EP = "ep"
/** `["vac", "<grant eid>", "<version>", "<hash>"]` — authority citation. Absent when the owner acts. */
const val VAC = "vac"
/** `["expiration", "<secs>"]` — NIP-40. MUST be on both wrap and rumor, same value. */
const val EXPIRATION = "expiration"
/** `["d", ""]` / `["d", "<index>"]` — addressable coordinate. */
const val D = "d"
/** `["snap", "<id>", "<i>", "<n>"]` — Guestbook snapshot chunk. */
const val SNAP = "snap"
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
const val INVITE = "invite"
/** `["k", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */
const val K = "k"
}