feat(marmot): push token gossip in the shape the spec adopted

Kind 451 had no callers, and the reason turned out to be everything around
it: the 447/448/449 events in this tree were the exploratory shape the spec
now names as not interoperable — tokens in `token` tags with empty content,
the sender's leaf implicit, no removals at all, and no owner authentication.
The token encryption derived its key from the old `mip05-v1` salt, and the
446 trigger still carried the `encoding` tag the adopted rumor dropped.
Wiring the proof into that would have produced records no peer can read.

So the gossip is now content-JSON under `marmot-push-v1`, and the version
string is the gate: the old value is refused rather than translated, because
the two versions are not predecessor and successor.

The design the rewrite is really about is owner authentication. A record's
authority comes from its own `owner_sig` and current membership, never from
who carried it — which is what lets one member relay another's records so a
group converges without every owner being online, while stopping the relayer
from repointing, re-signing or restamping what it carries. `PushSignedRecord`
is the canonical byte string that makes both halves computable; it uses the
spec's fixed-width fields rather than this codebase's usual QUIC varints,
which look identical locally and are wrong on the wire.

The part that costs real machinery is revocation. A removal does not merely
delete: it leaves a tombstone at its own `(owner_ts, digest)` stamp, and that
stamp has to be durable. Any current member can re-emit a revoked but still
validly-signed record in a fresh kind 448 at any later epoch, so its carrying
epoch is unbounded and no retained-message window can bound it. The stored
stamp is the only thing that recognises such a record as stale, which is why
`MarmotPushStateStore` exists and why Amethyst backs it with a file.

Everything here is advisory end to end. A bad entry, an unverifiable
signature, a stale list — each drops on its own and none of it may reach the
validity of the kind:445 that carried it. The decoders return what they could
read instead of throwing, and the coordinator catches at its boundary, so a
surprise cannot escape into ingest.

Not wired: announcing a token of our own. That needs Amethyst's own
notification-server public key, which is a deployment decision rather than
something the protocol discovers — a server can only wake the app whose push
credentials it holds. Until it exists this client participates correctly in
other members' routing and announces nothing.

