Servidor HTTP
Login y sesiones (web auth)
Engine v0.5.5+.
El web auth en Synsema es un juego chico de primitivas, cada una con la opción segura como default. Sin framework, sin middleware — un flujo de login son un puñado de líneas en un bloque serve.
-- Doc example: web auth primitives — password hashing, JWT, TOTP and the CSPRNG.
-- (Cookies/sessions ride on `serve`, which doesn't terminate — the serve page shows
-- the full login flow; this doctest asserts the primitives that flow is built from.)
intent: "doc example: web auth primitives"
require random
let phc be password_hash("hunter2")
print("phc → " + slice(phc, 0, 10) + "… verify → " + text(password_verify("hunter2", phc)))
print("session id → " + token())
test "password_hash gives a PHC string; verify is strict, wrong password is false"
let phc be password_hash("hunter2")
assert(starts_with(phc, "$argon2id$"))
assert_eq(password_verify("hunter2", phc), true)
assert_eq(password_verify("wrong", phc), false)
test "token() is 43 chars of base64url — 32 CSPRNG bytes, fine for session ids"
assert_eq(length(token()), 43)
assert_eq(length(random_bytes(16)), 16)
test "jwt: sign/verify roundtrip; wrong key or tampering → nothing"
let tok be jwt_sign({"sub": "u1", "role": "admin"}, "k", {"expires_in": 3600})
let claims be jwt_verify(tok, "k")
assert_eq(claims.sub, "u1")
assert(claims.exp > claims.iat)
assert_eq(jwt_verify(tok, "other-key"), nothing)
assert_eq(jwt_verify(tok + "x", "k"), nothing)
-- Throwaway test keys generated for this doctest (openssl genpkey / ecparam) — never used anywhere else.
let rsa_pem be `-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQD2Rp6YUEXIprNY
aOSCWK4GwMq3sDBF9VVZRC6g4ec3fvqOinUDSfEK2gqBMj19xJ3jOnQiRUlREn6v
0d04yl/OUEv7XsB7dSGBWs5HGk3z0Cxp/qwTcyu7MY5fnbsAmlvViedwIjiFlKri
rm2JQsnHXBQas92CiTvu8V8SMkPj0eqdGPOeyIwuY2MY2+Kifg7kMmNmwc6iGy9d
WfovTpY+lYx1ve+A57z7ktBxvJy3ATjipHRSJlDGMzTKRZltUdh+5Fxz4SYsqD07
mpOOb09dAD4GBnKbuT0/zCrF2U4I0PQPrjtba38OU5NMvtAu6g+SKE2XxaNMJYu3
PeJBeQApAgMBAAECggEBALSW2ksEafeVzeNhn1a5onsPOldyGiPin8BCkBKnNH5s
XVzSyLUtrlRvyM4uQgsMd16diXVr6zRI5J8WvCqds509Ikpkk660GGqbZLGc51aG
61qaRiJJDONKloUky3DIv9iZNB306g+CEcEBeQ7y67WFnrRp+l9zNIClfDqCo2fV
IP3reUqP+BKktRkqBt1u96DMgvZjEz9kreogjqEt1MUCDtTEDqhUcjBPVgJV4EhF
OwKXssH3HkjB+DX65AD9q14li4ILqOXzAIG2PgYoiricnSHiy603fcrFIbfMhWHK
GW1XFMDZd1snpmYanh4My9e2OGrxOIJU5MvMwdWQlwECgYEA/HIcjywuBqCbCAwC
mGB+FUlIpuwf38yTlFewEvotG23GoHzphyl+VAajbuODbfZPHUOaa3xqMGgUS8xf
rvhQStFn/rELTHLoWV5i+J1mlTGQqgz87QJebNa0fPt+racArrJTg4++17rGZ/Kh
AX0+rSLu145UL6BOib9jp/kZuuECgYEA+b5FFWZnQwhAcD3gvg3JFHL8gohKcbHj
GGOSLDGV+08r0KJ9fAtC+mrXHAo0asABfU2z4mAt21HUedwqvamyfk9sBluTZBt6
uMfjoxQ+Gr9cF9svXgTxg92H13w8zLN9cK56BjcAdltyL2LIjCabwBSfVCldUZSu
ZNbM/fPfdkkCgYEAhYonqWUJg9/5oqwXw1tn1wLJN+O4iL7DYrlwHtnQeq37YHIz
ZpiAhxpr4gDgSY7+EtDsIufRlIG6Gs6l3vzkHG/hv00QWQR//KZ9XF6HIWzyxcHR
Gd8i7Bg3qdWwEVRCfCdBHlsv7RaUVfB3Uz5ZpWTRS52y0u7OHPftU7Hl9oECgYAR
hY3nQHYCN/P9a/n2m5T+AEtDBTjj3S0nquS4gQoaxaU3UA8pwsiCNTi+WODwlGQG
XItAWKse9hDIM/AzulIxlnTnxUkD/wGPMUcBTAxHaCGX8uLR39rp+4LqVWcWnYkx
fKxG/1ji9Ot/85hKbPMEeO3sw7IuZRYPMRrUvmVxMQKBgQDOptrin1d8+TL7insx
dqkUphVgTP4RUwSBRxffZWTxgtm7cuXKx4v4UwKPsorTbhggaPyhklvoJdRBFObw
AW0KIFuMo4/xteJEyWt/2ZxZJW5KkZNXC9lD8rvf3o+3l5/enZ1eLHS5rSJ09tKd
U2WlEvdNOIaJh8VT2onoPj7ZUA==
-----END PRIVATE KEY-----`
let rsa_pub be `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9kaemFBFyKazWGjkgliu
BsDKt7AwRfVVWUQuoOHnN376jop1A0nxCtoKgTI9fcSd4zp0IkVJURJ+r9HdOMpf
zlBL+17Ae3UhgVrORxpN89Asaf6sE3MruzGOX527AJpb1YnncCI4hZSq4q5tiULJ
x1wUGrPdgok77vFfEjJD49HqnRjznsiMLmNjGNvion4O5DJjZsHOohsvXVn6L06W
PpWMdb3vgOe8+5LQcbyctwE44qR0UiZQxjM0ykWZbVHYfuRcc+EmLKg9O5qTjm9P
XQA+BgZym7k9P8wqxdlOCND0D647W2t/DlOTTL7QLuoPkihNl8WjTCWLtz3iQXkA
KQIDAQAB
-----END PUBLIC KEY-----`
let ec_pem be `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIN00MwBLZlEZz/7kb8JorRVSQKyeIKOeuZxWmLtwqaOhoAoGCCqGSM49
AwEHoUQDQgAE5IrnPGDmYTX8VzxyIwhnThfdvguECY9XNfipM1C3dzAxbNRm1NzU
e8LzjM4ksxdqEnfSNbBIQWuERT8L51SAmA==
-----END EC PRIVATE KEY-----`
let ec_pub be `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5IrnPGDmYTX8VzxyIwhnThfdvguE
CY9XNfipM1C3dzAxbNRm1NzUe8LzjM4ksxdqEnfSNbBIQWuERT8L51SAmA==
-----END PUBLIC KEY-----`
task b64url_segment(tok, i)
give split(tok, ".")[i]
test "jwt_sign RS256 / ES256 with a PEM key as a secret (engine v0.6.20+); jwt_verify still pins HS256"
let claims be {"iss": "12345", "exp": 4102444800}
let rs be jwt_sign(claims, as_secret(rsa_pem), {"alg": "RS256", "kid": "k1"})
assert_eq(decode(bytes(b64url_segment(rs, 0), "base64url")), "{\"alg\":\"RS256\",\"kid\":\"k1\",\"typ\":\"JWT\"}")
let signed be b64url_segment(rs, 0) + "." + b64url_segment(rs, 1)
assert(rsa_verify_sha256(signed, bytes(b64url_segment(rs, 2), "base64url"), rsa_pub))
assert_eq(rsa_verify_sha256(signed + "x", bytes(b64url_segment(rs, 2), "base64url"), rsa_pub), false)
let es be jwt_sign(claims, as_secret(ec_pem), {"alg": "ES256"})
assert_eq(decode(bytes(b64url_segment(es, 0), "base64url")), "{\"alg\":\"ES256\",\"typ\":\"JWT\"}")
assert(ecdsa_p256_verify(b64url_segment(es, 0) + "." + b64url_segment(es, 1), bytes(b64url_segment(es, 2), "base64url"), ec_pub))
assert_eq(jwt_verify(rs, "k"), nothing) -- the HS256 verifier rejects an RS256 token
assert_error(() => jwt_sign(claims, as_secret("k"), {"alg": "PS256"})) -- unknown alg → error
test "rsa_sign_sha256 / ecdsa_p256_sign are the raw primitives behind RS256 / ES256 (engine v0.6.20+)"
let sig be rsa_sign_sha256("hola", as_secret(rsa_pem))
assert_eq(length(sig), 256) -- 2048-bit key → 256-byte signature
assert(rsa_verify_sha256("hola", sig, rsa_pub))
assert(ecdsa_p256_verify("hola", ecdsa_p256_sign("hola", as_secret(ec_pem)), ec_pub))
test "totp matches the RFC 6238 vector and verifies within the window"
let seed be bytes("12345678901234567890")
assert_eq(totp(seed, {"digits": 8, "at": 59}), "94287082")
assert_eq(totp_verify(seed, "94287082", {"digits": 8, "at": 59}), true)
assert_eq(totp_verify(seed, "00000000", {"digits": 8, "at": 59}), false)
test "base64url differs from base64: URL-safe alphabet, no padding"
assert_eq(decode(bytes([251, 255]), "base64url"), "-_8")
assert_eq(decode(bytes([251, 255]), "base64"), "+/8=")
assert_eq(bytes("-_8", "base64url"), bytes([251, 255]))
task signed_at_a_fixed_time()
give jwt_sign({"sub": "u1", "iat": 1750000000, "exp": 1750003600}, "k")
task signed_now()
give jwt_sign({"sub": "u1"}, "k")
task verified_at(tok)
give jwt_verify(tok, "k", {"now": 1750000001})
test "what reads the clock needs `time` — or the time passed explicitly"
-- `sandbox` strips every capability, `time` included: the shape of an enclave with no trusted clock
assert_error(() => sandbox signed_now()) -- stamping `iat` reads the clock
let tok be sandbox signed_at_a_fixed_time() -- explicit `iat`/`exp` claims: no clock
assert_eq((sandbox verified_at(tok)).sub, "u1") -- `opts.now` for the verifier
assert_eq(sandbox totp(bytes("12345678901234567890"), {"digits": 8, "at": 59}), "94287082") -- `opts.at` for totp
assert_eq(sandbox password_verify("hunter2", phc), true) -- hashing reads no clock at allCookies de sesión§
require serve(8080)
require random -- gates token()/random_bytes
task check_session(token, request) -- 2 params → also receives the request
let sid be request.cookies.sid
when sid == nothing
give nothing -- nothing → 401
give state_get("sess:" + sid) -- the value lands in request.user
serve on 8080
auth with check_session
route "POST /login"
when password_verify(request.json.pw, stored_phc)
let sid be token()
state_set("sess:" + sid, {"name": request.json.user})
give set_cookie(ok({"ok": true}), "sid", sid, {"max_age": 86400})
give fail(401, "bad credentials")
route "GET /me" requires auth
give request.user
route "POST /logout" requires auth
give clear_cookie(ok({"bye": true}), "sid")
set_cookie(resp, name, value, opts?)envuelve cualquier valor de respuesta. Los defaults son los seguros:Path=/; Secure; HttpOnly; SameSite=Lax. Opts:max_age(segundos),path,domain,secure,http_only,same_site("Strict"|"Lax"|"None"—Noneexigesecure: true).clear_cookie(resp, name, opts?)—path/domaindeben coincidir con los usados al setear.request.cookies— las cookies entrantes como map (sin decodificar; sin header → map vacío).with_header(resp, name, value)— cualquier header de respuesta extra; las llamadas acumulan, los nombres repetidos salen como líneas separadas (lo que elSet-Cookiemúltiple necesita). Los headers de framing/hop-by-hop y los CR/LF son error duro — la header injection no se puede escribir.- Un auth task de 1 parámetro conserva el contrato histórico (solo bearer token).
Contraseñas, tokens, 2FA§
require random
let phc be password_hash(pw) -- "$argon2id$…" (OWASP params) — store as-is
password_verify(pw, phc) -- bool; corrupt/unknown hash → error, not false
let sid be token() -- 32 CSPRNG bytes as base64url (43 chars)
let tok be jwt_sign({"sub": id}, secret("JWT_KEY"), {"expires_in": 3600})
jwt_verify(tok, secret("JWT_KEY")) -- claims map, or nothing on ANY failure
let app be jwt_sign({"iss": app_id}, secret("GITHUB_APP_PEM"), {"alg": "RS256"}) -- RS256 / ES256 with a PEM key (v0.6.20+)
let seed be random_bytes(20) -- TOTP enrolment
totp(seed) -- current 6-digit code (sha1/30s profile)
totp_verify(seed, submitted) -- ±1 period window, constant-time
random_bytes/tokennecesitanrequire random— la misma puerta deny-by-default derandom()(unrng(seed)con semilla es puro, y nunca para secretos).password_hash/password_verifyno necesitan capability.- Lo que lee el reloj necesita
time:jwt_sign(sellaiat/exp),jwt_verify,totpytotp_verify.synsema run/testa secas otorgantimesolos, así que solo lo notás bajo un techo sin él (--deterministic, un--cap-setsintime, un bloquesandbox, un enclave). Ahí pasá el tiempo vos —iat/expcomo claims explícitos ajwt_sign,{"now": ts}ajwt_verify,{"at": ts}atotp/totp_verify— o agregárequire time. El error nombra las dos salidas. jwt_verifypinea el algoritmo a HS256 — un token conalg: "none"o RS256 se rechaza, y toda falla (firma,exp, malformado) es el mismonothing. Verificar el token RS256/ES256 de un tercero esoidc_verify(Identidad de agentes).- Firmar RS256 / ES256 (engine v0.6.20+):
jwt_sign(claims, key, {"alg": "RS256" | "ES256", "kid": …})con la clave privada como secret PEM (PKCS#8 o PKCS#1 para RSA, PKCS#8 o SEC1 para P-256) — lo que necesita una GitHub App o una service account. El algoritmo lo elige quien firma, nunca el token; unalgdesconocido es error. También están las primitivas crudas:rsa_sign_sha256(msg, pem)/rsa_verify_sha256(msg, sig, pub_pem)yecdsa_p256_sign/ecdsa_p256_verify— puras, sin capacidadsign(esa es para mover valor on-chain; acá la clave ya es unsecret). La firma RSA es de tiempo constante o con blinding (motor v0.6.45+): el binario nativo firma conring(BoringSSL) las claves que acepta (de 2048 a 4096 bits, e ≥ 65537, dos primos), y toda otra clave — y toda clave en el build wasm — se firma con blinding; los bytes de la firma son los mismos en los dos casos. - El secret de TOTP es bytes: un secret en base32 de una app de QR se decodifica con
bytes(s, "base32"). - CSRF para POSTs con cookies: emití un
token()por sesión, embebelo en el form, comparalo conconstant_time_eqen el handler. Una firma de webhook esverify_hmac; para calcular una,hmac(data, key)devuelve bytes desde el motor v0.6.29 (reemplazó ahmac_sha256, que devolvía texto hex) — ver Secretos. - Nunca guardes API keys vivas tal cual: guardá
sha256(key)y compará hashes.
Los argumentos de clave/contraseña aceptan un secret sellado, text o bytes.