# Especificación de la cadena de integridad (versión 1)

Para quien quiera comprobar un registro de jornada **sin fiarse de nuestra aplicación**:
una persona, una gestoría, un perito, la Inspección o un desarrollador. Con esto se puede
escribir un verificador propio en cualquier lenguaje. Si el suyo da el mismo veredicto que
los vectores oficiales (`verificador/vectores/`), es correcto.

Lo que se prueba y lo que no está al final (apartado 9). Resumen: **integridad, no
autoría ni veracidad.**

Implementaciones que cumplen esta especificación y se comparan en cada cambio con una
misma prueba (`motor/src/integridad/vectoresTodasImplementaciones.test.ts`):

| Implementación | Dónde |
| --- | --- |
| Motor (TypeScript) | `motor/src/integridad/cadena.ts`, `motor/src/api/recibo.ts` |
| Verificador de un fichero (Node 20+; también corre en el navegador) | `verificador/verificar.mjs` |
| Copia que ejecuta la web en `/verificacion` | `web/public/verificador/verificar.mjs` |
| Referencia en Python (solo biblioteca estándar, Ed25519 incluido) | `verificador/referencia/verificar.py` |

## 1. Convenciones

- Texto en UTF-8, sin marca de orden de bytes, sin normalización Unicode.
- Resumen: SHA-256, escrito en hexadecimal **minúscula** (64 caracteres).
- Instantes: milisegundos desde 1970-01-01T00:00:00Z (enteros). Fechas: `AAAA-MM-DD`.
- El **génesis** es 64 ceros (`"0" * 64`): el `hashPrevio` del primer asiento de una empresa
  y el `selloPrevio` del primer sello.

## 2. JSON canónico

Todo lo que se resume pasa antes por esta serialización, que no depende de espacios ni del
orden en que venían las claves:

