Synsema docsENES

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.

agent-identity.syn
-- 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:

(oidc_verify, más abajo); las terminales pueden usar un device flow. No hay nada

que robar.

posesión de una clave que jamás sale del secret sellado.

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 requirenet("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:

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:

verificado para un agente, la sesión para un humano, nothing para ninguno.

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.

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ásUsá
Que un subagente pueda menos que voscaptoken_mint + captoken_attenuate
Que una credencial robada no sirvahttp_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 clientemtls_identity
Un humano con un browserLogin y sesiones