MDK's `wn` exposes no push commands, so the harness cannot drive this against
the reference. Coverage is the spec's published removal fixture, byte-layout
assertions written independently of the encoder, and the ordering and
tombstone rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-09 20:00:35 +00:00
parent f7580c7f88
commit 9b8729db22
31 changed files with 3221 additions and 273 deletions
@@ -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`).
*
@@ -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,
@@ -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 `<rootDir>/marmot_push/<group id>.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"
}
}
@@ -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)
@@ -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<HexKey, PushRecordStore>()
/** The active records this client believes in for a group. */
suspend fun activeRecords(nostrGroupId: HexKey): List<PushTokenEntry> = 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<out Event>): Event {
@Suppress("UNCHECKED_CAST")
return RumorAssembler.assembleRumor(manager.signer.pubKey, template as EventTemplate<Event>)
}
}
@@ -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,
)
}
}
@@ -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<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
private class SnapshotMessageStore : MarmotMessageStore {
private val messages = mutableMapOf<String, MutableList<String>>()
private val snapshots = mutableMapOf<String, String>()
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<String> = 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
}
}
@@ -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<ByteArray>? =
com.vitorpamplona.quartz.marmot.mip05PushNotifications
.NotificationRequestEvent(
trigger.id,
trigger.pubKey,
trigger.createdAt,
trigger.tags,
trigger.content,
trigger.sig,
).chunks()
}
@@ -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<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
class SnapshotMessageStore : MarmotMessageStore {
private val messages = mutableMapOf<String, MutableList<String>>()
private val snapshots = mutableMapOf<String, String>()
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<String> = 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
}
}
@@ -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<HexKey, String>()
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<PushRecordKey, PushRecordStamp>()
val tombstones = mutableSetOf<PushRecordKey>()
(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()
}
@@ -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<ByteArray>? {
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<ByteArray>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NotificationRequestEvent>.() -> Unit = {},
) = eventTemplate(KIND, tokensBase64, createdAt) {
): com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<NotificationRequestEvent> {
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())
addUnique(EncodingTag.assemble())
initializer()
}
}
}
}
@@ -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 <T : Event> TagArrayBuilder<T>.tokens(tokens: List<TokenTagData>) = 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 <T : Event> TagArrayBuilder<T>.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
}
}
@@ -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<PushTokenEntry>): String =
buildJsonObject {
put("v", VERSION)
put(
"tokens",
buildJsonArray {
entries.forEach { add(encodeToken(it)) }
},
)
}.toString()
fun encodeRemovals(entries: List<PushRemovalEntry>): 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<PushTokenEntry> = decodeArray(content, "tokens")?.mapNotNull { decodeToken(it) } ?: emptyList()
fun decodeRemovals(content: String): List<PushRemovalEntry> = 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<JsonObject>? {
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()
}
}
@@ -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. */
@@ -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 }
}
}
@@ -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<PushRecordStamp> {
override fun compareTo(other: PushRecordStamp): Int {
val byTime = ownerTsMillis.compareTo(other.ownerTsMillis)
if (byTime != 0) return byTime
return digestHex.compareTo(other.digestHex)
}
}
@@ -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<PushRecordKey, PushTokenEntry>()
private val stamps = mutableMapOf<PushRecordKey, PushRecordStamp>()
private val tombstones = mutableSetOf<PushRecordKey>()
/** Every active token record, in no particular order. */
fun active(): List<PushTokenEntry> = 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<PushTokenEntry>,
persistedStamps: Map<PushRecordKey, PushRecordStamp>,
persistedTombstones: Collection<PushRecordKey>,
) {
records.clear()
stamps.clear()
tombstones.clear()
activeRecords.forEach { records[it.key] = it }
stamps.putAll(persistedStamps)
tombstones.addAll(persistedTombstones)
}
fun snapshotStamps(): Map<PushRecordKey, PushRecordStamp> = stamps.toMap()
fun snapshotTombstones(): Set<PushRecordKey> = 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<PushTokenEntry>,
nowMillis: Long,
isCurrentMember: (HexKey) -> Boolean,
): Set<PushRecordKey> {
val changed = mutableSetOf<PushRecordKey>()
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<PushRemovalEntry>,
nowMillis: Long,
isCurrentMember: (HexKey) -> Boolean,
): Set<PushRecordKey> {
val changed = mutableSetOf<PushRecordKey>()
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
}
@@ -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()
}
@@ -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()
}
@@ -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`.
@@ -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)
@@ -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
}
@@ -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<TokenTagData>,
requestEventId: HexKey,
entries: List<PushTokenEntry>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TokenListEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
tokens(allTokens)
add(arrayOf("e", requestEventId))
) = eventTemplate(KIND, PushGossip.encodeTokens(entries), createdAt) {
addUnique(VersionTag.assemble())
initializer()
}
}
@@ -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<TokenRemovalEvent>(KIND, "", createdAt)
fun build(
entries: List<PushRemovalEntry>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TokenRemovalEvent>.() -> Unit = {},
) = eventTemplate(KIND, PushGossip.encodeRemovals(entries), createdAt) {
addUnique(VersionTag.assemble())
initializer()
}
}
}
@@ -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<TokenTagData>,
entries: List<PushTokenEntry>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TokenRequestEvent>.() -> 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<TokenRequestEvent>.() -> Unit = {},
) = build(emptyList(), createdAt, initializer)
}
}
@@ -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<String>): 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<String> =
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<TokenTagData>) = tokens.map { assemble(it) }
}
}
@@ -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>): String? {
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
@@ -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<String, String> = emptyMap(),
drop: Set<String> = 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())
}
}
@@ -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 =
@@ -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))
}
}
@@ -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"))
}
}