diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d9f9dcb97b..f46871d4c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher +import com.vitorpamplona.amethyst.commons.marmot.MarmotPushCoordinator import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note @@ -390,6 +391,12 @@ class Account( * backdated gift wrap is re-unwrapped on every sync. */ val marmotIngestDedupStore: com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore? = null, + /** + * Durable push token records, stamps and tombstones. Null means a restart + * forgets every tombstone, so a relayed but revoked token record can win + * once and start waking a device its owner asked to be forgotten. + */ + val marmotPushStateStore: com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), @@ -956,6 +963,20 @@ class Account( ) } + /** + * Push token gossip (`features/push-notifications.md`) for the groups this + * account is in. + * + * Present whenever Marmot itself is, because CONSUMING gossip costs nothing + * and is what lets this client answer a peer's kind:447 later. Producing a + * record of our own is a separate decision: it needs a device token and a + * notification server public key, neither of which the protocol discovers. + */ + val marmotPushCoordinator: MarmotPushCoordinator? = + marmotManager?.let { + marmotPushStateStore?.let { store -> MarmotPushCoordinator(it, store) } ?: MarmotPushCoordinator(it) + } + /** * Raw QUIC for agent text stream previews (`transports/quic.md`). * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 4a8f0f0b0a..75c45dcd4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore +import com.vitorpamplona.amethyst.model.marmot.AndroidPushStateStore import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -294,6 +295,19 @@ class AccountCacheState( null } + val marmotPushStateStore = + try { + AndroidPushStateStore(accountDir) + } catch (e: Exception) { + Log.e( + "AccountCacheState", + "Failed to initialize AndroidPushStateStore " + + "(a revoked push token could be resurrected by a relayed token list after a restart)", + e, + ) + null + } + // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one // account never leaks into another (the store used to be a single app-wide file). val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) @@ -321,6 +335,7 @@ class AccountCacheState( marmotKeyPackageStore = marmotKeyPackageStore, marmotPublishObligationStore = marmotPublishObligationStore, marmotIngestDedupStore = marmotIngestDedupStore, + marmotPushStateStore = marmotPushStateStore, powQueue = powQueue, relayAuthPermissionStore = relayAuthPermissionStore, signerPermissionStore = signerPermissionStore, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPushStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPushStateStore.kt new file mode 100644 index 0000000000..e5ca421806 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPushStateStore.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.marmot + +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [MarmotPushStateStore] — one JSON file per group + * under `/marmot_push/.json`. + * + * Durability is the whole point of this class. A push tombstone is the only + * lasting record that a token was revoked: any current member can re-emit a + * revoked-but-still-signed record in a fresh kind `448` at any later epoch, and + * a client that forgot the tombstone would accept it and start waking a device + * its owner asked to be forgotten. So this survives restarts, and it is not + * bounded by any wall clock, `owner_ts` or epoch count — a key is cleared only + * by a strictly newer registration, or by its leaf leaving the group. + * + * Not encrypted, deliberately: the file holds tokens already encrypted to a + * notification server this device cannot read, plus public routing. A failure + * to decrypt would cost a tombstone, which is worse than the file being + * readable by a process that has already broken out of the app sandbox. + */ +class AndroidPushStateStore( + private val rootDir: File, +) : MarmotPushStateStore { + private val mutex = Mutex() + + private fun dir(): File = File(rootDir, "marmot_push") + + /** + * A group id is 32 hex characters from the protocol, but it reaches here as + * a plain string, so anything that is not hex is refused rather than turned + * into a path. + */ + private fun file(nostrGroupId: HexKey): File? { + if (nostrGroupId.isEmpty() || !nostrGroupId.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + return File(dir(), "$nostrGroupId.json") + } + + override suspend fun load(nostrGroupId: HexKey): String? = + withContext(Dispatchers.IO) { + mutex.withLock { + try { + file(nostrGroupId)?.takeIf { it.exists() }?.readText() + } catch (e: Exception) { + Log.w(TAG, "could not read push state for $nostrGroupId: ${e.message}", e) + null + } + } + } + + override suspend fun save( + nostrGroupId: HexKey, + state: String, + ) = withContext(Dispatchers.IO) { + mutex.withLock { + val target = file(nostrGroupId) ?: return@withLock + try { + target.parentFile?.mkdirs() + // Write-then-rename: a half-written state file would silently + // drop tombstones, and a lost tombstone is exactly the failure + // this store exists to prevent. + val temp = File(target.parentFile, "${target.name}.tmp") + temp.writeText(state) + if (!temp.renameTo(target)) { + target.writeText(state) + temp.delete() + } + } catch (e: Exception) { + Log.w(TAG, "could not persist push state for $nostrGroupId: ${e.message}", e) + } + } + } + + override suspend fun clear(nostrGroupId: HexKey) = + withContext(Dispatchers.IO) { + mutex.withLock { + try { + file(nostrGroupId)?.delete() + } catch (e: Exception) { + Log.w(TAG, "could not clear push state for $nostrGroupId: ${e.message}", e) + } + Unit + } + } + + companion object { + private const val TAG = "AndroidPushStateStore" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 4cdb1c0696..cb5e51da5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -696,6 +696,36 @@ class GroupEventHandler( } } + // Push token gossip (kinds 447/448/449) is routing data for + // a notification server, addressed to the other members' + // clients rather than to the people in the room. It still + // reaches the feed's dedupe and cache paths above like any + // inner event — `MarmotGroupList` is what keeps it off the + // screen — but its meaning is applied here. + // + // Everything this call does is advisory: a malformed entry, + // a signature that does not verify, a list that lost its + // ordering race are all dropped on their own and none of + // them may reach the validity of the kind:445 that carried + // them. That is why it neither throws nor is checked. + account.marmotPushCoordinator?.let { push -> + push.apply(result.groupId, innerEvent) + // A peer asking for records gets our view, once. We + // answer with the records we hold — including other + // members' — with their owner signatures untouched, so + // a member who has been offline can be caught up by + // whoever happens to be around. + if (push.isTokenRequest(innerEvent) && innerEvent.pubKey != account.signer.pubKey) { + push.buildTokenList(result.groupId)?.let { response -> + account.marmot.sendMarmotGroupMessage( + result.groupId, + response, + account.marmot.marmotGroupRelays(result.groupId), + ) + } + } + } + // Track the message in the Marmot group chatroom account.marmotGroupList.addMessage(result.groupId, innerNote) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPushCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPushCoordinator.kt new file mode 100644 index 0000000000..7ef8b389f8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPushCoordinator.kt @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.InMemoryPushStateStore +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.NotificationRequestEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushBase64 +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushGossip +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushOwnerProof +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushPlatform +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRecordKind +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRecordStore +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRemovalEntry +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushSignedRecord +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushStateCodec +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushTokenEntry +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenEncryption +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Push token gossip for the groups this client is in + * (`features/push-notifications.md`). + * + * ## What it owns, and what it deliberately does not + * + * It produces and consumes kinds `447`/`448`/`449` and assembles the kind `446` + * trigger rumor. It does NOT publish anything: the trigger's NIP-59 seal, its + * recipient addressing and its publish targets belong to the Nostr binding, and + * the gossip events are ordinary group messages the caller sends like any + * other. + * + * It also does not decide whether push is enabled. A device token and a + * notification server public key both come from the application — a server can + * only wake the app whose platform credentials it holds, so there is no + * protocol-level discovery to do here. + * + * ## Nothing here can affect a group + * + * Every failure in this file is advisory. A malformed entry, an unverifiable + * signature, a removal matching nothing, a stale list — all drop the datum and + * continue. None of it may reject a group message, mutate MLS state, or change + * which commit wins, and the code is shaped so it cannot: the coordinator never + * throws at its callers on bad input, it returns "nothing changed". + */ +class MarmotPushCoordinator( + private val manager: MarmotManager, + /** + * Durable per-group state. The default forgets tombstones on restart, + * which lets a relayed but revoked record win exactly once — acceptable + * for a CLI, not for a phone. + */ + private val stateStore: MarmotPushStateStore = InMemoryPushStateStore(), +) { + private val mutex = Mutex() + private val stores = mutableMapOf() + + /** The active records this client believes in for a group. */ + suspend fun activeRecords(nostrGroupId: HexKey): List = mutex.withLock { storeFor(nostrGroupId)?.active().orEmpty() } + + // ------------------------------------------------------------- producing + + /** + * Encrypt this device's token to [serverPubKeyHex], sign the owner proof, + * and build the kind `447` self-update that announces it. + * + * The record is applied locally first so a later kind `448` of ours carries + * it, and so a stale relay of an older record of ours loses on arrival. + * + * @return the inner event to send into the group, or null when this client + * is not a member of the group or holds no leaf in it. + */ + suspend fun buildSelfUpdate( + nostrGroupId: HexKey, + platform: PushPlatform, + deviceToken: ByteArray, + serverPubKeyHex: HexKey, + relayHint: String = "", + ownerTsMillis: Long = TimeUtils.nowMillis(), + ): Event? { + val entry = + signOwnRecord(nostrGroupId, platform, deviceToken, serverPubKeyHex, relayHint, ownerTsMillis) + ?: return null + + mutex.withLock { + val store = storeFor(nostrGroupId) ?: return null + store.applyTokens(listOf(entry), TimeUtils.nowMillis(), memberCheck(nostrGroupId)) + persist(nostrGroupId, store) + } + + return rumor(TokenRequestEvent.build(listOf(entry))) + } + + /** The empty kind `447`: "share the records you hold with me." */ + fun buildTokenRequest(): Event = rumor(TokenRequestEvent.buildRequest()) + + /** + * Build the kind `448` answer to a request: every active record we hold, + * including other members' records, with their signatures untouched. + * + * Null when we hold nothing to say — an empty list response is noise. + * Records beyond the 32-entry cap are dropped rather than split, because a + * responder is a convenience path and the owners will re-announce. + */ + suspend fun buildTokenList(nostrGroupId: HexKey): Event? { + val records = mutex.withLock { storeFor(nostrGroupId)?.active().orEmpty() } + if (records.isEmpty()) return null + return rumor(TokenListEvent.build(records.take(PushGossip.MAX_ENTRIES))) + } + + /** + * Sign and build the kind `449` that revokes this device's record on + * [serverPubKeyHex]. + * + * [deviceToken] is needed even though the token is not in the removal: the + * fingerprint is, and it is what states which token instance the owner + * meant to revoke. + */ + suspend fun buildRemoval( + nostrGroupId: HexKey, + platform: PushPlatform, + deviceToken: ByteArray, + serverPubKeyHex: HexKey, + ownerTsMillis: Long = TimeUtils.nowMillis(), + ): Event? { + val groupIdHex = manager.mlsGroupIdHex(nostrGroupId) ?: return null + val leafIndex = manager.leafIndexOf(nostrGroupId, manager.signer.pubKey) ?: return null + val fingerprint = PushSignedRecord.fingerprintOf(platform, deviceToken) + + val ownerSig = + proof(PushRecordKind.REMOVAL, groupIdHex, leafIndex, platform, serverPubKeyHex, fingerprint, ownerTsMillis) + ?: return null + + val entry = + PushRemovalEntry( + memberIdHex = manager.signer.pubKey, + leafIndex = leafIndex, + platform = platform, + tokenFingerprint = fingerprint, + serverPubKeyHex = serverPubKeyHex, + ownerTsMillis = ownerTsMillis, + ownerSig = ownerSig, + ) + + mutex.withLock { + val store = storeFor(nostrGroupId) ?: return null + store.applyRemovals(listOf(entry), TimeUtils.nowMillis(), memberCheck(nostrGroupId)) + persist(nostrGroupId, store) + } + + return rumor(TokenRemovalEvent.build(listOf(entry))) + } + + /** + * The kind `446` trigger rumor for a group's active records, or null when + * there is nothing to wake. + * + * [padding] chunks of uniform random bytes are appended to obscure the real + * recipient count from anyone watching the gift wrap's length. They are + * indistinguishable from tokens to an observer and merely fail to decrypt + * at the server — which is why a real token must never be used as padding: + * it would fire a wake with no content behind it. + * + * The caller seals and wraps this to the notification server; nothing here + * publishes. + */ + suspend fun buildTrigger( + nostrGroupId: HexKey, + serverPubKeyHex: HexKey, + padding: Int = 0, + ): Event? { + val chunks = + mutex + .withLock { storeFor(nostrGroupId)?.active().orEmpty() } + .filter { it.serverPubKeyHex == serverPubKeyHex } + .map { it.encryptedToken } + if (chunks.isEmpty()) return null + + val padded = + (chunks + List(padding) { RandomInstance.bytes(PushSignedRecord.ENCRYPTED_TOKEN_BYTES) }) + .take(NotificationRequestEvent.MAX_CHUNKS) + .shuffled() + + // A fresh ephemeral key per trigger, so the server cannot link two + // triggers to one sender — and cannot dedup on the outer event id + // either, which is why the spec keys dedup on the content hash. + val ephemeral = RandomInstance.bytes(32) + val ephemeralPubKey = Nip01Crypto.pubKeyCreate(ephemeral).toHexKey() + return RumorAssembler.assembleRumor(ephemeralPubKey, NotificationRequestEvent.build(padded)) + } + + // ------------------------------------------------------------- consuming + + /** + * Feed one decrypted inner app event to the push state. + * + * Returns true when a stored record changed, so a caller can decide whether + * to answer a request or re-persist. A non-push kind, an unreadable + * payload, and an entry that lost its ordering race all return false and + * are indistinguishable on purpose — none of them is an error. + */ + suspend fun apply( + nostrGroupId: HexKey, + innerEvent: Event, + ): Boolean = + try { + when (innerEvent.kind) { + TokenRequestEvent.KIND, TokenListEvent.KIND -> + applyChange(nostrGroupId) { store, now, isMember -> + store.applyTokens(PushGossip.decodeTokens(innerEvent.content), now, isMember) + } + + TokenRemovalEvent.KIND -> + applyChange(nostrGroupId) { store, now, isMember -> + store.applyRemovals(PushGossip.decodeRemovals(innerEvent.content), now, isMember) + } + + else -> false + } + } catch (e: Exception) { + // Push is advisory end to end: a surprise here must never reach the + // ingest path that decides whether the carrying group message was + // valid. + Log.w("MarmotPushCoordinator", "dropping unreadable push payload in $nostrGroupId", e) + false + } + + /** True when [innerEvent] is a kind `447` asking others to share their records. */ + fun isTokenRequest(innerEvent: Event): Boolean = innerEvent.kind == TokenRequestEvent.KIND && PushGossip.decodeTokens(innerEvent.content).isEmpty() + + /** + * Forget a leaf an accepted Commit removed — record, stamp and tombstone. + * + * Nothing that leaf signed can be applied again, so the durable high-water + * mark has no work left to do. A sibling leaf of the same account keeps + * its own records: different key, still a member. + */ + suspend fun forgetLeaf( + nostrGroupId: HexKey, + memberIdHex: HexKey, + leafIndex: Int, + ) { + mutex.withLock { + val store = storeFor(nostrGroupId) ?: return + store.forgetLeaf(memberIdHex, leafIndex) + persist(nostrGroupId, store) + } + } + + suspend fun forgetGroup(nostrGroupId: HexKey) { + mutex.withLock { + stores.remove(nostrGroupId) + stateStore.clear(nostrGroupId) + } + } + + // ------------------------------------------------------------- internals + + private suspend fun applyChange( + nostrGroupId: HexKey, + change: (PushRecordStore, Long, (HexKey) -> Boolean) -> Set<*>, + ): Boolean = + mutex.withLock { + val store = storeFor(nostrGroupId) ?: return false + val changed = change(store, TimeUtils.nowMillis(), memberCheck(nostrGroupId)) + if (changed.isNotEmpty()) persist(nostrGroupId, store) + changed.isNotEmpty() + } + + /** + * Membership is read from the MLS tree, never from the carrying event's + * sender: a verified entry applies whoever relayed it, and an entry naming + * a non-member is dropped however it arrived. + */ + private fun memberCheck(nostrGroupId: HexKey): (HexKey) -> Boolean { + val members = manager.memberPubkeys(nostrGroupId).map { it.pubkey }.toSet() + return { it in members } + } + + private suspend fun storeFor(nostrGroupId: HexKey): PushRecordStore? { + stores[nostrGroupId]?.let { return it } + val groupIdHex = manager.mlsGroupIdHex(nostrGroupId) ?: return null + val store = + PushRecordStore( + groupIdHex = groupIdHex, + // From the GroupContext, never from anything a sender claims: + // it decides which owner-proof forms are acceptable at all. + currentProfileGroup = manager.groupState(nostrGroupId)?.isCurrentProfile == true, + ) + stateStore.load(nostrGroupId)?.let { PushStateCodec.decodeInto(store, it) } + stores[nostrGroupId] = store + return store + } + + private suspend fun persist( + nostrGroupId: HexKey, + store: PushRecordStore, + ) { + try { + stateStore.save(nostrGroupId, PushStateCodec.encode(store)) + } catch (e: Exception) { + Log.w("MarmotPushCoordinator", "could not persist push state for $nostrGroupId", e) + } + } + + private suspend fun signOwnRecord( + nostrGroupId: HexKey, + platform: PushPlatform, + deviceToken: ByteArray, + serverPubKeyHex: HexKey, + relayHint: String, + ownerTsMillis: Long, + ): PushTokenEntry? { + val groupIdHex = manager.mlsGroupIdHex(nostrGroupId) ?: return null + val leafIndex = manager.leafIndexOf(nostrGroupId, manager.signer.pubKey) ?: return null + val fingerprint = PushSignedRecord.fingerprintOf(platform, deviceToken) + val encryptedTokenBase64 = + try { + TokenEncryption.encrypt(platform, deviceToken, serverPubKeyHex.hexToByteArray()) + } catch (e: Exception) { + Log.w("MarmotPushCoordinator", "could not encrypt the device token", e) + return null + } + val hint = PushSignedRecord.normalizeRelayHint(relayHint) + + val ownerSig = + proof( + record = PushRecordKind.TOKEN, + groupIdHex = groupIdHex, + leafIndex = leafIndex, + platform = platform, + serverPubKeyHex = serverPubKeyHex, + fingerprint = fingerprint, + ownerTsMillis = ownerTsMillis, + relayHint = hint, + encryptedTokenBase64 = encryptedTokenBase64, + ) ?: return null + + return PushTokenEntry( + memberIdHex = manager.signer.pubKey, + leafIndex = leafIndex, + platform = platform, + tokenFingerprint = fingerprint, + serverPubKeyHex = serverPubKeyHex, + relayHint = hint, + encryptedToken = requireNotNull(PushBase64.decodeOrNull(encryptedTokenBase64)), + ownerTsMillis = ownerTsMillis, + ownerSig = ownerSig, + ) + } + + /** + * Ask the account signer for the unpublished kind `451` proof. + * + * [PushOwnerProof.create] re-validates whatever the signer returns before + * copying the signature out, which matters for an external signer: a + * substituted group id or server pubkey would otherwise become a proof that + * silently authorizes the wrong destination. + */ + private suspend fun proof( + record: PushRecordKind, + groupIdHex: HexKey, + leafIndex: Int, + platform: PushPlatform, + serverPubKeyHex: HexKey, + fingerprint: String, + ownerTsMillis: Long, + relayHint: String = "", + encryptedTokenBase64: String = "", + ): ByteArray? = + try { + PushOwnerProof.create( + signer = manager.signer, + record = record, + groupIdHex = groupIdHex, + leafIndex = leafIndex, + platform = platform.wireName, + serverPubKeyHex = serverPubKeyHex, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTsMillis, + relayHint = relayHint, + encryptedTokenBase64 = encryptedTokenBase64, + ) + } catch (e: Exception) { + Log.w("MarmotPushCoordinator", "the signer did not produce a usable push owner proof", e) + null + } + + /** + * The unsigned inner rumor a caller sends like any other group message. + * + * The coordinator deliberately stops here rather than building the kind:445 + * itself: encrypting one advances the group's ratchet, and a message the + * caller then decides not to publish would burn a generation for nothing. + */ + private fun rumor(template: EventTemplate): Event { + @Suppress("UNCHECKED_CAST") + return RumorAssembler.assembleRumor(manager.signer.pubKey, template as EventTemplate) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index b0927b5aab..939ac252d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -155,12 +155,22 @@ class MarmotGroupList( private const val MARMOT_INNER_KIND_EDIT = 1009 private const val MARMOT_INNER_KIND_STREAM_START = 1200 + // Push token gossip. Routing data for a notification server, addressed + // to the other members' clients rather than to the people in the room — + // a reader must never see a row for one. + private const val MARMOT_INNER_KIND_PUSH_TOKEN_UPDATE = 447 + private const val MARMOT_INNER_KIND_PUSH_TOKEN_LIST = 448 + private const val MARMOT_INNER_KIND_PUSH_TOKEN_REMOVAL = 449 + private val NON_CHAT_INNER_KINDS = setOf( MARMOT_INNER_KIND_DELETION, MARMOT_INNER_KIND_REACTION, MARMOT_INNER_KIND_EDIT, MARMOT_INNER_KIND_STREAM_START, + MARMOT_INNER_KIND_PUSH_TOKEN_UPDATE, + MARMOT_INNER_KIND_PUSH_TOKEN_LIST, + MARMOT_INNER_KIND_PUSH_TOKEN_REMOVAL, ) } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt index 53ba60da22..7a85111cec 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt @@ -23,10 +23,7 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemType -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -187,79 +184,3 @@ class MarmotEditsAndSystemRowsTest { assertEquals(1, rows.size) } } - -/** Stands in for a relay that accepts every commit, so epochs actually advance. */ -private val ACCEPTING_RELAY = MarmotPublisher { _, _ -> true } - -private class SnapshotStateStore : MlsGroupStateStore { - private val states = mutableMapOf() - private val retained = mutableMapOf>() - - override suspend fun save( - nostrGroupId: String, - state: ByteArray, - ) { - states[nostrGroupId] = state - } - - override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId] - - override suspend fun delete(nostrGroupId: String) { - states.remove(nostrGroupId) - retained.remove(nostrGroupId) - } - - override suspend fun listGroups(): List = states.keys.toList() - - override suspend fun saveRetainedEpochs( - nostrGroupId: String, - retainedSecrets: List, - ) { - retained[nostrGroupId] = retainedSecrets - } - - override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId] ?: emptyList() -} - -private class SnapshotMessageStore : MarmotMessageStore { - private val messages = mutableMapOf>() - private val snapshots = mutableMapOf() - - override suspend fun appendMessage( - nostrGroupId: String, - innerEventJson: String, - ) { - val log = messages.getOrPut(nostrGroupId) { mutableListOf() } - if (innerEventJson !in log) log.add(innerEventJson) - } - - override suspend fun loadMessages(nostrGroupId: String): List = messages[nostrGroupId]?.toList() ?: emptyList() - - override suspend fun delete(nostrGroupId: String) { - messages.remove(nostrGroupId) - snapshots.remove(nostrGroupId) - } - - override suspend fun recordGroupSnapshot( - nostrGroupId: String, - snapshotJson: String, - ) { - snapshots[nostrGroupId] = snapshotJson - } - - override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshots[nostrGroupId] -} - -private class SnapshotBundleStore : KeyPackageBundleStore { - private var snapshot: ByteArray? = null - - override suspend fun save(snapshot: ByteArray) { - this.snapshot = snapshot - } - - override suspend fun load(): ByteArray? = snapshot - - override suspend fun delete() { - snapshot = null - } -} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPushCoordinatorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPushCoordinatorTest.kt new file mode 100644 index 0000000000..dd820785c9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPushCoordinatorTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.InMemoryPushStateStore +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushBase64 +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushGossip +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushOwnerProof +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushPlatform +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRecordKind +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushSignedRecord +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushTokenEntry +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenEncryption +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Push token gossip through the app layer + * (`features/push-notifications.md`). + * + * The interesting failures here are not parse errors — those are covered in + * quartz — but the ones where the app layer would quietly do the wrong thing: + * announce a record no peer can verify, answer a request with nothing, or + * forget a tombstone across a restart and start waking a revoked device again. + */ +class MarmotPushCoordinatorTest { + private val nostrGroupId = "d".repeat(64) + private val server = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4" + private val deviceToken = "a-real-looking-apns-token".encodeToByteArray() + + private class Fixture { + val signer = NostrSignerInternal(KeyPair()) + val mlsStore = SnapshotStateStore() + val messageStore = SnapshotMessageStore() + val stateStore = InMemoryPushStateStore() + val manager = MarmotManager(signer, mlsStore, messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY) + val push = MarmotPushCoordinator(manager, stateStore) + } + + private suspend fun Fixture.createGroup() = + manager.createGroup( + nostrGroupId, + MarmotGroupData(nostrGroupId = nostrGroupId, name = "push", relays = listOf("wss://relay.invalid")), + ) + + private suspend fun Fixture.selfUpdate( + ownerTs: Long = 1_735_680_000_000L, + relayHint: String = "", + ): Event = + assertNotNull( + push.buildSelfUpdate(nostrGroupId, PushPlatform.APNS, deviceToken, server, relayHint, ownerTs), + "the group's own member should be able to announce a token", + ) + + @Test + fun `a self update announces a record every peer can verify`() = + runBlocking { + val f = Fixture() + f.createGroup() + val event = f.selfUpdate(relayHint = "wss://push.example.com") + + assertEquals(TokenRequestEvent.KIND, event.kind) + // Unsigned: it is an inner Marmot app payload, and its authority is + // the entry's own owner_sig rather than a Nostr signature. + assertEquals("", event.sig) + + val entry = PushGossip.decodeTokens(event.content).single() + assertEquals(f.signer.pubKey, entry.memberIdHex) + assertEquals(PushPlatform.APNS, entry.platform) + assertEquals("wss://push.example.com", entry.relayHint) + assertEquals(PushSignedRecord.fingerprintOf(PushPlatform.APNS, deviceToken), entry.tokenFingerprint) + assertEquals(PushSignedRecord.ENCRYPTED_TOKEN_BYTES, entry.encryptedToken.size) + + // The proof is what a peer actually checks, and it binds the group. + val groupIdHex = assertNotNull(f.manager.mlsGroupIdHex(nostrGroupId)) + assertTrue(entry.verifyOwner(groupIdHex, currentProfileGroup = false)) + assertFalse(entry.verifyOwner("00".repeat(16), currentProfileGroup = false)) + } + + @Test + fun `the announced record is applied locally so a later list carries it`() = + runBlocking { + val f = Fixture() + f.createGroup() + f.selfUpdate() + + val list = assertNotNull(f.push.buildTokenList(nostrGroupId)) + assertEquals(TokenListEvent.KIND, list.kind) + assertEquals(1, PushGossip.decodeTokens(list.content).size) + } + + @Test + fun `there is no list response when we hold nothing`() = + runBlocking { + val f = Fixture() + f.createGroup() + // An empty kind 448 is noise: it tells a requester nothing it did + // not already know and still costs a group message. + assertNull(f.push.buildTokenList(nostrGroupId)) + } + + @Test + fun `an empty request is recognised and a self update is not`() = + runBlocking { + val f = Fixture() + f.createGroup() + assertTrue(f.push.isTokenRequest(f.push.buildTokenRequest())) + assertFalse(f.push.isTokenRequest(f.selfUpdate())) + } + + @Test + fun `an entry relayed by another member is applied on its own signature`() = + runBlocking { + // Two accounts, one group each, same MLS group id would be ideal — + // but the point is narrower and testable here: applying an entry + // does not consult who carried it, only whether the signature + // verifies and the named member is current. + val f = Fixture() + f.createGroup() + val announced = f.selfUpdate() + + val relayed = Fixture() + // A fresh coordinator over the same manager stands in for a peer + // that only ever saw the gossip, never the sender. + val peer = MarmotPushCoordinator(f.manager, InMemoryPushStateStore()) + assertTrue(peer.apply(nostrGroupId, announced)) + assertEquals(1, peer.activeRecords(nostrGroupId).size) + assertTrue(relayed.stateStore.load(nostrGroupId) == null) + } + + @Test + fun `a properly signed entry naming a non-member is still dropped`() = + runBlocking { + val f = Fixture() + f.createGroup() + val groupIdHex = assertNotNull(f.manager.mlsGroupIdHex(nostrGroupId)) + + // A real proof from an account that simply holds no leaf here. The + // signature verifies; membership is the separate gate, and it has + // to be, or anyone who ever learns a group id could point its + // members' notifications at a server of their choosing. + val outsiderPriv = ByteArray(32).also { it[31] = 7 } + val outsider = Nip01Crypto.pubKeyCreate(outsiderPriv).toHexKey() + val fingerprint = PushSignedRecord.fingerprintOf(PushPlatform.APNS, deviceToken) + val encryptedToken = TokenEncryption.encrypt(PushPlatform.APNS, deviceToken, server.hexToByteArray()) + val ownerTs = 1_735_680_000_000L + val tags = + PushOwnerProof.tags( + PushRecordKind.TOKEN, + groupIdHex, + outsider, + 0, + PushPlatform.APNS.wireName, + server, + fingerprint, + ownerTs, + "", + ) + val entry = + PushTokenEntry( + memberIdHex = outsider, + leafIndex = 0, + platform = PushPlatform.APNS, + tokenFingerprint = fingerprint, + serverPubKeyHex = server, + relayHint = "", + encryptedToken = assertNotNull(PushBase64.decodeOrNull(encryptedToken)), + ownerTsMillis = ownerTs, + ownerSig = Nip01Crypto.sign(PushOwnerProof.eventId(outsider, tags, encryptedToken), outsiderPriv), + ) + assertTrue(entry.verifyOwner(groupIdHex, currentProfileGroup = false), "the fixture should sign a real proof") + + val carried = + Event("0".repeat(64), f.signer.pubKey, 1L, TokenRequestEvent.KIND, emptyArray(), PushGossip.encodeTokens(listOf(entry)), "") + assertFalse(f.push.apply(nostrGroupId, carried)) + assertTrue(f.push.activeRecords(nostrGroupId).isEmpty()) + } + + @Test + fun `a removal revokes the record and the tombstone survives a restart`() = + runBlocking { + val f = Fixture() + f.createGroup() + val announced = f.selfUpdate(ownerTs = 1_735_680_000_000L) + + val removal = + assertNotNull( + f.push.buildRemoval(nostrGroupId, PushPlatform.APNS, deviceToken, server, ownerTsMillis = 1_735_680_001_000L), + ) + assertEquals(TokenRemovalEvent.KIND, removal.kind) + assertTrue(f.push.activeRecords(nostrGroupId).isEmpty()) + + // A member that assembled a kind 448 before the removal delivers it + // after. A restarted client must still refuse it — the tombstone is + // the only durable thing that recognises it as stale, and a relayed + // record's carrying epoch is unbounded. + val restarted = MarmotPushCoordinator(f.manager, f.stateStore) + assertFalse(restarted.apply(nostrGroupId, announced)) + assertTrue(restarted.activeRecords(nostrGroupId).isEmpty()) + } + + @Test + fun `a newer registration clears the tombstone`() = + runBlocking { + val f = Fixture() + f.createGroup() + f.selfUpdate(ownerTs = 1_735_680_000_000L) + f.push.buildRemoval(nostrGroupId, PushPlatform.APNS, deviceToken, server, ownerTsMillis = 1_735_680_001_000L) + + f.selfUpdate(ownerTs = 1_735_680_002_000L) + assertEquals(1, f.push.activeRecords(nostrGroupId).size) + } + + @Test + fun `a removed leaf loses its records entirely`() = + runBlocking { + val f = Fixture() + f.createGroup() + f.selfUpdate() + + val leafIndex = assertNotNull(f.manager.leafIndexOf(nostrGroupId, f.signer.pubKey)) + f.push.forgetLeaf(nostrGroupId, f.signer.pubKey, leafIndex) + assertTrue(f.push.activeRecords(nostrGroupId).isEmpty()) + } + + @Test + fun `a trigger carries the encrypted tokens and nothing else`() = + runBlocking { + val f = Fixture() + f.createGroup() + f.selfUpdate() + + val trigger = assertNotNull(f.push.buildTrigger(nostrGroupId, server, padding = 3)) + assertEquals(446, trigger.kind) + // A fresh ephemeral key, so the server cannot link two triggers to + // one sender — nor dedup on the outer id, which is why the spec + // keys dedup on the content hash instead. + assertFalse(trigger.pubKey == f.signer.pubKey) + assertEquals(1, trigger.tags.size) + assertEquals(listOf("v", PushGossip.VERSION), trigger.tags.single().toList()) + + val chunks = assertNotNull(Event.fromJson(trigger.toJson()).let { _ -> f.chunksOf(trigger) }) + assertEquals(4, chunks.size) + assertTrue( + chunks.any { + it.toHexKey() == + f.push + .activeRecords(nostrGroupId) + .single() + .encryptedToken + .toHexKey() + }, + ) + } + + @Test + fun `nothing to wake means no trigger`() = + runBlocking { + val f = Fixture() + f.createGroup() + assertNull(f.push.buildTrigger(nostrGroupId, server)) + } + + @Test + fun `an unreadable payload changes nothing and does not throw`() = + runBlocking { + val f = Fixture() + f.createGroup() + val junk = + Event("0".repeat(64), f.signer.pubKey, 1L, TokenListEvent.KIND, emptyArray(), "{not json", "") + assertFalse(f.push.apply(nostrGroupId, junk)) + assertTrue(f.push.activeRecords(nostrGroupId).isEmpty()) + } + + private fun Fixture.chunksOf(trigger: Event): List? = + com.vitorpamplona.quartz.marmot.mip05PushNotifications + .NotificationRequestEvent( + trigger.id, + trigger.pubKey, + trigger.createdAt, + trigger.tags, + trigger.content, + trigger.sig, + ).chunks() +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt new file mode 100644 index 0000000000..1ebd9d9270 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore + +// In-memory stand-ins for the durable stores a MarmotManager needs. +// +// Shared across the Marmot app-layer tests rather than re-declared per file: +// several of them turn on what survives a restart, and "restart" here means +// building a second manager over the SAME store instance. A per-file copy +// would quietly make each test's restart a different thing. + +/** Stands in for a relay that accepts every commit, so epochs actually advance. */ +val ACCEPTING_RELAY = MarmotPublisher { _, _ -> true } + +class SnapshotStateStore : MlsGroupStateStore { + private val states = mutableMapOf() + private val retained = mutableMapOf>() + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) { + states[nostrGroupId] = state + } + + override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId] + + override suspend fun delete(nostrGroupId: String) { + states.remove(nostrGroupId) + retained.remove(nostrGroupId) + } + + override suspend fun listGroups(): List = states.keys.toList() + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) { + retained[nostrGroupId] = retainedSecrets + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId] ?: emptyList() +} + +class SnapshotMessageStore : MarmotMessageStore { + private val messages = mutableMapOf>() + private val snapshots = mutableMapOf() + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) { + val log = messages.getOrPut(nostrGroupId) { mutableListOf() } + if (innerEventJson !in log) log.add(innerEventJson) + } + + override suspend fun loadMessages(nostrGroupId: String): List = messages[nostrGroupId]?.toList() ?: emptyList() + + override suspend fun delete(nostrGroupId: String) { + messages.remove(nostrGroupId) + snapshots.remove(nostrGroupId) + } + + override suspend fun recordGroupSnapshot( + nostrGroupId: String, + snapshotJson: String, + ) { + snapshots[nostrGroupId] = snapshotJson + } + + override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshots[nostrGroupId] +} + +class SnapshotBundleStore : KeyPackageBundleStore { + private var snapshot: ByteArray? = null + + override suspend fun save(snapshot: ByteArray) { + this.snapshot = snapshot + } + + override suspend fun load(): ByteArray? = snapshot + + override suspend fun delete() { + snapshot = null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/MarmotPushStateStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/MarmotPushStateStore.kt new file mode 100644 index 0000000000..ebca01eff7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/MarmotPushStateStore.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * Durable push state for one group: which token records are active, the + * high-water stamp of every record key, and which keys carry a tombstone. + * + * ## Why this needs a store of its own + * + * Because a tombstone that does not survive a restart is not a tombstone. Owner + * authentication makes a record relay-portable, so any current member can + * re-emit a revoked-but-still-signed record inside a fresh kind `448` at any + * later epoch. The per-key stamp is the ONLY thing that recognises it as stale, + * and it cannot be rebuilt by replaying retained app payloads — the relayed + * record's carrying epoch is unbounded, while the retained window is not. + * + * The state holds no secret: encrypted tokens are already encrypted to a + * notification server this client cannot read, and everything else is public + * routing. An implementation MAY still encrypt at rest, but a store that cannot + * is better than no store. + */ +interface MarmotPushStateStore { + /** The stored blob for a group, or null when nothing is stored yet. */ + suspend fun load(nostrGroupId: HexKey): String? + + suspend fun save( + nostrGroupId: HexKey, + state: String, + ) + + suspend fun clear(nostrGroupId: HexKey) +} + +/** Non-durable default. A restart forgets every tombstone, so a stale relay can win once. */ +class InMemoryPushStateStore : MarmotPushStateStore { + private val states = mutableMapOf() + + override suspend fun load(nostrGroupId: HexKey): String? = states[nostrGroupId] + + override suspend fun save( + nostrGroupId: HexKey, + state: String, + ) { + states[nostrGroupId] = state + } + + override suspend fun clear(nostrGroupId: HexKey) { + states.remove(nostrGroupId) + } +} + +/** + * The on-disk shape of a [PushRecordStore]. + * + * A local format, not a wire format: it is never sent anywhere, so it is free + * to store the derived stamp beside each key rather than recompute it. That is + * the point — the stamp of a tombstoned key has no record left to recompute it + * from. + */ +object PushStateCodec { + private val parser = Json { ignoreUnknownKeys = true } + + fun encode(store: PushRecordStore): String { + val stamps = store.snapshotStamps() + val tombstones = store.snapshotTombstones() + return buildJsonObject { + put("group_id", store.groupIdHex) + put( + "records", + buildJsonArray { + store.active().forEach { add(recordJson(it)) } + }, + ) + put( + "stamps", + buildJsonArray { + stamps.forEach { (key, stamp) -> + add( + buildJsonObject { + putKey(key) + put("owner_ts", stamp.ownerTsMillis) + put("digest", stamp.digestHex) + put("tombstone", key in tombstones) + }, + ) + } + }, + ) + }.toString() + } + + /** Restore [store] from [json]. A blob that cannot be read leaves the store untouched. */ + fun decodeInto( + store: PushRecordStore, + json: String, + ): Boolean { + val root = + try { + parser.parseToJsonElement(json) as? JsonObject + } catch (_: Exception) { + null + } ?: return false + + val records = + (root["records"] as? JsonArray) + ?.mapNotNull { it as? JsonObject } + ?.mapNotNull { readRecord(it) } + .orEmpty() + + val stamps = mutableMapOf() + val tombstones = mutableSetOf() + (root["stamps"] as? JsonArray)?.mapNotNull { it as? JsonObject }?.forEach { obj -> + val key = readKey(obj) ?: return@forEach + val ownerTs = obj.long("owner_ts") ?: return@forEach + val digest = obj.str("digest") ?: return@forEach + stamps[key] = PushRecordStamp(ownerTs, digest) + if ((obj["tombstone"] as? JsonPrimitive)?.content == "true") tombstones.add(key) + } + + store.restore(records, stamps, tombstones) + return true + } + + private fun recordJson(entry: PushTokenEntry): JsonObject = + buildJsonObject { + putKey(entry.key) + put("token_fingerprint", entry.tokenFingerprint) + put("relay_hint", entry.relayHint) + put("encrypted_token", entry.encryptedTokenBase64) + put("owner_ts", entry.ownerTsMillis) + put("owner_sig", PushHex.of(entry.ownerSig)) + } + + private fun readRecord(obj: JsonObject): PushTokenEntry? { + val key = readKey(obj) ?: return null + val fingerprint = obj.str("token_fingerprint") ?: return null + val token = + PushBase64 + .decodeOrNull(obj.str("encrypted_token") ?: return null) + ?.takeIf { it.size == PushSignedRecord.ENCRYPTED_TOKEN_BYTES } ?: return null + val ownerTs = obj.long("owner_ts") ?: return null + val sigHex = obj.str("owner_sig")?.takeIf { PushHex.isLower(it, 128) } ?: return null + return PushTokenEntry( + memberIdHex = key.memberIdHex, + leafIndex = key.leafIndex, + platform = key.platform, + tokenFingerprint = fingerprint, + serverPubKeyHex = key.serverPubKeyHex, + relayHint = obj.str("relay_hint").orEmpty(), + encryptedToken = token, + ownerTsMillis = ownerTs, + ownerSig = PushHex.bytes(sigHex), + ) + } + + private fun readKey(obj: JsonObject): PushRecordKey? { + val member = obj.str("member_id_hex")?.takeIf { PushHex.isLower(it, 64) } ?: return null + val server = obj.str("server_pubkey_hex")?.takeIf { PushHex.isLower(it, 64) } ?: return null + val leaf = obj.long("leaf_index")?.takeIf { it in 0..Int.MAX_VALUE.toLong() }?.toInt() ?: return null + val platform = obj.str("platform")?.let { PushPlatform.fromWireName(it) } ?: return null + return PushRecordKey(member, leaf, platform, server) + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.putKey(key: PushRecordKey) { + put("member_id_hex", key.memberIdHex) + put("leaf_index", key.leafIndex) + put("platform", key.platform.wireName) + put("server_pubkey_hex", key.serverPubKeyHex) + } + + private fun JsonObject.str(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.long(name: String): Long? = (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toLongOrNull() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt index e942ee4efa..aeedec16b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/NotificationRequestEvent.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -30,18 +29,21 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils /** - * Marmot Notification Request Event (MIP-05) — kind 446. + * Marmot push notification trigger — kind 446 + * (`features/push-notifications.md`, "Notification trigger"). * - * An unsigned rumor event delivered via NIP-59 gift wrap to the notification server. - * Contains concatenated EncryptedTokens (each 280 bytes), base64-encoded. + * The rumor inside a gift wrap addressed to a notification server's inbox. It + * is NOT an inner group payload: kinds 447-449 travel inside group messages, + * this one leaves the group entirely, so the Nostr binding owns its seal, wrap + * and publish targets. * - * Flow: Rumor(kind:446) → Seal(kind:13) → GiftWrap(kind:1059) → notification server + * `pubkey` MUST be a fresh ephemeral key. That is also why a server cannot + * deduplicate on the outer event id — a replayer re-wraps freely — and must key + * on the content hash instead. * - * The pubkey MUST be a fresh ephemeral key (not the sender's identity) - * to prevent the notification server from linking events to users. - * - * Content includes real group tokens plus decoy tokens from other groups - * (shuffled) to obscure group size and prevent social graph inference. + * The only tag is `v`. The earlier exploratory shape also required an + * `["encoding", "base64"]` tag; the adopted rumor does not carry one, because + * the transport's byte-encoding rule already fixes standard padded base64. */ @Immutable class NotificationRequestEvent( @@ -53,31 +55,57 @@ class NotificationRequestEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { /** - * Base64-encoded concatenation of EncryptedTokens. - * Each token is exactly 280 bytes when decoded. - * Total decoded length MUST be a multiple of 280. + * Base64 of 1 to 32 concatenated 1084-byte chunks, each an `EncryptedToken` + * or random padding. */ fun tokensBase64() = content - /** Notification protocol version (must be "mip05-v1") */ + /** Must be [PushGossip.VERSION]; anything else is not this protocol. */ fun version() = tags.notificationVersion() - /** Content encoding (must be "base64") */ - fun encoding() = tags.notificationEncoding() + /** + * The chunks, or null when the trigger is structurally malformed. + * + * The length check happens before any ECDH or AEAD work, which is the point: + * a server must be able to discard an oversized trigger without doing the + * expensive part. + */ + fun chunks(): List? { + val decoded = PushBase64.decodeOrNull(content) ?: return null + if (decoded.isEmpty()) return null + if (decoded.size % PushSignedRecord.ENCRYPTED_TOKEN_BYTES != 0) return null + val count = decoded.size / PushSignedRecord.ENCRYPTED_TOKEN_BYTES + if (count > MAX_CHUNKS) return null + return List(count) { + decoded.copyOfRange(it * PushSignedRecord.ENCRYPTED_TOKEN_BYTES, (it + 1) * PushSignedRecord.ENCRYPTED_TOKEN_BYTES) + } + } override fun isContentEncoded() = true companion object { const val KIND = 446 + /** Includes padding: padding cannot create unbounded server work. */ + const val MAX_CHUNKS = 32 + fun build( - tokensBase64: String, + chunks: List, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, tokensBase64, createdAt) { - addUnique(VersionTag.assemble()) - addUnique(EncodingTag.assemble()) - initializer() + ): com.vitorpamplona.quartz.nip01Core.signers.EventTemplate { + require(chunks.isNotEmpty() && chunks.size <= MAX_CHUNKS) { + "a push trigger carries 1..$MAX_CHUNKS chunks, got ${chunks.size}" + } + require(chunks.all { it.size == PushSignedRecord.ENCRYPTED_TOKEN_BYTES }) { + "every push trigger chunk is exactly ${PushSignedRecord.ENCRYPTED_TOKEN_BYTES} bytes" + } + val joined = ByteArray(chunks.size * PushSignedRecord.ENCRYPTED_TOKEN_BYTES) + chunks.forEachIndexed { index, chunk -> chunk.copyInto(joined, index * PushSignedRecord.ENCRYPTED_TOKEN_BYTES) } + return eventTemplate(KIND, PushBase64.encode(joined), createdAt) { + addUnique(VersionTag.assemble()) + initializer() + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushBase64.kt similarity index 58% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushBase64.kt index d53e616f28..ab68d2743d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushBase64.kt @@ -20,11 +20,27 @@ */ package com.vitorpamplona.quartz.marmot.mip05PushNotifications -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi -fun TagArrayBuilder.tokens(tokens: List) = addAll(TokenTag.assemble(tokens)) +/** + * Standard base64 with padding — the encoding push uses for an `EncryptedToken` + * and for the kind `446` trigger content. + * + * Wrapped rather than called directly so that "standard, padded, and it either + * decodes or the datum is dropped" is stated once. Everything push decodes is + * advisory: a bad entry is discarded, and nothing about it may reach the + * validity of the group message that carried it. + */ +@OptIn(ExperimentalEncodingApi::class) +object PushBase64 { + fun encode(bytes: ByteArray): String = Base64.encode(bytes) -fun TagArrayBuilder.token(data: TokenTagData) = add(TokenTag.assemble(data)) + /** Null when [text] is not valid standard base64. */ + fun decodeOrNull(text: String): ByteArray? = + try { + Base64.decode(text) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushGossip.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushGossip.kt new file mode 100644 index 0000000000..a3cce56287 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushGossip.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** + * The `content` JSON of the push gossip app events — kinds `447`, `448` and + * `449` (`features/push-notifications.md`, "Token gossip event shapes"). + * + * ## Everything here is advisory + * + * Push draws a hard line: a malformed entry, an unverifiable `owner_sig`, a + * removal that matches nothing, an array that lost an ordering race — all of it + * is dropped datum by datum, and NONE of it may reject the group message that + * carried it or touch group state. So the decoders below return the entries + * they could read and silently discard the rest, rather than throwing. + * + * The one array-wide rule is the 32-entry cap: an array longer than that is + * treated as invalid *in its entirety*, before any signature is verified, so an + * oversized array cannot make a recipient do unbounded verification work. + */ +object PushGossip { + /** The only `v` any of these events may carry. A different value is rejected outright. */ + const val VERSION = "marmot-push-v1" + + const val MAX_ENTRIES = 32 + + /** + * How far ahead of local wall clock an `owner_ts` may be: one hour. + * + * The bound exists because `owner_ts` is a latest-wins high-water mark. A + * far-future stamp would otherwise pin a record permanently, and no honest + * clock skew reaches an hour. + */ + const val OWNER_TS_MAX_FUTURE_MILLIS = 3_600_000L + + private val parser = Json { ignoreUnknownKeys = true } + + // ---------------------------------------------------------------- encode + + fun encodeTokens(entries: List): String = + buildJsonObject { + put("v", VERSION) + put( + "tokens", + buildJsonArray { + entries.forEach { add(encodeToken(it)) } + }, + ) + }.toString() + + fun encodeRemovals(entries: List): String = + buildJsonObject { + put("v", VERSION) + put( + "removals", + buildJsonArray { + entries.forEach { add(encodeRemoval(it)) } + }, + ) + }.toString() + + /** A kind `447` with no entries: "share your records with me". */ + fun encodeRequest(): String = encodeTokens(emptyList()) + + private fun encodeToken(entry: PushTokenEntry): JsonObject = + buildJsonObject { + put("member_id_hex", entry.memberIdHex) + put("leaf_index", entry.leafIndex) + put("platform", entry.platform.wireName) + put("token_fingerprint", entry.tokenFingerprint) + put("server_pubkey_hex", entry.serverPubKeyHex) + // An absent hint is omitted rather than written as "", matching what + // the owner proof signed over. + if (entry.relayHint.isNotEmpty()) put("relay_hint", entry.relayHint) + put("encrypted_token", entry.encryptedTokenBase64) + put("owner_ts", entry.ownerTsMillis) + put("owner_sig", PushHex.of(entry.ownerSig)) + } + + private fun encodeRemoval(entry: PushRemovalEntry): JsonObject = + buildJsonObject { + put("member_id_hex", entry.memberIdHex) + put("leaf_index", entry.leafIndex) + put("platform", entry.platform.wireName) + put("token_fingerprint", entry.tokenFingerprint) + put("server_pubkey_hex", entry.serverPubKeyHex) + put("owner_ts", entry.ownerTsMillis) + put("owner_sig", PushHex.of(entry.ownerSig)) + } + + // ---------------------------------------------------------------- decode + + /** + * Read a kind `447`/`448` content. + * + * An empty list means either "a request" or "nothing survived validation" — + * deliberately the same outcome, because both change no state. + */ + fun decodeTokens(content: String): List = decodeArray(content, "tokens")?.mapNotNull { decodeToken(it) } ?: emptyList() + + fun decodeRemovals(content: String): List = decodeArray(content, "removals")?.mapNotNull { decodeRemoval(it) } ?: emptyList() + + /** True when the content is a well-formed `marmot-push-v1` object at all. */ + fun isSupportedVersion(content: String): Boolean = root(content) != null + + private fun root(content: String): JsonObject? { + val obj = + try { + parser.parseToJsonElement(content) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + val version = (obj["v"] as? JsonPrimitive)?.takeIf { it.isString }?.content + return if (version == VERSION) obj else null + } + + private fun decodeArray( + content: String, + member: String, + ): List? { + val obj = root(content) ?: return null + // A missing member reads as an empty array; a present non-array member + // is invalid and contributes nothing. Both are "no entries". + val array = obj[member] ?: return emptyList() + val entries = array as? JsonArray ?: return null + if (entries.size > MAX_ENTRIES) return null + return entries.mapNotNull { it as? JsonObject } + } + + private fun decodeToken(obj: JsonObject): PushTokenEntry? { + val common = decodeCommon(obj) ?: return null + val encryptedToken = + PushBase64 + .decodeOrNull(obj.stringOrNull("encrypted_token") ?: return null) + ?.takeIf { it.size == PushSignedRecord.ENCRYPTED_TOKEN_BYTES } ?: return null + return PushTokenEntry( + memberIdHex = common.memberIdHex, + leafIndex = common.leafIndex, + platform = common.platform, + tokenFingerprint = common.tokenFingerprint, + serverPubKeyHex = common.serverPubKeyHex, + relayHint = PushSignedRecord.normalizeRelayHint(obj.stringOrNull("relay_hint")), + encryptedToken = encryptedToken, + ownerTsMillis = common.ownerTsMillis, + ownerSig = common.ownerSig, + ) + } + + private fun decodeRemoval(obj: JsonObject): PushRemovalEntry? { + val common = decodeCommon(obj) ?: return null + return PushRemovalEntry( + memberIdHex = common.memberIdHex, + leafIndex = common.leafIndex, + platform = common.platform, + tokenFingerprint = common.tokenFingerprint, + serverPubKeyHex = common.serverPubKeyHex, + ownerTsMillis = common.ownerTsMillis, + ownerSig = common.ownerSig, + ) + } + + private class Common( + val memberIdHex: String, + val leafIndex: Int, + val platform: PushPlatform, + val tokenFingerprint: String, + val serverPubKeyHex: String, + val ownerTsMillis: Long, + val ownerSig: ByteArray, + ) + + /** The six members a token entry and a removal entry encode identically. */ + private fun decodeCommon(obj: JsonObject): Common? { + val memberIdHex = obj.stringOrNull("member_id_hex")?.takeIf { PushHex.isLower(it, 64) } ?: return null + val serverPubKeyHex = obj.stringOrNull("server_pubkey_hex")?.takeIf { PushHex.isLower(it, 64) } ?: return null + val leafIndex = obj.longOrNull("leaf_index")?.takeIf { it in 0..Int.MAX_VALUE.toLong() }?.toInt() ?: return null + val platform = obj.stringOrNull("platform")?.let { PushPlatform.fromWireName(it) } ?: return null + val fingerprint = obj.stringOrNull("token_fingerprint") ?: return null + if (PushSignedRecord.fingerprintBytes(fingerprint) == null) return null + val ownerTs = obj.longOrNull("owner_ts")?.takeIf { it >= 0 } ?: return null + val ownerSigHex = obj.stringOrNull("owner_sig")?.takeIf { PushHex.isLower(it, 128) } ?: return null + return Common(memberIdHex, leafIndex, platform, fingerprint, serverPubKeyHex, ownerTs, PushHex.bytes(ownerSigHex)) + } + + private fun JsonObject.stringOrNull(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + + /** + * A JSON *number*, not a numeric string. `"leaf_index": "3"` is a different + * document from `"leaf_index": 3`, and only the latter is what the spec + * shows — accepting both would let two senders produce entries that agree + * on meaning and disagree on the digest. + */ + private fun JsonObject.longOrNull(name: String): Long? { + val primitive = this[name] as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.jsonPrimitive.content.toLongOrNull() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt index 8a6e9109aa..d86f18f931 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.sha256.sha256 /** The two record shapes an owner proof can cover. */ enum class PushRecordKind( @@ -227,6 +228,12 @@ object PushOwnerProof { * every leaf carries a `0x8009` identity proof, accepting a weaker legacy * form would let anyone who can produce one bypass the stronger binding the * group already guarantees. + * + * A legacy group accepts two more forms, both verification-only and never + * produced: the transitional kind [LEGACY_KIND] event deployed before `451` + * was allocated, and the raw proof — a signature directly over the 32-byte + * `SHA-256(SignedRecord)` digest. [signedRecord] supplies those canonical + * bytes lazily, so a current-profile group never computes them at all. */ fun verifyRecord( ownerSig: ByteArray, @@ -241,6 +248,7 @@ object PushOwnerProof { relayHint: String = "", encryptedTokenBase64: String = "", currentProfileGroup: Boolean, + signedRecord: (() -> ByteArray)? = null, ): Boolean { val builtTags = tags( @@ -257,7 +265,31 @@ object PushOwnerProof { val content = if (record == PushRecordKind.REMOVAL) "" else encryptedTokenBase64 if (verify(ownerSig, memberIdHex, builtTags, content, KIND)) return true if (currentProfileGroup) return false - return verify(ownerSig, memberIdHex, builtTags, content, LEGACY_KIND) + if (verify(ownerSig, memberIdHex, builtTags, content, LEGACY_KIND)) return true + val canonical = signedRecord?.invoke() ?: return false + return verifyRawDigest(ownerSig, memberIdHex, canonical) + } + + /** + * The oldest accepted form: a BIP-340 signature straight over + * `SHA-256(SignedRecord)`, with no event around it. + * + * Verification-only, and only in a legacy group. A producer MUST NOT create + * it — it binds the same fields, but through a digest an external signer + * cannot be asked to sign without handing it raw bytes, which is why the + * current form is an event id instead. + */ + fun verifyRawDigest( + ownerSig: ByteArray, + memberIdHex: HexKey, + signedRecord: ByteArray, + ): Boolean { + if (ownerSig.size != 64) return false + return try { + Nip01Crypto.verify(ownerSig, sha256(signedRecord), memberIdHex.hexToByteArray()) + } catch (_: Exception) { + false + } } /** Hex form, for embedding in a gossip record. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushPlatform.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushPlatform.kt new file mode 100644 index 0000000000..d28daac9a7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushPlatform.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +/** + * The platform a push token belongs to (`features/push-notifications.md`). + * + * Two encodings for the same thing, and both are load-bearing: [wireName] is + * what a gossip entry's `platform` member and the owner-proof tag carry, while + * [byte] is what goes into the encrypted token plaintext, the fingerprint + * preimage and the canonical [PushSignedRecord]. Keeping them on one type is + * what stops a signer and a verifier from disagreeing about which is which. + */ +enum class PushPlatform( + val wireName: String, + val byte: Byte, +) { + APNS("apns", 0x01), + FCM("fcm", 0x02), + ; + + companion object { + /** Null for an unknown platform — the entry is then advisory-invalid, not an error. */ + fun fromWireName(name: String): PushPlatform? = entries.firstOrNull { it.wireName == name } + + fun fromByte(value: Byte): PushPlatform? = entries.firstOrNull { it.byte == value } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordOrdering.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordOrdering.kt new file mode 100644 index 0000000000..41d6eabe2d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordOrdering.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The record key of a push token: `(member_id_hex, leaf_index, platform, + * server_pubkey_hex)` (`features/push-notifications.md`, "Record key and + * ordering primitive"). + * + * At most one active record exists per key per group. `leaf_index` is in the + * key deliberately: one Marmot account can hold several MLS leaves, and + * collapsing them would let one device's list entry or removal overwrite a + * sibling device's live token. `token_fingerprint` is NOT in the key — it is + * replaceable data on the record, and gating a delete on it would weaken + * tombstones. + */ +data class PushRecordKey( + val memberIdHex: HexKey, + val leafIndex: Int, + val platform: PushPlatform, + val serverPubKeyHex: HexKey, +) + +/** + * The ordering primitive for a record key: `(owner_ts, record digest)`. + * + * The `owner_ts` half is an owner-supplied latest-wins clock; the digest half + * makes two distinct records stamped in the same millisecond converge on the + * same winner everywhere. Both halves come out of fields the owner proof binds, + * so the primitive inherits `owner_sig`'s trust rather than the carrying + * event's sender. + * + * A client MUST NOT substitute the carrying event's `created_at`, arrival + * order, outer event ids, relay metadata or local receive time for this. Using + * `owner_ts` is exactly what makes a relayed kind `448` safe: the relaying + * member cannot advance or rewind a record it cannot re-sign. + */ +data class PushRecordStamp( + val ownerTsMillis: Long, + val digestHex: HexKey, +) : Comparable { + override fun compareTo(other: PushRecordStamp): Int { + val byTime = ownerTsMillis.compareTo(other.ownerTsMillis) + if (byTime != 0) return byTime + return digestHex.compareTo(other.digestHex) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordStore.kt new file mode 100644 index 0000000000..03b15f53aa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordStore.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One group's push token records: which token is active per record key, and + * which keys carry a tombstone (`features/push-notifications.md`, "Record + * state"). + * + * ## What it is not + * + * Never group state. Nothing here can reject, delay or reorder a group message, + * and no MLS decision may read it. It is local push routing that two members' + * clients happen to converge on. + * + * ## Why a tombstone has to be durable + * + * Owner authentication makes a record relay-portable: any current member can + * re-emit another member's still-valid signed record inside a fresh kind `448` + * at any later epoch. So a stale record's carrying epoch is unbounded, and the + * retained app-payload window cannot bound it. The per-key stamp and tombstone + * are the ONLY durable high-water marks that stop a revoked token from being + * resurrected by a relay, which is why they are cleared on exactly two events — + * a strictly-greater-stamped entry, or the owning leaf leaving the group — and + * never on a wall clock, an `owner_ts`, or an epoch count. + */ +class PushRecordStore( + val groupIdHex: HexKey, + /** + * Whether this group requires `0x8009`. It selects which owner-proof forms + * are acceptable, so it must come from the group's GroupContext rather than + * from anything a sender says. + */ + val currentProfileGroup: Boolean, +) { + private val records = mutableMapOf() + private val stamps = mutableMapOf() + private val tombstones = mutableSetOf() + + /** Every active token record, in no particular order. */ + fun active(): List = records.values.toList() + + fun activeFor(key: PushRecordKey): PushTokenEntry? = records[key] + + /** The high-water mark for a key, whether it currently holds a record or a tombstone. */ + fun stampFor(key: PushRecordKey): PushRecordStamp? = stamps[key] + + fun isTombstoned(key: PushRecordKey): Boolean = key in tombstones + + /** Restore a persisted store. Stamps and tombstones survive restarts or the guarantee is gone. */ + fun restore( + activeRecords: Collection, + persistedStamps: Map, + persistedTombstones: Collection, + ) { + records.clear() + stamps.clear() + tombstones.clear() + activeRecords.forEach { records[it.key] = it } + stamps.putAll(persistedStamps) + tombstones.addAll(persistedTombstones) + } + + fun snapshotStamps(): Map = stamps.toMap() + + fun snapshotTombstones(): Set = tombstones.toSet() + + /** + * Apply the entries of one kind `447`/`448` event. + * + * [isCurrentMember] is asked per member id because a record's authority + * comes from `owner_sig` plus current membership — never from who carried + * it. A verified entry is applied even when the sender is not its owner, + * and an entry naming a non-member is dropped even when it verifies. + * + * @return the keys whose stored record changed. + */ + fun applyTokens( + entries: List, + nowMillis: Long, + isCurrentMember: (HexKey) -> Boolean, + ): Set { + val changed = mutableSetOf() + entries.forEach { entry -> + if (entry.ownerTsMillis > nowMillis + PushGossip.OWNER_TS_MAX_FUTURE_MILLIS) return@forEach + if (!isCurrentMember(entry.memberIdHex)) return@forEach + if (!entry.verifyOwner(groupIdHex, currentProfileGroup)) return@forEach + + val key = entry.key + val stamp = entry.stamp(groupIdHex) + // Strictly greater, so re-applying the same signed record is a + // no-op whether it arrives fresh or relayed. Array position is not a + // tie-breaker: the highest stamp wins wherever it sits. + if (!stamp.wins(stamps[key])) return@forEach + + records[key] = entry + stamps[key] = stamp + tombstones.remove(key) + changed.add(key) + } + return changed + } + + /** + * Apply the entries of one kind `449` event. + * + * A removal that wins its key does two things: it deletes the record AND + * writes a tombstone at the removal's own stamp, so a token list assembled + * before the removal cannot bring the revoked token back. + * + * @return the keys whose stored record changed. + */ + fun applyRemovals( + entries: List, + nowMillis: Long, + isCurrentMember: (HexKey) -> Boolean, + ): Set { + val changed = mutableSetOf() + entries.forEach { entry -> + if (entry.ownerTsMillis > nowMillis + PushGossip.OWNER_TS_MAX_FUTURE_MILLIS) return@forEach + if (!isCurrentMember(entry.memberIdHex)) return@forEach + if (!entry.verifyOwner(groupIdHex, currentProfileGroup)) return@forEach + + val key = entry.key + val stamp = entry.stamp(groupIdHex) + if (!stamp.wins(stamps[key])) return@forEach + + records.remove(key) + stamps[key] = stamp + tombstones.add(key) + changed.add(key) + } + return changed + } + + /** + * Forget a leaf an accepted Commit removed. + * + * The whole key — record, stamp and tombstone — goes, because the leaf can + * no longer be a current member and nothing it signed can be applied again. + * A sibling leaf of the same account keeps its own records: they are + * different keys and the account is still in the group. + */ + fun forgetLeaf( + memberIdHex: HexKey, + leafIndex: Int, + ) { + val doomed = (records.keys + stamps.keys + tombstones).filter { it.memberIdHex == memberIdHex && it.leafIndex == leafIndex } + doomed.forEach { + records.remove(it) + stamps.remove(it) + tombstones.remove(it) + } + } + + private fun PushRecordStamp.wins(previous: PushRecordStamp?): Boolean = previous == null || this > previous +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushSignedRecord.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushSignedRecord.kt new file mode 100644 index 0000000000..eb1b9e0d32 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushSignedRecord.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * The canonical `SignedRecord` bytes of a push token record or removal + * (`features/push-notifications.md`, "Canonical record bytes"). + * + * ```text + * SignedRecord = domain_tag + * || group_id_len[2] big-endian u16 + * || group_id[group_id_len] + * || member_id[32] + * || leaf_index[4] big-endian u32 + * || platform_byte[1] + * || server_pubkey[32] + * || token_fingerprint[12] + * || owner_ts[8] big-endian u64, milliseconds + * || relay_hint_len[2] big-endian u16, 0 when absent or for a removal + * || relay_hint[relay_hint_len] + * || encrypted_token[1084] removals omit this field + * ``` + * + * ## Why this is hand-rolled rather than a TLS vector or a QUIC varint + * + * The spec says so, in as many words: "Signers and verifiers MUST NOT + * substitute QUIC varints, TLS vectors, or a serialization-library default." + * The rest of Marmot's binary profile uses QUIC varints, so the temptation to + * reach for [TlsWriter] here is real and wrong — a varint `group_id_len` would + * be one byte where this is two, every subsequent field would shift, and the + * digest would differ from every other implementation's while still looking + * perfectly well-formed locally. + * + * ## What it is for + * + * ONLY the digest. `SHA-256(SignedRecord)` is the deterministic tie-breaker in + * the `(owner_ts, digest)` ordering primitive; it is **not** the signature + * preimage in a current group, where [PushOwnerProof]'s unpublished kind `451` + * event is. It IS the preimage for the raw legacy proof form, which we verify + * in legacy groups and never produce. + */ +object PushSignedRecord { + /** `sha256:` plus 24 hex characters — the first 12 bytes of the token hash. */ + const val FINGERPRINT_PREFIX = "sha256:" + const val FINGERPRINT_BYTES = 12 + const val FINGERPRINT_HEX_LENGTH = FINGERPRINT_BYTES * 2 + + /** `EncryptedToken` is a fixed 1084 bytes; the record embeds it verbatim. */ + const val ENCRYPTED_TOKEN_BYTES = 1084 + + /** + * The fingerprint that names a token without revealing it: + * `sha256:` + the first 24 hex characters of `SHA-256(platform_byte || device_token)`. + */ + fun fingerprintOf( + platform: PushPlatform, + deviceToken: ByteArray, + ): String { + val preimage = ByteArray(1 + deviceToken.size) + preimage[0] = platform.byte + deviceToken.copyInto(preimage, 1) + return FINGERPRINT_PREFIX + sha256(preimage).copyOfRange(0, FINGERPRINT_BYTES).toHexKey() + } + + /** The 12 raw bytes a `sha256:`-prefixed fingerprint encodes, or null when malformed. */ + fun fingerprintBytes(fingerprint: String): ByteArray? { + if (!fingerprint.startsWith(FINGERPRINT_PREFIX)) return null + val hex = fingerprint.substring(FINGERPRINT_PREFIX.length) + if (hex.length != FINGERPRINT_HEX_LENGTH) return null + if (!hex.all { it in '0'..'9' || it in 'a'..'f' }) return null + return hex.hexToByteArray() + } + + /** + * A relay hint as it is signed over: trimmed, and absent when the result is + * empty. Both halves matter — a signer that kept the untrimmed string and a + * verifier that trimmed it would compute different digests from identical + * JSON, and the record would simply never verify anywhere. + */ + fun normalizeRelayHint(relayHint: String?): String = relayHint?.trim().orEmpty() + + /** + * Build the canonical bytes. + * + * @param encryptedToken the 1084-byte token for a record entry, or null for a removal. + */ + fun encode( + record: PushRecordKind, + groupIdHex: HexKey, + memberIdHex: HexKey, + leafIndex: Int, + platform: PushPlatform, + serverPubKeyHex: HexKey, + tokenFingerprint: String, + ownerTsMillis: Long, + relayHint: String = "", + encryptedToken: ByteArray? = null, + ): ByteArray { + val domain = record.domainTag.encodeToByteArray() + val groupId = groupIdHex.hexToByteArray() + val memberId = memberIdHex.hexToByteArray() + val serverPubKey = serverPubKeyHex.hexToByteArray() + val fingerprint = requireNotNull(fingerprintBytes(tokenFingerprint)) { "malformed token fingerprint" } + + require(memberId.size == 32) { "member id must be 32 bytes" } + require(serverPubKey.size == 32) { "server pubkey must be 32 bytes" } + require(groupId.size <= 0xFFFF) { "group id too long" } + + // A removal signs a zero-length hint and omits the token entirely, so + // the two record shapes can never collide on the same bytes. + val hintBytes = + if (record == PushRecordKind.REMOVAL) { + ByteArray(0) + } else { + normalizeRelayHint(relayHint).encodeToByteArray() + } + require(hintBytes.size <= 0xFFFF) { "relay hint too long" } + + val token = + if (record == PushRecordKind.REMOVAL) { + null + } else { + requireNotNull(encryptedToken) { "a token record must carry its encrypted token" } + .also { require(it.size == ENCRYPTED_TOKEN_BYTES) { "EncryptedToken must be $ENCRYPTED_TOKEN_BYTES bytes" } } + } + + val size = + domain.size + 2 + groupId.size + 32 + 4 + 1 + 32 + FINGERPRINT_BYTES + 8 + 2 + + hintBytes.size + (token?.size ?: 0) + val out = ByteArray(size) + var at = 0 + + fun put(bytes: ByteArray) { + bytes.copyInto(out, at) + at += bytes.size + } + + fun putU16(value: Int) { + out[at++] = (value ushr 8 and 0xFF).toByte() + out[at++] = (value and 0xFF).toByte() + } + + put(domain) + putU16(groupId.size) + put(groupId) + put(memberId) + // leaf_index is u32 big-endian; an Int is the same 4 bytes for every + // index MLS can actually reach. + out[at++] = (leafIndex ushr 24 and 0xFF).toByte() + out[at++] = (leafIndex ushr 16 and 0xFF).toByte() + out[at++] = (leafIndex ushr 8 and 0xFF).toByte() + out[at++] = (leafIndex and 0xFF).toByte() + out[at++] = platform.byte + put(serverPubKey) + put(fingerprint) + for (shift in 56 downTo 0 step 8) { + out[at++] = (ownerTsMillis ushr shift and 0xFF).toByte() + } + putU16(hintBytes.size) + put(hintBytes) + token?.let { put(it) } + + return out + } + + /** `SHA-256(SignedRecord)` — the ordering tie-breaker, as lowercase hex. */ + fun digestHex(signedRecord: ByteArray): HexKey = sha256(signedRecord).toHexKey() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushTokenEntry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushTokenEntry.kt new file mode 100644 index 0000000000..0803e35f76 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushTokenEntry.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * One push token record as it travels inside a kind `447` or `448` app event + * (`features/push-notifications.md`, "Token entries"). + * + * The entry is self-authenticating: [ownerSig] is the owning member's proof + * over every other field, so a member that merely RELAYS the record in a kind + * `448` cannot move it to another group, repoint it at a different notification + * server or relay, swap the token, or restamp it. That is what lets a group + * converge on the full token set without every owner being online. + * + * Fields here are already validated shapes — [PushGossip.decodeTokens] drops a + * malformed entry rather than constructing one — but NOT yet verified. Whether + * the signature is good, whether the member is current, and whether the stamp + * wins its record key are all recipient decisions that need context this type + * does not have. + */ +data class PushTokenEntry( + val memberIdHex: HexKey, + val leafIndex: Int, + val platform: PushPlatform, + val tokenFingerprint: String, + val serverPubKeyHex: HexKey, + /** Already trimmed; empty means absent. */ + val relayHint: String, + /** Exactly 1084 bytes. */ + val encryptedToken: ByteArray, + val ownerTsMillis: Long, + /** Exactly 64 bytes. */ + val ownerSig: ByteArray, +) { + val key get() = PushRecordKey(memberIdHex, leafIndex, platform, serverPubKeyHex) + + val encryptedTokenBase64: String get() = PushBase64.encode(encryptedToken) + + fun signedRecord(groupIdHex: HexKey): ByteArray = + PushSignedRecord.encode( + record = PushRecordKind.TOKEN, + groupIdHex = groupIdHex, + memberIdHex = memberIdHex, + leafIndex = leafIndex, + platform = platform, + serverPubKeyHex = serverPubKeyHex, + tokenFingerprint = tokenFingerprint, + ownerTsMillis = ownerTsMillis, + relayHint = relayHint, + encryptedToken = encryptedToken, + ) + + fun stamp(groupIdHex: HexKey) = PushRecordStamp(ownerTsMillis, PushSignedRecord.digestHex(signedRecord(groupIdHex))) + + /** + * Verify [ownerSig] the way a recipient must. + * + * [currentProfileGroup] is not a courtesy flag: in a group where every leaf + * already carries a `0x8009` identity proof, accepting the weaker legacy + * proof forms would throw away a binding the group otherwise guarantees. + */ + fun verifyOwner( + groupIdHex: HexKey, + currentProfileGroup: Boolean, + ): Boolean = + PushOwnerProof.verifyRecord( + ownerSig = ownerSig, + record = PushRecordKind.TOKEN, + groupIdHex = groupIdHex, + memberIdHex = memberIdHex, + leafIndex = leafIndex, + platform = platform.wireName, + serverPubKeyHex = serverPubKeyHex, + tokenFingerprint = tokenFingerprint, + ownerTsMillis = ownerTsMillis, + relayHint = relayHint, + encryptedTokenBase64 = encryptedTokenBase64, + currentProfileGroup = currentProfileGroup, + signedRecord = { signedRecord(groupIdHex) }, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PushTokenEntry) return false + return memberIdHex == other.memberIdHex && + leafIndex == other.leafIndex && + platform == other.platform && + tokenFingerprint == other.tokenFingerprint && + serverPubKeyHex == other.serverPubKeyHex && + relayHint == other.relayHint && + encryptedToken.contentEquals(other.encryptedToken) && + ownerTsMillis == other.ownerTsMillis && + ownerSig.contentEquals(other.ownerSig) + } + + override fun hashCode(): Int { + var result = memberIdHex.hashCode() + result = 31 * result + leafIndex + result = 31 * result + platform.hashCode() + result = 31 * result + tokenFingerprint.hashCode() + result = 31 * result + serverPubKeyHex.hashCode() + result = 31 * result + relayHint.hashCode() + result = 31 * result + encryptedToken.contentHashCode() + result = 31 * result + ownerTsMillis.hashCode() + result = 31 * result + ownerSig.contentHashCode() + return result + } +} + +/** + * One revocation as it travels inside a kind `449` app event + * (`features/push-notifications.md`, "Removal"). + * + * It carries [leafIndex] for the same reason the record key does: without it a + * removal would revoke every sibling device's token for the same account, + * platform and server rather than the one device that asked to be forgotten. + * + * [tokenFingerprint] is signed over and states which token instance the owner + * meant to revoke, but it does NOT gate the delete — the stamp alone decides + * which write to a record key wins. A fingerprint-scoped tombstone could not + * suppress a differently-fingerprinted stale record from resurrecting the key, + * which is the whole job of a tombstone. + */ +data class PushRemovalEntry( + val memberIdHex: HexKey, + val leafIndex: Int, + val platform: PushPlatform, + val tokenFingerprint: String, + val serverPubKeyHex: HexKey, + val ownerTsMillis: Long, + val ownerSig: ByteArray, +) { + val key get() = PushRecordKey(memberIdHex, leafIndex, platform, serverPubKeyHex) + + fun signedRecord(groupIdHex: HexKey): ByteArray = + PushSignedRecord.encode( + record = PushRecordKind.REMOVAL, + groupIdHex = groupIdHex, + memberIdHex = memberIdHex, + leafIndex = leafIndex, + platform = platform, + serverPubKeyHex = serverPubKeyHex, + tokenFingerprint = tokenFingerprint, + ownerTsMillis = ownerTsMillis, + ) + + fun stamp(groupIdHex: HexKey) = PushRecordStamp(ownerTsMillis, PushSignedRecord.digestHex(signedRecord(groupIdHex))) + + fun verifyOwner( + groupIdHex: HexKey, + currentProfileGroup: Boolean, + ): Boolean = + PushOwnerProof.verifyRecord( + ownerSig = ownerSig, + record = PushRecordKind.REMOVAL, + groupIdHex = groupIdHex, + memberIdHex = memberIdHex, + leafIndex = leafIndex, + platform = platform.wireName, + serverPubKeyHex = serverPubKeyHex, + tokenFingerprint = tokenFingerprint, + ownerTsMillis = ownerTsMillis, + currentProfileGroup = currentProfileGroup, + signedRecord = { signedRecord(groupIdHex) }, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PushRemovalEntry) return false + return memberIdHex == other.memberIdHex && + leafIndex == other.leafIndex && + platform == other.platform && + tokenFingerprint == other.tokenFingerprint && + serverPubKeyHex == other.serverPubKeyHex && + ownerTsMillis == other.ownerTsMillis && + ownerSig.contentEquals(other.ownerSig) + } + + override fun hashCode(): Int { + var result = memberIdHex.hashCode() + result = 31 * result + leafIndex + result = 31 * result + platform.hashCode() + result = 31 * result + tokenFingerprint.hashCode() + result = 31 * result + serverPubKeyHex.hashCode() + result = 31 * result + ownerTsMillis.hashCode() + result = 31 * result + ownerSig.contentHashCode() + return result + } +} + +/** Hex helpers that hold the spec to LOWERCASE, which [com.vitorpamplona.quartz.utils.Hex] deliberately does not. */ +internal object PushHex { + fun isLower( + value: String, + length: Int, + ): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' } + + fun bytes(value: String): ByteArray = value.hexToByteArray() + + fun of(value: ByteArray): HexKey = value.toHexKey() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/README.md new file mode 100644 index 0000000000..5cb2504ef1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/README.md @@ -0,0 +1,106 @@ +# Marmot push notifications + +`features/push-notifications.md`. Optional: a group MUST keep working when no +member supports push, and nothing in this package can affect group state. + +## The shape on the wire + +Four kinds, three of them ordinary unsigned inner app payloads carried inside +group messages: + +| kind | what it is | file | +|------|------------|------| +| 447 | token request (empty array) or self-update | `TokenRequestEvent` | +| 448 | list response, including other members' records | `TokenListEvent` | +| 449 | removal | `TokenRemovalEvent` | +| 446 | the trigger rumor to a notification server | `NotificationRequestEvent` | + +Kind 446 is the odd one: it leaves the group entirely, so the Nostr binding +owns its seal, its recipient addressing and its publish targets. + +All four carry `["v", "marmot-push-v1"]`. **This is not a rename of the earlier +`mip05-v1`.** That version carried tokens in `token` tags with empty content, +left the sender's leaf implicit, defined no removals and predated owner +authentication entirely. The two are not interoperable, and refusing the old +string is how they stay apart. + +## Owner authentication is the whole design + +A record's authority comes from `owner_sig` and current group membership — +never from who carried it. That is what lets one member relay another's records +in a kind 448 so a group converges without every owner being online, while +stopping the relayer from moving a record to another group, repointing it at a +different notification server or relay, swapping the token, or restamping it. + +The proof (`PushOwnerProof`) is a BIP-340 signature over the id of an exact, +**unpublished** kind 451 Nostr event. An event id is a ready-made canonical +digest over precisely the tuple that needs binding, and an external signer can +produce it without ever being handed raw bytes. Only the 64-byte signature +travels. + +`PushSignedRecord` is the other canonical encoding — a fixed-width byte string +whose SHA-256 is the ordering tie-breaker, and, in a legacy group only, the +preimage of the oldest accepted proof form. It deliberately uses `u16`/`u32`/ +`u64` big-endian fields rather than the Marmot binary profile's QUIC varints; +the spec says so in as many words, and substituting a varint would shift every +subsequent field while still looking correct locally. + +## Ordering, and why tombstones are durable + +The record key is `(member_id_hex, leaf_index, platform, server_pubkey_hex)`. +`leaf_index` is in it because one account can hold several MLS leaves, and +collapsing them would let one device revoke a sibling's live token. + +A write wins only when its `(owner_ts, SHA-256(SignedRecord))` is strictly +greater than the key's stored stamp. Never the carrying event's `created_at`, +arrival order, outer event ids, or local receive time — using `owner_ts` is +exactly what makes a relayed kind 448 safe, because the relayer cannot re-sign +it. + +A winning removal writes a **tombstone** at its own stamp, and that tombstone is +durable. Any current member can re-emit a revoked-but-still-signed record in a +fresh kind 448 at any later epoch, so the relayed record's carrying epoch is +unbounded and the retained app-payload window cannot bound it. The per-key stamp +is the only thing that recognises such a record as stale. It is cleared on +exactly two events — a strictly-greater-stamped registration, or the owning leaf +leaving the group — and never on a wall clock, an `owner_ts` or an epoch count. +`MarmotPushStateStore` exists for that reason alone; the in-memory default lets +a stale relay win exactly once after a restart. + +## Everything here is advisory + +A malformed entry, a signature that does not verify, an entry naming a +non-member, a removal matching nothing, a list that loses an ordering race, a +replayed trigger — every one of them drops the offending datum and continues. +None may reject a group message, mutate MLS state, or change which commit wins. +The decoders return what they could read rather than throwing, and +`MarmotPushCoordinator` catches at its own boundary, so a surprise cannot reach +the ingest path that decides whether the carrying kind 445 was valid. + +## What is wired, and what is not + +Wired: `MarmotPushCoordinator` (in `commons`) produces and consumes 447/448/449 +with real kind 451 proofs, assembles the 446 rumor, and persists records, stamps +and tombstones per group. Amethyst holds one per account, applies inbound gossip +in `DecryptAndIndexProcessor`, and answers a peer's request with a kind 448. + +**Not wired: registering a token of our own.** `buildSelfUpdate` needs a device +token and Amethyst's notification-server public key. The server key is a +deployment decision — a notification server can only wake the application whose +platform push credentials it holds, so the spec defines no discovery for it and +each application ships its own. Until that key exists, this client is a correct +participant in other members' push routing and announces nothing of its own. + +Nothing here publishes a kind 446 either. Selecting records, sealing, wrapping +and choosing publish targets belong to the Nostr binding; `buildTrigger` hands +back the rumor and stops. + +## Interop + +MDK implements the same adopted shape (`crates/marmot-app/src/notifications.rs`), +but its `wn` CLI exposes no push commands — `notifications` has only +`subscribe` — so there is no way to drive push through the interop harness the +way `amy`/`wn` drive messages, media and streams. Coverage is the spec's own +published removal fixture (event id and `owner_sig`, asserted in +`PushOwnerProofTest`), byte-layout assertions built independently of the encoder, +and the ordering and tombstone rules exercised in `PushRecordStoreTest`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt index 5895189d5c..47a2bff2cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TagArrayExt.kt @@ -20,13 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.mip05PushNotifications -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.notificationVersion() = firstNotNullOfOrNull(VersionTag::parse) - -fun TagArray.notificationEncoding() = firstNotNullOfOrNull(EncodingTag::parse) - -fun TagArray.tokens() = mapNotNull(TokenTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt index b9c930010d..bde4903873 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.quartz.marmot.mip05PushNotifications -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenEncryption.PLATFORM_APNS -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenEncryption.PLATFORM_FCM -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance @@ -41,11 +38,11 @@ import kotlin.io.encoding.ExperimentalEncodingApi * * Key derivation (MIP-05 §"Key Derivation"): * 1. ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x - * 2. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x) - * 3. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32) + * 2. PRK = HKDF-Extract(salt="marmot-push-token-v1", IKM=shared_x) + * 3. encryption_key = HKDF-Expand(PRK, info="marmot-push-token-encryption", 32) * 4. Encrypt padded plaintext with ChaCha20-Poly1305(key, nonce, plaintext, aad="") * - * Platform values: 0x01 = APNs, 0x02 = FCM + * Platform values live on [PushPlatform]. */ object TokenEncryption { /** Token plaintext MUST be exactly 1024 bytes per MIP-05. */ @@ -55,35 +52,32 @@ object TokenEncryption { private const val HEADER_SIZE = 3 // platform(1) + token_length(2) private const val MAX_TOKEN_SIZE = PADDED_PAYLOAD_SIZE - HEADER_SIZE - private val HKDF_SALT = "mip05-v1".encodeToByteArray() - private val HKDF_INFO = "mip05-token-encryption".encodeToByteArray() + private val HKDF_SALT = "marmot-push-token-v1".encodeToByteArray() + private val HKDF_INFO = "marmot-push-token-encryption".encodeToByteArray() private val EMPTY_AAD = ByteArray(0) - const val PLATFORM_APNS: Byte = 0x01 - const val PLATFORM_FCM: Byte = 0x02 - /** * Encrypts a device token for a notification server. * - * @param platform platform identifier (PLATFORM_APNS or PLATFORM_FCM) - * @param deviceToken raw device token bytes + * @param platform the owning platform + * @param deviceToken raw device token bytes, 1..1021 * @param serverPubKey 32-byte notification server public key - * @return base64-encoded EncryptedToken (280 bytes when decoded) + * @return base64-encoded EncryptedToken (1084 bytes when decoded) */ @OptIn(ExperimentalEncodingApi::class) fun encrypt( - platform: Byte, + platform: PushPlatform, deviceToken: ByteArray, serverPubKey: ByteArray, ): String { - require(deviceToken.size <= MAX_TOKEN_SIZE) { - "Device token too large: ${deviceToken.size} bytes, max $MAX_TOKEN_SIZE" + require(deviceToken.size in 1..MAX_TOKEN_SIZE) { + "Device token must be 1..$MAX_TOKEN_SIZE bytes, got ${deviceToken.size}" } require(serverPubKey.size == PUBKEY_SIZE) { "Server pubkey must be $PUBKEY_SIZE bytes" } // Build padded payload: platform(1) || token_length(2 BE) || token || random_padding val payload = ByteArray(PADDED_PAYLOAD_SIZE) - payload[0] = platform + payload[0] = platform.byte payload[1] = (deviceToken.size ushr 8 and 0xFF).toByte() payload[2] = (deviceToken.size and 0xFF).toByte() deviceToken.copyInto(payload, HEADER_SIZE) @@ -110,7 +104,7 @@ object TokenEncryption { val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey) // Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(1040) - val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE) + val result = ByteArray(PushSignedRecord.ENCRYPTED_TOKEN_BYTES) ephemeralPubKey.copyInto(result, 0) nonce.copyInto(result, PUBKEY_SIZE) ciphertextWithTag.copyInto(result, PUBKEY_SIZE + NONCE_SIZE) @@ -132,8 +126,8 @@ object TokenEncryption { serverPrivKey: ByteArray, ): DecryptedToken { val data = Base64.decode(encryptedTokenBase64) - require(data.size == TokenTag.ENCRYPTED_TOKEN_SIZE) { - "EncryptedToken must be ${TokenTag.ENCRYPTED_TOKEN_SIZE} bytes, got ${data.size}" + require(data.size == PushSignedRecord.ENCRYPTED_TOKEN_BYTES) { + "EncryptedToken must be ${PushSignedRecord.ENCRYPTED_TOKEN_BYTES} bytes, got ${data.size}" } // Parse components @@ -154,11 +148,14 @@ object TokenEncryption { // Parse payload: platform(1) || token_length(2 BE) || token || padding val platform = payload[0] val tokenLength = ((payload[1].toInt() and 0xFF) shl 8) or (payload[2].toInt() and 0xFF) - require(tokenLength in 0..MAX_TOKEN_SIZE) { "Invalid token length: $tokenLength" } + require(tokenLength in 1..MAX_TOKEN_SIZE) { "Invalid token length: $tokenLength" } val deviceToken = payload.copyOfRange(HEADER_SIZE, HEADER_SIZE + tokenLength) - return DecryptedToken(platform, deviceToken) + return DecryptedToken( + requireNotNull(PushPlatform.fromByte(platform)) { "Invalid platform byte: $platform" }, + deviceToken, + ) } /** @@ -181,8 +178,7 @@ object TokenEncryption { * Result of decrypting an EncryptedToken. */ data class DecryptedToken( - /** Platform identifier: [PLATFORM_APNS] or [PLATFORM_FCM] */ - val platform: Byte, + val platform: PushPlatform, /** Raw device token bytes */ val deviceToken: ByteArray, ) { @@ -193,7 +189,7 @@ object TokenEncryption { } override fun hashCode(): Int { - var result = platform.toInt() + var result = platform.hashCode() result = 31 * result + deviceToken.contentHashCode() return result } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt index cd518b0e0e..d07e5a6234 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenListEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -29,18 +29,17 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils /** - * Marmot Token List Response Event (MIP-05) — kind 448. + * Marmot push token list response — kind 448 + * (`features/push-notifications.md`, "List response"). * - * Unsigned application message sent inside a GroupEvent (kind:445) in response - * to a TokenRequestEvent (kind:447). Contains the responder's complete view - * of all active encrypted device tokens in the group. + * The responder's view of the group's active records, INCLUDING records it + * learned from other members. Relaying is the point: each entry keeps its + * original owner's `owner_sig` and `owner_ts` unchanged, so a member who has + * been offline can be brought up to date by anyone. A responder cannot mint a + * record for a member whose signature it does not hold, and cannot advance or + * rewind one it merely carries. * - * Token tags include a leaf_index field (4th value) to identify which - * MLS leaf owns each token. An "e" tag references the kind:447 event - * this is responding to. - * - * Members SHOULD add random delay (0-2s) before responding. - * MUST remain unsigned (no sig field) per MIP-03 security requirements. + * Unsigned, like every inner app payload. */ @Immutable class TokenListEvent( @@ -51,23 +50,17 @@ class TokenListEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - /** All known encrypted tokens with their leaf indices */ - fun tokens() = tags.tokens() - - /** Event ID of the kind:447 request this responds to */ - fun requestEventId() = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + fun entries() = PushGossip.decodeTokens(content) companion object { const val KIND = 448 fun build( - allTokens: List, - requestEventId: HexKey, + entries: List, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - tokens(allTokens) - add(arrayOf("e", requestEventId)) + ) = eventTemplate(KIND, PushGossip.encodeTokens(entries), createdAt) { + addUnique(VersionTag.assemble()) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt index 1d51545f16..7811527e5b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt @@ -21,23 +21,25 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils /** - * Marmot Token Removal Event (MIP-05) — kind 449. + * Marmot push token removal — kind 449 + * (`features/push-notifications.md`, "Removal"). * - * Unsigned application message sent inside a GroupEvent (kind:445) when a device - * leaves a group or wants to disable push notifications. + * A removal names one device's record exactly: the `leaf_index` in each entry + * is what stops a revocation from taking a sibling device's live token with it. + * A winning removal does not just delete — it leaves a tombstone at its own + * stamp, so a token list assembled before the removal cannot resurrect the + * revoked token when it finally arrives. * - * Per MIP-05 this event MUST have **no tags**. The MLS leaf index is implicit - * from the MLS sender identity; receiving clients MUST remove the token for - * the identified leaf. Adding extra tags could leak metadata or be rejected - * by strict MIP-05 validators (e.g. the MDK reference). - * - * MUST remain unsigned (no sig field) per MIP-03 security requirements. + * Unsigned, like every inner app payload; each entry carries its own + * `owner_sig`. */ @Immutable class TokenRemovalEvent( @@ -48,9 +50,18 @@ class TokenRemovalEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun entries() = PushGossip.decodeRemovals(content) + companion object { const val KIND = 449 - fun build(createdAt: Long = TimeUtils.now()) = eventTemplate(KIND, "", createdAt) + fun build( + entries: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, PushGossip.encodeRemovals(entries), createdAt) { + addUnique(VersionTag.assemble()) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt index f0ca609576..eeab739d94 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRequestEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -29,16 +29,16 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils /** - * Marmot Token Request Event (MIP-05) — kind 447. + * Marmot push token request / self-update — kind 447 + * (`features/push-notifications.md`, "Request and update"). * - * Unsigned application message sent inside a GroupEvent (kind:445) when a device - * joins a group, needs to refresh its token view, or has a token change. + * One kind carries two intents, told apart by the array alone: a non-empty + * `tokens` array announces the sender's own current record, an empty one asks + * everyone else to share theirs. An empty request changes no state anywhere, so + * nothing has to distinguish them beyond counting. * - * Includes the sender's own encrypted token in "token" tags to bootstrap - * the device into the group's notification system immediately. - * - * The MLS leaf index is implicit from the MLS sender identity. - * MUST remain unsigned (no sig field) per MIP-03 security requirements. + * An unsigned Marmot app payload like every other inner event — it MUST NOT + * carry a `sig`. Its authority lives entirely in each entry's `owner_sig`. */ @Immutable class TokenRequestEvent( @@ -49,19 +49,27 @@ class TokenRequestEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - /** Encrypted tokens included with this request (sender's own tokens) */ - fun tokens() = tags.tokens() + /** The records this event announces; empty for a request. */ + fun entries() = PushGossip.decodeTokens(content) + + fun isRequest() = entries().isEmpty() companion object { const val KIND = 447 fun build( - ownTokens: List, + entries: List, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - tokens(ownTokens) + ) = eventTemplate(KIND, PushGossip.encodeTokens(entries), createdAt) { + addUnique(VersionTag.assemble()) initializer() } + + /** The empty form: "share your records with me." */ + fun buildRequest( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(emptyList(), createdAt, initializer) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt deleted file mode 100644 index 5edbadf60f..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -/** - * Encrypted token tag for push notification token distribution (kinds 447, 448). - * - * Each EncryptedToken is exactly 280 bytes: - * ephemeral_pubkey(32) || nonce(12) || ciphertext(236) - * - * The token payload inside is padded to 220 bytes: - * platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N) - * - * Platform values: 0x01 = APNs, 0x02 = FCM. - */ -@Immutable -data class TokenTagData( - /** Base64-encoded EncryptedToken (1084 bytes when decoded per MIP-05) */ - val encryptedToken: String, - /** Hex-encoded notification server public key */ - val serverPubKey: HexKey, - /** Relay hint URL for finding the server's kind:10050 event */ - val relayHint: String, - /** MLS leaf index of the token owner (only present in kind:448 responses) */ - val leafIndex: Int? = null, -) - -class TokenTag { - companion object { - const val TAG_NAME = "token" - - /** - * Expected decoded size of an EncryptedToken per MIP-05: - * ephemeral_pubkey(32) || nonce(12) || ciphertext(1024 + 16 tag) = 1084 bytes. - */ - const val ENCRYPTED_TOKEN_SIZE = 1084 - - fun parse(tag: Array): TokenTagData? { - ensure(tag.has(3) && tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - ensure(tag[2].length == 64) { return null } - ensure(tag[3].isNotEmpty()) { return null } - - val leafIndex = tag.getOrNull(4)?.toIntOrNull() - - return TokenTagData( - encryptedToken = tag[1], - serverPubKey = tag[2], - relayHint = tag[3], - leafIndex = leafIndex, - ) - } - - fun assemble(data: TokenTagData): Array = - if (data.leafIndex != null) { - arrayOf(TAG_NAME, data.encryptedToken, data.serverPubKey, data.relayHint, data.leafIndex.toString()) - } else { - arrayOf(TAG_NAME, data.encryptedToken, data.serverPubKey, data.relayHint) - } - - fun assemble(tokens: List) = tokens.map { assemble(it) } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt index 87542573d1..20d94598e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/VersionTag.kt @@ -20,17 +20,23 @@ */ package com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushGossip import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure /** - * Version tag for Marmot push notification events (kind 446). - * Current version: "mip05-v1". + * The `v` tag every push event carries — kinds 446, 447, 448 and 449. + * + * A recipient MUST reject any other value. `marmot-push-v1` is not a rename of + * the earlier exploratory `mip05-v1`: that version carried tokens in tags with + * an empty content, left the sender's leaf implicit, defined no removals and + * predated owner authentication entirely. The two are not interoperable, and + * refusing the old string is how they stay apart. */ class VersionTag { companion object { const val TAG_NAME = "v" - const val CURRENT_VERSION = "mip05-v1" + const val CURRENT_VERSION = PushGossip.VERSION fun parse(tag: Array): String? { ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushGossipTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushGossipTest.kt new file mode 100644 index 0000000000..0689bb1ad9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushGossipTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `features/push-notifications.md`, "Token gossip event shapes" and + * "Validation". + * + * Everything push decodes is advisory, so the whole surface here is about + * DROPPING things quietly and correctly: a bad entry goes, the rest of the + * array stays, and the group message that carried it is never in question. + */ +class PushGossipTest { + private val member = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + private val server = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4" + private val fingerprint = "sha256:000102030405060708090a0b" + private val sig = "11".repeat(64) + private val token = PushBase64.encode(ByteArray(PushSignedRecord.ENCRYPTED_TOKEN_BYTES) { it.toByte() }) + + private fun tokenJson( + overrides: Map = emptyMap(), + drop: Set = emptySet(), + ): String { + val members = + linkedMapOf( + "member_id_hex" to "\"$member\"", + "leaf_index" to "3", + "platform" to "\"apns\"", + "token_fingerprint" to "\"$fingerprint\"", + "server_pubkey_hex" to "\"$server\"", + "encrypted_token" to "\"$token\"", + "owner_ts" to "1735680000000", + "owner_sig" to "\"$sig\"", + ) + overrides.forEach { (k, v) -> members[k] = v } + drop.forEach { members.remove(it) } + val entry = members.entries.joinToString(",") { "\"${it.key}\":${it.value}" } + return """{"v":"marmot-push-v1","tokens":[{$entry}]}""" + } + + @Test + fun aWellFormedEntryRoundTrips() { + val entries = PushGossip.decodeTokens(tokenJson()) + assertEquals(1, entries.size) + val entry = entries.single() + assertEquals(member, entry.memberIdHex) + assertEquals(3, entry.leafIndex) + assertEquals(PushPlatform.APNS, entry.platform) + assertEquals(1735680000000L, entry.ownerTsMillis) + assertEquals(PushSignedRecord.ENCRYPTED_TOKEN_BYTES, entry.encryptedToken.size) + assertEquals(entries, PushGossip.decodeTokens(PushGossip.encodeTokens(entries))) + } + + @Test + fun anAbsentHintSurvivesTheRoundTripAsAbsent() { + // "" and omitted have to mean the same thing on both sides, because the + // owner proof signed one of them and a verifier reconstructs the other. + val withBlank = PushGossip.decodeTokens(tokenJson(mapOf("relay_hint" to "\" \""))).single() + assertEquals("", withBlank.relayHint) + val reEncoded = PushGossip.encodeTokens(listOf(withBlank)) + assertFalse(reEncoded.contains("relay_hint")) + } + + @Test + fun onlyTheAdoptedVersionIsRead() { + // The earlier exploratory shape is not a compatible predecessor, and + // reading it would mean reading records that predate owner + // authentication entirely. + assertTrue(PushGossip.decodeTokens(tokenJson().replace("marmot-push-v1", "mip05-v1")).isEmpty()) + assertFalse(PushGossip.isSupportedVersion("""{"v":"mip05-v1","tokens":[]}""")) + assertTrue(PushGossip.isSupportedVersion("""{"v":"marmot-push-v1"}""")) + } + + @Test + fun aMissingTokensMemberIsAnEmptyArray() { + assertTrue(PushGossip.decodeTokens("""{"v":"marmot-push-v1"}""").isEmpty()) + // A present non-array member contributes no entries either. + assertTrue(PushGossip.decodeTokens("""{"v":"marmot-push-v1","tokens":{}}""").isEmpty()) + } + + @Test + fun anOversizedArrayIsInvalidInItsEntirety() { + // Not "the first 32 apply" — the whole array goes, BEFORE any signature + // is verified, so an oversized array cannot buy unbounded verification. + val entry = tokenJson().substringAfter("\"tokens\":[").removeSuffix("]}") + val thirtyThree = """{"v":"marmot-push-v1","tokens":[${List(33) { entry }.joinToString(",")}]}""" + assertTrue(PushGossip.decodeTokens(thirtyThree).isEmpty()) + } + + @Test + fun aMalformedEntryIsDroppedOnItsOwn() { + val good = tokenJson().substringAfter("\"tokens\":[").removeSuffix("]}") + val bad = good.replace("\"$member\"", "\"not-hex\"") + val mixed = """{"v":"marmot-push-v1","tokens":[$bad,$good]}""" + assertEquals(1, PushGossip.decodeTokens(mixed).size) + } + + @Test + fun everyPerFieldRuleRejectsItsOwnEntry() { + // Uppercase hex is rejected: the spec says lowercase, and a + // case-insensitive reader would derive a different `member_id_hex` + // string for the same key. + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("member_id_hex" to "\"${member.uppercase()}\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("server_pubkey_hex" to "\"abc\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("platform" to "\"web\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("token_fingerprint" to "\"sha256:00\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("owner_sig" to "\"${"11".repeat(63)}\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("owner_ts" to "-1"))).isEmpty()) + // A token that decodes to the wrong length is not an EncryptedToken. + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("encrypted_token" to "\"${PushBase64.encode(ByteArray(10))}\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(drop = setOf("leaf_index"))).isEmpty()) + } + + @Test + fun aNumericStringIsNotANumber() { + // `"leaf_index": "3"` is a different document from `"leaf_index": 3`. + // Accepting both would let two senders agree on meaning and disagree on + // the digest that breaks their ordering ties. + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("leaf_index" to "\"3\""))).isEmpty()) + assertTrue(PushGossip.decodeTokens(tokenJson(mapOf("owner_ts" to "\"1735680000000\""))).isEmpty()) + } + + @Test + fun unknownMembersAreIgnored() { + assertEquals(1, PushGossip.decodeTokens(tokenJson(mapOf("something_new" to "\"whatever\""))).size) + } + + @Test + fun aRemovalCarriesNoHintAndNoToken() { + val removals = + PushGossip.decodeRemovals( + """ + {"v":"marmot-push-v1","removals":[{ + "member_id_hex":"$member","leaf_index":3,"platform":"fcm", + "token_fingerprint":"$fingerprint","server_pubkey_hex":"$server", + "owner_ts":1735680000000,"owner_sig":"$sig"}]} + """.trimIndent(), + ) + assertEquals(1, removals.size) + val encoded = PushGossip.encodeRemovals(removals) + assertFalse(encoded.contains("encrypted_token")) + assertFalse(encoded.contains("relay_hint")) + assertEquals(removals, PushGossip.decodeRemovals(encoded)) + } + + @Test + fun garbageIsNotAnError() { + assertTrue(PushGossip.decodeTokens("not json at all").isEmpty()) + assertTrue(PushGossip.decodeTokens("[]").isEmpty()) + assertTrue(PushGossip.decodeRemovals("").isEmpty()) + assertNull(null) + } + + @Test + fun anEmptyRequestIsAWellFormedEmptyArray() { + val request = PushGossip.encodeRequest() + assertTrue(PushGossip.isSupportedVersion(request)) + assertTrue(PushGossip.decodeTokens(request).isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt index 532ed893a1..6df7909148 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -152,6 +154,65 @@ class PushOwnerProofTest { } /** A removal carries no relay hint and no token, whatever the caller passes. */ + @Test + fun aLegacyGroupAcceptsTheRawDigestProofAndACurrentOneDoesNot() { + // The oldest form: a signature straight over SHA-256(SignedRecord), + // with no event around it. Verification-only and never produced — but a + // legacy group can still hold a member that only ever made these, and + // refusing them there would silently strand that member's routing. + val priv = ByteArray(32).also { it[31] = 3 } + val signedRecord = { + PushSignedRecord.encode( + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = PushPlatform.APNS, + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + ) + } + val ownerSig = Nip01Crypto.sign(sha256(signedRecord()), priv) + + fun verify(currentProfileGroup: Boolean) = + PushOwnerProof.verifyRecord( + ownerSig = ownerSig, + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = "apns", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + currentProfileGroup = currentProfileGroup, + signedRecord = signedRecord, + ) + + assertTrue(verify(currentProfileGroup = false)) + // In a group where every leaf already carries a 0x8009 identity proof, + // accepting the weaker form would throw away a binding the group + // otherwise guarantees. + assertFalse(verify(currentProfileGroup = true)) + // And without the canonical bytes there is nothing to check it against, + // so a caller that does not supply them simply gets a no. + assertFalse( + PushOwnerProof.verifyRecord( + ownerSig = ownerSig, + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = "apns", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + currentProfileGroup = false, + ), + ) + } + @Test fun aRemovalAlwaysEncodesAnEmptyRelayHint() { val withHint = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordStoreTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordStoreTest.kt new file mode 100644 index 0000000000..63ceab81a1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushRecordStoreTest.kt @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `features/push-notifications.md`, "Record state". + * + * The store's whole job is convergence: two members who see the same records in + * different orders must end up with the same active set. Every test here is + * therefore about a decision the store must NOT make on arrival order, sender + * identity, or array position. + */ +class PushRecordStoreTest { + private val groupId = "000102030405060708090a0b0c0d0e0f" + private val server = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4" + private val now = 1735680000000L + + private val alicePriv = ByteArray(32).also { it[31] = 3 } + private val bobPriv = ByteArray(32).also { it[31] = 5 } + private val alice = Nip01Crypto.pubKeyCreate(alicePriv).toHexKey() + private val bob = Nip01Crypto.pubKeyCreate(bobPriv).toHexKey() + + private val everyone: (HexKey) -> Boolean = { it == alice || it == bob } + + private fun token(seed: Int) = ByteArray(PushSignedRecord.ENCRYPTED_TOKEN_BYTES) { ((it + seed) % 251).toByte() } + + private fun store(currentProfile: Boolean = true) = PushRecordStore(groupId, currentProfile) + + private fun entry( + priv: ByteArray, + ownerTs: Long, + leafIndex: Int = 0, + platform: PushPlatform = PushPlatform.APNS, + relayHint: String = "", + seed: Int = 0, + fingerprint: String = "sha256:000102030405060708090a0b", + ): PushTokenEntry { + val member = Nip01Crypto.pubKeyCreate(priv).toHexKey() + val encryptedToken = token(seed) + val tags = + PushOwnerProof.tags( + PushRecordKind.TOKEN, + groupId, + member, + leafIndex, + platform.wireName, + server, + fingerprint, + ownerTs, + relayHint, + ) + val content = PushBase64.encode(encryptedToken) + val sig = Nip01Crypto.sign(PushOwnerProof.eventId(member, tags, content), priv) + return PushTokenEntry(member, leafIndex, platform, fingerprint, server, relayHint, encryptedToken, ownerTs, sig) + } + + private fun removal( + priv: ByteArray, + ownerTs: Long, + leafIndex: Int = 0, + platform: PushPlatform = PushPlatform.APNS, + fingerprint: String = "sha256:000102030405060708090a0b", + ): PushRemovalEntry { + val member = Nip01Crypto.pubKeyCreate(priv).toHexKey() + val tags = + PushOwnerProof.tags( + PushRecordKind.REMOVAL, + groupId, + member, + leafIndex, + platform.wireName, + server, + fingerprint, + ownerTs, + "", + ) + val sig = Nip01Crypto.sign(PushOwnerProof.eventId(member, tags, ""), priv) + return PushRemovalEntry(member, leafIndex, platform, fingerprint, server, ownerTs, sig) + } + + @Test + fun aVerifiedEntryBecomesTheActiveRecord() { + val store = store() + val e = entry(alicePriv, now) + assertEquals(setOf(e.key), store.applyTokens(listOf(e), now, everyone)) + assertEquals(e, store.activeFor(e.key)) + } + + @Test + fun anUnverifiableEntryIsDroppedAndNothingElseHappens() { + val store = store() + val forged = entry(alicePriv, now).copy(ownerSig = ByteArray(64)) + assertTrue(store.applyTokens(listOf(forged), now, everyone).isEmpty()) + assertNull(store.activeFor(forged.key)) + assertNull(store.stampFor(forged.key)) + } + + @Test + fun anEntryFromANonMemberIsDroppedEvenThoughItVerifies() { + val store = store() + val e = entry(bobPriv, now) + assertTrue(store.applyTokens(listOf(e), now) { it == alice }.isEmpty()) + assertNull(store.activeFor(e.key)) + } + + @Test + fun aRelayedEntryIsAppliedRegardlessOfWhoCarriedIt() { + // The whole point of owner authentication: alice can bring bob's record + // to a member who has never been online at the same time as bob. + val store = store() + val bobs = entry(bobPriv, now) + assertEquals(setOf(bobs.key), store.applyTokens(listOf(bobs), now, everyone)) + assertEquals(bobs, store.activeFor(bobs.key)) + } + + @Test + fun theLatestStampWinsWhicheverOrderTheyArrive() { + val older = entry(alicePriv, now, seed = 1) + val newer = entry(alicePriv, now + 1000, seed = 2) + + val forwards = store().also { it.applyTokens(listOf(older, newer), now + 1000, everyone) } + val backwards = store().also { it.applyTokens(listOf(newer, older), now + 1000, everyone) } + + assertEquals(newer, forwards.activeFor(newer.key)) + assertEquals(newer, backwards.activeFor(newer.key)) + } + + @Test + fun anEqualStampTieBreaksOnTheDigestNotOnArrayPosition() { + // Two devices of one account can stamp the same millisecond. Without the + // digest tie-break, two readers would converge on different tokens and + // neither would be wrong. + val a = entry(alicePriv, now, seed = 7) + val b = entry(alicePriv, now, seed = 9) + val expected = if (a.stamp(groupId) > b.stamp(groupId)) a else b + + assertEquals(expected, store().also { it.applyTokens(listOf(a, b), now, everyone) }.activeFor(a.key)) + assertEquals(expected, store().also { it.applyTokens(listOf(b, a), now, everyone) }.activeFor(a.key)) + } + + @Test + fun reApplyingTheSameSignedRecordIsANoOp() { + val store = store() + val e = entry(alicePriv, now) + store.applyTokens(listOf(e), now, everyone) + assertTrue(store.applyTokens(listOf(e), now, everyone).isEmpty()) + assertEquals(e, store.activeFor(e.key)) + } + + @Test + fun aFarFutureStampIsRefused() { + // owner_ts is a latest-wins high-water mark; a far-future signed stamp + // would otherwise pin the record forever. + val store = store() + val e = entry(alicePriv, now + PushGossip.OWNER_TS_MAX_FUTURE_MILLIS + 1) + assertTrue(store.applyTokens(listOf(e), now, everyone).isEmpty()) + // Right at the bound it is still accepted. + val edge = entry(alicePriv, now + PushGossip.OWNER_TS_MAX_FUTURE_MILLIS) + assertEquals(setOf(edge.key), store.applyTokens(listOf(edge), now, everyone)) + } + + @Test + fun aRemovalDeletesAndLeavesATombstone() { + val store = store() + val e = entry(alicePriv, now) + store.applyTokens(listOf(e), now, everyone) + + val r = removal(alicePriv, now + 1000) + assertEquals(setOf(r.key), store.applyRemovals(listOf(r), now + 1000, everyone)) + assertNull(store.activeFor(r.key)) + assertTrue(store.isTombstoned(r.key)) + } + + @Test + fun aTombstoneSuppressesAStaleListThatArrivesLater() { + // The realistic race: a member assembled a kind 448 before the removal + // and delivers it after. Arrival order must not resurrect the token. + val store = store() + val stale = entry(alicePriv, now) + val r = removal(alicePriv, now + 1000) + store.applyTokens(listOf(stale), now, everyone) + store.applyRemovals(listOf(r), now + 1000, everyone) + + assertTrue(store.applyTokens(listOf(stale), now + 2000, everyone).isEmpty()) + assertNull(store.activeFor(stale.key)) + assertTrue(store.isTombstoned(stale.key)) + } + + @Test + fun aNewerRegistrationClearsTheTombstone() { + val store = store() + val r = removal(alicePriv, now + 1000) + store.applyRemovals(listOf(r), now + 1000, everyone) + + val fresh = entry(alicePriv, now + 2000, seed = 4) + assertEquals(setOf(fresh.key), store.applyTokens(listOf(fresh), now + 2000, everyone)) + assertFalse(store.isTombstoned(fresh.key)) + assertEquals(fresh, store.activeFor(fresh.key)) + } + + @Test + fun aStaleRemovalCannotRevokeANewerToken() { + val store = store() + val fresh = entry(alicePriv, now + 2000) + store.applyTokens(listOf(fresh), now + 2000, everyone) + + val stale = removal(alicePriv, now) + assertTrue(store.applyRemovals(listOf(stale), now + 2000, everyone).isEmpty()) + assertEquals(fresh, store.activeFor(fresh.key)) + } + + @Test + fun aRemovalDoesNotTouchASiblingLeaf() { + // leaf_index is in the record key precisely so one device cannot revoke + // another device's live token. + val store = store() + val leafZero = entry(alicePriv, now, leafIndex = 0) + val leafOne = entry(alicePriv, now, leafIndex = 1) + store.applyTokens(listOf(leafZero, leafOne), now, everyone) + + store.applyRemovals(listOf(removal(alicePriv, now + 1000, leafIndex = 0)), now + 1000, everyone) + assertNull(store.activeFor(leafZero.key)) + assertEquals(leafOne, store.activeFor(leafOne.key)) + } + + @Test + fun oneAccountKeepsSeparateRecordsPerPlatformAndServer() { + val store = store() + val apns = entry(alicePriv, now, platform = PushPlatform.APNS) + val fcm = entry(alicePriv, now, platform = PushPlatform.FCM) + store.applyTokens(listOf(apns, fcm), now, everyone) + assertEquals(2, store.active().size) + } + + @Test + fun aRemovedLeafLosesItsRecordItsStampAndItsTombstone() { + val store = store() + val leafZero = entry(alicePriv, now, leafIndex = 0) + val leafOne = entry(alicePriv, now, leafIndex = 1) + store.applyTokens(listOf(leafZero, leafOne), now, everyone) + store.applyRemovals(listOf(removal(alicePriv, now + 1000, leafIndex = 0)), now + 1000, everyone) + + store.forgetLeaf(alice, 0) + assertNull(store.stampFor(leafZero.key)) + assertFalse(store.isTombstoned(leafZero.key)) + // The sibling leaf is a different key and a still-current member. + assertEquals(leafOne, store.activeFor(leafOne.key)) + assertNotNull(store.stampFor(leafOne.key)) + } + + @Test + fun aRestoredStoreStillRefusesAStaleRelay() { + // A tombstone is durable or it is worthless: it is the only high-water + // mark stopping a relayed record from resurrecting a revoked token, and + // a relayed record's carrying epoch is unbounded. + val first = store() + val stale = entry(alicePriv, now) + first.applyTokens(listOf(stale), now, everyone) + first.applyRemovals(listOf(removal(alicePriv, now + 1000)), now + 1000, everyone) + + val restarted = store() + restarted.restore(first.active(), first.snapshotStamps(), first.snapshotTombstones()) + + assertTrue(restarted.applyTokens(listOf(stale), now + 5000, everyone).isEmpty()) + assertNull(restarted.activeFor(stale.key)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushSignedRecordTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushSignedRecordTest.kt new file mode 100644 index 0000000000..0fd82c427a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushSignedRecordTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * `features/push-notifications.md`, "Canonical record bytes". + * + * The layout is asserted field by field against bytes assembled by hand, + * because every alternative encoding this codebase already owns would look + * correct locally and be wrong on the wire. The spec is explicit about it: + * "Signers and verifiers MUST NOT substitute QUIC varints, TLS vectors, or a + * serialization-library default." + */ +class PushSignedRecordTest { + private val groupId = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + private val member = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + private val server = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4" + private val fingerprint = "sha256:000102030405060708090a0b" + private val ownerTs = 1700000000000L + private val token = ByteArray(PushSignedRecord.ENCRYPTED_TOKEN_BYTES) { (it % 251).toByte() } + + @Test + fun aRemovalRecordIsExactlyTheFieldsInOrder() { + val bytes = + PushSignedRecord.encode( + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = PushPlatform.APNS, + serverPubKeyHex = server, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + ) + + val expected = + "marmot-push-token-removal-v1".encodeToByteArray().toHexKey() + + // group_id_len = 32, big-endian u16, NOT a varint + "0020" + groupId + + member + + // leaf_index = 3 as u32 + "00000003" + + // platform apns + "01" + + server + + // token_fingerprint, the 12 bytes the sha256: prefix encodes + "000102030405060708090a0b" + + // owner_ts as u64 milliseconds + "0000018bcfe56800" + + // relay_hint_len = 0, and no encrypted_token at all + "0000" + + assertEquals(expected, bytes.toHexKey()) + } + + @Test + fun aTokenRecordAppendsTheHintAndTheWholeToken() { + val bytes = + PushSignedRecord.encode( + record = PushRecordKind.TOKEN, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = PushPlatform.FCM, + serverPubKeyHex = server, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + relayHint = "wss://relay.example.com", + encryptedToken = token, + ) + + val hint = "wss://relay.example.com" + val expected = + "marmot-push-token-record-v1".encodeToByteArray().toHexKey() + + "0020" + groupId + + member + + "00000003" + + "02" + + server + + "000102030405060708090a0b" + + "0000018bcfe56800" + + // relay_hint_len = 23 + "0017" + hint.encodeToByteArray().toHexKey() + + token.toHexKey() + + assertEquals(expected, bytes.toHexKey()) + } + + @Test + fun aWhitespaceOnlyHintSignsAsAbsent() { + // The signer and the verifier have to agree, and JSON round-trips can + // pick up padding. Normalizing on both sides is the only way an entry + // that means "no hint" verifies wherever it lands. + val blank = + PushSignedRecord.encode( + PushRecordKind.TOKEN, + groupId, + member, + 0, + PushPlatform.APNS, + server, + fingerprint, + ownerTs, + " ", + token, + ) + val absent = + PushSignedRecord.encode( + PushRecordKind.TOKEN, + groupId, + member, + 0, + PushPlatform.APNS, + server, + fingerprint, + ownerTs, + "", + token, + ) + assertEquals(absent.toHexKey(), blank.toHexKey()) + } + + @Test + fun aRemovalAndATokenRecordNeverCollide() { + // Distinct domain tags, a zeroed hint length and the missing token all + // pull in the same direction: one signature can never be replayed as + // the other shape. + val removal = + PushSignedRecord.encode( + PushRecordKind.REMOVAL, + groupId, + member, + 0, + PushPlatform.APNS, + server, + fingerprint, + ownerTs, + ) + val record = + PushSignedRecord.encode( + PushRecordKind.TOKEN, + groupId, + member, + 0, + PushPlatform.APNS, + server, + fingerprint, + ownerTs, + "", + token, + ) + assertNotEquals(removal.toHexKey(), record.toHexKey()) + } + + @Test + fun theFingerprintIsTheFirstTwelveBytesOfThePlatformPrefixedHash() { + val deviceToken = "a-device-token".encodeToByteArray() + val fingerprint = PushSignedRecord.fingerprintOf(PushPlatform.APNS, deviceToken) + assertEquals(PushSignedRecord.FINGERPRINT_PREFIX, fingerprint.substring(0, 7)) + assertEquals(PushSignedRecord.FINGERPRINT_HEX_LENGTH, fingerprint.length - 7) + // The platform byte is in the preimage, so the same raw token on two + // platforms names two different records. + assertNotEquals(fingerprint, PushSignedRecord.fingerprintOf(PushPlatform.FCM, deviceToken)) + assertEquals( + fingerprint.substring(7).hexToByteArray().toHexKey(), + PushSignedRecord.fingerprintBytes(fingerprint)?.toHexKey(), + ) + } + + @Test + fun aMalformedFingerprintIsNotBytes() { + assertNull(PushSignedRecord.fingerprintBytes("000102030405060708090a0b")) + assertNull(PushSignedRecord.fingerprintBytes("sha256:000102030405060708090a")) + assertNull(PushSignedRecord.fingerprintBytes("sha256:000102030405060708090A0B")) + assertNull(PushSignedRecord.fingerprintBytes("sha256:00010203040506070809zzzz")) + } +}