Identidad de agentes y auth
Engine v0.5.6+.
Login y sesiones trata del humano con un browser: una contraseña,
una cookie, un código 2FA. Esta página trata del otro sujeto con el que habla tu
servidor: un agente. No tiene browser, no debería cargar un secreto de larga
vida, y muchas veces necesita entregarle una porción más débil de su propia
autoridad a un subagente que lanzó hace cinco segundos.
Synsema trata eso como caso de primera clase: el runtime sabe quién está llamando,
y mide el gasto y el rate limit por identidad en vez de por IP o por proceso.
-- Doc example: agent identity — capability tokens that can only narrow, signed
-- requests (proof-of-possession) and per-identity metering.
-- (The server side rides on `serve`, which doesn't terminate — the page shows the
-- full flow; this doctest asserts the primitives that flow is built from.)
intent: "doc example: agent identity"
require random
-- Signing a request goes through the same deny-by-default gate as signing a
-- blockchain transaction, scoped to the key's name (here the label given to
-- as_secret) and audited. Without this line http_sign is refused.
require sign("SIG")
let root be "root-key-for-the-doctest"
let full be captoken_mint({"net": "*.example.com", "spend": "ETH"}, root,
{"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
let sub be captoken_attenuate(full, {"net": "api.example.com"},
{"ttl": 60, "spend": {"ETH": 0.01}})
print("delegated token → " + slice(sub, 0, 12) + "…")
test "a capability token verifies and reports what it grants"
let v be captoken_verify(full, root)
assert_ne(v, nothing)
assert_eq(v.id, "orchestrator")
assert_eq(v.depth, 1)
assert(captoken_allows(v, "net", "api.example.com"))
test "attenuating needs no key, and the result is strictly weaker"
let v be captoken_verify(sub, root)
assert_eq(v.depth, 2)
-- the delegated host still works…
assert(captoken_allows(v, "net", "api.example.com"))
-- …but the rest of the parent's glob does not, and `spend` was not delegated
assert_eq(captoken_allows(v, "net", "other.example.com"), false)
assert_eq(captoken_allows(v, "spend", "ETH"), false)
-- the spend caveat came down from 0.5 to 0.01 (any unit: fiat, crypto, commodities)
assert_eq(v.caveats.spend.ETH, "0.01")
test "attenuation can never widen — the error names the problem"
assert_error(() => captoken_attenuate(sub, {"net": "*.example.com"}, nothing))
assert_error(() => captoken_attenuate(sub, {"exec": nothing}, nothing))
assert_error(() => captoken_attenuate(sub, {"net": "api.example.com"}, {"ttl": 99999}))
test "a forged or tampered token is nothing, never a partial success"
assert_eq(captoken_verify(full, "another-root"), nothing)
assert_eq(captoken_verify("not-a-token", root), nothing)
assert_eq(captoken_verify(slice(full, 0, 20), root), nothing)
test "expiry and revocation"
let short be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "id": "temp-1"})
assert_ne(captoken_verify(short, root), nothing)
-- `at` moves the clock forward for a deterministic test
assert_eq(captoken_verify(short, root, {"at": 4102444800}), nothing)
-- revocation is a denylist of ids (typically read from redis)
assert_eq(captoken_verify(short, root, {"revoked": ["temp-1"]}), nothing)
test "contextual caveats are fail-closed"
let scoped be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "aud": "orders-api"})
assert_ne(captoken_verify(scoped, root, {"aud": "orders-api"}), nothing)
assert_eq(captoken_verify(scoped, root, {"aud": "other-api"}), nothing)
-- not supplying the context at all is a rejection: you cannot claim a
-- condition holds if you never checked it
assert_eq(captoken_verify(scoped, root), nothing)
test "signed requests: the signature covers method, URL and body"
let key be as_secret("shared-signing-key", "SIG")
let req be {"method": "POST", "url": "https://api.example.com/orders", "body": "{\"n\":1}"}
let headers be http_sign(req, key, {"alg": "hmac-sha256", "keyid": "agent-7"})
let incoming be {"method": "POST", "url": "https://api.example.com/orders",
"headers": headers, "body": "{\"n\":1}"}
let v be http_signature_verify(incoming, key, {"alg": "hmac-sha256"})
assert_eq(v.keyid, "agent-7")
-- same signature, different body → the content-digest no longer matches
let tampered be {"method": "POST", "url": "https://api.example.com/orders",
"headers": headers, "body": "{\"n\":999}"}
assert_eq(http_signature_verify(tampered, key, {"alg": "hmac-sha256"}), nothing)
test "the verifier pins the algorithm — never reads it from the message"
let key be as_secret("shared-signing-key", "SIG")
let req be {"method": "GET", "url": "https://api.example.com/health"}
let headers be http_sign(req, key, {"alg": "hmac-sha256"})
let incoming be {"method": "GET", "url": "https://api.example.com/health", "headers": headers}
-- a verifier expecting ed25519 rejects an hmac message outright
assert_eq(http_signature_verify(incoming, key, {"alg": "ed25519"}), nothing)
-- and omitting alg is a programmer error, not a permissive default
assert_error(() => http_signature_verify(incoming, key))
test "oidc_verify demands iss and aud"
assert_error(() => oidc_verify("a.b.c", {"aud": "my-client", "jwks": "{}"}))
assert_error(() => oidc_verify("a.b.c", {"iss": "https://idp", "jwks": "{}"}))
-- a bad token with complete options is nothing, not an error
assert_eq(oidc_verify("not-a-jwt", {"iss": "https://idp", "aud": "my-client", "jwks": "{}"}), nothing)
test "spend_total reads per-identity totals"
assert_eq(spend_total("ETH"), 0)
assert_eq(spend_total("ETH", "orchestrator"), 0)
Por qué no alcanza con darle una API key al agente
Una API key en el contexto de un agente es una credencial al portador dentro del
lugar más propenso a filtrarse de todo el sistema: un prompt. Tres propiedades
cambian eso:
- No debería ser de larga vida. Los workloads en la nube ya tienen identidad
(oidc_verify, más abajo); las terminales pueden usar un device flow. No hay nada
que robar.
- No debería servir si la copian. Una request firmada (
http_sign) demuestra
posesión de una clave que jamás sale del secret sellado.
- No debería delegarse entera. Cuando un orquestador lanza un subagente, la
práctica universal hoy es pasarle la misma key — suplantación total. Los tokens de
capacidad permiten entregar estrictamente menos.
Tokens de capacidad — delegación que solo puede achicar
Un captoken dice qué puede hacer quien lo porta, y quien lo porta puede debilitarlo
offline, sin la clave raíz:
-- la unidad de spend es la que use el host: fiat, cripto, commodities, créditos
let full be captoken_mint({"net": "*.example.com", "db": "postgres://localhost/app", "spend": "ETH"},
secret("ROOT_KEY"),
{"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
-- delegar trabajo a un subagente: un solo host, sin base de datos, una cincuentava parte del presupuesto
let sub be captoken_attenuate(full, {"net": "api.example.com", "spend": "ETH"},
{"ttl": 60, "spend": {"ETH": 0.01}})
captoken_attenuate no recibe clave — ese es justamente el punto, y por eso
delegar no necesita ida y vuelta con quien acuñó el token. Funciona porque cada
bloque de la cadena se firma usando la firma anterior como clave (la construcción de
macaroons): quien porta el token puede agregar un bloque, nunca quitar ni editar
uno.
Los permisos son las mismas formas de require — net("api.example.com"),
db("postgres://host/db"), file.read("./data/*") — y el achique se verifica con la
misma relación covers() que usa el sistema de capabilities, así que un scope en un
token significa exactamente lo que significa en un require. Ampliar se rechaza
dos veces: al atenuar (con un error que dice qué no entraba) y de nuevo al
verificar, así que un bloque forjado a mano tampoco puede ampliar.
Verificar, y qué significa acá "fail-closed"
let caps be captoken_verify(token, secret("ROOT_KEY"),
{"aud": "orders-api", "ip": request.ip, "method": request.method})
when caps == nothing
give unauthorized("token inválido")
when not captoken_allows(caps, "net", target_host)
give fail(403, "ese host no está en tu token")
Toda falla — firma mala, vencido, forjado, revocado, audiencia equivocada — es
nothing, sin pistas de cuál fue: un endpoint no debe poder distinguirse por *por
qué* te rechazó. Y un caveat para el que no aportás contexto es un rechazo, no un
permiso: si el token dice aud: "orders-api" y nunca pasás aud, no podés afirmar
que la condición se cumple.
Revocación, dicha en voz alta
La atenuación es offline, así que no hay chequeo central donde colgar la revocación.
La respuesta de diseño son dos cosas, y conviene decidir ambas antes de un incidente,
no durante:
- TTLs cortos. El default son 15 minutos a propósito.
- Una denylist de ids.
captoken_verify(t, k, {"revoked": [...]}), con la lista
típicamente leída de redis. id es lo que se revoca: fijalo explícitamente en los
tokens que después vas a querer nombrar.
Requests firmadas (proof-of-possession)
En vez de mandar un token que cualquiera que lo copie puede repetir, firmá la
request:
let key be secret("AGENT_KEY") -- dentro del handler; ver el pitfall abajo
let req be {"method": "POST", "url": "https://api.partner.com/orders", "body": payload}
let headers be http_sign(req, key, {"alg": "ed25519", "keyid": "agent-7"})
let res be http_post("https://api.partner.com/orders", payload, headers)
Synsema implementa un perfil pineado de RFC 9421: la firma siempre cubre
@method, @target-uri y content-digest (el digest va incluso con body vacío: si
fuera opcional, alguien podría agregarle body a una request firmada sin romper la
firma), más created, keyid y alg. Sin listas arbitrarias de componentes ni
canonicalización general de structured fields: esa superficie es donde las
implementaciones se equivocan en silencio.
Firmar pasa por la misma puerta que firmar una transacción de blockchain:
require sign("AGENT_KEY"), deny-by-default, y cada firma queda en el log de
auditoría. La clave es un secret sellado y nunca se convierte en un valor del
lenguaje.
Del lado receptor, http_signature_verify es la mitad simétrica — y **exige que
pinees el algoritmo**:
let v be http_signature_verify(incoming, pubkey, {"alg": "ed25519"})
No es ceremonia. Si el verificador tomara alg del mensaje, un atacante lo cambiaría
a hmac-sha256 y firmaría usando tu clave pública como secreto HMAC — el mismo
ataque de confusión que rompió librerías de JWT durante años. El algoritmo lo elige
el verificador; el mensaje nunca. La ventana anti-replay por defecto es ±300 s sobre
created (un timestamp futuro también se rechaza: sería un replay diferido), y si
el cliente mandó un nonce, la verificación te lo devuelve para que lo chequees
contra tu propio store de replay en las rutas de mutación.
Identidad de terceros: OIDC y workloads en la nube
jwt_verify es para tokens que firmaste vos con tu secreto. Para un token de
Google, GitHub, Auth0 — o de la nube donde corre tu agente — va oidc_verify:
require net("www.googleapis.com")
let claims be oidc_verify(id_token, {
"iss": "https://accounts.google.com",
"aud": "tu-client-id.apps.googleusercontent.com",
"jwks_url": "https://www.googleapis.com/oauth2/v3/certs"
})
iss y aud son obligatorios, y eso es una decisión de diseño deliberada, no un
olvido a corregir después: un token acuñado por el mismo proveedor para *otra
aplicación* tiene una firma perfectamente válida. Chequear la firma sin la audiencia
es el agujero clásico del confused deputy. Se soportan RS256 y ES256 (una RSA de
menos de 2048 bits se rechaza); el JWKS se trae por net, se cachea 10 minutos y se
vuelve a traer cuando aparece un kid que no está en el set cacheado — que es
exactamente cómo se ve una rotación de claves.
Fijate cuáles fallas son nothing y cuáles son errores: un token malo es
nothing; un JWKS que no se pudo traer es un error. "No pude verificarlo" nunca
debe confundirse con "no vale".
Esto es lo que hace funcionar la workload identity: un agente en AWS, GCP, Azure
o GitHub Actions ya tiene una identidad emitida por la plataforma. La leés del
metadata service, la canjeás por credenciales cortas, y no queda ningún secreto de
larga vida en tu .env. (En AWS usá SIEMPRE IMDSv2 — el flujo con token primero; v1
es el vector clásico de SSRF.)
mTLS — identidad por certificado
Para service meshes y partners que exigen certificado de cliente:
require file.read("./certs/*")
mtls_identity("./certs/agent.pem", "./certs/agent.key",
{"hosts": ["*.mesh.internal", "vault.example"]})
-- las requests https:// a esos hosts presentan el certificado; el resto no
Es por proceso, no por request, porque un certificado identifica al workload —
la misma idea de SPIFFE.
opts.hosts acepta una lista de hosts (o uno solo como texto) y sigue la misma regla
de comodín que require net: "*.mesh.internal" cubre el dominio y sus subdominios.
Si lo omitís, el certificado va a todos los hosts que el programa pueda alcanzar —
ya acotados por require net, así que un alcance de net angosto lo contiene solo.
Declará hosts cuando tu alcance de net sea amplio: presentar un certificado de
cliente es decir "yo soy este agente", y un tercero que simplemente lo pida no
debería poder cosechar tu identidad de workload.
Terminar mTLS del lado serve (verificar los certs de cliente entrantes) todavía no
está en el engine; para eso, un reverse proxy adelante.
Servir agentes: identidad, cuotas y descubrimiento
El lado servidor vive en Serve. La versión corta:
- Un solo task de
auth withatiende a los dos tipos de sujeto: devolvé el captoken
verificado para un agente, la sesión para un humano, nothing para ninguno.
- El runtime saca la identidad de lo que devolviste (
id,sub,keyid, o un
texto pelado) y la usa para medir:
- rate limit por identidad además del escudo por IP que corre antes del auth
(ese escudo es lo que evita que una avalancha anónima queme un worker en cada
intento de autenticación);
- spend, imputado por identidad en el ledger y en la línea de auditoría — y el
caveat spend de un captoken se vuelve un **techo real que el servidor hace
cumplir**, que es como un orquestador limita de verdad el presupuesto de un
subagente, y no por convención. Pueden aplicar tres techos a la vez y gana el más
restrictivo: por unidad (SYNSEMA_SPEND_CEILING="EUR:500,ETH:0.1"), por identidad
(SYNSEMA_SPEND_CEILING_PER_IDENTITY="agent-1=EUR:50" — variable propia, con =
antes de la unidad, así una unidad que contenga : jamás colisiona con la clave
de una identidad) y el delegado. La **unidad es texto libre y ninguna moneda está
privilegiada**: fiat, cripto, commodities, créditos, kWh — los montos guardan
hasta 28 decimales, así que una unidad cripto de 18 decimales entra entera.
/.well-known/synsema-authpublica, en JSON, qué mecanismos entiende el servidor y
qué endpoints están protegidos — el compañero legible por máquina de /llms.txt,
para un agente que nunca leyó esta página.
Cómo elegir entre los mecanismos
| Lo que necesitás | Usá |
|---|---|
| Que un subagente pueda menos que vos | captoken_mint + captoken_attenuate |
| Que una credencial robada no sirva | http_sign / http_signature_verify |
| "Iniciar sesión con Google/GitHub" | oidc_verify |
| Ningún secreto en el entorno (nube) | workload identity → oidc_verify |
| Un service mesh que exige certs de cliente | mtls_identity |
| Un humano con un browser | Login y sesiones |