diff --git a/README.md b/README.md index 39aa51f7..a585d426 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,10 @@ Stored admission depends on relay availability and retention. A group link and its encrypted envelope provide durable access to epoch 0, including retained history. Replacing the link asks cooperative clients to refuse new admission; it cannot revoke copies of the key. Managed member removal, later-epoch recovery -and mobile push are separate features. See the published +and mobile push are separate features. An epoch request carries an admission +proof under the epoch-0 room key (the `epochRequestAdmission` vectors), and the +creator's recovery responder refuses a request without one, so a stranger who +reads a room id and its authority off a public rekey is never answered. See the published [persistent group contract](https://github.com/forgesworn/kithmoot/blob/171de0a0e697add5d7ca0793b6f3980f4242b50c/docs/persistent-groups.md). The home screen lists rooms saved on this device, with local names, search, diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/epoch/EpochRecoveryResponder.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/epoch/EpochRecoveryResponder.kt index 0385342b..33485c63 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/epoch/EpochRecoveryResponder.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/epoch/EpochRecoveryResponder.kt @@ -12,14 +12,17 @@ class EpochRecoveryResponder( private val vault: EpochVault, private val stableRoom: String, authoritySecretKey: ByteArray, + /** The epoch-0 room key: what a request has to prove it holds before it is answered. */ + roomKey: ByteArray, private val policy: RoomPolicy?, private val now: () -> Long, ) { private val authoritySecretKey = authoritySecretKey.copyOf() + private val roomKey = roomKey.copyOf() private val authority = Schnorr.publicKeyHex(authoritySecretKey) fun answer(event: NostrEvent): NostrEvent? { - val request = decodeEpochRequest(event, stableRoom, authoritySecretKey, now(), policy) ?: return null + val request = decodeEpochRequest(event, stableRoom, authoritySecretKey, roomKey, now(), policy) ?: return null val durable = vault.get(stableRoom) ?: return null require(durable.authority == authority) { "room authority conflicts with the recovery signer" } val refused = when { diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt index 5172fa2b..a76915d1 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt @@ -893,7 +893,7 @@ class RoomSession( val response = try { coroutineScope { val request = encodeEpochRequest( - room.roomId, trusted, identity.deviceSecretKey, identity.credential, now(), proof, + room.roomId, trusted, room.roomKey, identity.deviceSecretKey, identity.credential, now(), proof, ) val answer = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(EPOCH_RECOVERY_TIMEOUT_MS) { diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/ui/RoomViewModel.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/ui/RoomViewModel.kt index 391fac9b..99492e49 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/ui/RoomViewModel.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/ui/RoomViewModel.kt @@ -2564,7 +2564,7 @@ class RoomViewModel(application: Application) : AndroidViewModel(application) { it.delegation.isEmpty() && record.authority == Schnorr.publicKeyHex(it.inviterSecretKey) } val epochResponder = epochAuthorityHost?.let { - EpochRecoveryResponder(roomEpochs, record.id, it.inviterSecretKey, record.policy, ::epochSeconds) + EpochRecoveryResponder(roomEpochs, record.id, it.inviterSecretKey, derived.roomKey, record.policy, ::epochSeconds) } val summaries = savedRooms.list() _start.update { it.copy(savedRooms = summaries) } diff --git a/app/src/test/kotlin/dev/forgesworn/kithmoot/epoch/EpochVaultTest.kt b/app/src/test/kotlin/dev/forgesworn/kithmoot/epoch/EpochVaultTest.kt index d8f641e9..f53ebc29 100644 --- a/app/src/test/kotlin/dev/forgesworn/kithmoot/epoch/EpochVaultTest.kt +++ b/app/src/test/kotlin/dev/forgesworn/kithmoot/epoch/EpochVaultTest.kt @@ -118,9 +118,10 @@ class EpochVaultTest { "aa".repeat(32), null, 101, ) vault.activate(room, 2, 102) - val responder = EpochRecoveryResponder(EpochVault(storage), room, authoritySecret, null) { 103 } + val roomKey = dev.forgesworn.kithmoot.protocol.deriveRoom(initial).roomKey + val responder = EpochRecoveryResponder(EpochVault(storage), room, authoritySecret, roomKey, null) { 103 } - val retainedRequest = encodeEpochRequest(room, authorityPubkey, retained.deviceSecretKey, retained.credential, 103) + val retainedRequest = encodeEpochRequest(room, authorityPubkey, roomKey, retained.deviceSecretKey, retained.credential, 103) val retainedAnswer = requireNotNull(responder.answer(retainedRequest)) val current = assertIs( decodeEpochGrant(retainedAnswer, room, authorityPubkey, retained.deviceSecretKey, retainedRequest.id, 103), @@ -128,7 +129,7 @@ class EpochVaultTest { assertEquals(2, current.epoch) assertArrayEquals(successor, current.secret) - val removedRequest = encodeEpochRequest(room, authorityPubkey, removed.deviceSecretKey, removed.credential, 103) + val removedRequest = encodeEpochRequest(room, authorityPubkey, roomKey, removed.deviceSecretKey, removed.credential, 103) val removedAnswer = requireNotNull(responder.answer(removedRequest)) assertEquals( EpochGrant.Refused("removed"), @@ -136,7 +137,7 @@ class EpochVaultTest { ) vault.terminal(room, 2, RekeyNotice(3, emptyList(), null, true, null, 104), "bb".repeat(32), 104) - val closedRequest = encodeEpochRequest(room, authorityPubkey, retained.deviceSecretKey, retained.credential, 104) + val closedRequest = encodeEpochRequest(room, authorityPubkey, roomKey, retained.deviceSecretKey, retained.credential, 104) val closedAnswer = requireNotNull(responder.answer(closedRequest)) assertEquals( EpochGrant.Refused("closed"), diff --git a/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt b/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt index 619cebd5..8dfa7a68 100644 --- a/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt +++ b/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt @@ -159,7 +159,7 @@ class RoomEpochTransitionTest { stable, identity, relay, authority = authority, epochSettleMs = 1_500, epochGate = { _, notice -> committed += notice; EpochGateResult.COMMITTED }, epochResponder = { event -> - val request = decodeEpochRequest(event, stable.roomId, authoritySecret, 0) ?: return@session null + val request = decodeEpochRequest(event, stable.roomId, authoritySecret, stable.roomKey, 0) ?: return@session null encodeEpochGrant( stable.roomId, authoritySecret, request.device, request.request, 0, epoch = granted, removed = listOf("55".repeat(32)), @@ -196,7 +196,7 @@ class RoomEpochTransitionTest { stable, identity, relay, authority = authority, epochGate = { _, _ -> EpochGateResult.COMMITTED }, epochResponder = { event -> - val request = decodeEpochRequest(event, stable.roomId, authoritySecret, 0) ?: return@session null + val request = decodeEpochRequest(event, stable.roomId, authoritySecret, stable.roomKey, 0) ?: return@session null encodeEpochGrant( stable.roomId, authoritySecret, request.device, request.request, 0, refused = "removed", ) diff --git a/protocol/src/main/kotlin/dev/forgesworn/kithmoot/protocol/Rekey.kt b/protocol/src/main/kotlin/dev/forgesworn/kithmoot/protocol/Rekey.kt index b335bdb5..ac200348 100644 --- a/protocol/src/main/kotlin/dev/forgesworn/kithmoot/protocol/Rekey.kt +++ b/protocol/src/main/kotlin/dev/forgesworn/kithmoot/protocol/Rekey.kt @@ -19,6 +19,7 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import java.security.MessageDigest const val KIND_ROOM_REKEY = 1462 const val KIND_EPOCH_REQUEST = 20_468 @@ -28,6 +29,9 @@ const val EPOCH_MAX_AGE_SECONDS = 90L private const val EPOCH_ID_INFO = "kithmoot/v1/epoch-id" private const val EPOCH_KEY_INFO = "kithmoot/v1/epoch-key" +/** HKDF info for the key an epoch request's admission proof is made under: its own domain, like the media key's. */ +private const val EPOCH_REQUEST_KEY_INFO = "kithmoot/v1/epoch-request-key" +private const val EPOCH_REQUEST_MESSAGE = "kithmoot/v1/epoch-request:" private val HEX64 = Regex("[0-9a-fA-F]{64}") private val EPOCH_TAG = Regex("^[1-9][0-9]{0,6}$") @@ -172,9 +176,42 @@ fun decodeRekeyEvent( } finally { secret?.fill(0) } }.getOrNull() +/** + * The key an epoch request's admission proof is computed under: the EPOCH-0 room key, + * `deriveRoom(secret).roomKey`, expanded under its own info string. Epoch 0's on purpose: + * the device asking is the one that has fallen behind, and epoch 0 is the one key every + * admitted device holds however far behind it is. + */ +fun deriveEpochRequestKey(roomKey: ByteArray): ByteArray { + require(roomKey.size == 32) { "a room key is 32 bytes" } + return Digests.hkdfSha256(roomKey, null, EPOCH_REQUEST_KEY_INFO.toByteArray(Charsets.UTF_8), 32) +} + +/** + * Proof, inside an epoch request, that the asking device was admitted to the room. + * + * `HMAC-SHA256(deriveEpochRequestKey(roomKey), "kithmoot/v1/epoch-request:" + roomId + ":" + + * authority + ":" + device + ":" + createdAt)` as lower-case hex, the identifiers lower-case + * hex. The room id and the authority's pubkey are public on every rekey and a credential is + * minted by any participant key, so without this a stranger reading the relay could be + * handed an open room's current epoch. Bound to the device and the event's own `created_at` + * so a proof lifted from one request is no use in another. + */ +fun epochRequestAdmission(roomKey: ByteArray, roomId: String, authority: String, device: String, createdAt: Long): String { + require(createdAt >= 0) { "created_at must be a non-negative integer" } + val message = EPOCH_REQUEST_MESSAGE + requireHex(roomId, "room id") + ":" + requireHex(authority, "authority pubkey") + + ":" + requireHex(device, "device pubkey") + ":" + createdAt + val key = deriveEpochRequestKey(roomKey) + return try { + Digests.hmacSha256(key, message.toByteArray(Charsets.UTF_8)).toHex() + } finally { key.fill(0) } +} + fun encodeEpochRequest( roomId: String, authority: String, + /** The epoch-0 room key, which proves this device was admitted. */ + roomKey: ByteArray, deviceSecretKey: ByteArray, credential: NostrEvent, now: Long, @@ -185,10 +222,12 @@ fun encodeEpochRequest( require(deviceSecretKey.size == 32) val room = requireHex(roomId, "room id") val peer = requireHex(authority, "authority pubkey") + val admission = epochRequestAdmission(roomKey, room, peer, Schnorr.publicKeyHex(deviceSecretKey), now) val body = buildJsonObject { put("v", 1) put("credential", credential.toJson()) if (proof != null) put("proof", proof.toJson()) + put("admission", admission) } val key = Nip44.conversationKey(deviceSecretKey, peer.hexToBytes()) return try { @@ -196,10 +235,17 @@ fun encodeEpochRequest( } finally { key.fill(0) } } +/** + * Null for anything malformed, stale, misaddressed, from a device that cannot prove which + * participant it speaks for in this room, or from one that cannot prove it was admitted to + * the room at all. A request refused here must not be answered, so a stranger learns nothing. + */ fun decodeEpochRequest( event: NostrEvent, roomId: String, authoritySecretKey: ByteArray, + /** The epoch-0 room key the desk checks admission proofs against. */ + roomKey: ByteArray, now: Long, policy: RoomPolicy? = null, maxAgeSeconds: Long = EPOCH_MAX_AGE_SECONDS, @@ -215,6 +261,11 @@ fun decodeEpochRequest( val credential = (body["credential"] as? JsonObject)?.let(NostrEvent::fromJson) ?: return null val verdict = verifyDeviceCredential(credential, room, now) as? CredentialCheck.Valid ?: return null if (!verdict.device.hexEquals(event.pubkey)) return null + // Admission before policy: a stranger with no room key is turned away + // before anything about the room's tiers is consulted. + val presented = body["admission"]?.jsonPrimitive?.content?.takeIf { HEX64.matches(it) } ?: return null + val expected = epochRequestAdmission(roomKey, room, authority, verdict.device, event.createdAt) + if (!MessageDigest.isEqual(presented.hexToBytes(), expected.hexToBytes())) return null if (policy != null) { val proof = (body["proof"] as? JsonObject)?.let(KindredProof::fromJson) if (!evaluateAccess(policy, verdict.participant, proof, now, room).admitted) return null diff --git a/protocol/src/test/kotlin/dev/forgesworn/kithmoot/protocol/EpochRequestTest.kt b/protocol/src/test/kotlin/dev/forgesworn/kithmoot/protocol/EpochRequestTest.kt index 05452b66..f734b988 100644 --- a/protocol/src/test/kotlin/dev/forgesworn/kithmoot/protocol/EpochRequestTest.kt +++ b/protocol/src/test/kotlin/dev/forgesworn/kithmoot/protocol/EpochRequestTest.kt @@ -1,7 +1,13 @@ package dev.forgesworn.kithmoot.protocol +import dev.forgesworn.kithmoot.crypto.Nip44 import dev.forgesworn.kithmoot.crypto.Schnorr import dev.forgesworn.kithmoot.crypto.hexToBytes +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -48,26 +54,62 @@ class EpochRequestTest { @Test fun `a credential-bound device asks the authority and malformed or stale requests fail closed`() { val request = encodeEpochRequest( - room.roomId, authority, deviceSecret, credential, now, + room.roomId, authority, room.roomKey, deviceSecret, credential, now, nonce = ByteArray(32) { 1 }, auxRand = ByteArray(32) { 2 }, ) - val decoded = decodeEpochRequest(request, room.roomId, authoritySecret, now) + val decoded = decodeEpochRequest(request, room.roomId, authoritySecret, room.roomKey, now) assertEquals(device, decoded?.device) assertEquals(credential.pubkey, decoded?.participant) assertEquals(request.id, decoded?.request) - assertNull(decodeEpochRequest(request, room.roomId, authoritySecret, now + EPOCH_MAX_AGE_SECONDS + 1)) - assertNull(decodeEpochRequest(request.copy(tags = listOf(listOf("d", "ff".repeat(32)), listOf("p", authority))), room.roomId, authoritySecret, now)) + assertNull(decodeEpochRequest(request, room.roomId, authoritySecret, room.roomKey, now + EPOCH_MAX_AGE_SECONDS + 1)) + assertNull(decodeEpochRequest(request.copy(tags = listOf(listOf("d", "ff".repeat(32)), listOf("p", authority))), room.roomId, authoritySecret, room.roomKey, now)) val borrowed = encodeEpochRequest( - room.roomId, authority, "0b".repeat(32).hexToBytes(), credential, now, + room.roomId, authority, room.roomKey, "0b".repeat(32).hexToBytes(), credential, now, nonce = ByteArray(32) { 3 }, auxRand = ByteArray(32) { 4 }, ) - assertNull(decodeEpochRequest(borrowed, room.roomId, authoritySecret, now)) + assertNull(decodeEpochRequest(borrowed, room.roomId, authoritySecret, room.roomKey, now)) + } + + @Test fun `a request proves admission under the room key and a stranger's request is refused`() { + val request = encodeEpochRequest( + room.roomId, authority, room.roomKey, deviceSecret, credential, now, + nonce = ByteArray(32) { 21 }, auxRand = ByteArray(32) { 22 }, + ) + val expected = epochRequestAdmission(room.roomKey, room.roomId, authority, device, now) + val body = Json.parseToJsonElement( + Nip44.decrypt(request.content, Nip44.conversationKey(authoritySecret, device.hexToBytes())), + ).jsonObject + assertEquals(expected, body["admission"]?.jsonPrimitive?.content) + assertTrue(expected != epochRequestAdmission(room.roomKey, room.roomId, authority, device, now + 1)) + + // A desk holding another room key cannot verify it, and a proof made + // under another key - a stranger guessing, or a device that used the + // current epoch's key instead of epoch 0's - is refused by this desk. + val otherKey = ByteArray(32) { 9 } + assertNull(decodeEpochRequest(request, room.roomId, authoritySecret, otherKey, now)) + val strangers = encodeEpochRequest( + room.roomId, authority, otherKey, deviceSecret, credential, now, + nonce = ByteArray(32) { 23 }, auxRand = ByteArray(32) { 24 }, + ) + assertNull(decodeEpochRequest(strangers, room.roomId, authoritySecret, room.roomKey, now)) + + // A request from before the proof existed carries no admission and is refused. + val bare = buildJsonObject { + put("v", 1) + put("credential", credential.toJson()) + } + val conversation = Nip44.conversationKey(deviceSecret, authority.hexToBytes()) + val stripped = Events.sign( + deviceSecret, KIND_EPOCH_REQUEST, now, listOf(listOf("d", room.roomId), listOf("p", authority)), + Nip44.encrypt(bare.toString(), conversation, ByteArray(32) { 25 }), ByteArray(32) { 26 }, + ) + assertNull(decodeEpochRequest(stripped, room.roomId, authoritySecret, room.roomKey, now)) } @Test fun `the authority grants the current epoch or returns a terminal refusal to this request only`() { val request = encodeEpochRequest( - room.roomId, authority, deviceSecret, credential, now, + room.roomId, authority, room.roomKey, deviceSecret, credential, now, nonce = ByteArray(32) { 5 }, auxRand = ByteArray(32) { 6 }, ) val nextSecret = "0c".repeat(32).hexToBytes() diff --git a/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/EpochRequestAdmissionVectorsTest.kt b/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/EpochRequestAdmissionVectorsTest.kt new file mode 100644 index 00000000..a2274521 --- /dev/null +++ b/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/EpochRequestAdmissionVectorsTest.kt @@ -0,0 +1,55 @@ +package dev.forgesworn.kithmoot.vectors + +import dev.forgesworn.kithmoot.crypto.toHex +import dev.forgesworn.kithmoot.protocol.NostrEvent +import dev.forgesworn.kithmoot.protocol.decodeEpochRequest +import dev.forgesworn.kithmoot.protocol.deriveEpochRequestKey +import dev.forgesworn.kithmoot.protocol.epochRequestAdmission +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Every shared epochRequestAdmission vector is executed: the proof's derivation + * and message, a whole request decoded by the desk, and the three refusals. + */ +class EpochRequestAdmissionVectorsTest { + private fun vector(name: String) = Vectors.group("epochRequestAdmission").single { it.text("name") == name } + + @Test fun `the admission proof derives to the web bytes`() { + val value = vector("admission-proof") + val input = value.child("input") + val output = value.child("output") + val roomKey = input.bytes("roomKeyHex") + assertEquals(output.text("requestKeyHex"), deriveEpochRequestKey(roomKey).toHex()) + assertEquals( + output.text("admission"), + epochRequestAdmission(roomKey, input.text("roomId"), input.text("authority"), input.text("device"), input.number("createdAt")), + ) + assertEquals( + "kithmoot/v1/epoch-request:" + input.text("roomId") + ":" + input.text("authority") + ":" + input.text("device") + ":" + input.number("createdAt"), + input.text("message"), + ) + } + + @Test fun `a whole request decodes and each refusal refuses`() { + for (name in listOf("request", "request-without-admission", "request-under-another-key", "request-proof-for-another-moment")) { + val value = vector(name) + val decode = value.child("expected").child("decode") + val event = NostrEvent.fromJson(value.child("input").child("event")) + val result = decodeEpochRequest( + event, decode.text("roomId"), decode.bytes("authoritySkHex"), decode.bytes("roomKeyHex"), decode.number("now"), + ) + if (value.text("kind") == "negative") { + assertNull("$name must be refused", result) + } else { + val expected = value.child("expected").child("result") + assertNotNull("$name must decode", result) + assertEquals("$name device", expected.text("device"), result!!.device) + assertEquals("$name participant", expected.text("participant"), result.participant) + assertEquals("$name request", expected.text("request"), result.request) + } + } + } +} diff --git a/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/VectorCoverageTest.kt b/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/VectorCoverageTest.kt index efa6afb4..ac9ce47d 100644 --- a/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/VectorCoverageTest.kt +++ b/protocol/src/test/kotlin/dev/forgesworn/kithmoot/vectors/VectorCoverageTest.kt @@ -36,6 +36,7 @@ class VectorCoverageTest { "turnCredential" to 4, "roomDescriptor" to 6, "roomEpoch" to 10, + "epochRequestAdmission" to 5, "agentOwnership" to 8, "chatAttachment" to 7, "approvalControl" to 9, @@ -51,12 +52,12 @@ class VectorCoverageTest { for ((group, size) in expectedSizes) { assertEquals("vectors in $group", size, Vectors.group(group).size) } - assertEquals("total vectors", 199, expectedSizes.values.sum()) + assertEquals("total vectors", 204, expectedSizes.values.sum()) } @Test fun everyDecidingGroupCarriesNegatives() { - for (group in listOf("joinUrl", "deviceCredential", "rosterEvent", "signalWrap", "accessEvaluation", "chatThread", "chatEdit", "chatRetract", "chatMention", "chatInvite", "readPosition")) { + for (group in listOf("joinUrl", "deviceCredential", "rosterEvent", "signalWrap", "accessEvaluation", "epochRequestAdmission", "chatThread", "chatEdit", "chatRetract", "chatMention", "chatInvite", "readPosition")) { val negatives = Vectors.group(group).count { it.text("kind") == "negative" } assertTrue("$group must carry negative vectors", negatives > 0) } diff --git a/protocol/src/test/resources/kithmoot-vectors.json b/protocol/src/test/resources/kithmoot-vectors.json index b305e687..0ae92ead 100644 --- a/protocol/src/test/resources/kithmoot-vectors.json +++ b/protocol/src/test/resources/kithmoot-vectors.json @@ -4494,6 +4494,211 @@ } } ], + "epochRequestAdmission": [ + { + "name": "admission-proof", + "kind": "positive", + "note": "The proof a kind-20468 epoch request carries as `admission`. `requestKey = HKDF-SHA256(ikm = roomKey, info = \"kithmoot/v1/epoch-request-key\", 32)`, no salt, from the EPOCH-0 room key - `deriveRoom(secret).roomKey`, never a later epoch's key, so a device that has fallen any number of epochs behind can still make one. Then `admission = HMAC-SHA256(requestKey, \"kithmoot/v1/epoch-request:\" + roomId + \":\" + authority + \":\" + device + \":\" + createdAt)` as lower-case hex, the three identifiers lower-case hex and `createdAt` the request event's own `created_at` in decimal.", + "input": { + "roomKeyHex": "593a3bdd10ea8589533c31ce5860e1f2698fcbc6dc9a7b64713cc94c0c38713c", + "roomId": "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b", + "authority": "47ab2a9f89f155b277f1e847d7d4eba664d28324ee903ffc051bdc9e14869f86", + "device": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "createdAt": 1800000000, + "message": "kithmoot/v1/epoch-request:2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b:47ab2a9f89f155b277f1e847d7d4eba664d28324ee903ffc051bdc9e14869f86:fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe:1800000000" + }, + "output": { + "requestKeyHex": "51c647c6096dddd20dd646103d6667e8915beaebe5c86646940d686a5fddfbe5", + "admission": "87a278bd02e95df8c19a2eabd6a86e04476b1c87565ae4716ffd947e398f1d64" + } + }, + { + "name": "request", + "kind": "positive", + "note": "A complete epoch request: the device credential and the admission proof inside a NIP-44 body between the device key and the authority, on a kind-20468 event tagged `d` room id and `p` authority, signed by the device. The desk decodes it to the device, the participant the credential names, and the request id the grant will answer.", + "input": { + "event": { + "kind": 20468, + "created_at": 1800000000, + "tags": [ + [ + "d", + "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b" + ], + [ + "p", + "47ab2a9f89f155b277f1e847d7d4eba664d28324ee903ffc051bdc9e14869f86" + ] + ], + "content": "ArfzHVC+8B6YNMBsF0awGflQhqqsbINUeyByVDwUTl01TxviXkxH77TINN14a8A9TLr9j/I+qwZAbEtZURYvUubA//Y6AvuAjGrEUBpoJs8kIpG1MwrcCnKbv4TM9CZTbBbhb3K7pIj7gIp0T47ZFLQAa/gKx9K4QtFfOC6zpx9rU57pe4jU8GLKEKnh//mtakZ3CaPyKpC9SXBsIFyFuXYb9ZyCia26+XQLb7K6zvOOm1T5DCGSJOTmb/RwO6ia0Zh1wSDGHNlayAahmZiSHSSIxO+HphRAacTpwGAgpp79HEOV4D1fPTN108NZ6hddOkHrSwKOWdox0zTjVTlpcY1NJmHCfT7h2bBlRPXikbdRyDfO+NcLXNsBsoxZjrHSUuwORAbKiw4RLtJi9es4tFW5idFLkeoMO6D6DCwvUhEOzS7WReVuEzmWxgjVq972Hki3GT/UKxxh5ISDl29P33Dt94EiREhSg0d5gUUpVPehVRK8jdyjhGFauR2Q8Ue0cuLjMYZUsllSuPH+EL9yDr1FmjOdAtQoN/BUiwzrl4qsCvrZf5OWeHL9BazNeHfZvp02hZ8AQaMywfsQvMJpZQvSrIZMydvuDetIG4ZMaAKkjFi4zT3gNHEZyPw7eW7B6cetPT0utHguntAVwXzFluW3o9Hhkn0nxdJcbMbO7tsg1AR1NSaVbYqzwKmC0G2gZ23dVPloT7qemQqqqoA8TJo9bgqEoaZ0GR4PB7vLZSH10If9X4h+bzfL//KGeJBGQcxHf+7zk6KWbwCfyKjHSdWQtCY3VrWNUnJqX9hkRDiX4ebh1kKBHja7iWEeAwe73WV7X3826gJ/iFtIp7TNrLUjTq7bYgWK3wNAZ4LthnLJRrzgPMuj7ypryDVcyAdXLiJKWYvA+jj667PrW7HbPI+BzRZM43OnxD5GIXILPYIOfRM=", + "pubkey": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "id": "860709d92f992f24ccd9bdaf99da6b6201f1665ef22166c0b84a5e22aec9a4de", + "sig": "fd292b92bf14b4ac27a1d2c29465d69d08b9737a071e75f4a320f500cb03dd6fa4f6e913ee172b93dfa6c06de7c4f4da21b71332c02bb0e2f0222d6baf6f6145" + }, + "body": { + "v": 1, + "credential": { + "kind": 20460, + "created_at": 1799990000, + "tags": [ + [ + "d", + "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b" + ], + [ + "device", + "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe" + ], + [ + "expiration", + "1800003600" + ] + ], + "content": "", + "pubkey": "b07ff66ffca2d5445cfb4a384e94fba8ecc5000283b43f2d5425c85df5032e74", + "id": "955860a514bffb37f649013c7b3dbfedc4673bc1faadded13d1847b0e027d15d", + "sig": "844567d3b069426b95d26462864ffbd49d55a07244cef702ebaf6a4fda2ee3f5fd0b12e0ecdd43e3ca1d799051142dfe643c416a2e84051fd32bba68f7018841" + }, + "admission": "87a278bd02e95df8c19a2eabd6a86e04476b1c87565ae4716ffd947e398f1d64" + }, + "deviceSkHex": "751627289895e0df520a348eed03006ac69522989ce970c8a4926268de6f69de", + "nonceHex": "b7f31d50bef01e9834c06c1746b019f95086aaac6c83547b2072543c144e5d35", + "auxRandHex": "f22f47f5a46f90e1108a3f37152079b04daf291b52116ad10384f96aa0dee5dd" + }, + "output": { + "result": { + "device": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "participant": "b07ff66ffca2d5445cfb4a384e94fba8ecc5000283b43f2d5425c85df5032e74", + "request": "860709d92f992f24ccd9bdaf99da6b6201f1665ef22166c0b84a5e22aec9a4de" + } + }, + "expected": { + "decode": { + "roomId": "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b", + "authoritySkHex": "e407c0989f606d8165f63d104e1674a462b191e1df99a29ae9be3f2907deca32", + "roomKeyHex": "593a3bdd10ea8589533c31ce5860e1f2698fcbc6dc9a7b64713cc94c0c38713c", + "now": 1800000000 + }, + "result": { + "device": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "participant": "b07ff66ffca2d5445cfb4a384e94fba8ecc5000283b43f2d5425c85df5032e74", + "request": "860709d92f992f24ccd9bdaf99da6b6201f1665ef22166c0b84a5e22aec9a4de" + } + } + }, + { + "name": "request-without-admission", + "kind": "negative", + "note": "The same request with no `admission` field: what a client from before the proof sent, and exactly what a stranger with the room id, the authority's pubkey and a participant key of their own can produce. Refused before the policy is consulted; the desk publishes nothing, so the stranger does not learn that a desk is there.", + "input": { + "event": { + "kind": 20468, + "created_at": 1800000000, + "tags": [ + [ + "d", + "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b" + ], + [ + "p", + "47ab2a9f89f155b277f1e847d7d4eba664d28324ee903ffc051bdc9e14869f86" + ] + ], + "content": "AoFUOhU2v9MLbRocQqQZnqndVMW0nOn8IBUdp+Gi9GfgDV+MKXVM9AWsZuFqTjG6gpGmOLSkqeVdR1TYBIwk3dmtF+UttI7zhEaTWcLW6iUSe//Z2fYOExXU68z2VTgYVerIoZpgeQWl4RLOCDV6yTeIoAULoQndnqRqJEi6QjGrOqGrpxvgxFpHUqYkx3KVq0UNWea/zhP2627MkEdcYmklVF86jzBFT2HByeF8YlRWh2lS81D8ORCdT+tmhf1iSbLqhCV4EWtJ+QdxdTxMNeG3mvojLeLEeLMaorpKlunxcpqtbJAWoPGGsrPSn8b0Zc36tdOVo9JwMgDpI8l3SgGW2C5Gvkswa97eWbQ5nl3nP/AEv04zkZ6jvp1f1g8mGzvfhTKGxpWjALC52dG5fJV1RIvmetxy/aTx8e2oXDoKXDMofJr8wZ1WmgJwCK7o/dGroW9QNIB63tBZIl0gk65bSwMFAPYCkcvhaYWHeYGATODe8Ip2Xr1xamW+9Sn9gtoLV6lWEA//Oo/Y1Glpi5DvcnhkgIoVXEHVF70xJCqEePPu0YIINaF1OpeP/MUJ7ANzxT7drMCfOXn4dMS1jhrplARzNWUnmTEi47UaIe0d9yjjp/+o2NcbtOrcZyChaF6iJgWxaTEli8/8tfvfU1ZrCOlvE/ik0F7m1+6vGK+H2GLNsISj4pQZb686oWabr7bbu8MnFKKOGp95bgidzbEac2HrkFJbB/HMTDSbpzWNMeUaZmneJJ/sq+F1hOpXL2vF3DYM0xMverh4YIKGeBeNLj1GrbLoVElShqGO95I2FS468DpcYb/h10BA+VgfMySdTtvwnQx+/FyltA3std7h9pAgZD8FWVuKFmW+T9lcK03ISt0bxo847aMSn9zyZVGHc440b4QPxUhbe5QNFz5a5foLiImSa2AOYoDU+rC0CLs=", + "pubkey": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "id": "1ecd7084c7b2a4b8a039f5eb7be3a1a1c2e6a19fb53d222b9c50e24f4a8185d8", + "sig": "f5a3b54740f0655f3bbcae0b65df41b95ad171375925c4cd181bc378c18d8fdf3bed31c97c5239cb6f90657e0fa5d55fa98c244cafaa9fd74358b06dc6c57e45" + } + }, + "output": { + "result": null + }, + "expected": { + "decode": { + "roomId": "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b", + "authoritySkHex": "e407c0989f606d8165f63d104e1674a462b191e1df99a29ae9be3f2907deca32", + "roomKeyHex": "593a3bdd10ea8589533c31ce5860e1f2698fcbc6dc9a7b64713cc94c0c38713c", + "now": 1800000000 + }, + "result": null + } + }, + { + "name": "request-under-another-key", + "kind": "negative", + "note": "The proof computed under the key of epoch 1 rather than the epoch-0 room key. Refused: the proof key is always derived from epoch 0, because that is the one key every admitted device holds however far behind it is, and a desk checking against anything else would refuse exactly the devices the desk exists for.", + "input": { + "event": { + "kind": 20468, + "created_at": 1800000000, + "tags": [ + [ + "d", + "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b" + ], + [ + "p", + "47ab2a9f89f155b277f1e847d7d4eba664d28324ee903ffc051bdc9e14869f86" + ] + ], + "content": "AuUfBktokBfC8jEAZ1XW+9645urzhmGGFc4NrEdOiP+5uzK7LdP3+bF4u6wCdbToEe1YWFaD4zj9xjN+VfvFGdp1x0XHESePQbYBFJAKuBvyFGGIgS52aNYZtIlEsAoXMo4gqJ1RQJdFTwsrnCG4ZoKNx0+zvzchG7UF8xt9fBUSoQFDnmOSyp42AIxMKJsubW9xUn120VQt3FkMLrGEfaGZIW6Em0RKQORQ2OeGhJVVqSL94r2iJMNvD166Ecxzrw85nfxqxX1bLeUKEkbqOgIvpv38XNBZ8rKBWeH16MXS5E30p12QTm4zZFrx3tqMD16175LOZoWTAeKe+KU/ex8SAyC7tblOwUo9vjuHv9mvxPirBsuWNftg88FkKcrHImWOYdyGU95bb3lUQ4z6aNfDcZBv3M7jKbRe55AxaxS3gs3NL0HrgstmD6fJ18degnhdhobohzvLGxbzVsKk6sqBSEuukqwxJPSzYLOklYHWfSwfqroLAYnp4oqNZZRZsBYcUzmjPpQk5hxneBZTrGp541hS2cEVbf1p0XRBadU9I0yUf7bMWcIJuzzl8JCVNNplApmsXr047CU7Eq/cRV1jyP7/jfnRCD0egj6lNxEaUlwyVpinhCk1zsNYf7TgIwKrDhpctlEShoj/Ix3e9l1PrZ2i+JCjeoKeUgSONpxp1an3wGKgBzjj4O0+jdYJv5yXv12PLB3viRXzoInyWwlTyikzS0HvZ6IDi/leFmftBbzj+FtL1GQneiVWbxDU3isR4YXjC/xkk2gAlCfmLb+PB9WlYtyuQG8R6gLw361xMLgSisCc/jgwGB2xUMbGRpV62pohxtwpKmLTACcdf/W89OL6xNO0nRGRwZVrPhTakqlEarU+rUv6mVcrSE+uB5yxtc7S345p2MV73yI/YnoIYcNwx2uqvTn+SZIikpYImK4=", + "pubkey": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "id": "f6761f70ade8e0bc4caa60bfdd0b3a9252c1c49a678b33cdf28fee6e7c4c53ad", + "sig": "dce2560b24cca141a93c2caa4b860472467aafce2256f397e9abc2b857357d52285f77ceaf1d494ebcdd8df4193566376cb05750f544a53df1cc07102932c7cf" + }, + "proofKeyHex": "1376c99d11c91603ba3904328d3fd6c857b098c62a187e4a1322c84e832d63b7" + }, + "output": { + "result": null + }, + "expected": { + "decode": { + "roomId": "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b", + "authoritySkHex": "e407c0989f606d8165f63d104e1674a462b191e1df99a29ae9be3f2907deca32", + "roomKeyHex": "593a3bdd10ea8589533c31ce5860e1f2698fcbc6dc9a7b64713cc94c0c38713c", + "now": 1800000000 + }, + "result": null + } + }, + { + "name": "request-proof-for-another-moment", + "kind": "negative", + "note": "A correct proof for `created_at` one second later than the event carries. Refused: the proof binds the device and the moment, so a proof lifted from one request is no use inside another, even one from the same device.", + "input": { + "event": { + "kind": 20468, + "created_at": 1800000000, + "tags": [ + [ + "d", + "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b" + ], + [ + "p", + "47ab2a9f89f155b277f1e847d7d4eba664d28324ee903ffc051bdc9e14869f86" + ] + ], + "content": "Aum2++QEm52Lth4BQRQZi1AM2yQIzEcXA35bnqJbbz5w/tigHvF48pAbcgIiDGxF9SoHVve5jktDIn6ooJ+YV77/0z5y5RFqgFFer5d1ARPFnSfGy0tUhUZTBscQhQ33t8FmF9gqdhKgvxhkYPUdTMeS6ADNrrbu8CkuEffzn8SOncmMrqrxChhWPcekuIiDEfNBgwzkmiMLXeXyKRIEz/HeWAMefKClpO8zHkLtkhOK/zeEP+xv/iuPB5fohlwevFm8orVuE9C5f15LaxC/LxUkQx/qr6jLl824R1rfyscAv3FqzhstM2lg7PDwjFQh2Gk0yQ0TskelGjPFFHzTYlILQOEH2KceoDRwmlNjep5AO1EPgiW71HMsat94OlGzrN0x6OjUJyoZkCEV6Nd+5xSc0syWaQjqORin5/Px2dqu/wXBFxY4jl3d4MlUZlI7E/vJDgbbVCuwErPFe4Fw2AaCAbfV/n3WqLM6+YN6ls4hVClF4SFrBusgyoFoYHqTZhh/i6mnGZPRbYD82j3kteS/JGxhC0LQC9ReoMUk5a0uJK02iaAJVI/uj9aVUM9b0STHGItg8mHgnYec198/efD0hnApmM50no6fizHEA1WDpH10ljyR5/gXZPGVFirKqmVeQEDiqegIUpW0OM/q3HstrzSVOajma3r9LNK4J4yEcu+591VteUZaxNzRfPwT2WSSKjpT+UvQ9JMZodXPjJKZXFk6wH4WcDcuAfU4ojNFzYR+k78MoIoIwovYlkrdfCZOhwWWEp8h7jf6XTqDyuJZ3lC3s2Pi/UbX3y/M3G6vKTS02a4JTgAgQvbqUpoZf0cXZxUXZGXPfBllsayw7f2y/kXesL2i/VANRBAx7e1qHJURgWTUH/DVwLJbN4r0OCQMl5ighi+gLRSAI/IbPtS7PZV2VSSmZXLEl7yZ7S/1rRY=", + "pubkey": "fcd74d122f0a212a673839482f69ad973f6d38735faeeb3e57929c2b916492fe", + "id": "4a33347d5ab4ec176957487060cdef0a6f642fec21eb58d8ea8974423a6bbb73", + "sig": "9e84d416382251472dc494ce1e1b7fb8f18ba7dceb6d07d3742f5af557be879d75ea3ad97ec8118a1f575fe86b5b06f843e43e7ad4774835b8c29d07f789ceaf" + } + }, + "output": { + "result": null + }, + "expected": { + "decode": { + "roomId": "2848bdd5c60a79e2dfe667ae9616c1107f61709032b66e556e28f4a96e69450b", + "authoritySkHex": "e407c0989f606d8165f63d104e1674a462b191e1df99a29ae9be3f2907deca32", + "roomKeyHex": "593a3bdd10ea8589533c31ce5860e1f2698fcbc6dc9a7b64713cc94c0c38713c", + "now": 1800000000 + }, + "result": null + } + } + ], "agentOwnership": [ { "name": "valid",