Synsemadocsv0.6.xENES

Capabilities & Security

Secrets

A secret is an opaque, redacted value: you can use it but never read its plaintext from the language. It is LLM-proof by design — it never appears in print, logs, error bodies, JSON responses, the blackboard, or an LLM prompt.

Encoding a secret as text is an error (engine v0.6.46+): json_encode, jsonl_encode, json_for_script and csv_encode refuse a secret anywhere in the value and say where it is (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). Before, it came out as "[redacted]", a marker the program could send on as if it were the key. Pass the secret to the request itself — a header, or the URL/body once it is bound (below) — or leave it out of what you encode. See JSON.

env() vs secret()§

Both are deny-by-default: declare the capability or the read fails.

The default, when the variable is not defined (engine 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

Where the values come from§

secret("NAME") resolves process environ > .env > default, and with none of the three it errors — never a silent empty value. synsema init writes a .env.example whose last section is exactly this: your own secrets, with the ones the auth features expect already named (JWT_KEY, CAPTOKEN_ROOT_KEY, AGENT_SIGNING_KEY — see Agent identity). The name you pick is also the capability scope: require secret("JWT_KEY") grants that one and no other.

The same rule covers private keys: the VAPID key of push_send (engine v0.6.15+, VAPID_PRIVATE_KEY, see Your app on the phone) and the blockchain key of sign are accepted only as a secret — from .env via secret(...), or sealed at runtime with as_secret(...). A plain string is refused with the fix in the message, and the key value never appears in any error. Keys the engine generates (push_vapid_keys(), mnemonic_generate) come back already sealed.

Rotation without a restart (Unix, engine v0.6.20+). A SIGHUP to a running serve makes it re-read the .env on the next resolution — secret("NAME") sees the new value and the LLM provider is rebuilt with the new key. A value already copied into a let at startup is not renewed (it is a value, not a reference). See Deploy.

Credentials go in any header — not just Bearer§

A secret placed as a header value is materialized to its real value only at the socket, regardless of the header name. So you are not limited to Authorization:

A secret that is not bound to a host still goes only in a header. In the URL, the query, the body or a form it is an error (engine v0.6.44+; before, it went out as the text secret(NAME), which the server took as the value), and since v0.6.46 the error gives the exact line to write, with the host of that 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")``.

A secret in the URL, the query or the body: bind it to a host (engine v0.6.46+)§

Some APIs take the key only in the URL (RPC providers, bot and map APIs), in the query, or in the body (OAuth client_secret_post). Before v0.6.46 that was an error and the only way out was reveal(). Now you bind the secret to the hosts it may go to, and then it may travel in the URL, the query, a JSON body at any depth, a form, or a header — only to those 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

The engine writes the value at the socket; the program never holds it as text. The rules:

The host can bind too. synsema run --cap-set "secret=K@api.example.com,net=api.example.com,stdout" app.syn (several hosts: secret=K@a.example.com|b.example.com), and the same in a delegated ceiling (run_program's ceiling). A program that asks for K unbound, or bound to another host, gets the line to write: secret("K") not permitted: the host binds K to api.example.com; write require secret("K") for net("api.example.com")`. A program may bind tighter than the host (an unbound secret=K in the ceiling accepts a program that binds K). A child whose ceiling asks for K` unbound gets nothing above its parent's binding.

Every value that goes out as text — a header, a query param, bearer(...) — is text, a number, a bool or a secret. Since engine v0.6.44, nothing (a missing env var or map key), bytes, lists and maps are an error that names the header or parameter; before, they went out as their printed form (bearer(nothing) sent Authorization: Bearer None). A sealed secret (attestation_key()) or a secret holding bytes that are not UTF-8 is an error too, never the placeholder secret(NAME).

As a database parameter (sql, MySQL/Postgres/SQLite, mongo_, redis_) a secret is materialized at the database edge: a text secret as text, a bytes secret as its bytes (BLOB/bytea/binary — before v0.6.44 they were converted to text and bytes that are not UTF-8 changed). A sealed secret is an error, also inside a list or a document.

hmac() and verify_hmac() — sign and check without revealing§

A webhook secret is used, never read. Three pure builtins consume a secret, text or bytes as the key:

Renamed in engine v0.6.29. hmac replaces hmac_sha256, and the result type changed: hmac_sha256 returned hex text, hmac returns bytes. The old name still runs until v1.0 (it warns once on stderr and synsema check flags it) and still returns text. When you switch, a comparison against a hex string needs decode(mac, "hex") — or better, verify_hmac.

as_secret() — seal a value that arrives at runtime§

secret("NAME") reads from config. For a value that arrives at runtime and is not in .env (a tenant's key in a request header, a token from another HTTP call), seal it at the edge with as_secret(value, label?). It is pure (no require), idempotent on a secret, and accepts text or bytes. Without a label it is named sealed — just a name (it shows as secret(sealed) and is the scope of reveal("sealed")); it is not a sealed secret in the sense of attestation_key(). Pass a label so redactions and errors say which value they are about.

reveal() — the audited last resort§

reveal(secret) returns the plaintext. It requires require reveal("NAME") scoped to the secret's name/label, writes an append-only audit entry for every attempt, and warns on the bare (unscoped) form. Prefer bearer/hmac/verify_hmac — they consume the secret without exposing it.

With labels on (engine v0.6.45+), reveal — like every operation that writes an audit log (sign, spend, the wallet operations) — does not run in a branch decided by a private value: it fails with label_violation and writes nothing, because whether its entry exists would tell which way the branch went. Decide with a public value, or declassify the condition first — see Labels.

The audit directory ($SYNSEMA_AUDIT_DIR, default ~/.synsema/audit) holds one append-only log per gated family, all with the same guarantees — every attempt recorded (granted or denied), never the sensitive value, fail-loud on the granted path (if the entry cannot be written, the operation refuses): reveal.log, sign.log (signatures; a denied_by=ceiling entry marks a SYNSEMA_SIGN_CEILING cut), wallet.log (custody creation) and spend.log (the money ledger: canonical decimal amount, unit, quoted reason, file:line, program; denied_by=ceiling marks a SYNSEMA_SPEND_CEILING cut — see Capabilities).