mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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`).
|
||||
*
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+116
@@ -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"
|
||||
}
|
||||
}
|
||||
+30
@@ -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)
|
||||
|
||||
|
||||
+432
@@ -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>)
|
||||
}
|
||||
}
|
||||
+10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-79
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+318
@@ -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()
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
}
|
||||
+202
@@ -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()
|
||||
}
|
||||
+49
-21
@@ -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) {
|
||||
addUnique(VersionTag.assemble())
|
||||
addUnique(EncodingTag.assemble())
|
||||
initializer()
|
||||
): 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())
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -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
|
||||
}
|
||||
}
|
||||
+225
@@ -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()
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -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. */
|
||||
|
||||
+46
@@ -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 }
|
||||
}
|
||||
}
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
+177
@@ -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
|
||||
}
|
||||
+191
@@ -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()
|
||||
}
|
||||
+223
@@ -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()
|
||||
}
|
||||
+106
@@ -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`.
|
||||
-6
@@ -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)
|
||||
|
||||
+22
-26
@@ -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
|
||||
}
|
||||
|
||||
+14
-21
@@ -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
-10
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-86
@@ -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) }
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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 }
|
||||
|
||||
+184
@@ -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())
|
||||
}
|
||||
}
|
||||
+61
@@ -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 =
|
||||
|
||||
+295
@@ -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))
|
||||
}
|
||||
}
|
||||
+202
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user