Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,25 +118,26 @@ 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<EpochGrant.Current>(
decodeEpochGrant(retainedAnswer, room, authorityPubkey, retained.deviceSecretKey, retainedRequest.id, 103),
)
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"),
decodeEpochGrant(removedAnswer, room, authorityPubkey, removed.deviceSecretKey, removedRequest.id, 103),
)

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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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",
)
Expand Down
51 changes: 51 additions & 0 deletions protocol/src/main/kotlin/dev/forgesworn/kithmoot/protocol/Rekey.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}$")

Expand Down Expand Up @@ -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,
Expand All @@ -185,21 +222,30 @@ 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 {
Events.sign(deviceSecretKey, KIND_EPOCH_REQUEST, now, listOf(listOf("d", room), listOf("p", peer)), Nip44.encrypt(body.toString(), key, nonce), auxRand)
} 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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Loading