mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(commons): add AuthApprovalPolicy classifier for tiered NIP-42 AUTH
The current Android-only AuthCoordinator signs every NIP-42 AUTH challenge from every relay unconditionally (and across every logged-in account). For desktop there is no AUTH wiring at all — challenges are ignored, so AUTH-walled relays silently drop DMs. Both behaviours fail the security review: unconditional signing lets any relay the user reads (or any malicious relay they touch) extract an identity-key signature with timestamp, and signing across all accounts links them under one relay observer. This commit adds the substrate for a tiered classifier — wire-up will follow with the desktop AuthCoordinator (P2.5) and SQLite-backed persistence (P2.4). The policy itself is platform-agnostic and lives in commons so Android can adopt the same design later. Two tiers, no third silent-drop path: - auto-allow when the relay is in the user's own outbox/DM-inbox set, or has a persisted ALWAYS grant (subject to BLOCKED override) - prompt-and-suspend via CompletableDeferred for everything else, with the user's `[Once] [Always] [Never]` choice driving the deferred Includes InMemoryAuthApprovalStore for tests + the ONCE session cache; SqliteAuthApprovalStore lands in P2.4 with the sibling outbox.db. Eight unit tests cover tier-1, persisted ALWAYS, persisted BLOCKED (including BLOCKED overriding tier-1), unknown-prompt-then-cache, re-eval of selfApprovedRelays on Account changes, and store.clear().
This commit is contained in:
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
/**
|
||||
* Persisted scope for an AUTH approval decision.
|
||||
*
|
||||
* `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED`
|
||||
* persist via [AuthApprovalStore].
|
||||
*/
|
||||
enum class AuthApprovalScope {
|
||||
/** Approve this session; don't persist. */
|
||||
ONCE,
|
||||
|
||||
/** Approve indefinitely (or until the store's TTL expires the row). */
|
||||
ALWAYS,
|
||||
|
||||
/** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */
|
||||
BLOCKED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier verdict for a single AUTH challenge.
|
||||
*
|
||||
* `Allow` and `Block` are immediate. `Pending` means the user needs to decide;
|
||||
* the policy hands back a [CompletableDeferred] that the UI banner completes
|
||||
* once the user picks `[Once] [Always] [Never]`.
|
||||
*/
|
||||
sealed interface AuthApprovalDecision {
|
||||
/** Auto-sign the AUTH event for this relay. */
|
||||
data object Allow : AuthApprovalDecision
|
||||
|
||||
/** Silently drop the AUTH challenge. */
|
||||
data object Block : AuthApprovalDecision
|
||||
|
||||
/**
|
||||
* Suspend the signer until the user resolves the prompt.
|
||||
*
|
||||
* @property pending populated with the user's choice when the banner is
|
||||
* actioned. The signer awaits this deferred; if it resolves to
|
||||
* [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed.
|
||||
*/
|
||||
data class Pending(
|
||||
val pending: CompletableDeferred<AuthApprovalScope>,
|
||||
) : AuthApprovalDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending tier-2 AUTH approval surfaced to the user.
|
||||
*
|
||||
* Created when the policy decides a challenge needs user consent. Subscribers
|
||||
* (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with
|
||||
* `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`.
|
||||
*
|
||||
* `pendingCount` lets the banner coalesce multiple challenges from the same
|
||||
* relay into one row (`"<relay-url> requires authentication for 3 messages"`)
|
||||
* rather than stacking duplicate banners.
|
||||
*/
|
||||
data class PendingAuthApproval(
|
||||
val relayUrl: NormalizedRelayUrl,
|
||||
val decision: CompletableDeferred<AuthApprovalScope>,
|
||||
val pendingCount: Int = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED`
|
||||
* grants (typically to a SQLite `auth_approvals` table, wired in P2.4).
|
||||
*
|
||||
* `getScope` returns `null` if no decision is recorded for the relay.
|
||||
*/
|
||||
interface AuthApprovalStore {
|
||||
/** Returns the persisted decision for `relayUrl`, or `null` if unknown. */
|
||||
suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope?
|
||||
|
||||
/**
|
||||
* Record a user decision. `ONCE` decisions are NOT persisted by contract —
|
||||
* the policy caches them in-memory for the current session only.
|
||||
*/
|
||||
suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
)
|
||||
|
||||
/** Wipe all persisted approvals. Called on account delete / logout. */
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AuthApprovalStore] used as a development scaffold and as the
|
||||
* `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals
|
||||
* with `ONCE` scope live here for the session and are dropped on logout.
|
||||
*/
|
||||
class InMemoryAuthApprovalStore : AuthApprovalStore {
|
||||
private val scopes = mutableMapOf<NormalizedRelayUrl, AuthApprovalScope>()
|
||||
private val lock = Any()
|
||||
|
||||
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = synchronized(lock) { scopes[relayUrl] }
|
||||
|
||||
override suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
synchronized(lock) { scopes[relayUrl] = scope }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
synchronized(lock) { scopes.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier between the relay client's `signWithAllLoggedInUsers` lambda
|
||||
* and the actual signer.
|
||||
*
|
||||
* Two tiers:
|
||||
*
|
||||
* - **Tier 1 (auto-allow):** the relay is in the user's own outbox or
|
||||
* NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately,
|
||||
* no prompt. These are relays the user has already declared they trust.
|
||||
* - **Tier 2 (prompt):** anything else, with the exception of relays that
|
||||
* carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via
|
||||
* [onPromptRequired] and suspend until the user resolves the
|
||||
* [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or
|
||||
* `BLOCKED`, persist via the store.
|
||||
*
|
||||
* No tier-3: every challenge is either auto-allowed, blocked by a persisted
|
||||
* decision, or surfaced to the user. There is no silent third path.
|
||||
*
|
||||
* @property selfApprovedRelays the union of own outbox + DM-inbox + any
|
||||
* account-level pre-approval. Recomputed by the caller on Account state
|
||||
* changes. Tier 1 if the challenger is in this set.
|
||||
* @property store persistence layer (SQLite-backed in production, in-memory in
|
||||
* tests).
|
||||
* @property onPromptRequired called when a [PendingAuthApproval] needs to be
|
||||
* surfaced to the UI. The UI subscribes to this side-channel and completes
|
||||
* the contained [CompletableDeferred] with the user's pick.
|
||||
*/
|
||||
class AuthApprovalPolicy(
|
||||
val selfApprovedRelays: () -> Set<NormalizedRelayUrl>,
|
||||
val store: AuthApprovalStore,
|
||||
val onPromptRequired: (PendingAuthApproval) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Decide what to do with an AUTH challenge from `relayUrl`.
|
||||
*
|
||||
* @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS,
|
||||
* [AuthApprovalDecision.Block] for persisted-BLOCKED,
|
||||
* [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for
|
||||
* unknown relays.
|
||||
*/
|
||||
suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision {
|
||||
// Persisted decision wins over tier-1: if user explicitly blocked a
|
||||
// relay that happens to also be in their outbox, respect the block.
|
||||
when (store.getScope(relayUrl)) {
|
||||
AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow
|
||||
AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block
|
||||
AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
if (relayUrl in selfApprovedRelays()) {
|
||||
return AuthApprovalDecision.Allow
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<AuthApprovalScope>()
|
||||
onPromptRequired(PendingAuthApproval(relayUrl, deferred))
|
||||
return AuthApprovalDecision.Pending(deferred)
|
||||
}
|
||||
|
||||
/** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */
|
||||
suspend fun recordDecision(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
// `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles
|
||||
// this transparently). `ALWAYS` and `BLOCKED` persist via the store.
|
||||
store.setScope(relayUrl, scope)
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AuthApprovalPolicyTest {
|
||||
private val ownOutbox = NormalizedRelayUrl("wss://own.outbox/")
|
||||
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
|
||||
private val blockedRelay = NormalizedRelayUrl("wss://blocked.relay/")
|
||||
|
||||
private fun newPolicy(
|
||||
ownSet: Set<NormalizedRelayUrl> = setOf(ownOutbox),
|
||||
onPrompt: (PendingAuthApproval) -> Unit = {},
|
||||
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
|
||||
val store = InMemoryAuthApprovalStore()
|
||||
val policy =
|
||||
AuthApprovalPolicy(
|
||||
selfApprovedRelays = { ownSet },
|
||||
store = store,
|
||||
onPromptRequired = onPrompt,
|
||||
)
|
||||
return policy to store
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier1OwnOutboxRelayIsAutoAllowed() =
|
||||
runTest {
|
||||
val (policy, _) = newPolicy()
|
||||
val decision = policy.classify(ownOutbox)
|
||||
assertSame(AuthApprovalDecision.Allow, decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRelayPromptsAndReturnsPending() =
|
||||
runTest {
|
||||
val prompts = mutableListOf<PendingAuthApproval>()
|
||||
val (policy, _) = newPolicy(onPrompt = { prompts += it })
|
||||
|
||||
val decision = policy.classify(unknown)
|
||||
|
||||
assertIs<AuthApprovalDecision.Pending>(decision)
|
||||
assertEquals(1, prompts.size)
|
||||
assertEquals(unknown, prompts.first().relayUrl)
|
||||
assertSame(decision.pending, prompts.first().decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedAlwaysIsAutoAllowed() =
|
||||
runTest {
|
||||
val (policy, store) = newPolicy()
|
||||
store.setScope(unknown, AuthApprovalScope.ALWAYS)
|
||||
val decision = policy.classify(unknown)
|
||||
assertSame(AuthApprovalDecision.Allow, decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedBlockedIsAutoBlockedEvenForOwnOutbox() =
|
||||
runTest {
|
||||
// Explicit user `[Never]` overrides tier-1 — if the user blocked a relay
|
||||
// that happens to be in their outbox, respect that.
|
||||
val (policy, store) = newPolicy()
|
||||
store.setScope(ownOutbox, AuthApprovalScope.BLOCKED)
|
||||
val decision = policy.classify(ownOutbox)
|
||||
assertSame(AuthApprovalDecision.Block, decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordDecisionPersistsAndChangesSubsequentClassification() =
|
||||
runTest {
|
||||
var promptCount = 0
|
||||
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
|
||||
|
||||
// First call prompts.
|
||||
val first = policy.classify(unknown)
|
||||
assertIs<AuthApprovalDecision.Pending>(first)
|
||||
assertEquals(1, promptCount)
|
||||
|
||||
// User picks `[Always]`.
|
||||
policy.recordDecision(unknown, AuthApprovalScope.ALWAYS)
|
||||
|
||||
// Subsequent calls return Allow without prompting.
|
||||
val second = policy.classify(unknown)
|
||||
assertSame(AuthApprovalDecision.Allow, second)
|
||||
assertEquals(1, promptCount, "should not prompt again after Always grant")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockedDecisionPersistsAndStaysBlocked() =
|
||||
runTest {
|
||||
var promptCount = 0
|
||||
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
|
||||
|
||||
// First call prompts.
|
||||
policy.classify(blockedRelay)
|
||||
assertEquals(1, promptCount)
|
||||
|
||||
// User picks `[Never]`.
|
||||
policy.recordDecision(blockedRelay, AuthApprovalScope.BLOCKED)
|
||||
|
||||
// Subsequent classify returns Block without prompting.
|
||||
val decision = policy.classify(blockedRelay)
|
||||
assertSame(AuthApprovalDecision.Block, decision)
|
||||
assertEquals(1, promptCount, "should not prompt again after Never")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfApprovedRelaysIsReevaluatedPerCall() =
|
||||
runTest {
|
||||
// Account state changes (user adds a relay to their outbox) must take
|
||||
// effect immediately — the policy reads the supplier per classify.
|
||||
var ownSet = setOf<NormalizedRelayUrl>()
|
||||
val policy =
|
||||
AuthApprovalPolicy(
|
||||
selfApprovedRelays = { ownSet },
|
||||
store = InMemoryAuthApprovalStore(),
|
||||
onPromptRequired = {},
|
||||
)
|
||||
|
||||
assertIs<AuthApprovalDecision.Pending>(policy.classify(ownOutbox))
|
||||
|
||||
ownSet = setOf(ownOutbox)
|
||||
assertSame(AuthApprovalDecision.Allow, policy.classify(ownOutbox))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storeClearWipesAllApprovals() =
|
||||
runTest {
|
||||
val store = InMemoryAuthApprovalStore()
|
||||
store.setScope(unknown, AuthApprovalScope.ALWAYS)
|
||||
store.setScope(blockedRelay, AuthApprovalScope.BLOCKED)
|
||||
|
||||
store.clear()
|
||||
|
||||
// Both relays now unknown → fresh classification prompts.
|
||||
assertTrue(store.getScope(unknown) == null)
|
||||
assertTrue(store.getScope(blockedRelay) == null)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user