Protocoloscapability-token (SCT v1)

SCT v1 — especificación

Spec canónica de protocols/capability-token/SCT_SPEC.md, copiada sin reescribir.

ImplementadoSin versión del tren todavía· generada desde protocols/capability-token/SCT_SPEC.md

Página generada desde protocols/capability-token/SCT_SPEC.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere.

SCT v1 — especificación

  • schemaVersion: 1 (byte 0 del token).
  • Compatibilidad: el formato es de longitud fija por versión. Un verificador v1 rechaza cualquier otra versión (UnsupportedVersion); una v2 será un formato nuevo, no una extensión. Durante una transición el emisor firma la versión que el daemon desplegado acepta; no hay negociación.
  • Campos desconocidos: no existen (longitud fija). El byte de flags reservado debe ser 0; otro valor es Malformed.

Formato binario

Big-endian, 204 bytes. Los 140 primeros son el cuerpo firmado.

OffsetBytesCampoContenido
01version1
11scope1 read · 2 publish · 3 ingest · 4 control
21range_kind0 ninguno · 1 bytes · 2 grupos MoQT
31flags0 (reservado)
48kidsha256(clave pública Ed25519 cruda)[0..8]
1216audsha256("styx.sct.aud\0" ‖ node_id)[0..16]
2816sidlos 16 bytes del sess_, pkg_ o ing_<32 hex>
4416actorsha256("styx.sct.actor\0" ‖ actor_id)[0..16]
6032resourcedigest del recurso (abajo)
928range_start0 si range_kind = 0
1008range_end[start, end), start < end si hay rango
1088nbfsegundos Unix
1168expsegundos Unix, exp > nbf
12416jtialeatorio, único por token
14064signatureEd25519 sobre "styx-sct/v1\0" ‖ bytes[0..140]

Transporte en texto: base64url sin padding, 272 caracteres exactos (204 es múltiplo de 3, no hay bits sobrantes que un codificador no canónico pueda usar).

Digests de recurso

Cada tipo tiene su dominio; las tuplas se codifican u32 BE count ‖ (u32 BE len ‖ bytes)*, así dos recursos distintos nunca comparten digest por concatenación (["a/b"] ≠ ["a","b"]).

RecursoDigest
asset (WT/H3)sha256("styx.sct.res.asset\0" ‖ asset_id)
namespace MoQTsha256("styx.sct.res.moqt-ns\0" ‖ tuple(ns))
pista MoQTsha256("styx.sct.res.moqt-track\0" ‖ tuple(ns) ‖ u32 len ‖ name)
raíz de ingesta (dec-0121)sha256("styx.sct.res.ingest\0" ‖ root_id)

Dónde viaja

TransporteLugar
WebTransport (CONNECT)query cap= de /styx/<sess_id> (el navegador no deja poner cabeceras)
HTTP/3query cap= o Authorization: Bearer <sct> (los dos a la vez: rechazo)
HTTP/3 empaquetadosólo Authorization: Bearer <sct> en /styx/pkg/<pkg_id>/... (cada playlist, init y segmento; el reproductor la pone en cada petición)
Ingesta (conduit)Authorization: Bearer <sct> en cada petición del wire de conduit al IngestSink (protocols/session-ipc/messages/ingest.md)
MoQTSETUP del cliente, opción AUTHORIZATION TOKEN (0x03): Alias Type = USE_VALUE (0x3), Token Type = 0x53435401, Token Value = los 204 bytes crudos

El daemon no anuncia caché de tokens MoQT: REGISTER → AUTH_TOKEN_CACHE_OVERFLOW, USE_ALIAS/DELETE → UNKNOWN_AUTH_TOKEN_ALIAS.

Verificación (daemon)

En este orden; la primera que falla decide el error y el audit:

  1. Estructura (Malformed, UnsupportedVersion).
  2. Hay claves configuradas (NoKeysConfigured) y el kid es de una de ellas (UnknownKey). El daemon acepta a la vez la actual y la siguiente (rotación).
  3. Firma Ed25519 estricta (BadSignature).
  4. aud = el del daemon (WrongAudience).
  5. Ventana de tiempo con tolerancia de 30 s: nbf ≤ now + 30 (NotYetValid), now < exp + 30 (Expired).
  6. Vida exp - nbf ≤ 600 s para read/control, ≤ 300 s para publish/ingest (LifetimeTooLong).
  7. La sesión sid existe, la abrió playback-svc por IPC, y lo hizo con este mismo actor y resource (UnknownSession, SessionMismatch).
  8. publish/ingest: el jti no se ha usado (Replayed). La caché de jti está acotada y pre-reservada; llena de tokens vivos, rechaza (ReplayCacheFull) en vez de olvidar.

Cada petición posterior bajo un token admitido se re-autoriza: caducidad, sesión todavía abierta y ligada al mismo actor y resource (cerrar la sesión por IPC revoca), scope compatible con la operación, recurso de la petición y rango (bytes para lecturas WT/H3, grupos para FETCH; un token con rango de grupos no cubre un SUBSCRIBE, que es abierto). En WT, una lectura abierta (end = 0, "hasta EOF") o con end más allá del fichero se autoriza con el fin real del asset, el mismo que se sirve. En H3, la sesión del path se comprueba contra el token nada más admitirlo, antes de mirar la sesión, registrar la petición o resolver el Range.

