add concord crypto
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user