Synsemadocsv0.6.xENES

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()§

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+):

require env("PORT")
let port be env("PORT", nothing)
when port == nothing
    print("PORT is not set: using 8080")
secret-bearer.syn
-- 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 binding

De 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:

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:

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:

Renombrado en el motor v0.6.29. hmac reemplaza a hmac_sha256, y cambió el tipo del resultado: hmac_sha256 devolvía texto hex, hmac devuelve bytes. El nombre viejo sigue andando hasta v1.0 (avisa una vez por stderr y synsema check lo marca) y sigue devolviendo texto. Al migrar, una comparación contra un string hex necesita decode(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).