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:
Claude
2026-07-22 01:34:56 +00:00
parent bce544e8c3
commit 4a4baf6a23
5 changed files with 329 additions and 7 deletions

View File

@@ -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>()

View File

@@ -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()) {

View File

@@ -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()
}
}

View File

@@ -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)))
}
}

View File

@@ -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