Synsemadocsv0.6.xENES

Identidad y autenticación

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")
require sign("DOC")
require sandbox_run              -- only to produce an input for the lineage test below

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)

test "the token is the ceiling: caveats that shape execution, and what a token can never carry"
    -- `deterministic` = the holder runs without clock or entropy; `llm_tokens` = a delegated
    -- LLM budget. Both travel in the token and only ever narrow.
    let frozen be captoken_mint({"net": "x.com"}, root,
                                {"ttl": 60, "deterministic": true, "llm_tokens": 1000})
    let v be captoken_verify(frozen, root)
    assert_eq(v.caveats.deterministic, true)
    assert_eq(v.caveats.llm_tokens, 1000)
    -- a sub-agent can get less budget, never more — and cannot turn the clock back on
    assert_ne(captoken_attenuate(frozen, {"net": "x.com"}, {"llm_tokens": 500}), nothing)
    assert_error(() => captoken_attenuate(frozen, {"net": "x.com"}, {"llm_tokens": 5000}))
    -- process-local capabilities are not transferable authority: nobody delegates
    -- another process's stdout or clock, so a token cannot even be minted with them
    assert_error(() => captoken_mint({"stdout": nothing}, root, {"ttl": 60}))
    assert_error(() => captoken_allows(v, "time"))

-- ---- A portable identity (did:key), signed documents, receipts and passkeys ----
-- `require sign("DOC")` (preamble) gates signing with the seed sealed under that label.

