Capacidades y seguridad
Secretos
Un secret es un valor opaco y redactado: lo podés usar pero nunca leer su texto plano desde el lenguaje. Es a prueba de LLMs por diseño — nunca aparece en print, logs, cuerpos de error, respuestas JSON, el blackboard ni un prompt de LLM.
Codificar un secret como texto es un error (motor v0.6.46+): json_encode, jsonl_encode, json_for_script y csv_encode rechazan un secret en cualquier parte del valor y dicen dónde está (json_encode: the value["auth"]["key"] is secret(K), and a secret is never written as text — …; csv_encode: row 1, column "k": secret(K) is never written as text — leave it out of the rows). Antes salía como "[redacted]", una marca que el programa podía mandar como si fuera la clave. Pasá el secret a la request misma — un header, o la URL/el body una vez ligado (abajo) — o dejalo afuera de lo que codificás. Ver JSON.
env() vs secret()§
env(name, default?)→ configuración de texto plano y visible (URLs, puertos, flags).secret(name, default?)→ una credencial opaca y redactada (API keys, tokens, secretos de firma).
Ambos son deny-by-default: declarás la capacidad o la lectura falla.
El default, cuando la variable no está definida (motor v0.6.45+):
nothingcomo default danothing.env("PORT", nothing)ysecret("API_KEY", nothing)dannothingcuando la variable no está definida; chequealo conwhen v == nothing. Antes de v0.6.45 el default pasaba por su forma de texto de Python:envdaba el texto"None", que pasaba como un valor válido y fallaba lejos, ysecretsellaba"None"(como header salíaBearer None).- Cualquier otro default vuelve como texto de Synsema:
env("DEBUG", true)da"true"(era"True"),env("PORT", 8080)da"8080". - Una variable definida sigue ganándole al default, y sin default una variable no definida sigue siendo un error.
require env("PORT")
let port be env("PORT", nothing)
when port == nothing
print("PORT is not set: using 8080")
-- Doc example (UNIVERSAL — one source, shared by every language).
-- Comments are English on purpose: the code is the same in /en, /es, /pt, ...
-- Doctest gate: `synsema test docs/0.6.x/examples/` must be green to publish.
intent: "doc example: a secret stays redacted everywhere except the socket"
require secret("API_KEY")
require secret("DOCS_UNSET_KEY")
require env("DOCS_UNSET_PORT")
require secret("BOUND_KEY") for net("api.allowed.example") -- v0.6.46+: may go only there
require secret("OTHER_KEY") for net("other.example")
require net("api.allowed.example")
require net("other.example")
-- The message of an error, or nothing when the call did not fail.
task error_of(f)
try
f()
recover e
give e
give nothing
print("a secret prints as → " + text(secret("API_KEY", "sk-demo-123"))) -- redacted
test "a secret is redacted in every string surface"
let k be secret("API_KEY", "sk-demo-123")
assert_eq(text(k), "secret(API_KEY)") -- never the real value
assert_eq(type_of(k), "secret")
-- GOTCHA: concatenating text + secret yields a SECRET, and it redacts WHOLE —
-- the "Authorization: " prefix is absorbed, you do NOT get "Authorization: secret(...)".
assert_eq(type_of("Authorization: " + k), "secret")
assert_eq(text("Authorization: " + k), "secret(API_KEY)") -- prefix absorbed (taint)
test "bearer() builds a tainted Authorization value"
let b be bearer(secret("API_KEY", "sk-demo-123"))
assert_eq(type_of(b), "secret") -- still a secret
assert_eq(text(b), "secret(API_KEY)") -- redacted, even as a Bearer value
test "basic() builds HTTP Basic credentials from a secret (engine v0.6.44+)"
-- RFC 7617: "Aladdin" / "open sesame" → "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==". The password stays
-- a secret; the header materializes only at the socket. OAuth client credentials take this form.
let b be basic("Aladdin", as_secret("open sesame", "PASSWORD"))
assert_eq(type_of(b), "secret")
assert_eq(text(b), "secret(PASSWORD)")
assert_eq(b == as_secret("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="), true)
assert_error(() => basic("user:x", "p")) -- ':' separates user and password
-- OAuth client credentials (RFC 6749 §2.3.1): oauth_basic form-encodes both parts first.
assert_eq(oauth_basic("my app", as_secret("a+b/c=")) == as_secret("Basic bXkrYXBwOmElMkJiJTJGYyUzRA=="), true)
-- `+` with a secret follows the text rules: nothing is an error, not the text "nothing".
assert_error(() => as_secret("k") + nothing)
test "a raw secret goes in ANY header, not just Bearer"
-- materialization to the real value happens ONLY at the socket; here we just show
-- it stays a redacted secret in your program's value space (see /docs/.../secrets).
let headers be {"x-api-key": secret("API_KEY", "sk-demo-123")}
assert_eq(text(headers["x-api-key"]), "secret(API_KEY)")
test "as_secret seals a value that arrives at runtime"
let sealed be as_secret("rk-from-user", "user_key") -- e.g. a tenant's key from a header
assert_eq(text(sealed), "secret(user_key)")
assert_eq(type_of(sealed), "secret")
test "hmac signs with a secret and returns BYTES; verify_hmac checks in constant time"
-- v0.6.29+: `hmac(data, key, algo?)` replaces `hmac_sha256`, and returns bytes, not hex text
let key be as_secret("whsec-demo", "WEBHOOK_SECRET")
let mac be hmac("{\"id\": 1}", key)
assert_eq(type_of(mac), "bytes")
assert_eq(length(mac), 32) -- sha256 (the default)
assert_eq(length(hmac("{\"id\": 1}", key, "sha512")), 64) -- algo: "sha256" | "sha512"
assert(starts_with(hex(mac), "0x")) -- hex(mac) to show it
-- a provider sends the signature as hex text: verify_hmac decodes it and compares in constant time
let sent be decode(mac, "hex")
assert(verify_hmac("{\"id\": 1}", sent, key))
assert_eq(verify_hmac("{\"id\": 2}", sent, key), false)
assert(verify_hmac("{\"id\": 1}", decode(mac, "base64"), key)) -- base64 signatures too
assert(verify_hmac("{\"id\": 1}", decode(hmac("{\"id\": 1}", key, "sha512"), "hex"), key, "sha512"))
assert_error(() => hmac("x", key, "sha1")) -- SHA-1 is refused
-- constant_time_eq compares two MACs (or a secret) without a timing leak
assert(constant_time_eq(hmac("{\"id\": 1}", key), mac))
test "a nothing default gives nothing when the variable is not defined (engine v0.6.45+)"
-- Before v0.6.45 the default went through its Python text: env gave "None", secret sealed "None".
assert_eq(secret("DOCS_UNSET_KEY", nothing), nothing)
assert_eq(env("DOCS_UNSET_PORT", nothing), nothing)
-- any other default comes back as Synsema text
assert_eq(env("DOCS_UNSET_PORT", 8080), "8080")
assert_eq(env("DOCS_UNSET_PORT", true), "true")
test "a secret is never encoded as text: json_encode, csv_encode and a container in a hole are errors (engine v0.6.46+)"
let k be as_secret("sk-demo-123", "K")
assert(contains(error_of(() => json_encode({"auth": {"key": k}})), "the value[\"auth\"][\"key\"] is secret(K)"))
assert(contains(error_of(() => csv_encode([{"a": 1, "k": k}])), "row 1, column \"k\": secret(K) is never written as text"))
let creds be {"k": k}
assert(contains(error_of(() => `Bearer {creds}`), "a secret inside a map cannot go into a text hole"))
assert(contains(error_of(() => `Bearer {[k]}`), "a secret inside a list cannot go into a text hole"))
assert_eq(type_of(`Bearer {creds["k"]}`), "secret") -- the secret on its own stays a secret
test "an unbound secret only goes in a header; the error gives the line to write (engine v0.6.46+)"
let k be secret("API_KEY", "sk-demo-123")
let e be error_of(() => http_get("https://api.allowed.example/v1", query = {"key": k}))
assert(contains(e, "an unbound secret only goes in a header"))
assert(contains(e, "require secret(\"API_KEY\") for net(\"api.allowed.example\")"))
assert(not contains(e, "sk-demo-123"))
test "a bound secret goes only to its hosts, only encrypted, never as the host (engine v0.6.46+)"
let k be secret("BOUND_KEY", "bk-demo-456")
assert(contains(error_of(() => http_get(`https://other.example/v2/{k}`)), "secret(BOUND_KEY) is bound to [api.allowed.example]; this request goes to other.example"))
assert(contains(error_of(() => http_get("https://other.example/", {"x-api-key": k})), "not even in a header"))
assert(contains(error_of(() => http_get(`http://api.allowed.example/v2/{k}`)), "only travels over https"))
assert(contains(error_of(() => http_get(`https://{k}.evil/`)), "the host of a request never comes from a secret"))
-- adding two bound secrets keeps the hosts both allow: none in common is an error
assert(contains(error_of(() => k + secret("OTHER_KEY", "ok-demo-789")), "no request can carry both"))
assert_eq(type_of(bearer(k)), "secret") -- bearer/basic keep the bindingDe dónde salen los valores§
secret("NOMBRE") resuelve entorno del proceso > .env > default, y sin ninguno de los tres da error — nunca un valor vacío en silencio. synsema init escribe un .env.example cuya última sección es justamente esta: tus propios secretos, con los que piden las features de auth ya nombrados (JWT_KEY, CAPTOKEN_ROOT_KEY, AGENT_SIGNING_KEY — ver Identidad de agentes). El nombre que elegís es además el scope de la capability: require secret("JWT_KEY") habilita ese y ningún otro.
La misma regla cubre las claves privadas: la clave VAPID de push_send (engine v0.6.15+, VAPID_PRIVATE_KEY, ver Tu app en el teléfono) y la clave blockchain de sign se aceptan sólo como secret — desde .env con secret(...), o selladas en runtime con as_secret(...). Un texto plano se rechaza con el arreglo en el mensaje, y el valor de la clave jamás aparece en un error. Las claves que genera el engine (push_vapid_keys(), mnemonic_generate) vuelven ya selladas.
Rotación sin reinicio (Unix, engine v0.6.20+). Un SIGHUP a un serve corriendo hace que relea el .env en la próxima resolución — secret("NOMBRE") ve el valor nuevo y el provider LLM se reconstruye con la clave nueva. Un valor ya copiado a un let al arrancar no se renueva (es un valor, no una referencia). Ver Deploy.
Las credenciales van en cualquier header — no solo Bearer§
Un secret puesto como valor de header se materializa a su valor real solo en el socket, sin importar el nombre del header. O sea que no estás limitado a Authorization:
- Header custom:
{"x-api-key": secret("API_KEY")} - Un header propio:
{"x-lo-que-sea": secret("API_KEY")} - Bearer (solo azúcar para
Authorization: Bearer <token>):{"Authorization": bearer(secret("API_KEY"))} - HTTP Basic (motor v0.6.44+):
{"Authorization": basic("client_id", secret("CLIENT_SECRET"))}—basic(user, password)armaBasic base64(user:password)(RFC 7617) y sigue siendo un secret — así toman la API key Twilio, Mailgun, Jira o Stripe. El usuario no puede contener:;nothing, bytes o un secret sellado son error. - Credenciales de cliente de OAuth (motor v0.6.44+):
{"Authorization": oauth_basic("client_id", secret("CLIENT_SECRET"))}. El RFC 6749 §2.3.1 obliga a todo servidor de autorización a aceptar esta forma, y pide codificar el client id y el secret como formulario antes de armar el Basic:oauth_basiclo hace (basicno), así un secret con+,/o=no lo rechaza un servidor estricto.
Un secret que no está ligado a un host sigue yendo sólo en un header. En la URL, la query, el body o un form es un error (motor v0.6.44+; antes salía como el texto secret(NAME), que el servidor tomaba como el valor), y desde v0.6.46 el error da la línea exacta a escribir, con el host de esa request: http_get: query parameter "key" is secret(K), and an unbound secret only goes in a header. If the API takes it in the query, bind it to the host it goes to: require secret("K") for net("api.example.com")``.
Un secret en la URL, la query o el body: ligalo a un host (motor v0.6.46+)§
Algunas APIs toman la clave sólo en la URL (proveedores de RPC, APIs de bots y de mapas), en la query, o en el body (OAuth client_secret_post). Antes de v0.6.46 eso era un error y la única salida era reveal(). Ahora ligás el secret a los hosts a los que puede ir, y entonces puede viajar en la URL, la query, un body JSON a cualquier profundidad, un form o un header — sólo a esos hosts:
require secret("RPC_KEY") for net("rpc.example.com")
require secret("APP_SECRET") for net("auth.example.com")
require net("rpc.example.com")
require net("auth.example.com")
let k be secret("RPC_KEY")
let a be http_get(`https://rpc.example.com/v2/{k}`) -- in the path, as is
let b be http_get("https://rpc.example.com/v2", query = {"key": k}) -- in the query, percent-encoded
let c be http_post("https://rpc.example.com/v2", {"auth": {"key": k}, "op": "status"}) -- JSON body, any depth
let tok be http_post("https://auth.example.com/oauth/token", form = {
"grant_type": "client_credentials",
"client_id": "app-1",
"client_secret": secret("APP_SECRET")}) -- a form body
El motor escribe el valor en el socket; el programa nunca lo tiene como texto. Las reglas:
- La ligadura no es un permiso.
for net("h")dice adónde puede ir el secret; la request igual necesitarequire net("h")(sin él:Capability not granted: net("h")). - Qué hosts. Uno o varios:
require secret("K") for net("a.example.com"), net("b.example.com"). Un host sin puerto cubre todos los puertos;net("h:8443"), sólo ese. Un comodín de subdominio.example.comcubre los subdominios, noexample.commismo — nombrá los dos si necesitás los dos. El host puede venir de la configuración:for net(env("RPC_HOST"))(conrequire env("RPC_HOST")). Un prefijo de nombre funciona como en cualquier scope desecret(require secret("API_") for net("h")). Son errores de carga:for net("")(binding a secret to every host is not binding it: name the host it goes to),for net(".com")(`.comwould cover a whole top-level domain; name the domain),for net("a.example.com")(amay only be the whole first label (.example.com), and nowhere else), y un nombre de secret con@o|`. - Sólo a esos hosts — también en un header.
http_get: secret(K) is bound to [api.example.com]; this request goes to 127.0.0.1:47100 — it never goes to another host, not even in a header. Una ligadura con comodín tampoco llega a una dirección privada (igual que un permisonetcon comodín): `http_get:127.0.0.1.nip.ioresolved to 127.0.0.1, a private address; a secret bound to [*.nip.io] goes there only if the binding names it (the host exactly, its address or its CIDR)(enerror of r`). - Sólo cifrado. Un secret ligado va por
https://ywss://;http://yws://sólo a127.0.0.1,localhosto::1. Si no:http_get: secret(K) only travels over https; this request to example.com is http://. - El host nunca sale de un secret. En una URL que tiene un secret, el esquema y el host se escriben antes. `
https://{k}.evil/,https://{k}@api.example.com/yhttps://api.example.com{k}/son errores:the URL is secret(K) and its host is not written out before the secret; write the scheme and host first (https://host/…{key}): the host of a request never comes from a secret`. - En el path va tal cual; en
queryyformse codifica. `https://h/v2/{k}pone el valor en la URL sin percent-encoding (url_encoderechaza un secret:cannot encode a secret (it would leave as text)). Si la clave puede tener+,/,=,&o espacios, pasala conquery = {"key": k}`, que la codifica. - Sumar dos secrets ligados deja sólo los hosts que permiten los dos; sin ninguno en común es un error:
Cannot add secret(A) and secret(C): the first may only go to [a.example.com, b.example.com], the second only to [c.example.com] — no request can carry both.bearer,basicyoauth_basicconservan la ligadura del secret que envuelven (y un"pre-" + ktambién). - Un secret derivado es un secret nuevo, sin ligar.
hkdf_sha256(k, …)yas_secret(v, "S")dan un secret sin ligadura: va sólo en un header. (El error para ellos igual sugiererequire secret("S") for net(...), que no liga un valor sellado en ejecución — usá un header.) - Lo que el servidor devuelve se enmascara. En el body, los headers y el error de la respuesta — y en un frame de texto de WebSocket y un error de JSON-RPC — cada secret que salió se vuelve
[secret K], venga tal cual, percent-encoded, escapado en JSON o en base64. Es una defensa contra un eco accidental, no contra un servidor hostil (un servidor puede devolverlo transformado de una forma que la máscara no conoce): la garantía es la ligadura. Un secret de menos de 4 caracteres no se enmascara, y la ejecución avisa una vez:secret(K) is shorter than 4 characters, so its echo in a response is not masked. - El registro nombra el secret, nunca el valor. Con
--audit <archivo>, cada secret ligado que salió escribe una entrada{"capability": "secret(\"K\")", "granted": true, "source": "bound-send", "reason": "→ api.example.com", …, "file", "line"};lineage()lista los secrets de cada request ensecrets: ["K"]. Nunca la URL ni el valor. - Ninguna redirección lo lleva. El cliente nativo no sigue redirecciones: un
301/302vuelve como la respuesta, con su headerLocation, y decide tu programa. (Un embebedor que implementahttpreciberedirect: "error"cuando la request lleva un secret — ver WebAssembly.) synsema checkavisa un secret sin ligar que va en la URL, la query, el body o el form de una request, con la línea a escribir:app.syn:4: warning: secret("K") goes in the url of http_get() but is not bound to a host, and an unbound secret only goes in a header: writerequire secret("K") for net("api.example.com")``.- Los otros clientes.
evm_rpcy los demás builtins JSON-RPC,algod,btc_rpcyws_connectaceptan una URL ligada (`ws_connect(wss://stream.example.com/v1/{k})). Los params de una llamada JSON-RPC siguen sin poder llevar un secret:evm_rpc: params[0] is a secret — a secret never goes into an RPC request`.
El host también puede ligar. synsema run --cap-set "secret=K@api.example.com,net=api.example.com,stdout" app.syn (varios hosts: secret=K@a.example.com|b.example.com), y lo mismo en un techo delegado (el ceiling de run_program). Un programa que pide K sin ligar, o ligado a otro host, recibe la línea a escribir: secret("K") not permitted: the host binds K to api.example.com; write require secret("K") for net("api.example.com")`. Un programa puede ligar más estrecho que el host (un secret=K sin ligar en el techo acepta un programa que liga K). Un hijo cuyo techo pide K` sin ligar no recibe nada por encima de la ligadura de su padre.
Todo valor que sale como texto — un header, un query param, bearer(...) — es texto, un número, un bool o un secret. Desde el motor v0.6.44, nothing (una variable de entorno o clave de mapa que falta), bytes, listas y mapas son error y nombran el header o el parámetro; antes salían como su forma impresa (bearer(nothing) mandaba Authorization: Bearer None). Un secret sellado (attestation_key()) o uno con bytes que no son UTF-8 también es error, nunca el marcador secret(NAME).
Como parámetro de base de datos (sql, MySQL/Postgres/SQLite, mongo_, redis_) un secret se materializa en el borde de la base: uno de texto como texto, uno de bytes como sus bytes (BLOB/bytea/binario — antes de v0.6.44 se convertían a texto y los bytes que no son UTF-8 cambiaban). Un secret sellado es error, también dentro de una lista o un documento.
hmac() y verify_hmac() — firmar y verificar sin revelar§
Un secreto de webhook se usa, nunca se lee. Tres builtins puros consumen un secret, texto o bytes como clave:
hmac(data, key, algo?)→ el MAC como bytes.algoes"sha256"(default, 32 bytes) o"sha512"(64 bytes); SHA-1 se rechaza. El MAC no es secreto: mostralo conhex(mac)("0x…") odecode(mac, "hex")(hex pelado).dataykeyson texto (su UTF-8), bytes (los bytes crudos) o unsecret; cualquier otra cosa es error. Así se pueden encadenar claves, como hace AWS SigV4 — con la clave comosecret, sin revelarla nunca:hmac(region, hmac(date, "AWS4" + secret("AWS_SECRET_ACCESS_KEY")))("AWS4" + secretsigue siendo un secret; cada MAC es bytes y es la clave del paso siguiente).+con un secret sigue las reglas del texto: números y bools se pegan;nothing, bytes, listas y mapas son error — también un secret sellado y uno con bytes que no son UTF-8 (motor v0.6.44+; antes se pegaban como su forma impresa). Un template con backticks es un template: cada hueco muestra su texto, también después de un secret (`token={tok} scopes={xs}es un secret con la lista como texto). Una lista o un mapa con un secret adentro, en un hueco, es un error (motor v0.6.46+):Bearer {creds}concreds = {"k": secret("K")}falla cona secret inside a map cannot go into a text hole (secret(K) would travel as its placeholder): take it out on its own (creds.k) or use bearer(...)/basic(...)(inside a listpara una lista). Antes de v0.6.46 daba el texto comúnBearer {k: secret(K)}, que salía como header con el marcador en vez de la clave. Sacá el secret solo —Bearer {creds["k"]}es un secret con el valor real — o usábearer/basic. Un secret solo en un hueco no cambia. No envuelvas un secret enbytes(...): un secret no se convierte, y no hace falta. Corregido en motor v0.6.44: antes, un argumentobytesse hasheaba como su forma impresa (bytes(6b65…)`) y daba otro MAC sin avisar; con texto da exactamente lo de siempre.verify_hmac(data, signature, key, algo?)→ bool, comparado en tiempo constante.signaturees el texto que manda un proveedor (Stripe, GitHub, Shopify): hex o base64 — o el MAC crudo en bytes (lo que devuelvehmac). Viene de afuera, así que un texto que no decodifica esfalse, y también una firma ausente (nothing:get(request.headers, "x-signature")cuando el header no vino) — tu handler responde 401, no 500. Una firma de otro tipo (un número, una lista, unsecret) es un error del programa.constant_time_eq(a, b)→ bool, tiempo constante; cualquiera de los dos lados puede ser unsecret.
Renombrado en el motor v0.6.29.hmacreemplaza ahmac_sha256, y cambió el tipo del resultado:hmac_sha256devolvía texto hex,hmacdevuelve bytes. El nombre viejo sigue andando hasta v1.0 (avisa una vez por stderr ysynsema checklo marca) y sigue devolviendo texto. Al migrar, una comparación contra un string hex necesitadecode(mac, "hex")— o mejor,verify_hmac.
as_secret() — sellar un valor que llega en runtime§
secret("NAME") lee del config. Para un valor que llega en runtime y no está en .env (la key de un usuario en un header entrante, un token de otra llamada HTTP), sellalo en el borde con as_secret(value, label?). Es puro (sin require), idempotente sobre un secret, y acepta texto o bytes. Sin label se llama sealed — es solo un nombre (se ve como secret(sealed) y es el alcance de reveal("sealed")); no es un secret sellado como attestation_key(). Pasá un label para que las redacciones y los errores digan de qué valor hablan.
reveal() — el último recurso (auditado)§
reveal(secret) devuelve el texto plano. Exige require reveal("NAME") scopeado al nombre/label del secret, escribe una entrada de auditoría append-only por cada intento, y avisa con un warning en la forma pelada (sin scope). Preferí bearer/hmac/ verify_hmac — consumen el secret sin exponerlo.
Con labels prendidos (motor v0.6.45+), reveal — como toda operación que escribe un log de auditoría (sign, spend, las operaciones de wallet) — no corre en una rama decidida por un valor privado: falla con label_violation y no escribe nada, porque que su entrada exista diría para qué lado fue la rama. Decidí con un valor público, o hacé declassify de la condición antes — ver Labels.
El directorio de audit ($SYNSEMA_AUDIT_DIR, default ~/.synsema/audit) guarda un log append-only por familia gateada, todos con las mismas garantías — todo intento registrado (concedido o denegado), jamás el valor sensible, fail-loud en el camino concedido (si la entrada no se puede escribir, la operación se rehúsa): reveal.log, sign.log (firmas; una entrada denied_by=ceiling marca un corte de SYNSEMA_SIGN_CEILING), wallet.log (creación de custodia) y spend.log (el ledger de dinero: monto decimal canónico, unidad, motivo entre comillas, file:line, programa; denied_by=ceiling marca un corte de SYNSEMA_SPEND_CEILING — ver Capacidades).