mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3906 from vitorpamplona/claude/relay-auth-group-coverage-o64zcn
Auth: recognize NIP-29 groups and Concord communities as joined venues
This commit is contained in:
@@ -147,6 +147,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues
|
||||
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
@@ -424,11 +425,47 @@ class Account(
|
||||
isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false },
|
||||
isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false },
|
||||
isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors },
|
||||
isTrustedVenue = { venueId ->
|
||||
isTrustedVenue = { relayUrl, venueId ->
|
||||
venueId in publicChatList.flowSet.value ||
|
||||
venueId in communityList.flowSet.value ||
|
||||
isJoinedRoomId(relayUrl, venueId) ||
|
||||
Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true
|
||||
},
|
||||
isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false },
|
||||
)
|
||||
|
||||
/**
|
||||
* Relays that exist here because *this account* joined a room on them: the host of every NIP-29
|
||||
* relay group on its kind-10009 list, plus the relays of every Concord community on its
|
||||
* kind-13302 list.
|
||||
*
|
||||
* Both are venues in the [RelayAuthCustomToggles.myRelaysAndVenues] sense but neither shows up in
|
||||
* a NIP-65/DM/search list, so nothing else in the auth path can see them: a NIP-29 group's content
|
||||
* is `#h`-scoped and never names the user, and a Concord plane is addressed to a derived stream
|
||||
* key rather than to anyone's pubkey.
|
||||
*/
|
||||
fun venueHostRelays(): Set<NormalizedRelayUrl> =
|
||||
RelayAuthVenues.hostRelays(
|
||||
joinedGroups = relayGroupList.liveRelayGroupIds.value,
|
||||
joinedCommunities = concordChannelList.liveCommunities.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when [venueId], served by [relayUrl], is a room this account joined that the venue *lists*
|
||||
* above don't cover: a NIP-29 group id (from the kind-10009 list) or a Concord community id (from
|
||||
* the kind-13302 list). Those are the ids the subscription assemblers declare on their filters, so
|
||||
* this is what turns a `READ_VENUE`/`POST_VENUE` on a joined group or community into a trusted
|
||||
* venue.
|
||||
*/
|
||||
private fun isJoinedRoomId(
|
||||
relayUrl: String,
|
||||
venueId: String,
|
||||
): Boolean =
|
||||
RelayAuthVenues.isJoinedRoom(
|
||||
venueId = venueId,
|
||||
relayUrl = relayUrl.normalizeRelayUrlOrNull(),
|
||||
joinedGroups = relayGroupList.liveRelayGroupIds.value,
|
||||
joinedCommunities = concordChannelList.liveCommunities.value,
|
||||
)
|
||||
|
||||
// Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming
|
||||
|
||||
@@ -846,6 +846,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
|
||||
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
|
||||
|
||||
/**
|
||||
* Any known channel of Concord community [communityId], or null when we hold none.
|
||||
*
|
||||
* A community is not itself a cached object — it has no metadata event, only a folded Control
|
||||
* Plane — so its display fields (`communityName`, icon, relays) are carried on every one of its
|
||||
* channels. Callers that need to *name* a community therefore ask for whichever channel we have.
|
||||
*/
|
||||
fun getAnyConcordChannelOfCommunity(communityId: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.communityId == communityId }.firstOrNull()
|
||||
|
||||
/**
|
||||
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
|
||||
* message-like kinds, attaches it to its channel so the shared chat feed and
|
||||
|
||||
+55
-14
@@ -82,7 +82,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/** Avatars shown in the facepile before the "+N" overflow badge. */
|
||||
private const val FACEPILE_MAX = 5
|
||||
@@ -177,7 +179,7 @@ private fun RelayAuthPromptDialog(
|
||||
val who =
|
||||
when (primary?.kind) {
|
||||
AuthPurposeKind.POST_VENUE, AuthPurposeKind.READ_VENUE ->
|
||||
primary.venues.firstOrNull()?.let { rememberVenueLabel(it, primary.kind, accountViewModel) }
|
||||
primary.venues.firstOrNull()?.let { rememberVenueLabel(it, primary.kind, prompt.relayUrl, accountViewModel) }
|
||||
else -> counterpartyLabel(faces, accountViewModel)
|
||||
}
|
||||
|
||||
@@ -478,33 +480,48 @@ private fun rememberCounterpartyName(
|
||||
}
|
||||
|
||||
/**
|
||||
* A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity,
|
||||
* or a NIP-72 community.
|
||||
* A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity, a
|
||||
* NIP-72 community, a NIP-29 relay group, or a Concord community.
|
||||
*
|
||||
* Only a [AuthPurposeKind.POST_VENUE] id is *known* to be a channel (it is the root of a channel
|
||||
* message we are sending). A READ id may have come from the tag-shape fallback, where a bare `#e`
|
||||
* list is as likely to be note ids on a thread as channel roots — so we only ever *look up* an
|
||||
* existing channel there. Get-or-creating on read is what used to mint phantom public chats in
|
||||
* [LocalCache] for ordinary notes, complete with a metadata subscription for a room that never was.
|
||||
*
|
||||
* Both joined-room shapes are resolved **before** any of that, because the rules above would not just
|
||||
* mislabel them, they would act on them. A NIP-29 group id is only meaningful together with its host
|
||||
* relay ([relayUrl], the relay doing the asking). A Concord community id is a bare 64-hex string, so
|
||||
* the public-chat branch would take it: on a `POST_VENUE` — which is exactly what a pending plane wrap
|
||||
* now derives — that get-or-create mints the phantom channel this function was rewritten to stop
|
||||
* minting, and then names the room after its own nevent.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberVenueLabel(
|
||||
venueId: String,
|
||||
kind: AuthPurposeKind,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
// Resolved off LocalCache first and the active account's list second: a prompt is raised per
|
||||
// account, so the account on screen is not necessarily the one being asked about — the cache is
|
||||
// shared by all of them, the list is not.
|
||||
val concordName = remember(venueId) { concordCommunityLabel(venueId, accountViewModel) }
|
||||
if (concordName != null) return concordName
|
||||
|
||||
val channel: Channel? =
|
||||
remember(venueId, kind) {
|
||||
when {
|
||||
venueId.length == 64 ->
|
||||
if (kind == AuthPurposeKind.POST_VENUE) {
|
||||
accountViewModel.checkGetOrCreatePublicChatChannel(venueId)
|
||||
} else {
|
||||
LocalCache.getPublicChatChannelIfExists(venueId)
|
||||
}
|
||||
venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) }
|
||||
else -> null
|
||||
}
|
||||
remember(venueId, kind, relayUrl) {
|
||||
LocalCache.getRelayGroupChannelIfExists(GroupId(venueId, relayUrl))
|
||||
?: when {
|
||||
venueId.length == 64 ->
|
||||
if (kind == AuthPurposeKind.POST_VENUE) {
|
||||
accountViewModel.checkGetOrCreatePublicChatChannel(venueId)
|
||||
} else {
|
||||
LocalCache.getPublicChatChannelIfExists(venueId)
|
||||
}
|
||||
venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
if (channel != null) {
|
||||
@@ -518,6 +535,30 @@ private fun rememberVenueLabel(
|
||||
return venueId.substringAfterLast(':').ifEmpty { venueId.take(8) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The label for a Concord community id, or null when [venueId] is not a community we know of — which
|
||||
* is what tells the caller to go on treating the id as a channel root.
|
||||
*
|
||||
* A community has no metadata event to look up: its name comes from the folded Control Plane, held on
|
||||
* the `ConcordChannel`s in [LocalCache] (shared by every logged-in account, so this still resolves a
|
||||
* prompt raised for a different one), and failing that from the active account's joined list. A
|
||||
* community we can place but cannot name still returns a label, so the caller never falls through to
|
||||
* the channel branches with an id it would get-or-create.
|
||||
*/
|
||||
private fun concordCommunityLabel(
|
||||
venueId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String? {
|
||||
val folded = LocalCache.getAnyConcordChannelOfCommunity(venueId)
|
||||
val joined =
|
||||
accountViewModel.account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == venueId }
|
||||
if (folded == null && joined == null) return null
|
||||
return folded?.communityName?.takeIf { it.isNotBlank() }
|
||||
?: joined?.name?.takeIf { it.isNotBlank() }
|
||||
?: venueId.take(8)
|
||||
}
|
||||
|
||||
/**
|
||||
* The best display name for [pubkey], reactive to metadata arriving from relays. Falls back to a
|
||||
* generic "someone you haven't loaded yet" rather than a hex prefix dressed up as a person's name.
|
||||
|
||||
+17
-7
@@ -76,6 +76,10 @@ class AuthCoordinator(
|
||||
RelayAuthPurposeDeriver.derive(
|
||||
pendingEvents = client.activeOutboxEvents(relayUrl),
|
||||
activeFilters = client.activeRequests(relayUrl),
|
||||
// The context describes the shared socket, not one account, so a
|
||||
// plane is looked up across every watched account — same as the
|
||||
// stream-key AUTHs above.
|
||||
venueForPlaneAuthor = ::concordCommunityForPlane,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -165,6 +169,13 @@ class AuthCoordinator(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The joined Concord community whose plane [planeAddress] is, across every watched account, or
|
||||
* null when the pubkey isn't a plane of ours. Feeds [RelayAuthPurposeDeriver] so a pending plane
|
||||
* wrap reads as a post into that community rather than as a DM to the throwaway key it `p`-tags.
|
||||
*/
|
||||
private fun concordCommunityForPlane(planeAddress: HexKey): HexKey? = authWithAccounts.distinct().firstNotNullOfOrNull { it.concordSessions.communityIdForPlane(planeAddress) }
|
||||
|
||||
/**
|
||||
* Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every
|
||||
* watched account. Signed locally from the derived stream secret (a raw [KeyPair] via
|
||||
@@ -237,13 +248,12 @@ class AuthCoordinator(
|
||||
relayUrl = relayUrl,
|
||||
pendingEvents = client.activeOutboxEvents(relayUrl),
|
||||
myRelays = account.trustedRelays.flow.value,
|
||||
// A NIP-29 relay group the user explicitly joined (kind-10009) is a first-party reason
|
||||
// to authenticate with its host relay: private/closed group content is `#h`-scoped and
|
||||
// never names the user, so it fails the pubkey checks above — without this, a joined
|
||||
// private group's messages are refused with `auth-required` and the group stays empty.
|
||||
myGroupRelays =
|
||||
account.relayGroupList.liveRelayGroupIds.value
|
||||
.mapTo(mutableSetOf()) { it.relayUrl },
|
||||
// A room the user explicitly joined — a NIP-29 relay group (kind-10009) or a Concord
|
||||
// community (kind-13302) — makes its host relay first-party. Neither room's traffic
|
||||
// names the user: NIP-29 content is `#h`-scoped and Concord planes ride derived stream
|
||||
// keys, so both fail the pubkey checks above. Without this, a joined private group's
|
||||
// messages are refused with `auth-required` and the room stays empty.
|
||||
myVenueRelays = account.venueHostRelays(),
|
||||
)
|
||||
|
||||
fun destroy() {
|
||||
|
||||
+11
-9
@@ -35,13 +35,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
* the recipient's inbox relay), or
|
||||
* - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which
|
||||
* is where its own inbox/outbox reads are routed anyway), or
|
||||
* - the relay hosts a NIP-29 relay group the account explicitly joined ([myGroupRelays], from its
|
||||
* kind-10009 list). A private/closed group's content (kind-9 chat, kind-11 threads, …) is
|
||||
* `#h`-scoped and served only to authenticated members; that read never names the user, so it
|
||||
* can't qualify via [pendingEvents], and a group host relay is not one of the account's own
|
||||
* NIP-65/DM/… lists, so it can't qualify via [myRelays] either. Without this, a joined private
|
||||
* group is refused with `auth-required` and renders empty — the group's own metadata (39000) is
|
||||
* public and still loads, so the group appears but shows no messages.
|
||||
* - the relay hosts a room the account explicitly joined ([myVenueRelays]): a NIP-29 relay group
|
||||
* from its kind-10009 list, or a Concord community from its kind-13302 list. Neither kind of room
|
||||
* can qualify by the two rules above. A private/closed NIP-29 group's content (kind-9 chat,
|
||||
* kind-11 threads, …) is `#h`-scoped and served only to authenticated members, so the read never
|
||||
* names the user; a Concord plane is authored by — and addressed to — a derived stream key, so
|
||||
* even the wraps the account *publishes* there carry someone else's pubkey. And neither host relay
|
||||
* is in the account's own NIP-65/DM/… lists, so [myRelays] doesn't see them either. Without this,
|
||||
* a joined private group is refused with `auth-required` and renders empty — the group's own
|
||||
* metadata (39000) is public and still loads, so the group appears but shows no messages.
|
||||
*
|
||||
* Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is
|
||||
* NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans
|
||||
@@ -56,10 +58,10 @@ object RelayAuthFirstParty {
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
pendingEvents: List<Event>,
|
||||
myRelays: Set<NormalizedRelayUrl>,
|
||||
myGroupRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
myVenueRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
): Boolean {
|
||||
if (pendingEvents.any { it.pubKey == me }) return true
|
||||
if (relayUrl in myRelays) return true
|
||||
return relayUrl in myGroupRelays
|
||||
return relayUrl in myVenueRelays
|
||||
}
|
||||
}
|
||||
|
||||
+39
-16
@@ -37,6 +37,14 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
* [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the
|
||||
* [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the
|
||||
* [RelayAuthContext] into followed vs. stranger.
|
||||
*
|
||||
* Venues come in two shapes and both count as "a room I joined" for the
|
||||
* [RelayAuthCustomToggles.myRelaysAndVenues] toggle: [isTrustedVenue] recognizes a venue *id* named
|
||||
* by a purpose (a NIP-28 chat, a NIP-72 community, a NIP-53 stream, a NIP-29 group id, a Concord
|
||||
* community id), while [isVenueHostRelay] recognizes the relay that *hosts* one — the only signal
|
||||
* available for a room whose traffic never names it in a way the deriver can see, or whose challenge
|
||||
* arrives before its subscription does. [isTrustedVenue] is asked about the (relay, venue) pair
|
||||
* rather than the id alone because a NIP-29 group id means nothing without its host relay.
|
||||
*/
|
||||
class RelayAuthPermissionLedger(
|
||||
val store: RelayAuthPermissionStore,
|
||||
@@ -45,7 +53,8 @@ class RelayAuthPermissionLedger(
|
||||
val isInMyRelayList: (String) -> Boolean = { false },
|
||||
val isBlocked: (String) -> Boolean = { false },
|
||||
val isFollowed: (String) -> Boolean = { false },
|
||||
val isTrustedVenue: (String) -> Boolean = { false },
|
||||
val isTrustedVenue: (relayUrl: String, venueId: String) -> Boolean = { _, _ -> false },
|
||||
val isVenueHostRelay: (String) -> Boolean = { false },
|
||||
) {
|
||||
/**
|
||||
* The authorization verdict for [ctx], taking the challenge's purpose into account.
|
||||
@@ -59,6 +68,15 @@ class RelayAuthPermissionLedger(
|
||||
isFirstParty: Boolean = true,
|
||||
): RelayAuthVerdict {
|
||||
fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX
|
||||
|
||||
// The relay itself hosts a room this account joined — a NIP-29 relay group or a Concord
|
||||
// community. Computed apart from the purposes because those rooms are the relay's whole
|
||||
// reason to be here: their traffic is `#h`-scoped (NIP-29) or addressed to derived stream
|
||||
// keys (Concord), so an AUTH challenge that lands before the room's subscription is
|
||||
// assembled carries no venue to match — and the joined room would sit empty behind a
|
||||
// question the user already answered by joining it.
|
||||
val hostsMyVenue = isVenueHostRelay(ctx.relayUrl)
|
||||
|
||||
val inputs =
|
||||
RelayAuthInputs(
|
||||
storedOverride = store.loadDecision(ctx.relayUrl),
|
||||
@@ -67,10 +85,11 @@ class RelayAuthPermissionLedger(
|
||||
toggles = customToggles(),
|
||||
isInMyRelayList = isInMyRelayList(ctx.relayUrl),
|
||||
servesTrustedVenue =
|
||||
ctx.purposes.any { p ->
|
||||
(p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) &&
|
||||
p.venues.any(isTrustedVenue)
|
||||
},
|
||||
hostsMyVenue ||
|
||||
ctx.purposes.any { p ->
|
||||
(p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) &&
|
||||
p.venues.any { isTrustedVenue(ctx.relayUrl, it) }
|
||||
},
|
||||
// Reading a followed author's outbox.
|
||||
servesFollowedReadCounterparty =
|
||||
ctx.purposes.any { p ->
|
||||
@@ -82,18 +101,22 @@ class RelayAuthPermissionLedger(
|
||||
// Messaging a non-followed user's inbox.
|
||||
servesStrangerWriteCounterparty =
|
||||
ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } },
|
||||
// Hosting a joined room is itself an explanation, and the only one available when the
|
||||
// challenge arrives before any of that room's filters do. Without it, a user on
|
||||
// "decide per relay" (or with the venue toggle off) would get a silent denial for a
|
||||
// room they joined instead of the question. MY_INBOX and THREAD likewise name no
|
||||
// counterparty by design — the relay is holding back the user's *own* inbox or the
|
||||
// conversation on screen — and are fully explainable, so they too must reach ASK.
|
||||
hasAttributablePurpose =
|
||||
ctx.purposes.any {
|
||||
// MY_INBOX and THREAD name no counterparty by design — the relay is holding
|
||||
// back the user's *own* inbox or the conversation on screen. They are still
|
||||
// fully explainable, so they must reach ASK rather than a silent DENY.
|
||||
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
|
||||
it.kind == AuthPurposeKind.OTHER ||
|
||||
it.kind == AuthPurposeKind.MY_INBOX ||
|
||||
it.kind == AuthPurposeKind.THREAD ||
|
||||
it.counterparties.isNotEmpty() ||
|
||||
it.venues.isNotEmpty()
|
||||
},
|
||||
hostsMyVenue ||
|
||||
ctx.purposes.any {
|
||||
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
|
||||
it.kind == AuthPurposeKind.OTHER ||
|
||||
it.kind == AuthPurposeKind.MY_INBOX ||
|
||||
it.kind == AuthPurposeKind.THREAD ||
|
||||
it.counterparties.isNotEmpty() ||
|
||||
it.venues.isNotEmpty()
|
||||
},
|
||||
isFirstParty = isFirstParty,
|
||||
)
|
||||
return RelayAuthResolver.resolve(inputs)
|
||||
|
||||
+35
@@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.toAuthPurposeKind
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
@@ -41,6 +45,18 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
|
||||
* activities. Their `a` addresses are `kind:ownerPubkey:dTag`. */
|
||||
private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEvent.KIND)
|
||||
|
||||
/** The kinds a Concord plane wrap can be — the only ones worth asking `venueForPlaneAuthor` about. */
|
||||
private val STREAM_WRAP_KINDS = setOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL)
|
||||
|
||||
/**
|
||||
* Marmot (MLS) carries the group id in an `h` tag exactly like NIP-29 does, but an MLS group is not a
|
||||
* room we can name: the id is an opaque MLS value with no metadata event and no channel object behind
|
||||
* it. Reading it as a venue would put that id in front of the user as a room name — and, on a
|
||||
* `POST_VENUE`, get-or-create a phantom public chat for it (the id is 64-hex). These keep their prior
|
||||
* reading: no `p` tags, so they land on the unattributed safety net.
|
||||
*/
|
||||
private val MLS_GROUP_KINDS = setOf(GroupEvent.KIND, WelcomeEvent.KIND)
|
||||
|
||||
/**
|
||||
* Infers *why* a relay wants NIP-42 auth from what Amethyst is currently doing with it — the
|
||||
* events pending delivery and the active subscription filters (both from the [INostrClient]).
|
||||
@@ -48,10 +64,19 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve
|
||||
*
|
||||
* Sends (from the outbox) are read from the events themselves:
|
||||
*
|
||||
* - a pending Concord plane wrap (recognized by [venueForPlaneAuthor], since the wrap is *signed* by
|
||||
* the plane's stream key) => [AuthPurposeKind.POST_VENUE] for that community;
|
||||
* - a pending `h`-tagged event => [AuthPurposeKind.POST_VENUE] for that NIP-29 relay group (except
|
||||
* the [MLS_GROUP_KINDS], whose `h` names something unnameable);
|
||||
* - a pending gift wrap (kind 1059) => sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]);
|
||||
* - a pending channel/community/live post => [AuthPurposeKind.POST_VENUE] for that venue;
|
||||
* - any other pending event with `p` tags => delivering it to those users' inboxes ([AuthPurposeKind.NOTIFY_INBOX]).
|
||||
*
|
||||
* The two room rules come first because both would otherwise be read as something else entirely: a
|
||||
* Concord wrap is kind 1059 `p`-tagged to a *throwaway* pubkey, so the gift-wrap rule would explain a
|
||||
* community post as a DM to a stranger nobody can name, and a NIP-29 chat message mentioning someone
|
||||
* would be explained as a notification rather than as a message into the group.
|
||||
*
|
||||
* Reads prefer the purpose the subscription **declared**. Assemblers build every filter as an
|
||||
* [ExplainedFilter] carrying a [SubPurpose][com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose]
|
||||
* and the entity ids it serves, so we simply read it (see `toAuthPurposeKind`). That is the only way
|
||||
@@ -66,9 +91,14 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve
|
||||
* is prompted about instead of silently failing.
|
||||
*/
|
||||
object RelayAuthPurposeDeriver {
|
||||
/**
|
||||
* @param venueForPlaneAuthor maps the pubkey that *signed* a pending event to the room it streams
|
||||
* for — today, a Concord plane address to its community id. Returns null for anything else.
|
||||
*/
|
||||
fun derive(
|
||||
pendingEvents: List<Event>,
|
||||
activeFilters: Map<String, List<Filter>>,
|
||||
venueForPlaneAuthor: (HexKey) -> String? = { null },
|
||||
): List<AuthPurpose> {
|
||||
val dmRecipients = mutableSetOf<HexKey>()
|
||||
val notifyRecipients = mutableSetOf<HexKey>()
|
||||
@@ -78,7 +108,12 @@ object RelayAuthPurposeDeriver {
|
||||
pendingEvents.forEach { event ->
|
||||
val pubkeys = event.tags.mapNotNull(PTag::parseKey)
|
||||
val venues = event.tags.mapNotNull(ATag::parseAddress).filter { it.kind in VENUE_KINDS }
|
||||
// Only a stream wrap can belong to a plane, so only a wrap pays for the lookup.
|
||||
val planeVenue = if (event.kind in STREAM_WRAP_KINDS) venueForPlaneAuthor(event.pubKey) else null
|
||||
val groupId = if (event.kind in MLS_GROUP_KINDS) null else event.tags.firstNotNullOfOrNull(GroupIdTag::parse)
|
||||
when {
|
||||
planeVenue != null -> postVenues.add(planeVenue)
|
||||
groupId != null -> postVenues.add(groupId)
|
||||
event.kind == GiftWrapEvent.KIND -> dmRecipients.addAll(pubkeys)
|
||||
event.kind == ChannelMessageEvent.KIND -> event.tags.channelRootId()?.let(postVenues::add)
|
||||
venues.isNotEmpty() -> venues.forEach { postVenues.add(it.toValue()) }
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* The rooms an account joined that the NIP-51 venue lists don't describe: NIP-29 relay groups (the
|
||||
* kind-10009 list) and Concord communities (the kind-13302 list).
|
||||
*
|
||||
* Both are venues in the "…it's my relay, or a room I joined" sense, and both are invisible to every
|
||||
* other signal in the auth path — a NIP-29 group's content is `#h`-scoped and never names the user,
|
||||
* and a Concord plane is authored by and addressed to derived stream keys — so this is what feeds
|
||||
* them to [RelayAuthPermissionLedger] and [RelayAuthFirstParty]. Pure, so the id/url matching is
|
||||
* testable without an [com.vitorpamplona.amethyst.model.Account].
|
||||
*
|
||||
* Everything here is per account, deliberately: these lists come off *this* account's own list events,
|
||||
* so one account's rooms can never grant another account's identity away. That rules out the
|
||||
* process-wide joined sets (Buzz workspaces) — they carry no account, so folding them in here would
|
||||
* auto-authenticate every logged-in account on a workspace only one of them joined.
|
||||
*/
|
||||
object RelayAuthVenues {
|
||||
/**
|
||||
* The relays these rooms live on. A NIP-29 group id is only meaningful together with its host, so
|
||||
* the [GroupId] carries an already-normalized url; a Concord entry stores raw url strings written
|
||||
* by whoever created the community, so those are normalized here — comparing them verbatim
|
||||
* against a challenge's relay url is what makes a trailing slash or a `wss://` case difference
|
||||
* quietly drop the whole community.
|
||||
*/
|
||||
fun hostRelays(
|
||||
joinedGroups: Set<GroupId>,
|
||||
joinedCommunities: List<ConcordCommunityListEntry>,
|
||||
): Set<NormalizedRelayUrl> =
|
||||
buildSet {
|
||||
joinedGroups.mapTo(this) { it.relayUrl }
|
||||
joinedCommunities.forEach { entry ->
|
||||
entry.relays.forEach { url -> RelayUrlNormalizer.normalizeOrNull(url)?.let(::add) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [venueId], as served by [relayUrl], names one of these rooms. These are the ids the
|
||||
* subscription assemblers declare as their filters' entity ids, so this is what turns a
|
||||
* `READ_VENUE`/`POST_VENUE` on a joined room into a trusted venue.
|
||||
*
|
||||
* A NIP-29 group id is matched **together with its host**: ids are scoped to their relay and are
|
||||
* routinely generic (`_` is the spec's relay-wide group), so an id alone would let a group we
|
||||
* merely browsed on some other relay pass for one we joined. A Concord community id is a 64-hex
|
||||
* derived value that names exactly one community wherever it is served, so it matches on its own
|
||||
* — and its relays are covered by [hostRelays] anyway.
|
||||
*/
|
||||
fun isJoinedRoom(
|
||||
venueId: String,
|
||||
relayUrl: NormalizedRelayUrl?,
|
||||
joinedGroups: Set<GroupId>,
|
||||
joinedCommunities: List<ConcordCommunityListEntry>,
|
||||
): Boolean =
|
||||
(relayUrl != null && GroupId(venueId, relayUrl) in joinedGroups) ||
|
||||
joinedCommunities.any { it.id == venueId }
|
||||
}
|
||||
+10
@@ -88,4 +88,14 @@ class RelayAuthFirstPartyTest {
|
||||
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
|
||||
assertFalse(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRelayHostingAJoinedConcordCommunityIsFirstParty() {
|
||||
// Concord is the harder case of the same rule: a plane wrap this account publishes is signed
|
||||
// by the plane's *stream key*, so even its own outbound traffic carries someone else's pubkey
|
||||
// and the pendingEvents rule can never fire. The joined-communities list is the only signal.
|
||||
val concordRelay = NormalizedRelayUrl("wss://relay.dreamith.to/")
|
||||
val planeWrap = event(other)
|
||||
assertTrue(RelayAuthFirstParty.hasReason(me, concordRelay, listOf(planeWrap), emptySet(), setOf(concordRelay)))
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -229,6 +229,114 @@ class RelayAuthPurposeDeriverTest {
|
||||
assertEquals(setOf(alice), purposes[0].counterparties)
|
||||
}
|
||||
|
||||
// ---- rooms whose traffic doesn't look like a room -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun postingIntoARelayGroupIsAVenuePostAndNotANotification() {
|
||||
// A NIP-29 chat message is `#h`-scoped; the mention it carries would otherwise make this read
|
||||
// as "delivering a notification to alice" instead of "posting into the group".
|
||||
val groupId = "abcd1234"
|
||||
val ev =
|
||||
Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = "11".repeat(32),
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 9,
|
||||
tags = arrayOf(arrayOf("h", groupId), arrayOf("p", alice)),
|
||||
content = "hi",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
|
||||
val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap())
|
||||
|
||||
assertEquals(listOf(AuthPurposeKind.POST_VENUE), purposes.map { it.kind })
|
||||
assertEquals(setOf(groupId), purposes[0].venues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarmotGroupMessageIsNotTreatedAsARoomWeCanName() {
|
||||
// MLS carries its group id in an `h` tag exactly like NIP-29, but the id is an opaque MLS
|
||||
// value with no metadata event behind it — and it is 64-hex, so a POST_VENUE on it would have
|
||||
// the label get-or-create a phantom public chat. Stays on the unattributed safety net.
|
||||
val ev =
|
||||
Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = "11".repeat(32),
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 445,
|
||||
tags = arrayOf(arrayOf("h", "d".repeat(64))),
|
||||
content = "",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
|
||||
val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap())
|
||||
|
||||
assertEquals(listOf(AuthPurposeKind.OTHER), purposes.map { it.kind })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPendingEventThatIsNotAWrapIsNeverAskedAboutPlanes() {
|
||||
// The plane lookup walks every joined community on every pending event; only a stream wrap
|
||||
// can belong to a plane, so only a wrap may pay for it.
|
||||
var asked = 0
|
||||
RelayAuthPurposeDeriver.derive(
|
||||
pendingEvents = listOf(event(1, listOf(alice)), event(GiftWrapEvent.KIND, listOf(bob))),
|
||||
activeFilters = emptyMap(),
|
||||
venueForPlaneAuthor = {
|
||||
asked++
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(1, asked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun postingIntoAConcordChannelIsAVenuePostAndNotADmToItsThrowawayPTag() {
|
||||
// A Concord plane wrap is kind 1059 signed by the plane's stream key and `p`-tagged to a fresh
|
||||
// random pubkey. On tag shape alone it is a gift wrap, so the prompt used to offer to "send a
|
||||
// message" to a key that belongs to nobody and will never be seen again.
|
||||
val planeAddress = "9".repeat(64)
|
||||
val communityId = "c".repeat(64)
|
||||
val throwaway = "e".repeat(64)
|
||||
val wrap =
|
||||
Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = planeAddress,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = GiftWrapEvent.KIND,
|
||||
tags = arrayOf(arrayOf("p", throwaway)),
|
||||
content = "",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
|
||||
val purposes =
|
||||
RelayAuthPurposeDeriver.derive(
|
||||
pendingEvents = listOf(wrap),
|
||||
activeFilters = emptyMap(),
|
||||
venueForPlaneAuthor = { if (it == planeAddress) communityId else null },
|
||||
)
|
||||
|
||||
assertEquals(listOf(AuthPurposeKind.POST_VENUE), purposes.map { it.kind })
|
||||
assertEquals(setOf(communityId), purposes[0].venues)
|
||||
assertEquals(emptySet<String>(), purposes[0].counterparties)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGiftWrapFromAnUnknownAuthorIsStillADm() {
|
||||
// The plane lookup must not swallow real NIP-17 traffic: an author we don't recognize as a
|
||||
// plane keeps the gift-wrap reading.
|
||||
val purposes =
|
||||
RelayAuthPurposeDeriver.derive(
|
||||
pendingEvents = listOf(event(GiftWrapEvent.KIND, listOf(alice))),
|
||||
activeFilters = emptyMap(),
|
||||
venueForPlaneAuthor = { null },
|
||||
)
|
||||
|
||||
assertEquals(listOf(AuthPurposeKind.SEND_DM), purposes.map { it.kind })
|
||||
assertEquals(setOf(alice), purposes[0].counterparties)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingMyInboxAndSendingADmAreBothReported() {
|
||||
val purposes =
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* "…it's my relay, or a room I joined" must actually cover every room the app lets you join. NIP-29
|
||||
* relay groups and Concord communities were invisible to it: neither id is on the public-chat or
|
||||
* NIP-72 community lists the venue check consulted, so a relay whose only job is hosting a group the
|
||||
* user joined fell through to a prompt on every connection (or, with nothing attributable yet, to a
|
||||
* silent denial that left the room empty).
|
||||
*/
|
||||
class RelayAuthVenueCoverageTest {
|
||||
private val groupRelay = "wss://groups.example.com/"
|
||||
private val concordRelay = "wss://relay.dreamith.to/"
|
||||
private val joinedGroupId = "abcd1234"
|
||||
private val joinedCommunityId = "c".repeat(64)
|
||||
|
||||
private class NoStore : RelayAuthPermissionStore {
|
||||
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null
|
||||
|
||||
override suspend fun storeDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
) = Unit
|
||||
|
||||
override suspend fun clearDecision(relayUrl: String) = Unit
|
||||
|
||||
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = emptyMap()
|
||||
}
|
||||
|
||||
private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) =
|
||||
RelayAuthPermissionLedger(
|
||||
store = NoStore(),
|
||||
globalPolicy = { RelayAuthPolicy.CUSTOM },
|
||||
customToggles = { toggles },
|
||||
isTrustedVenue = { _, venueId -> venueId == joinedGroupId || venueId == joinedCommunityId },
|
||||
isVenueHostRelay = { it == groupRelay || it == concordRelay },
|
||||
)
|
||||
|
||||
private fun readVenue(
|
||||
relayUrl: String,
|
||||
venueId: String,
|
||||
) = RelayAuthContext(relayUrl, listOf(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = setOf(venueId))))
|
||||
|
||||
@Test
|
||||
fun readingAJoinedRelayGroupAutoAuthenticates() =
|
||||
runTest {
|
||||
// The group's chat is `#h`-scoped: the subscription declares RELAY_GROUPS, which carries
|
||||
// the group id as its venue. That id is on the kind-10009 list, so the venue toggle covers
|
||||
// it exactly like a public chat the user joined.
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(readVenue(groupRelay, joinedGroupId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingAJoinedConcordCommunityAutoAuthenticates() =
|
||||
runTest {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(readVenue(concordRelay, joinedCommunityId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJoinedRoomsHostAutoAuthenticatesBeforeAnyOfItsFiltersExist() =
|
||||
runTest {
|
||||
// The challenge that matters arrives on connect, before the room's subscription is
|
||||
// assembled — so there is no venue in the context to match. The host relay itself is the
|
||||
// signal: nothing else is on it.
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(RelayAuthContext(groupRelay)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJoinedRoomsHostStillAsksWhenTheVenueToggleIsOff() =
|
||||
runTest {
|
||||
// Turning the toggle off must mean "ask me", not "deny in silence": hosting a room the user
|
||||
// joined is an explanation, so the challenge is attributable even with no purposes.
|
||||
val verdict = ledger(RelayAuthCustomToggles(myRelaysAndVenues = false)).decide(RelayAuthContext(groupRelay))
|
||||
assertEquals(RelayAuthVerdict.ASK, verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGroupOnARelayIHaveNotJoinedIsNotAutoAuthenticated() =
|
||||
runTest {
|
||||
// Browsing a group directory names a venue we never joined on a relay that hosts nothing of
|
||||
// ours — explainable, so it asks, but it is never silently granted.
|
||||
val verdict = ledger().decide(readVenue("wss://stranger.example.com/", "somebodyElsesGroup"))
|
||||
assertEquals(RelayAuthVerdict.ASK, verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJoinedRoomsHostIsNotAutoAuthenticatedForABystanderAccount() =
|
||||
runTest {
|
||||
// The room belongs to another logged-in account; this one has no first-party reason to be
|
||||
// on the relay, so it asks instead of revealing its npub on its own.
|
||||
val verdict = ledger().decide(readVenue(groupRelay, joinedGroupId), isFirstParty = false)
|
||||
assertEquals(RelayAuthVerdict.ASK, verdict)
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The joined-room sources behind the "…it's my relay, or a room I joined" auto-login toggle. */
|
||||
class RelayAuthVenuesTest {
|
||||
private val groupHost = RelayUrlNormalizer.normalize("wss://groups.example.com")
|
||||
private val communityId = "c".repeat(64)
|
||||
|
||||
private val joinedGroups = setOf(GroupId("abcd1234", groupHost))
|
||||
|
||||
private fun community(relays: List<String>) =
|
||||
ConcordCommunityListEntry(
|
||||
id = communityId,
|
||||
owner = "1".repeat(64),
|
||||
ownerSalt = "2".repeat(64),
|
||||
root = "3".repeat(64),
|
||||
relays = relays,
|
||||
name = "Dreamith",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun bothJoinedRoomKindsContributeTheirHostRelays() {
|
||||
val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("wss://relay.dreamith.to"))))
|
||||
|
||||
assertEquals(
|
||||
setOf(groupHost, RelayUrlNormalizer.normalize("wss://relay.dreamith.to")),
|
||||
relays,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aConcordEntrysRawRelayStringIsNormalizedBeforeItIsCompared() {
|
||||
// The community list stores whatever url the community's creator wrote. Comparing that
|
||||
// verbatim against a normalized challenge url is how a whole community silently stops
|
||||
// matching over a trailing slash or an upper-case host.
|
||||
val relays = RelayAuthVenues.hostRelays(emptySet(), listOf(community(listOf("wss://Relay.Dreamith.to/"))))
|
||||
|
||||
assertEquals(setOf(RelayUrlNormalizer.normalize("wss://relay.dreamith.to")), relays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnparseableRelayIsDroppedRatherThanFailingTheWholeList() {
|
||||
val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("not a url at all"))))
|
||||
|
||||
assertEquals(setOf(groupHost), relays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJoinedGroupIdAndCommunityIdAreBothJoinedRooms() {
|
||||
val communities = listOf(community(listOf("wss://relay.dreamith.to")))
|
||||
|
||||
assertTrue(RelayAuthVenues.isJoinedRoom("abcd1234", groupHost, joinedGroups, communities))
|
||||
assertTrue(RelayAuthVenues.isJoinedRoom(communityId, groupHost, joinedGroups, communities))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRoomIHaveNotJoinedIsNotAJoinedRoom() {
|
||||
val communities = listOf(community(listOf("wss://relay.dreamith.to")))
|
||||
|
||||
assertFalse(RelayAuthVenues.isJoinedRoom("someoneElsesGroup", groupHost, joinedGroups, communities))
|
||||
assertFalse(RelayAuthVenues.isJoinedRoom("d".repeat(64), groupHost, joinedGroups, communities))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGroupIdOnlyCountsOnTheRelayIJoinedItOn() {
|
||||
// NIP-29 ids are relay-scoped and often generic — `_` is the spec's relay-wide group — so an
|
||||
// id-only match would let a group merely browsed on some other relay pass for one I joined,
|
||||
// and hand that relay an automatic login.
|
||||
val elsewhere = RelayUrlNormalizer.normalize("wss://other.example.com")
|
||||
|
||||
assertFalse(RelayAuthVenues.isJoinedRoom("abcd1234", elsewhere, joinedGroups, emptyList()))
|
||||
assertFalse(RelayAuthVenues.isJoinedRoom("abcd1234", null, joinedGroups, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCommunityIdCountsWhereverItIsServed() {
|
||||
// Unlike a group id, a Concord community id is a 64-hex derived value that names exactly one
|
||||
// community anywhere — and a community's own relays are covered by hostRelays regardless.
|
||||
val elsewhere = RelayUrlNormalizer.normalize("wss://other.example.com")
|
||||
val communities = listOf(community(listOf("wss://relay.dreamith.to")))
|
||||
|
||||
assertTrue(RelayAuthVenues.isJoinedRoom(communityId, elsewhere, emptySet(), communities))
|
||||
}
|
||||
}
|
||||
+16
@@ -277,6 +277,22 @@ class ConcordCommunitySession(
|
||||
*/
|
||||
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys + historicalChannelKeysByAddress.keys }
|
||||
|
||||
/**
|
||||
* True when [address] is one of this community's plane addresses — Control (current or a prior
|
||||
* epoch we still hold), Guestbook, next-epoch rekey, or any folded Chat Plane.
|
||||
*
|
||||
* A membership test rather than a set to iterate, because the caller is the NIP-42 auth path
|
||||
* asking "whose room is this wrap for?" on every challenge: answering that from
|
||||
* [channelAddresses] + [historicalControlPlaneAddresses] allocates a fresh union per community
|
||||
* per question, where four map lookups do.
|
||||
*/
|
||||
fun ownsPlane(address: HexKey): Boolean =
|
||||
address == controlPlaneAddress ||
|
||||
address == guestbookAddress ||
|
||||
address == nextBaseRekeyAddress ||
|
||||
address in historicalControlKeys ||
|
||||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
|
||||
|
||||
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
|
||||
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
|
||||
|
||||
|
||||
+7
@@ -157,6 +157,13 @@ class ConcordSessionManager(
|
||||
return outcome.claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* The joined community [planeAddress] streams for, or null when it isn't one of ours. Lets the
|
||||
* NIP-42 auth path name a pending plane wrap as a post into that community instead of reading its
|
||||
* throwaway `p` tag as a DM recipient (see [ConcordSessionRegistry.communityIdForPlane]).
|
||||
*/
|
||||
fun communityIdForPlane(planeAddress: HexKey): HexKey? = registry.communityIdForPlane(planeAddress)
|
||||
|
||||
fun sessions() = registry.sessions()
|
||||
|
||||
fun sessionFor(communityId: HexKey) = registry.sessionFor(communityId)
|
||||
|
||||
+13
@@ -108,6 +108,19 @@ class ConcordSessionRegistry(
|
||||
out
|
||||
}
|
||||
|
||||
/**
|
||||
* The joined community whose planes [planeAddress] belongs to, or null when no session claims it.
|
||||
*
|
||||
* The NIP-42 auth path reads this to recognize an outbound plane wrap for what it is. A wrap is
|
||||
* signed by the plane's stream key and `p`-tagged to a throwaway pubkey, so on tag shape alone it
|
||||
* is indistinguishable from a NIP-17 gift wrap — a post into a community would otherwise be
|
||||
* explained to the user as a direct message to a stranger nobody can name.
|
||||
*/
|
||||
fun communityIdForPlane(planeAddress: HexKey): HexKey? =
|
||||
lock.withLock {
|
||||
sessions.entries.firstOrNull { (_, session) -> session.ownsPlane(planeAddress) }?.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an inbound stream [wrap] to whichever session recognizes it, returning that session's
|
||||
* [ConcordIngestOutcome] (or [ConcordIngestOutcome.NOT_MINE] if none claim it). A wrap belongs to
|
||||
|
||||
+6
-4
@@ -24,8 +24,8 @@ package com.vitorpamplona.amethyst.commons.relayauth
|
||||
* The per-situation switches applied under [RelayAuthPolicy.CUSTOM]. Each independently authorizes
|
||||
* one category of relay; a situation with no matching toggle falls through to a prompt.
|
||||
*
|
||||
* @param myRelaysAndVenues your own relays, plus venues (public chats, communities, live streams)
|
||||
* you've joined, subscribed to, or favorited.
|
||||
* @param myRelaysAndVenues your own relays, plus venues (public chats, NIP-72 communities, live
|
||||
* streams, NIP-29 relay groups, Concord communities) you've joined, subscribed to, or favorited.
|
||||
* @param readFollows a relay serving the outbox of someone you follow (to download their posts).
|
||||
* @param messageFollows a relay serving the inbox of someone you follow (to send DMs, replies,
|
||||
* notifications).
|
||||
@@ -49,8 +49,10 @@ data class RelayAuthCustomToggles(
|
||||
* @param policy the top-level [RelayAuthPolicy].
|
||||
* @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM].
|
||||
* @param isInMyRelayList the relay is in the user's own relay list.
|
||||
* @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the
|
||||
* user has joined, subscribed to, or favorited.
|
||||
* @param servesTrustedVenue this relay hosts a venue the user has joined, subscribed to, or
|
||||
* favorited — a public chat, a NIP-72 community, a live stream, a NIP-29 relay group, or a Concord
|
||||
* community. True either because a purpose names one of those venues or because the relay itself is
|
||||
* a joined room's host (a group/community relay serves nothing else).
|
||||
* @param servesFollowedReadCounterparty a followed user's outbox is served here (reading them).
|
||||
* @param servesFollowedWriteCounterparty a followed user's inbox is served here (messaging them).
|
||||
* @param servesStrangerWriteCounterparty a non-followed user's inbox is served here (messaging them).
|
||||
|
||||
Reference in New Issue
Block a user