let DOC_SEED be bytes("0303030303030303030303030303030303030303030303030303030303030303", "hex")
let OTHER_DID be did_key_encode(bytes("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "hex"))

test "canonical JSON is deterministic bytes, and refuses what it cannot carry exactly"
    assert_eq(canonical_json({"b": 1, "a": [true, nothing, "é"]}), "{\"a\":[true,null,\"é\"],\"b\":1}")
    -- an integer beyond 2^53 would be rounded by any JCS consumer: put it in the document as text
    assert_error(() => canonical_json({"big": 9007199254740993}))
    -- bytes and secrets have no JSON form: encode the bytes first; a secret is never serialized
    assert_error(() => canonical_json({"raw": DOC_SEED}))
    assert_error(() => canonical_json({"k": as_secret("x", "DOC")}))

test "a key is an identifier: did:key round-trips and resolves to a DID Document offline"
    let k be as_secret(DOC_SEED, "DOC")
    let did be did_key_encode(ed25519_pubkey(k))
    assert(starts_with(did, "did:key:z6Mk"))
    let parts be did_key_decode(did)
    assert_eq(parts.alg, "ed25519")
    assert_eq(parts.public_key, ed25519_pubkey(k))
    assert_eq(parts.did, did)
    let doc be did_key_document(did)
    assert_eq(doc.id, did)
    assert_eq(doc.verificationMethod[0]["type"], "Multikey")
    assert_eq(doc.assertionMethod[0], did + "#" + parts.multibase)
    assert_error(() => did_key_decode("did:key:zNotAKey"))

test "a signed document verifies for anyone with the did — and nothing else does"
    let k be as_secret(DOC_SEED, "DOC")
    let did be did_key_encode(ed25519_pubkey(k))
    let parts be did_key_decode(did)
    let signed be document_sign({"offer": 12, "unit": "credits"}, k, {"verification_method": did + "#" + parts.multibase})
    assert_eq(signed.proof["type"], "DataIntegrityProof")
    assert_eq(signed.proof.cryptosuite, "eddsa-jcs-2022")
    assert_eq(signed.proof.proofPurpose, "assertionMethod")
    let v be document_verify(signed, did)
    assert_eq(v.verified, true)
    assert_eq(v.verification_method, did + "#" + parts.multibase)
    -- one field changed → nothing; another key's did → nothing
    set signed["offer"] to 13
    assert_eq(document_verify(signed, did), nothing)
    assert_eq(document_verify(document_sign({"offer": 12}, k, nothing), OTHER_DID), nothing)
    -- the public key is never a secret: handing the signing key as the verifier is an error
    assert_error(() => document_verify(signed, k))

test "the same key signs JWTs, verified by did — no JWKS, no network"
    let k be as_secret(DOC_SEED, "DOC")
    let did be did_key_encode(ed25519_pubkey(k))
    let parts be did_key_decode(did)
    let tok be jwt_sign({"sub": "agent-7", "iat": 1750000000, "exp": 1750003600}, k, {"alg": "EdDSA", "kid": did + "#" + parts.multibase})
    let claims be jwt_verify(tok, {"did": did}, {"now": 1750000001})
    assert_eq(claims.sub, "agent-7")
    assert_eq(jwt_verify(tok, {"did": OTHER_DID}, {"now": 1750000001}), nothing)
    assert_eq(jwt_verify(tok, {"did": did}, {"now": 1750009999}), nothing)

test "a receipt is derived from the audit and verifies as a credential"
    let k be as_secret(DOC_SEED, "DOC")
    let did be did_key_encode(ed25519_pubkey(k))
    let parts be did_key_decode(did)
    let r be receipt({"sign": k, "verification_method": did + "#" + parts.multibase, "result": {"answer": 42}})
    assert_eq(r["type"][1], "SynsemaReceipt")
    assert_eq(r.issuer, did)
    assert_ne(r.credentialSubject.program_sha, nothing)
    -- the result is the one thing the program declares, and the field says so
    assert_ne(r.credentialSubject.declared_result_sha256, nothing)
    -- the issuer is derived from the signing key: not an option
    assert_error(() => receipt({"sign": k, "issuer": "did:key:zOther"}))
    -- the audit inside is the engine's: the `sign` grants used EARLIER in this program are listed
    -- (the receipt's own signature comes after the snapshot; nothing can cover itself)
    let used be 0
    each e in r.credentialSubject.capabilities
        when contains(e.capability, "sign") and e.granted
            set used to used + 1
    assert(used >= 1)
    assert_eq(receipt_verify(r, did).verified, true)
    set r["issuer"] to "did:key:zImpostor"
    assert_eq(receipt_verify(r, did), nothing)
    -- unsigned: the same document, no proof
    assert_eq(contains(receipt(), "proof"), false)

test "lineage: every input the unit read is recorded, and the receipt publishes it without the data"
    -- v0.6.29+. A child program's result is an input (so is a file, an HTTP body, a SQL answer…)
    let opts be {"ceiling": "stdout", "timeout": 20}
    let child be run_program("print(6 * 7)", opts)
    let e be where(lineage(), (x) => x.source == "run_program")[0]
    -- `sha256` (hex, no 0x) covers the bytes the program received; `encoding` says which bytes
    assert_eq(e.encoding, "jcs")
    assert_eq("0x" + e.sha256, hex(sha256(canonical_json(child))))
    assert_eq(e.bytes, length(canonical_json(child)))
    -- the arguments are a SALTED commitment: nobody recovers them by hashing guesses…
    assert(starts_with(e.what, "query sha256-salted:"))
    -- …but the owner, who keeps the salt locally, can reveal them later and anyone can check
    assert_eq(e.committed_encoding, "jcs")
    let args be ["print(6 * 7)", opts]
    assert_eq("query sha256-salted:" + slice(hex(sha256(bytes(e.salt, "hex") + bytes(canonical_json(args), "utf8"))), 2), e.what)
    -- the receipt carries the same entry as `inputs` — without the salt
    let published be where(receipt().credentialSubject.inputs, (x) => x.source == "run_program")[0]
    assert_eq(published.what, e.what)
    assert_eq(published.sha256, e.sha256)
    assert_eq(contains(published, "salt"), false)

test "passkeys: a malformed request is an error with the fix, never a silent nothing"
    let opts be {"rp_id": "app.example", "origin": "https://app.example", "challenge": "AAAA"}
    -- no `response` at all: that is a bug in the caller, not a failed login
    assert_error(() => webauthn_register({"id": "x"}, opts))
    -- the key alone is not a credential: the id must be bound to it (identity is the STORED one)
    assert_error(() => webauthn_verify({"id": "x", "response": {}}, {"kty": "EC"}, opts))
    -- a missing rp_id is a programmer error (the verifier cannot guess what to pin)
    assert_error(() => webauthn_verify({"id": "x", "response": {}}, {"id": "x", "public_key": {"kty": "EC"}}, {"origin": "https://app.example"}))

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:

-- the spend unit is whatever the host works in: fiat, crypto, commodities, credits
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"})

-- handing work to a sub-agent: one host, no database, a fiftieth of the budget
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("bad token")
when not captoken_allows(caps, "net", target_host)
    give fail(403, "that host is not in your 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.

El token es el techo§

Un token verificado no es consultivo. Sus caps se vuelven un techo delegado — un segundo techo debajo del del host — para la unidad de trabajo que corre bajo él, y hay tres maneras de correr bajo él:

handler corre con caps_efectivas ⊆ require ∩ techo del host ∩ token. Una capacidad que el programa declara pero el token no lleva se deniega al usarla — db en un handler cuyo caller sólo tiene {"net": …} falla, llm y judge incluidas. El cliente ve 403 {"error": "insufficient permissions", "status": 403}, un cuerpo fijo que nunca nombra la capacidad (un login dice "las credenciales no coinciden", jamás "la contraseña está mal"); el detalle va al log del servidor y al audit (above delegated ceiling (token <id>)). Una denegación que causó el propio programa — sin require — sigue siendo 500: ésa es culpa del servidor.

autoridad del token (Sandbox).

con require adentro todavía como no-op (Capacidades).

require net("api.example.com")
require db("orders")

-- the caller's token decides what THIS step may touch
let agent be captoken_verify(token, secret("ROOT_KEY"), {"aud": "orders-api"})
sandbox under agent
    let rows be sql(db, "select id from orders where owner = ?", [agent.id])

Lo que un token lleva es autoridad transferible: net, file, exec, env, db, llm, judge, serve, secret, reveal, sign, wallet, spend, memory, sandbox_run, attest. Las capacidades locales al proceso — stdout, stdin, time, random — nunca van en un token, porque nadie puede delegar el reloj de otro proceso: captoken_mint({"time": nothing}, …) es un error, y un token que no dice nada de time deja a now() funcionando. Dos caveats restringen la ejecución en vez de conceder autoridad: deterministic: true corre al portador sin reloj ni entropía (lo mismo que hace --deterministic, pedido por el emisor; una vez puesto, ninguna atenuación lo apaga) y llm_tokens: N es un presupuesto LLM delegado, contado sobre el id del token junto a SYNSEMA_LLM_BUDGET_PER_IDENTITY, y que sólo se puede achicar.

Y el sujeto viaja con el techo: un agente spawneado desde el handler, un worker de parallel_map y un hijo de run_program corren en nombre de la misma identidad, bajo el mismo techo delegado, los mismos límites de gasto y el mismo presupuesto LLM — un spend dentro de un agente spawneado se imputa a quien pidió, no al nombre del agente. Un tick de cron corre como cron:<job>; synsema run corre como el operador (SYNSEMA_IDENTITY, opcional).

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")             -- inside the handler, see the pitfall below
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": "your-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"]})
-- https:// requests to those hosts present the certificate; the rest don't

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; - capacidades — los caps del token son el techo de la request (ver El token es el techo arriba): lo que el token no lleva se deniega, y el cliente recibe un 403 insufficient permissions genérico.

qué endpoints están protegidos — el compañero legible por máquina de /llms.txt, para un agente que nunca leyó esta página.

did:key), qué ofrece (un skill por ruta), cómo autenticarse, todo derivado de la tabla de rutas y firmado con SYNSEMA_IDENTITY_KEY — ver La Agent Card más abajo.

Passkeys — WebAuthn, el lado humano de la prueba de posesión§

Una passkey es un par de claves que guarda el navegador o el dispositivo. El servidor nunca ve un secreto, solo firmas sobre un desafío que él eligió: http_sign para humanos. Dos builtins verifican lo que navigator.credentials.create() y .get() devuelven, en la forma exacta en que el navegador lo serializa (PublicKeyCredential.toJSON(): {id, rawId, response: {…}}, base64url):

require serve(8080)
require random

serve on 8080
    -- enrolment: the page posts the credential; the challenge was generated with
    -- random_bytes(32) and kept in the session
    route "POST /passkeys"
        let body be json of request
        let reg be webauthn_register(credential of body, {"rp_id": "app.example", "origin": "https://app.example", "challenge": challenge of body})
        -- reg = {id, public_key (a JWK), alg, sign_count, fmt, aaguid, user_verified, ...}
        give {"id": reg.id, "public_key": reg.public_key, "sign_count": reg.sign_count}

    -- login: the same shape, verified against the stored key and counter
    route "POST /login"
        let body be json of request
        let stored be stored_passkey of body
        -- `stored` is the map register returned, plus the sign_count and user_handle you keep
        let v be webauthn_verify(assertion of body, stored, {"rp_id": "app.example", "origin": "https://app.example", "challenge": challenge of body})
        when v == nothing
            give fail(401, "sign in failed")
        -- v = {id, user_handle, alg, sign_count, user_present, user_verified, backup_eligible, backup_state}
        -- id and user_handle are the STORED ones: the assertion cannot declare an identity
        give {"user": v.user_handle, "sign_count": v.sign_count}

Qué comprueba el verificador, todo y en orden: el tipo de ceremonia, el desafío, el origen (uno, o una lista cuando tenés varios front ends), el hash del rp_id, los flags (user_verification: "required" exige UV), la firma sobre authenticatorData ‖ sha256(clientDataJSON), y el contador cuando pasás el guardado: un contador que no avanza es una llave clonada, y se rechaza; y que el id de la credencial del assertion sea el guardado. Esa última comprobación es la razón de que el segundo argumento sea la credencial guardada entera y no la clave sola: rawId y userHandle no van cubiertos por la firma, así que la identidad que vuelve es la que guardaste junto a la clave, nunca la que el assertion declara. El algoritmo lo fija la clave registrada (ES256, RS256 o EdDSA), nunca se lee del mensaje. Toda falla de verificación es nothing; solo una forma mal armada o una opción que falta es un error, con el arreglo en el mensaje.

Dos decisiones, dichas en voz alta. La attestation se ignora a propósito: fmt se reporta y attStmt no se verifica, porque confiar en la marca de una llave significa distribuir listas de confianza de fabricantes y sus revocaciones, una dependencia que este motor no toma. El contador lo guardás vos: los builtins no tienen estado, así que sign_count vuelve y se espera de vuelta.

Como webauthn_verify devuelve el id de la credencial como id, un task de auth with que devuelva su map le da identidad a la request: cuotas, spend y la línea de auditoría siguen solas, sin adaptadores.

Una identidad portable: did:key, JSON canónico y documentos firmados§

Un registro es una dependencia; una clave no. did:key convierte una clave pública en un identificador que cualquier verificador resuelve offline (did:key:z6Mk… para ed25519, did:key:zDn… para P-256), y W3C Data Integrity permite firmar un documento JSON de modo que un verificador que jamás oyó hablar de Synsema igual lo comprueba. Debajo de ambos está el JSON canónico (RFC 8785): el mismo documento da siempre los mismos bytes, así que hashear y firmar quedan bien definidos.

require secret("ID_SEED")
require sign("ID_SEED")                    -- signing with a secret goes through the sign gate, audited

let k be secret("ID_SEED")                 -- a 32-byte ed25519 seed, hex in .env
let did be did_key_encode(ed25519_pubkey(k))
let parts be did_key_decode(did)
let vm be did + "#" + parts.multibase      -- the verificationMethod URL

let offer be {"unit": "credits", "amount": 12, "for": "agent-7"}
let signed be document_sign(offer, k, {"verification_method": vm, "created": "2026-09-22T00:00:00Z"})
-- signed.proof = {type: "DataIntegrityProof", cryptosuite: "eddsa-jcs-2022",
--                 verificationMethod, proofPurpose, created, proofValue}
print(text(document_verify(signed, did)))  -- {verified: true, cryptosuite: ..., ...} — or nothing

Dos cryptosuites: eddsa-jcs-2022 (un secret ed25519, vía require sign, o un PEM PKCS#8 Ed25519 como texto) y ecdsa-jcs-2019 (P-256: un secret con el escalar, o un PEM SEC1/PKCS#8 como texto, sin puerta, la misma regla que ecdsa_p256_sign). Si omitís verification_method, la prueba nombra el did:key de la propia clave que firma. document_verify toma la clave pública como bytes, un did:key, un PEM público o un JWK, y devuelve nothing ante cualquier falla: alterado, otra clave, otra suite, un challenge o domain que no coincide.

La misma clave firma JWTs — jwt_sign(claims, k, {"alg": "EdDSA", "kid": vm}) — y el otro lado los verifica con jwt_verify(token, {"did": did}): sin JWKS, sin fetch, la clave viaja en el identificador. did_key_document(did) devuelve el DID Document que daría un resolver, calculado localmente.

canonical_json rechaza lo que JCS no puede llevar exacto en vez de aproximarlo: enteros más allá de 2^53 y decimales con más de 15 dígitos significativos (ponelos en el documento como texto), bytes (codificalos antes), secretos (nunca).

Recibos — qué hizo una unidad de trabajo, derivado y firmado§

Un agente que informa sobre sí mismo pide que le crean. Un recibo es lo que vio el motor: la identidad con la que corrió la unidad, los tokens de capacidad vigentes, cada capacidad pedida — concedida o denegada, con motivo y fuente —, el gasto imputado a esa identidad, cada declassify, la cuenta de pasos, el hash del programa, la versión del motor, el hash del resultado y — desde el motor v0.6.29 — cada input que leyó la unidad (su linaje, abajo). receipt() lo devuelve como Verifiable Credential; receipt({"sign": k, …}) lo firma como prueba Data Integrity, así un cliente, un auditor o un validador ERC-8004 lo verifica con receipt_verify — o con cualquier biblioteca de VC — sin saber qué es Synsema. Dos campos que el programa no puede escribir: issuer es siempre el did:key de la clave que firma, y validFrom (con el created de la prueba) es el reloj del motor — se omite cuando la unidad no tiene time —, así que un recibo no se puede antedatar ni emitir a nombre de otro, y receipt_verify rechaza un emisor que no sea la clave que verifica. Lo único que el programa sí declara es el resultado que entrega, y el campo lo dice: declared_result_sha256.

require serve(8080)
require secret("ID_SEED")
require sign("ID_SEED")
require llm

serve on 8080
    route "POST /answer"
        let k be secret("ID_SEED")
        let did be did_key_encode(ed25519_pubkey(k))
        let parts be did_key_decode(did)
        let answer be reason(question of (json of request))
        -- with `auth with` wired, the receipt's id is the request's identity; its audit is this request's own
        give {"answer": answer, "receipt": receipt({"sign": k, "verification_method": did + "#" + parts.multibase, "result": answer})}

Lo que un recibo no puede prometer, dicho en voz alta: completitud. Un agente solo muestra los recibos que le convienen, y ninguna criptografía arregla eso: pedí uno por request y contalos vos. Lo que sí promete es que cada recibo es verdad y verificable: los campos los deriva el motor, nunca los escribe el programa.

Linaje — qué datos entraron§

Desde el motor v0.6.29 el motor registra cada dato que el programa lee de afuera, en orden: archivos (read_file, list_dir, grep, parquet_read…), respuestas HTTP, lecturas SQL/Mongo/Redis, lecturas de cadena (EVM, Solana, Algorand, Bitcoin), mensajes de sockets y procesos, read_line, respuestas de modelo (reason/decide/analyze/ generate) y lo que devolvieron run/run_program. El programa no puede agregar, editar ni quitar una entrada. lineage() devuelve la lista hasta ahora; receipt() la publica como credentialSubject.inputs, así un recibo firmado prueba qué inputs, qué programa y qué salida.

Cada entrada es {source, what, sha256, bytes, encoding}:

user:pass@, la ruta ni la query; una respuesta 4xx/5xx agrega status N), un tamaño (bytes <n> para un archivo pasado como bytes) o un compromiso salado — query sha256-salted:<hex> para una query SQL, un filtro Mongo, los argumentos de run, una llamada RPC o un prompt. El recibo nunca lleva datos.

(UTF-8), "bytes", "jcs" (canonical_json(x)), "json" (json_encode(x), cuando el valor tiene un entero más allá de 2^53) o "display" (la forma impresa, para lo que ninguna de las otras puede llevar).

Un compromiso es sha256(salt ‖ canonical_json([args…])) con una sal nueva de 128 bits por entrada, como una disclosure de SD-JWT: nadie recupera la query hasheando candidatos. La sal y su committed_encoding quedan en el lineage() local y nunca llegan al recibo, así el dueño puede revelar una query después y cualquiera puede chequearla:

require sandbox_run

let opts be {"ceiling": "stdout", "timeout": 20}
let child be run_program("print(6 * 7)", opts)
let e be lineage()[0]
print(e.source, e.encoding, "0x" + e.sha256 == hex(sha256(canonical_json(child))))   -- run_program jcs true
-- revealing the arguments: hand over the salt and the arguments, and anyone recomputes `what`
let args be ["print(6 * 7)", opts]
print("query sha256-salted:" + slice(hex(sha256(bytes(e.salt, "hex") + bytes(canonical_json(args), "utf8"))), 2) == e.what)   -- true
print(length(receipt().credentialSubject.inputs))                                    -- 1

Solo cuentan las lecturas que llegaron (una request que falló en la red no es un input). No se registra: lo que el programa escribe, env/secret, el blackboard. La lista se corta en 10.000 entradas y el recibo dice entonces inputs_truncated: true. Para fijar en el tiempo un recibo firmado, anclá sha256(canonical_json(receipt)) en una cadena — ver Atestación para la otra mitad: qué código lo leyó.

La Agent Card — quién es tu servidor, derivado y firmado§

Todo serve publica /.well-known/agent-card.json (alias /.well-known/agent.json): la forma de la Agent Card de A2A, derivada de la tabla de rutas como /openapi.json — así nunca queda vieja — y firmada con la clave del servidor, así nadie en el medio la cambia.

base. La forma es exactamente message AgentCard del a2a.proto de A2A 1.0, en su forma proto-JSON: un parser estricto la acepta entera.

modos de entrada y salida), securitySchemes (httpAuthSecurityScheme / apiKeySecurityScheme) y securityRequirements cuando hay auth with, capabilities.streaming cuando alguna ruta hace stream.

con lo que A2A no modela: el did:key del servidor, la URL base, /openapi.json, /.well-known/synsema-auth, /.well-known/attestation bajo --attested, la versión del motor y capabilityTokens: true: acá los caps de un captoken son el techo de la request.

A2A (message/send), y la tarjeta jamás lo afirma. Lo que es verdad está, y un cliente A2A lo lee.

La firma es la de A2A (§8.4): un JWS en serialización JSON — signatures: [{protected, signature}] — sobre el JSON canónico de la tarjeta sin signatures, kid = el verification method del did (did:key:z…#z…). La clave es SYNSEMA_IDENTITY_KEY (la semilla ed25519, 64 hex, en .env; EdDSA) o, bajo serve --attested, la clave P-256 atestada (ES256): entonces la tarjeta y el documento de attestation hablan de la misma clave. Sin clave, la tarjeta sale sin firma y sin did: no se inventa nada.

Verificar la tarjeta de otro servidor desde Synsema, offline:

require net("other.example")
require time

let card be json_decode(fetch("https://other.example/.well-known/agent-card.json"))
let unsigned be remove(card, "signatures")        -- a new map; `card` keeps its signatures
let sig be card.signatures[0]
let tok be sig.protected + "." + decode(bytes(canonical_json(unsigned)), "base64url") + "." + sig.signature
let did be card.capabilities.extensions[0].params.did
when jwt_verify(tok, {"did": did}) == nothing
    print("the card does not verify")

ERC-8004. El registro de agentes on-chain apunta sus services exactamente a esta URL y al did, y sus feedbackHash/requestHash a documentos como los recibos de arriba. Synsema mantiene al registro como cliente: examples/erc8004/ en el repositorio del motor arma el registration file y el calldata de los registros de Identity, Reputation y Validation con abi_encode: ninguna primitiva nueva, ninguna dependencia nueva.

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
Un humano (o un dispositivo) con una passkeywebauthn_register / webauthn_verify
Una identidad que cualquiera verifica, sin registrodid:key + document_sign / jwt_sign EdDSA
Prueba de qué hizo una unidad de trabajoreceipt / receipt_verify
Decirle a otros agentes — o a un registro — quién es tu servidorla Agent Card en /.well-known/agent-card.json