Alcance de la revocación y la caducidad sobre lo que ya está abierto:

  • MoQT: el daemon barre los grants de sus sesiones como mucho una vez por segundo; una sesión cuyo token caducó o cuya sesión se cerró por IPC pierde sus SUBSCRIBEs y FETCHes en curso (no recibe ni un objeto más) y se cierra con EXPIRED_AUTH_TOKEN o UNAUTHORIZED. Cada chunk de un stream de publisher se re-autoriza, así que un stream abierto antes no inyecta nada después.
  • Ingesta (dec-0121): la primera petición al IngestSink admite el token (consume el jti); las siguientes con el mismo token se re-autorizan en cada petición (caducidad, sesión ing_* aún ligada, scope, recurso). El rango bytes es el tope [0, max) del fichero, no una región: un token ingest sin él, o con inicio distinto de 0, se rechaza (RangeDenied). Acota un fichero, no lo que un actor reserva: eso lo acotan los topes del daemon (fichero y staging por actor, dec-0121 §2). Reanudar tras caducar pide un token nuevo para la misma sesión. cmd.media.closeIngest por IPC revoca y aborta las subidas de la sesión.
  • WT/H3: la autorización es por petición. Una lectura WT ya en curso cuando caduca el token termina de servirse; cerrar la sesión por IPC sí la corta. La siguiente lectura o petición se rechaza.
  • Renovación del token read (SEC-Z13): un token vale como mucho 600 s (+30 s de tolerancia) y una reproducción larga lo renueva sin que el player lo note. playback-svc firma uno nuevo del mismo sid, actor y recurso en POST /sessions/:sessionId/token con el vigente en el cuerpo como prueba de posesión (firma, audiencia, exp + skew, sesión y actor de la entrega); sólo lo pide el dueño de la entrega. Sólo renueva el último token emitido o el anterior (el anterior cubre una respuesta perdida): uno de hace dos renovaciones no sirve. Cupo de 4 renovaciones por entrega con reposición de una por minuto, y edad máxima de 12 h (410): pasada ésta, el cliente abre una sesión nueva. Cada renovación es una emisión (sct_issued) y cada rechazo va al audit sin el token. El daemon no se entera de la renovación y no guarda nada de ella: sigue verificando cada petición, así que una entrega cerrada o revocada rechaza también el token renovado.
    • HTTP/3 y /styx/pkg: cada petición lleva su token; el player cambia el Authorization o el cap= de las siguientes. El token anterior vale hasta su exp + skew.
    • WebTransport: el CONNECT lleva el token y la sesión se cierra a exp + skew. Para no cortarla, el cliente abre un stream bidi y envía {"renew":"<SCT base64url>"}. El daemon verifica el token como el del CONNECT (cuenta Ed25519 de la conexión, un rechazo es un strike y el límite de rechazos cierra la conexión), exige que sea de la misma sesión, actor y recurso y que viva más que el grant actual, y sustituye el grant (el barrido y el lease de admisión lo siguen). La respuesta es el FIN del stream (renovado) o su reset 0x04 (el grant no cambia). Una sesión que el barrido ya revocó no se resucita.
  • Sesión empaquetada (pkg_*): si nadie pide nada durante STYX_MEDIA_PACKAGING_IDLE_S (30..600 s, defecto 600: nunca más de lo que vive un token read) el daemon la cierra, libera su plaza de STYX_MEDIA_PACKAGING_MAX y la desliga: el mismo token recibe 403 desde entonces. Una petición en vuelo o un fetch que la retiene no cuentan como inactividad.
  • Una petición sin credencial (sin Authorization ni cap=) no llega a la authority, así que el daemon la audita él: línea security_audit con reason=missing_credential op=<read|connect> y evento evt.security.capabilityRejected con motivo MissingCredential, sin ruta, cabeceras ni cuerpo del cliente.

Errores en el cable

TransporteRechazo
MoQTcierre de la sesión: UNAUTHORIZED (0x2), EXPIRED_AUTH_TOKEN (0x18), MALFORMED_AUTH_TOKEN (0x16). Cualquier request antes de un SETUP admitido cierra con UNAUTHORIZED
WebTransportla sesión se cierra con código 403 y motivo unauthorized; una lectura fuera del token se resetea con 0x04 sin enviar bytes
HTTP/3403 forbidden genérico, antes de mirar si la sesión existe (no enumera sesiones)

Límites y seguridad

  • La clave privada vive sólo en playback-svc (STYX_SCT_SIGNING_KEY, semilla de 32 bytes). El daemon tiene claves públicas (STYX_SCT_PUBLIC_KEYS, hasta 4).
  • Los tokens nunca se loguean: cap= se redacta en los logs del daemon. El audit lleva motivo, operación, scope, sid y jti en hex tal cual (identificadores, no credenciales: sin la firma no abren nada) y el actor sólo como su digest de 16 bytes.
  • Un token de lectura se puede reutilizar dentro de su vida (reconexiones); uno de escritura una sola vez.

Fixtures

vectors.json: clave de prueba, reloj, sesión ligada, digests y ~20 tokens con su veredicto esperado (válidos, firma alterada, cuerpo alterado, caducado, aún no válido, otra audiencia, kid desconocido, otro firmante con el mismo kid, sesión no ligada, otro recurso, otro actor, vida excesiva, versión, flags y truncado).