mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
feat(buzz): NIP-OA agent auth at connect (hold + inject auth tag)
Tier 1 connect path. BuzzHeldAttestations (commons) stores the OwnerAttestations this device received, keyed by the agent pubkey each authorizes (verify-passing only). AuthCoordinator.buzzAugmented appends the owner-signed auth tag to an account's NIP-42 AUTH event when that account's key has a held attestation and the relay speaks the Buzz dialect — and only that account's AUTH, never the Concord stream-key AUTHs sharing the template, and never on non-Buzz relays. So an un-enrolled agent key gets virtual membership while its owner stays a member. AgentAttestationScreen gains a 'Hold an attestation' section (paste the auth tag JSON, verified against the current account, stored/removed) alongside the existing owner-side issuance. Store is in-memory for now — persisting per-account is a follow-up. 4 store tests added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
@@ -135,7 +137,7 @@ class AuthCoordinator(
|
||||
// Remember why we granted this relay so the settings screen can explain it.
|
||||
account.relayAuthLedger.recordGrant(context)
|
||||
try {
|
||||
signed.add(account.signer.sign(authTemplate))
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
}
|
||||
@@ -169,6 +171,27 @@ class AuthCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
|
||||
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
|
||||
* appended — so the relay grants virtual membership to an un-enrolled agent key while
|
||||
* its owner stays a member. Otherwise returns [template] unchanged.
|
||||
*
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
|
||||
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
|
||||
* add an `auth` tag where one isn't wanted.
|
||||
*/
|
||||
private fun buzzAugmented(
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
accountPubKey: HexKey,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): EventTemplate<RelayAuthEvent> {
|
||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
|
||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||
}
|
||||
|
||||
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
|
||||
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -48,6 +49,7 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
@@ -58,6 +60,9 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
|
||||
@@ -77,9 +82,10 @@ fun AgentAttestationScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
val keyPair = accountViewModel.account.settings.keyPair
|
||||
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Issue Attestation", nav) },
|
||||
topBar = { TopBarWithBackButton("Attestations", nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
@@ -88,8 +94,14 @@ fun AgentAttestationScreen(
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Agent side: hold an attestation an owner gave you, so this account
|
||||
// authenticates to the owner's Buzz relays as a virtual member. Available to
|
||||
// any signer — holding a credential doesn't require the raw key.
|
||||
HoldAttestationSection(myPubkey = myPubkey)
|
||||
|
||||
// Owner side: issue an attestation for an agent key. Needs the raw private key.
|
||||
val privKey = keyPair.privKey
|
||||
if (privKey == null) {
|
||||
ReadOnlyKeyNotice()
|
||||
@@ -100,6 +112,118 @@ fun AgentAttestationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
|
||||
* now — re-paste after an app restart.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
val held by BuzzHeldAttestations.flow.collectAsState()
|
||||
val mine = held[myPubkey]
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Hold an attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (mine != null) {
|
||||
Text(
|
||||
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
Text("Remove")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("auth tag JSON") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = parseHeldAttestation(input, myPubkey)) {
|
||||
is HoldOutcome.Failure -> error = outcome.message
|
||||
is HoldOutcome.Success -> {
|
||||
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
|
||||
input = ""
|
||||
error = null
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Hold attestation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface HoldOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : HoldOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : HoldOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
|
||||
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
|
||||
* non-`auth` tag, or a signature that doesn't verify for this key.
|
||||
*/
|
||||
private fun parseHeldAttestation(
|
||||
input: String,
|
||||
myPubkey: String,
|
||||
): HoldOutcome {
|
||||
val tag =
|
||||
try {
|
||||
Json
|
||||
.parseToJsonElement(input.trim())
|
||||
.jsonArray
|
||||
.map { it.jsonPrimitive.content }
|
||||
.toTypedArray()
|
||||
} catch (e: Exception) {
|
||||
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
|
||||
}
|
||||
val attestation =
|
||||
OwnerAttestation.parse(tag)
|
||||
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
|
||||
if (!attestation.verify(myPubkey)) {
|
||||
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
|
||||
}
|
||||
return HoldOutcome.Success(attestation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadOnlyKeyNotice() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
|
||||
* authorization per agent key that lets that key publish in the owner's Buzz workspace
|
||||
* without being enrolled as a relay member.
|
||||
*
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
|
||||
* agent operator out-of-band): when the account whose pubkey equals a stored key
|
||||
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
|
||||
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
|
||||
* membership while the owner stays a member.
|
||||
*
|
||||
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
|
||||
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
|
||||
* store never carries a credential the relay would reject.
|
||||
*
|
||||
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
|
||||
* only: a held attestation is re-pasted after a process restart. Persisting it across
|
||||
* launches (per-account, encrypted) is a follow-up.
|
||||
*/
|
||||
object BuzzHeldAttestations {
|
||||
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
|
||||
|
||||
/** All held attestations, keyed by agent pubkey; UI can collect this. */
|
||||
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
|
||||
|
||||
/** The attestation held for [agentPubKey], or null. */
|
||||
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
|
||||
|
||||
/**
|
||||
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
|
||||
* verified attestation is held for that key.
|
||||
*/
|
||||
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
|
||||
|
||||
/**
|
||||
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
|
||||
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
|
||||
* writers don't clobber each other.
|
||||
*/
|
||||
fun put(
|
||||
agentPubKey: HexKey,
|
||||
attestation: OwnerAttestation,
|
||||
) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (current[agentPubKey] == attestation) return
|
||||
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes any attestation held for [agentPubKey]. */
|
||||
fun remove(agentPubKey: HexKey) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (agentPubKey !in current) return
|
||||
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clears all held attestations so unit tests don't leak state. */
|
||||
fun clearForTesting() {
|
||||
heldByAgent.value = emptyMap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class BuzzHeldAttestationsTest {
|
||||
private val agent = "a".repeat(64)
|
||||
private val owner = "b".repeat(64)
|
||||
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = BuzzHeldAttestations.clearForTesting()
|
||||
|
||||
@Test
|
||||
fun emptyStoreYieldsNoTag() {
|
||||
assertNull(BuzzHeldAttestations.attestationFor(agent))
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heldAttestationSurfacesAsItsAuthTag() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
|
||||
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
|
||||
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeClearsTheHeldAttestation() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
BuzzHeldAttestations.remove(agent)
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAgentsAttestationDoesNotLeakToAnother() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
|
||||
}
|
||||
}
|
||||
@@ -141,15 +141,35 @@ kind-filtered, so they can never receive Buzz kinds by accident):
|
||||
replaceably, the rest store as queryable regular events, and ephemeral signals
|
||||
(typing 20002, observer 24200, huddle reactions 24810, pairing 24134) mark the
|
||||
dialect but are deliberately not persisted.
|
||||
- The group-chat REQ builders (`RelayGroupFilterBuilders`) request 40002/40003/40008/40099
|
||||
**unconditionally** alongside the NIP-29 kinds (they match nothing on a vanilla relay).
|
||||
- The group-chat REQ builders (`RelayGroupFilterBuilders`) request the whole Buzz timeline
|
||||
set — 40002/40003/40008/40099, canvas 40100, forum 45001-45003, agent jobs 43001-43006,
|
||||
huddle lifecycle 48100-48103 — **unconditionally** alongside the NIP-29 kinds (they match
|
||||
nothing on a vanilla relay; all are `h`-scoped so the same `#h` group REQ returns them).
|
||||
A dialect-gated version was reverted: gating advanced history cursors past ranges that
|
||||
had been queried without the Buzz kinds, permanently skipping older workspace messages.
|
||||
- Kind-aware chat rendering (`RenderBuzzNotes` + `ChatMessageCompose`): 40002 as text,
|
||||
40003 as an edit overlay, 40099 as a system row, **40008 diffs** as a monospace scrollable
|
||||
block with a repo/commit/file header, **agent-job (43xxx) + huddle (48xxx) lifecycle** as
|
||||
centered system narration (huddles carry JSON content, so they must not fall through to a
|
||||
raw text bubble), and **forum votes (45002)** as a glyph line. Forum posts stay plain text.
|
||||
- Dialect marking fires only off a **verified** event (`markBuzzIfVerified`), so an
|
||||
unverifiable frame from a hostile/buggy relay can't flip what the composer sends.
|
||||
|
||||
Not yet wired: kind-aware rendering of 40002/40099/diff rows in the chat UI, the
|
||||
composer switch (write 40002 on Buzz relays), and NIP-42-at-connect workspace sessions.
|
||||
### NIP-OA agent auth at connect — implemented
|
||||
|
||||
`commons/.../model/buzz/BuzzHeldAttestations.kt` holds the attestations this device has
|
||||
*received* (keyed by the agent pubkey they authorize; only `verify`-passing ones are
|
||||
stored). When an account whose pubkey matches a held attestation AUTHs (NIP-42) to a
|
||||
**Buzz-dialect** relay, `AuthCoordinator.buzzAugmented` appends the owner-signed `auth`
|
||||
tag to that account's AUTH event — and only that account's, never the Concord stream-key
|
||||
AUTHs that share the template — so an un-enrolled agent key gets virtual membership while
|
||||
its owner stays a member. The paste/hold + owner-side issue flows both live in
|
||||
`AgentAttestationScreen`. (Store is in-memory for now; persisting per-account is a follow-up.)
|
||||
|
||||
Not yet wired (write path): the edit composer (40003) and forum/job/huddle/DM message
|
||||
composers, plus Buzz-native reactions/deletes — all require changes to the central chat
|
||||
composer and are deferred to a focused pass. The canvas (40100) is now requested and
|
||||
consumed as overlay state but has no dedicated viewer yet.
|
||||
|
||||
## Owner Attestation (NIP-OA) — implemented
|
||||
|
||||
|
||||
Reference in New Issue
Block a user