Durante casi toda la vida de sh0, una clave de licencia tenía este aspecto:
sh0-biz-3f2a1c9e-88d4b077-1e5a9f30Y el servidor deducía el plan así:
rustlet plan = if key.starts_with("sh0-biz-") {
"business"
} else if key.starts_with("sh0-scl-") {
"scale"
} else {
"pro"
};Vuelve a leer ese else. Cualquier cadena no reconocida se activaba como Pro. hello-world te daba el
nivel de pago. Esa mitad se cerró el día anterior: las cadenas desconocidas pasaron a devolver un 400.
Pero el problema de fondo sobrevivió a la corrección, y es el que merece contarse.
La clave no probaba nada. Llevaba un plan, y ninguna prueba de que la hubiéramos emitido nosotros.
Un cliente que había comprado Pro por 19 $ y quería Business por 99 $ no tenía nada que atacar. Le
bastaba con saber que las claves Business empiezan por sh0-biz-, algo que la suya ya le mostraba.
Lo que una licencia tiene que aguantar
La corrección evidente es una firma. Lo que no es evidente es la lista de restricciones que la rodea, y esa lista es la que define el diseño.
sh0 es autoalojado. Ese es el producto entero. Una comprobación de licencia que exige Internet es una comprobación que falla precisamente en el despliegue al que vendemos. Así que la verificación tiene que funcionar sin conexión, lo que significa que la prueba viaja dentro de la clave y que el verificador es el binario.
La clave la pega una persona, en un formulario, desde un correo. Ocupa una línea y tiene que sobrevivir al copiar y pegar.
El emisor está en Node — un sitio SvelteKit con Prisma. El verificador está en Rust. Dos lenguajes, dos serializadores JSON, dos implementaciones de base64 y ningún código compartido.
Y un plan que se retira hay que retirarlo por una razón defendible. Un servidor que pierde Pro porque falló una resolución DNS es peor que un servidor que conserva un Pro que ya no paga.
El formato
SH0-LIC-1.<base64url(payload JSON)>.<base64url(Ed25519 signature)>Tres partes, con la etiqueta de versión primero para que una futura rotación de clave sea un incremento
de formato y no una migración. La carga útil lleva licence_id, plan, holder, issued_at y un
expires_at opcional.
La decisión que importa ocupa una línea y es fácil de equivocar:
La firma cubre la carga útil en base64url tal como está escrita, no el JSON decodificado.
Si firmas el objeto decodificado, ambos lados tienen que ponerse de acuerdo sobre cómo serializarlo:
orden de las claves, espacios, cómo se emite un null, si se escapa o no lo que no es ASCII.
JSON.stringify en Node y serde_json en Rust no coinciden en todo eso, y no tienen por qué. Firma los
bytes exactos que viajan y la pregunta desaparece. Es la misma razón por la que JWS firma los segmentos
codificados.
La segunda decisión es sobre el orden de las operaciones:
rust// Signed bytes are the payload *as written*, not the decoded JSON.
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, pubkey)
.verify(payload_b64.as_bytes(), &signature)
.map_err(|_| LicenceError::BadSignature)?;
// Only now is the payload worth reading: everything below this line has
// been proven to come from us.
let payload: LicencePayload = serde_json::from_slice(&payload_json)?;Verificar y luego deserializar. Nunca al revés. Un deserializador es un analizador sintáctico, un analizador es superficie de ataque, y no hay ninguna razón para apuntarlo a bytes elegidos por un desconocido.
Fallar en cerrado, y decirlo
La clave pública es una constante del binario. ¿Qué debe ocurrir cuando esa constante está vacía, en una compilación donde nadie la pegó?
La respuesta tentadora es «se acepta todo, no es más que una licencia». La respuesta correcta es la contraria:
rust/// When the key is not configured in a build, every activation is **refused**
/// -- a licence system that cannot verify must not accept.
const LICENCE_PUBKEY_HEX: &str = "…";Un sistema de licencias que no puede verificar no debe aceptar. El panel lo dice, en cinco idiomas, en lugar de mostrar un error genérico.
Hay una decisión más discreta escondida en el mismo archivo. free no es un plan firmable:
rust/// Plans a licence may carry. `free` is not among them: Free is the absence of
/// a licence, and signing one would be a way to *downgrade* a server remotely.
fn known_plan(plan: &str) -> bool {
matches!(plan, "pro" | "scale" | "business")
}Si pudiéramos firmar una licencia free, habríamos construido un interruptor de apagado remoto y
entregado esa capacidad a cualquiera que algún día se hiciera con la clave de firma. Eso no se
desinventa una vez que existe en el formato.
La revocación, y la regla que más costó escribir
Una licencia firmada es válida hasta que caduca. Los reembolsos y los contracargos llegan antes que eso,
así que el servidor pregunta a sh0.dev una vez al día si la licencia que tiene sigue siendo buena.
Todos los modos de fallo de esa pregunta tienen la misma respuesta:
rust/// Read one revocation answer. Any failure -- DNS, timeout, 500, a body we do
/// not recognise -- is `Unknown`, deliberately: the only thing that removes a
/// plan is an explicit `revoked`.Tiempo agotado: se conserva el plan. 500: se conserva el plan. Cuerpo ilegible: se conserva el plan.
Identificador de licencia desconocido: se conserva el plan — ese caso viene del servidor, y es el
que una implementación perezosa falla. Una copia de seguridad restaurada, una errata, una licencia
emitida por otro despliegue, y de pronto un 404 se lee como «no válida» y un cliente que paga pierde
funciones. Por eso el endpoint devuelve 404 y el cliente trata el 404 como «no he podido preguntar».
Y una línea más, en el registro que se emite cuando un plan sí se retira de verdad:
Licence revoked upstream -- Pro features stop, running applications are untouchedLa revocación corta las funciones de pago. No corta nada de lo que está en marcha. Sea cual sea la disputa comercial, la producción de alguien sigue en pie.
Dos lenguajes, un solo test
Este es el fallo al que invita este diseño. Los dos lados pasan sus propios tests. Node firma, Node verifica su propia firma, verde. Rust verifica, Rust hace ida y vuelta con sus propias claves, verde. Después producción emite una clave que rechazan todos los servidores del mundo, porque un lado usó base64 estándar y el otro base64url, o porque uno firmó el JSON y el otro la codificación.
Nada en ninguna de las dos baterías de tests puede verlo. Por eso el test de interoperabilidad es un vector congelado: una licencia producida por el firmador de Node, con una clave desechable generada para la ocasión, versionada dentro de la batería de Rust.
rust/// It exists because the two halves are written in different languages: a
/// base64 alphabet, a padding rule or a "sign the JSON rather than the encoded
/// payload" mismatch would pass every test on each side alone and refuse every
/// real licence in production.
#[test]
fn a_licence_signed_by_the_node_issuer_verifies_here() {El vector lleva un holder con acentos y una marca de tiempo ISO con milisegundos — las dos cosas que
un emisor JavaScript produce de forma natural y con las que un analizador Rust puede ser estricto.
Lo que encontró la revisión adversaria
La implementación pasó por un revisor de solo lectura antes del commit. Confirmó la criptografía y el orden de las operaciones. También encontró cinco defectos reales, y los dos peores no estaban ni cerca de la criptografía.
La clave privada estaba entrando en el contexto de build de Docker. .gitignore cubría *.pem —
correcto, y el historial de git estaba limpio. .dockerignore no lo hacía, y el Dockerfile hace
COPY . .. La imagen publicada estaba bien; la capa builder no, y las capas de build sobreviven a su
build en las cachés y en las exportaciones de caché del registro. Una clave de firma es el único secreto
que no se puede rotar en silencio: sustituirla significa reconstruir todos los servidores desplegados.
El correo de entrega se rompía con la nueva clave. La clave antigua tenía 34 caracteres. La nueva
tiene 330, y la plantilla la mostraba en negrita, a 16 px, centrada, con letter-spacing y sin
word-break. Una cadena base64url ininterrumpida no tiene por dónde partirse. Gmail y Outlook la
habrían destrozado — en el único canal que entrega la clave, y en el mismo al que apunta nuestro propio
mensaje de error: «Vuelve a copiarla desde el correo de tu pedido».
Un tercer hallazgo era más sutil: expires_at se comprobaba en la activación y nunca más. Un servidor
se reinicia, recarga su plan desde la base de datos y no vuelve a mirar la fecha. La fecha era
decorativa. Ahora se vuelve a comprobar al arranque y en cada pasada diaria — con la misma regla que la
revocación: una fecha ilegible no le cuesta el plan a nadie.
Demostrarlo
Los tests estaban en verde, 27 de 27, incluidos siete que manejan un servidor HTTP real para demostrar que un 500, un 404, un cuerpo ilegible y un endpoint inalcanzable dejan el plan intacto. Aun así, unos tests en verde no son una prueba. Así que:
Dos VPS desechables, Debian 12 y Ubuntu 24.04. Instalación limpia, 50 segundos cada una. Una licencia
firmada con la clave de producción, en el sitio de producción, activada por la API en un servidor real.
El plan pasa a pro, el titular aparece. Se revoca en el servidor. Reinicio. Cinco minutos y
veinticinco segundos después:
pro active → free revokedY la aplicación desplegada respondió 200 en cada uno de los sondeos mientras ocurría.
Clave falsificada: rechazada. Carga útil manipulada: rechazada. Clave antigua con prefijo —
sh0-biz-1234-5678-9abc, la forma exacta que antes compraba Business: rechazada.
Esa última línea es la historia entera.
Lo que no se construyó
La licencia sigue siendo un token al portador. server_id no se rellena, así que una clave Business
activada en cinco servidores funciona en cinco servidores. No es una regresión — el formato con prefijo
tenía la misma propiedad —, pero ahora es el agujero más ancho del modelo, y el diseño que cerró el de
la falsificación no cerró este. Escribirlo negro sobre blanco forma parte del trabajo.
La activación tampoco consulta el endpoint de revocación. Una licencia revocada se activa y funciona hasta la siguiente comprobación diaria. Es un compromiso deliberado: preguntar a la red en la activación contradiría «una licencia firmada se activa sin conexión», que es justamente la propiedad que todo este diseño existe para proteger. Es un argumento, no un descuido — y su sitio está en el registro, junto a los tests que pasan.