1. `null`, `true` y `false` literales.
2. **Cadenas**: entre comillas dobles. Solo se escapan `"` (`\"`), `\` (`\\`), los controles
   `\b \f \n \r \t` y cualquier otro carácter por debajo de U+0020 (`\u00xx`, hexadecimal en
   minúscula). Los sustitutos Unicode sueltos (U+D800 a U+DFFF) se escriben `\udxxx`. Todo lo
   demás va tal cual, **sin** escapar (acentos, emoji, U+2028, U+007F), igual que
   `JSON.stringify` de ECMAScript.
3. **Números**: la representación de `Number::toString` de ECMAScript: enteros sin `.0`
   (`1.0` es `1`), `-0` es `0`, notación exponencial solo si el exponente es 21 o más o −7 o
   menos (`1e+21`, `1e-7`), el decimal más corto que recupera el mismo número (`0.1`).
   Los enteros por encima de 2^53 no se usan en los asientos.
4. **Listas**: `[` elementos canónicos separados por `,` `]`, en su orden.
5. **Objetos**: `{` pares `"clave":valor` separados por `,` `}`, con las claves ordenadas por
   **unidades de código UTF-16** (no por punto de código: un emoji, que son dos unidades
   0xD83D 0xDE00, va antes que U+FFFF). Las propiedades con valor «indefinido» (ausentes) no se
   escriben; `null` sí.
6. Sin espacios ni saltos de línea en ningún sitio.

Los vectores `verificador/vectores/canonizacion.json` recogen los casos difíciles (orden
UTF-16, números, escapes) y se comprueban en las cuatro implementaciones.

## 3. Asiento y resumen

Un asiento es un objeto con los campos del fichaje más cinco de control:
`id`, `secuencia`, `hashPrevio`, `hash`, `registradoEn`.

```text
datos  = el asiento SIN id, secuencia, hashPrevio, hash ni registradoEn
hash   = SHA-256( hashPrevio + "|" + canónico(datos) )
```

`hashPrevio` entra en el resumen tal cual (64 caracteres hexadecimales), seguido de la barra
vertical. Todos los demás campos del asiento (empresa, persona, tipo, instante, desfase,
modalidad, naturaleza, origen, método, autor, posición puntual si la hay, corrección, nota,
hora del servidor y hora del dispositivo si las hay…)
entran en `datos`: cambiar uno cambia el resumen. Por eso la lista de la API de lectura
(`GET /v1/fichajes`) **no** sirve de entrada: es una proyección sin todos esos campos.

## 4. Cadena

Los asientos de una empresa, ordenados por `secuencia` ascendente, deben cumplir para cada
uno, con `previo` = el anterior (para el primero: secuencia 0 y hash génesis; para un tramo
a mitad de cadena, la cabeza que se declara):

| Código | Condición que falla |
| --- | --- |
| `cadena_secuencia_incorrecta` | `secuencia` ≠ `previo.secuencia + 1` |
| `cadena_discontinua` | `hashPrevio` ≠ `previo.hash` |
| `cadena_hash_no_corresponde` | `hash` ≠ el resumen recalculado (apartado 3) |
| `cadena_orden_temporal` | `registradoEn` menor que el **máximo** de los de todos los asientos de secuencia menor (no solo el del inmediato anterior) |

Un `registradoEn` ausente o que no es un número no cuenta para el orden temporal (ni lo
reinicia). Esta aclaración (revisión de 05-10-2026) no cambia el formato: la versión de la
cadena sigue siendo la 1 y los asientos válidos que genera el motor, cuyo `registradoEn` nunca
retrocede, se verifican igual. Solo es más estricta con cadenas ya manipuladas.

Se informan **todas** las roturas, no solo la primera, y se sigue con el `hash` declarado del
asiento (no con el recalculado): así un asiento alterado marca una rotura y no arrastra a
todos los siguientes. Una lista vacía es válida. Los asientos son **append-only**: una
corrección es otro asiento que referencia al anterior, nunca una edición.

## 5. Sello diario encadenado (sin firma)

Objeto con `empresaId`, `fecha`, `hastaSecuencia`, `hashCierre`, `asientos` (cuántos de ese
día), `selloPrevio`, `hash` y `selladoEn`.

```text
hash = SHA-256( canónico( el sello SIN hash ni selladoEn ) )
```

Se comprueba: el resumen propio (`sello_alterado`); que `selloPrevio` sea el `hash` del sello
anterior (`serie_discontinua`) y que las fechas sean consecutivas (`dia_sin_sellar`); y, si el
cierre cae dentro del tramo aportado, que `hashCierre` sea el `hash` del asiento de secuencia
`hastaSecuencia` (`cierre_no_corresponde`) y que `hastaSecuencia − (hastaSecuencia del sello
anterior, o hastaSecuencia − asientos si es el primero) = asientos` (`recuento_sello`). Si cae
fuera del tramo, es un aviso, no un fallo.

## 6. Sello diario firmado y huella del día

```text
huella = SHA-256( canónico({ v: 1, empresaId, fecha, previo, cadena }) )
previo = hashPrevio del primer asiento del día (por secuencia), o null si no hay ninguno
cadena = [ [secuencia, hash], … ] de los asientos del día, por secuencia ascendente
```

«Del día» es la fecha **UTC** de `registradoEn` (`AAAA-MM-DD`). El sello firmado es un JWS
compacto (apartado 7) de tipo `erj-sello-dia+jws` cuya carga es
`{ v: 1, iss: "erj", emp, fecha, n, seq, huella, iat }`: `n` asientos del día, `seq` la
secuencia del último. Para los días dentro del periodo del paquete se exige que los asientos
del paquete registrados ese día UTC den el mismo `n`, el mismo `seq` y la misma `huella`
(`sello_firmado_recuento`, `sello_firmado_huella`), y que la carga firmada coincida con el
índice del paquete (`sello_firmado_no_coincide`).

## 7. Recibo firmado (JWS Ed25519)

JWS compacto `base64url(cabecera).base64url(carga).base64url(firma)` (RFC 7515, sin relleno).
Cabecera `{ alg: "EdDSA", typ: "erj-recibo+jws", kid }`. La firma es Ed25519 (RFC 8032) sobre
los bytes ASCII `base64url(cabecera) + "." + base64url(carga)`, con la clave pública cuyo
`kid` indica la cabecera (JWK `OKP`/`Ed25519`, campo `x`, RFC 8037). Se rechaza si `alg` no es
`EdDSA`, si `typ` no es el esperado (un recibo no vale como sello y viceversa), si el `kid`
no consta o si la firma no cuadra (`recibo_invalido`). La carga es
`{ v, iss: "erj", emp, id, seq, hash, prev, tipo, t, iat }`; corresponde a un asiento si
`id`, `emp` (= `empresaId`), `seq` (= `secuencia`), `hash` y `prev` (= `hashPrevio`) coinciden
(`recibo_no_coincide`). Las claves públicas se publican como JWKS (RFC 7517).

## 8. Paquete pericial

Archivo JSON `{ formato: "erj-paquete-pericial", version: 1, huella, contenido }` cuyo texto
es **exactamente** `canónico(paquete)`: cualquier byte distinto (un salto de línea final
incluido) es `no_canonico`. Además:

1. `huella` = SHA-256 de `canónico(contenido)` (`huella_global`).
2. `contenido.manifiesto.huellas` guarda el SHA-256 de cada parte (`huella_parte`):

| Parte | Texto que se resume |
| --- | --- |
| `asientos` | `canónico(asientos)` |
| `sellos` | `canónico(sellos)` (lista vacía si no hay) |
| `recibos` | `canónico({ recibos, clavesPublicas })` |
| `sellosFirmados` (solo si el paquete los lleva) | `canónico({ sellosFirmados, clavesSellos })` |
| `incidencias` | `canónico(incidencias)` |
| `informeTxt`, `informeHtml`, `instrucciones` | el texto tal cual |

3. `manifiesto.recuentos` coincide con el número de asientos, sellos, recibos y sellos
   firmados (`recuento`).
4. `tramo` = `{ desdeSecuencia, hastaSecuencia, hashCabeza, hashCola }`: el primer asiento
   tiene esa secuencia y `hashPrevio = hashCabeza`; el último, `hastaSecuencia` y
   `hash = hashCola` (`tramo`). La cadena (apartado 4) se verifica desde esa cabeza; si el
   tramo empieza en la secuencia 1 la cabeza debe ser el génesis.
5. Sellos encadenados (5), recibos (7) y sellos firmados (6), cada uno contra los asientos.
6. Todo asiento, sello y recibo es de `contenido.empresa.id` (`empresa`).
7. Si se aporta el JWKS publicado, las claves del paquete deben tener el mismo `kid` y `x`
   (`clave_no_coincide_jwks`). Sin él, las firmas solo prueban coherencia con las claves
   que lleva el propio paquete: un atacante que rehace todo con claves nuevas pasaría.

Un sello de tiempo externo (RFC 3161, `selloTiempo`) es opcional; los verificadores de Node
comprueban que el token contiene la huella del día y su fecha; `verificar.mjs` comprueba además que
la firma del token es válida con el certificado que lleva (`sello_tiempo_firma` si no). **Ninguno**
comprueba que ese certificado sea de una autoridad de confianza (para eso, `openssl ts -verify`;
ver `docs/PAQUETE-PERICIAL.md`).
La referencia en Python no lo interpreta y lo avisa.

## 9. Qué no acredita

- **Integridad, no autoría ni veracidad**: un fichaje falso desde el origen queda igual de
  encadenado. Los asientos no los firma la persona.
- Los recibos y sellos firmados son sellos de integridad con una clave del proveedor: **no**
  son firma electrónica cualificada ni sello cualificado de tiempo del
  [Reglamento (UE) 910/2014](https://eur-lex.europa.eu/eli/reg/2014/910/oj) (art. 3.12 y 3.34;
  los efectos del art. 25 y 41 dependen de que lo sean).
- **`id` y `registradoEn` no entran en el resumen del asiento** (apartado 3): cambiar el `id`
  de un asiento, o mover su `registradoEn` sin que deje de ser creciente, no rompe la cadena.
  Los delatan los recibos (llevan el `id`) y los sellos diarios firmados (agrupan por el día UTC
  de `registradoEn`). Un paquete sin recibos ni sellos firmados no los cubre.
- **`ocurridoEnDispositivo` (hora que marcaba el reloj del dispositivo)**: solo existe cuando el reloj iba adelantado más de 5 min y el fichaje se registró con la hora del servidor en `ocurridoEn`. Es un campo opcional que **sí entra en `datos`** cuando está presente (no figura en la lista de exclusiones del apartado 3), de modo que alterarlo o quitarlo rompe el resumen; si falta, `canónico` lo omite y los resúmenes de los asientos anteriores no cambian. Su límite es de veracidad, no de integridad: es un dato declarado por el dispositivo (el servidor solo comprueba que sea posterior a `ocurridoEn`), así que el resumen prueba que no cambió después de grabarse, no que el reloj marcara de verdad esa hora. Un verificador no necesita tratarlo de forma especial: lo trata como cualquier otro campo.
- **Un recorte limpio del final** (quitar los últimos asientos y, con ellos, sus sellos y
  recibos, y rehacer huellas y manifiesto) deja un paquete coherente consigo mismo. Solo se
  descubre contrastando con la huella global o la cabeza anotadas fuera del sistema al
  entregar el archivo, o con un sello de tiempo externo.
- Quien conserve la clave privada podría emitir recibos nuevos; por eso se contrasta con el
  JWKS publicado y con la huella anotada fuera del sistema al entregar el archivo.
- No sustituye a un perito. Textos jurídicos pendientes de revisión por un asesor.

## 10. Vectores oficiales y cómo probar la suya

`verificador/vectores/esperado.json` da, para cada vector, la salida (`0` íntegro, `1` hay
problemas) y los códigos de problema esperados (conjunto, sin repetir, ordenado). Los
detalles de cada vector están en `docs/VERIFICADOR.md`. Su implementación debe, para cada
fichero: dar la misma salida y el mismo conjunto de códigos, y reproducir
`canonizacion.json`. Regenerar los vectores (solo si cambia el formato):
`GENERAR_VECTORES=1 npx vitest run vectoresVerificador vectoresTodasImplementaciones` en `motor/`.
Las claves de los vectores son de prueba, fijas y públicas: no son de ningún servidor.
