mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
feat(buzz): geode BuzzMembershipPolicy — self-host the agent channel
A thin server-side policy so `amy serve --buzz` hosts a private, agent-authorized Buzz workspace on one JVM process — no Block Rust relay + Postgres/Redis/MinIO. - quartz: BuzzMembershipPolicy : FullAuthPolicy (buzz/relay/). Runs the NIP-42 handshake, then layers Buzz's two authorization rules: (1) only members (the team) may read/write; (2) NIP-OA virtual membership — an un-enrolled agent key is granted membership for its connection when its AUTH event carries an owner-signed `auth` tag whose owner is a member and whose signature authorizes that agent. An unauthenticated read is told `auth-required` (not `restricted`) so the client runs NIP-42 and retries. Deliberately does NOT emit relay-signed 39000-39003 metadata or run workflows (a policy can't emit events; the job channel doesn't need them). 8 unit tests. - cli: `amy serve --buzz [--members npubs]` composes the policy into geode's RelayEngine. Members = admins + --members. Verified end-to-end against a live `amy serve --buzz`: a member writes (published:true), an outsider is rejected (restricted: not a workspace member); plus the 8-case unit suite (member/non-member/unauth/reads/allowed-kinds/NIP-OA-agent/non-member-owner/tampered). Plan doc + cli README updated with the two relay options (amy serve --buzz vs the Rust stack) and when you'd need each. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
+1
-1
@@ -460,7 +460,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. Blocks until interrupted. |
|
||||
| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS] [--buzz [--members NPUBS]]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. `--buzz` makes it a **private Buzz workspace relay** (`BuzzMembershipPolicy`): NIP-42 required, and only members (admins + `--members`) or NIP-OA-attested agents may read/write — so a team can self-host the agent channel on one JVM process instead of Block's Rust `buzz-relay` + Postgres/Redis/MinIO. Blocks until interrupted. |
|
||||
|
||||
### Identity
|
||||
|
||||
|
||||
@@ -81,9 +81,14 @@ Keep that credential minimal; branch protection is what actually stops a bad mer
|
||||
|
||||
## Architecture (MVP)
|
||||
|
||||
1. **One `buzz-relay`** (Block's Rust relay — geode does NOT implement Buzz server
|
||||
semantics: kind accept-list, `h`-scope, NIP-OA fallback, relay-signed metadata) = the
|
||||
"Amethyst workspace" tenant. Team npubs enrolled as members; a maintainer is owner.
|
||||
1. **The workspace relay.** For the agent job channel you have two options:
|
||||
- **`amy serve --buzz --members <npubs>`** (recommended to start) — a private, agent-authorized
|
||||
workspace on a single JVM process via **`BuzzMembershipPolicy`** (quartz): NIP-42 required,
|
||||
only members + NIP-OA-attested agents may read/write. No Rust, no Postgres/Redis/MinIO. The
|
||||
job board + scheduler run on this today. It does NOT emit relay-signed NIP-29 metadata
|
||||
(39000-39003) or run workflows — the job channel doesn't need them.
|
||||
- **Block's Rust `buzz-relay`** — only if you want the full in-app Buzz *workspace/DM* UI
|
||||
(relay-signed rosters, relay-assigned DM UUIDs) or server-run workflows. Heavier stack.
|
||||
2. **One agent identity** = its own nostr key, authorized by a NIP-OA attestation the owner
|
||||
issues (`amy buzz attest` / `AgentAttestationScreen`). On GitHub it authenticates with a
|
||||
PR-only token; `main` is branch-protected.
|
||||
|
||||
@@ -26,7 +26,10 @@ import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.geode.KtorRelay
|
||||
import com.vitorpamplona.geode.RelayEngine
|
||||
import com.vitorpamplona.quartz.buzz.relay.BuzzMembershipPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
|
||||
@@ -48,9 +51,12 @@ object ServeCommand {
|
||||
|
|
||||
| serve [--host H] [--port N] [--path P] in-memory by default (ephemeral); --db FILE
|
||||
| [--db FILE] [--admin NPUBS] for a persistent SQLite store. The account's
|
||||
| pubkey is always an admin; --admin adds more
|
||||
| [--buzz [--members NPUBS]] pubkey is always an admin; --admin adds more
|
||||
| (comma-separated npub/hex). Blocks until
|
||||
| interrupted. Defaults: 127.0.0.1:7447/
|
||||
|
|
||||
| --buzz turns this into a private Buzz workspace relay (NIP-42 required; only members
|
||||
| and NIP-OA-attested agents may read/write). Members = admins + --members.
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun run(
|
||||
@@ -66,34 +72,54 @@ object ServeCommand {
|
||||
val port = args.intFlag("port", 7447)
|
||||
val path = args.flag("path") ?: "/"
|
||||
val dbFile = args.flag("db")
|
||||
val extraAdmins =
|
||||
|
||||
fun csv(name: String) =
|
||||
args
|
||||
.flag("admin")
|
||||
.flag(name)
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
val extraAdmins = csv("admin")
|
||||
val buzz = args.bool("buzz")
|
||||
val extraMembers = csv("members")
|
||||
args.rejectUnknown()
|
||||
|
||||
// Resolve admin pubkeys (self + --admin) up front, then drop the
|
||||
// Resolve admin + member pubkeys (self + --admin [+ --members]) up front, then drop the
|
||||
// Context — the embedded relay owns its own store and needs no account.
|
||||
val adminPubkeys =
|
||||
val (adminPubkeys, memberPubkeys) =
|
||||
Context.open(dataDir).use { ctx ->
|
||||
buildSet {
|
||||
add(ctx.identity.pubKeyHex)
|
||||
extraAdmins.forEach { add(ctx.requireUserHex(it)) }
|
||||
}
|
||||
val admins =
|
||||
buildSet {
|
||||
add(ctx.identity.pubKeyHex)
|
||||
extraAdmins.forEach { add(ctx.requireUserHex(it)) }
|
||||
}
|
||||
val members =
|
||||
buildSet {
|
||||
addAll(admins)
|
||||
extraMembers.forEach { add(ctx.requireUserHex(it)) }
|
||||
}
|
||||
admins to members
|
||||
}
|
||||
|
||||
// 0.0.0.0 isn't routable in a NIP-42 challenge; advertise loopback.
|
||||
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
|
||||
val url = "ws://$advertisedHost:$port$path".normalizeRelayUrl()
|
||||
|
||||
// --buzz locks the relay to members + NIP-OA-attested agents; otherwise a vanilla relay.
|
||||
val policyBuilder: () -> IRelayPolicy =
|
||||
if (buzz) {
|
||||
{ BuzzMembershipPolicy(url, memberPubkeys) }
|
||||
} else {
|
||||
{ EmptyPolicy }
|
||||
}
|
||||
|
||||
// In-memory is RelayEngine's default; only build a SQLite store for --db.
|
||||
val relay =
|
||||
if (dbFile != null) {
|
||||
RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), adminPubkeys = adminPubkeys)
|
||||
RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), policyBuilder = policyBuilder, adminPubkeys = adminPubkeys)
|
||||
} else {
|
||||
RelayEngine(url, adminPubkeys = adminPubkeys)
|
||||
RelayEngine(url, policyBuilder = policyBuilder, adminPubkeys = adminPubkeys)
|
||||
}
|
||||
val server = KtorRelay(relay, host = host, port = port, path = path).start()
|
||||
|
||||
@@ -112,9 +138,11 @@ object ServeCommand {
|
||||
"path" to path,
|
||||
"persistent" to (dbFile != null),
|
||||
"admin_pubkeys" to adminPubkeys.toList(),
|
||||
"buzz" to buzz,
|
||||
"members" to if (buzz) memberPubkeys.toList() else null,
|
||||
),
|
||||
)
|
||||
System.err.println("[serve] relay up at ${server.url} — Ctrl-C to stop")
|
||||
System.err.println("[serve] relay up at ${server.url}${if (buzz) " (Buzz workspace, ${memberPubkeys.size} members)" else ""} — Ctrl-C to stop")
|
||||
|
||||
// Block until the process is interrupted; the shutdown hook tears down.
|
||||
awaitCancellation()
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.buzz.relay
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.tags.AuthTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
|
||||
/**
|
||||
* A **private, agent-authorized workspace** relay policy — the thin server half that lets
|
||||
* `geode` (via `amy serve --buzz`) host a Buzz-style agent channel without Block's Rust relay
|
||||
* + Postgres/Redis/MinIO stack. Built on quartz's own relay-server code.
|
||||
*
|
||||
* It extends [FullAuthPolicy], so it runs the full NIP-42 handshake and then layers Buzz's
|
||||
* two authorization rules on top:
|
||||
*
|
||||
* 1. **Membership.** Only a [members] key (the team) may publish or read. A completed NIP-42
|
||||
* auth alone is not enough — auth proves identity; membership is the authorization.
|
||||
* 2. **NIP-OA virtual membership.** An un-enrolled *agent* key is granted membership **for its
|
||||
* connection** when its NIP-42 auth event carries an owner-signed `auth` tag
|
||||
* ([com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation]) whose owner is a
|
||||
* member and whose signature authorizes that agent — the same primitive Block's relay uses
|
||||
* to make agents first-class without enrolling every key. The grant lives only as long as
|
||||
* the connection (a fresh policy is built per connection), so revoking the owner's
|
||||
* membership or dropping the socket revokes the agent.
|
||||
*
|
||||
* What this deliberately does NOT do (out of scope for a job channel; needs a relay that
|
||||
* *emits* signed events, which a policy cannot): relay-signed NIP-29 metadata (39000-39003),
|
||||
* relay-assigned DM UUIDs, and workflow execution (46xxx). See
|
||||
* `cli/plans/2026-07-25-buzz-agent-support-channel.md`.
|
||||
*
|
||||
* [allowedKinds], when non-null, further restricts every author to those kinds (e.g. lock a
|
||||
* channel down to the Buzz job + chat + reaction kinds); null accepts any kind.
|
||||
*/
|
||||
open class BuzzMembershipPolicy(
|
||||
relay: NormalizedRelayUrl,
|
||||
private val members: Set<HexKey>,
|
||||
private val allowedKinds: Set<Int>? = null,
|
||||
) : FullAuthPolicy(relay) {
|
||||
/** Agent keys granted virtual membership on THIS connection via a valid NIP-OA `auth` tag. */
|
||||
private val authorizedAgents = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event
|
||||
* carries an owner-signed attestation authorizing this agent, and the owner is a member,
|
||||
* remember the agent as a member for this connection. We never throw here — a missing or
|
||||
* invalid attestation just means the key is authenticated but not (yet) authorized, which the
|
||||
* membership gate below handles.
|
||||
*/
|
||||
override suspend fun authorize(event: RelayAuthEvent) {
|
||||
val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return
|
||||
if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) {
|
||||
authorizedAgents.add(event.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMember(pubKey: HexKey): Boolean = pubKey in members || pubKey in authorizedAgents
|
||||
|
||||
/**
|
||||
* The read gate as a rejection reason, or null to allow. Crucially, an *unauthenticated*
|
||||
* connection is told `auth-required` (not `restricted`) so the client runs the NIP-42
|
||||
* handshake and retries — only an authenticated non-member is `restricted`.
|
||||
*/
|
||||
private fun readGate(): String? =
|
||||
when {
|
||||
authenticatedUsers.isEmpty() -> "auth-required: authenticate before reading this workspace"
|
||||
authenticatedUsers.any(::isMember) -> null
|
||||
else -> "restricted: not a workspace member"
|
||||
}
|
||||
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
val author = cmd.event.pubKey
|
||||
if (author !in authenticatedUsers) {
|
||||
return PolicyResult.Rejected("auth-required: authenticate before publishing")
|
||||
}
|
||||
if (!isMember(author)) {
|
||||
return PolicyResult.Rejected("restricted: not a workspace member")
|
||||
}
|
||||
allowedKinds?.let {
|
||||
if (cmd.event.kind !in it) return PolicyResult.Rejected("restricted: kind ${cmd.event.kind} is not accepted on this workspace")
|
||||
}
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
|
||||
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd)
|
||||
|
||||
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.buzz.relay
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.tags.AuthTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzMembershipPolicyTest {
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://work.example.com")!!
|
||||
private val owner = KeyPair()
|
||||
private val agent = KeyPair()
|
||||
private val stranger = KeyPair()
|
||||
private val ownerPub = owner.pubKey.toHexKey()
|
||||
private val agentPub = agent.pubKey.toHexKey()
|
||||
private val strangerPub = stranger.pubKey.toHexKey()
|
||||
|
||||
private class Ctx(
|
||||
override val authenticatedUsers: Set<HexKey>,
|
||||
) : RequestContext {
|
||||
override val connectionId = 1L
|
||||
override val policy: IRelayPolicy = EmptyPolicy
|
||||
}
|
||||
|
||||
/** A fresh policy whose connection has [authed] recorded as authenticated. */
|
||||
private fun policyOn(authed: Set<HexKey>): BuzzMembershipPolicy = BuzzMembershipPolicy(relay, members = setOf(ownerPub)).apply { onConnect(Ctx(authed)) {} }
|
||||
|
||||
private fun event(
|
||||
author: HexKey,
|
||||
kind: Int = 40002,
|
||||
) = EventCmd(Event("00", author, 0, kind, emptyArray(), "", "sig"))
|
||||
|
||||
private fun req() = ReqCmd("sub", emptyList())
|
||||
|
||||
private fun accepted(r: PolicyResult<*>) = r is PolicyResult.Accepted
|
||||
|
||||
private fun reason(r: PolicyResult<*>) = (r as PolicyResult.Rejected).reason
|
||||
|
||||
@Test
|
||||
fun memberMayPublish() {
|
||||
assertTrue(accepted(policyOn(setOf(ownerPub)).accept(event(ownerPub))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticatedNonMemberIsRejected() {
|
||||
val r = policyOn(setOf(strangerPub)).accept(event(strangerPub))
|
||||
assertTrue(reason(r).startsWith("restricted"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unauthenticatedIsRejected() {
|
||||
val r = policyOn(emptySet()).accept(event(ownerPub))
|
||||
assertTrue(reason(r).startsWith("auth-required"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsAreMemberGated() {
|
||||
assertTrue(accepted(policyOn(setOf(ownerPub)).accept(req())))
|
||||
// Authenticated non-member: restricted. Unauthenticated: auth-required (so the client
|
||||
// runs NIP-42 and retries, rather than silently getting nothing).
|
||||
assertTrue(reason(policyOn(setOf(strangerPub)).accept(req())).startsWith("restricted"))
|
||||
assertTrue(reason(policyOn(emptySet()).accept(req())).startsWith("auth-required"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowedKindsRestrictsMembers() {
|
||||
val policy = BuzzMembershipPolicy(relay, setOf(ownerPub), allowedKinds = setOf(40002)).apply { onConnect(Ctx(setOf(ownerPub))) {} }
|
||||
assertTrue(accepted(policy.accept(event(ownerPub, kind = 40002))))
|
||||
assertTrue(reason(policy.accept(event(ownerPub, kind = 1))).startsWith("restricted"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nipOaAgentIsGrantedMembershipForTheConnection() =
|
||||
runTest {
|
||||
// The owner (a member) attests the un-enrolled agent key.
|
||||
val attestation = OwnerAttestation.sign(agentPub, "", owner.privKey!!)
|
||||
val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(attestation)), "", "sig")
|
||||
|
||||
val policy = policyOn(setOf(agentPub))
|
||||
policy.onAuthenticated(authEvent) // engine calls this after the NIP-42 proof verifies
|
||||
|
||||
assertTrue(accepted(policy.accept(event(agentPub))), "attested agent may publish")
|
||||
assertTrue(accepted(policy.accept(req())), "attested agent may read")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attestationFromANonMemberOwnerIsIgnored() =
|
||||
runTest {
|
||||
// The attestation is validly signed, but by an owner who is NOT a workspace member.
|
||||
val outsider = KeyPair()
|
||||
val attestation = OwnerAttestation.sign(agentPub, "", outsider.privKey!!)
|
||||
val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(attestation)), "", "sig")
|
||||
|
||||
val policy = policyOn(setOf(agentPub))
|
||||
policy.onAuthenticated(authEvent)
|
||||
|
||||
assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted"), "unauthorized agent is rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedAttestationIsIgnored() =
|
||||
runTest {
|
||||
// Owner IS a member, but the signature doesn't match the agent (tampered).
|
||||
val real = OwnerAttestation.sign(strangerPub, "", owner.privKey!!) // signed for a DIFFERENT key
|
||||
val forged = OwnerAttestation(ownerPub, real.conditions, real.sig)
|
||||
val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(forged)), "", "sig")
|
||||
|
||||
val policy = policyOn(setOf(agentPub))
|
||||
policy.onAuthenticated(authEvent)
|
||||
|
||||
assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user