atrusDatrus

Verificar un documento sellado por Datrus

Datrus sella dos documentos y los dos mandan acá: la evidencia de un consentimiento y el corte del RAT. Cada uno es la foto sellada de un acto, y su hash se recomputa desde el archivo que lo acompaña. Acá se recomputa y se compara con el hash que trae el documento impreso.

Esta comprobación no consulta ninguna base de datos. No sabemos —ni preguntamos— de qué empresa es el documento que estás comprobando, ni si existe en alguna parte: lo único que se responde es si el JSON que pegaste reproduce el hash que pediste. Nada de lo que pegues se guarda.

Comprobar un sello

Los 64 caracteres que van después de /verificar/ en el pie del documento. En el corte del RAT es el «hash raíz» de la portada, que es el mismo.

Pega acá el archivo JSON que acompaña al documento: la evidencia de consentimiento o el corte del RAT. Lo entrega la empresa desde su consola, junto al PDF. Puedes pegar el archivo entero o sólo el sello, y no hace falta que nos digas cuál de los dos es.

Nada de lo que pegues acá se guarda, se registra ni sale de esta comprobación.

El JSON sellado lo entrega la empresa responsable desde su consola de Datrus, junto al PDF: la evidencia lo trae en su descarga «JSON» y el corte del RAT en la suya. Si sólo tienes el PDF, pídeselo: el hash impreso y el archivo son las dos mitades de la misma comprobación.

Cómo recomputar el sello por tu cuenta

El sello es el sha256 de una forma canónica del JSON, y esa forma no es «JSON ordenado»: quien la reimplemente con la manera obvia de su lenguaje falla en la primera tilde. Las reglas son seis y están todas acá, con el script que las ejecuta.

  1. 1Sin un solo espacio

    Entre una clave y su valor no va nada, y entre dos elementos tampoco: {"a":1,"b":[2,3]}. Un espacio cambia el texto y con él el sha256.

  2. 2Claves ordenadas por unidad de código UTF-16

    En cada nivel de anidamiento, las claves de un objeto se ordenan como las ordena el `sort()` de JavaScript: por unidad de código UTF-16, no por punto de código. La diferencia sólo aparece con caracteres fuera del plano básico —un emoji va ANTES que «ff» (U+FB00), porque su primera unidad es 0xD83D— y el `sorted()` de Python los ordena al revés. Los arreglos se dejan en su orden original: ahí el orden es dato.

  3. 3Los no-ASCII van tal cual, en UTF-8

    Una tilde se escribe «ó», no «\u00f3». Sólo se escapan la comilla doble, la barra invertida y los caracteres de control por debajo de U+0020 (con \b \f \n \r \t cuando tienen forma corta, y \u00XX en el resto). El texto canónico se codifica en UTF-8 antes de entrar al sha256.

  4. 4Los números, sólo enteros seguros

    Un número se escribe con su notación decimal simple y sin adornos: 3, 0, -7 (y -0 se escribe 0). Los sellos de este módulo sólo llevan enteros chicos —números de versión y de días— y las fechas viajan como texto ISO. Un número no entero, o mayor que 2^53-1 en valor absoluto, NO es verificable: la notación de las magnitudes extremas no es igual entre lenguajes, así que un verificador correcto RECHAZA en vez de adivinar.

  5. 5null, true y false, en minúsculas y sin comillas

    Un valor ausente entra como null. Un texto con un sustituto UTF-16 sin pareja no es verificable.

  6. 6El sello es sha256 del texto canónico, en hexadecimal minúsculo

    sha256(utf8(canonico(objeto))).hex() — sin sal, sin clave y sin nada más.

Qué cubre cada hash

Los tres se calculan con la misma canonicalización. Lo que un tercero NO puede deducir mirando el dato es QUÉ entra en cada uno, y la asimetría del número de versión entre los dos últimos es deliberada: por eso queda escrita.

Hash de la evidencia

El objeto {documento, diff}, entero y tal cual.

Es el que imprime el PDF y el que trae la exportación en JSON. El «documento» lleva adentro el hash de la versión del acuerdo, así que verificar la evidencia verifica también la versión —pero no al revés—.

Hash de la versión del acuerdo

El «contenido» de la versión, tal cual, CON su número de versión adentro.

Dos versiones del mismo acuerdo con los mismos permisos no son la misma versión: la segunda es un acto posterior del titular. Un hash que no las distinguiera haría que reordenar el historial no se notara.

Hash de la versión de plantilla

El «contenido» de la plantilla SIN su clave «version».

LA ASIMETRÍA, que es lo único de todo esto que no se deduce mirando el dato: el número se agrega recién al escribir la fila. Es a propósito y por lo contrario del anterior —el mismo texto publicado dos veces ES el mismo documento—, y quien verifica compara el texto contra su hash sin tener que saber en qué número quedó. Quien recompute la columna tal cual declarará adulterada una plantilla intacta.

El sello del corte del RAT, que es otra cuenta

El corte del RAT no se sella con la canonicalización de arriba. Su hash raíz es el sha256 de los hashes de versión unidos por un salto de línea, en el orden de la cadena: lo que el corte firma es QUÉ versiones entraron y en qué orden, porque el texto de cada una ya viene firmado por su propio hash. Quien le aplique al corte la especificación del consentimiento saca otro sha256 y declara adulterado un RAT intacto.

  1. 1El hash raíz encadena hashes, no documentos

    Un corte no vuelve a hashear el texto de ningún tratamiento: cada texto ya viene firmado por el hash de su versión. Lo que el corte firma es QUÉ VERSIONES entraron y en qué orden, y por eso su sello se calcula sobre la lista de esos hashes y sobre nada más.

  2. 2Unidos por un salto de línea, y el separador es dato

    Se concatenan los 64 caracteres de cada hash separados por \n —un salto de línea, no una coma y no nada—. Sin separador, una lista de «ab» y «c» y otra de «a» y «bc» darían el mismo sello, y dos cortes distintos se confundirían.

  3. 3El orden es el de la cadena, no el que a uno le parezca

    Los hashes van en el orden en que el corte los selló —el de los códigos de tratamiento el día del corte—, y el archivo verificable los entrega ya en ese orden. Reordenarlos cambia el sello: eso es exactamente lo que el hash raíz existe para detectar.

  4. 4sha256 del texto en UTF-8, en hexadecimal minúsculo

    sha256(utf8("\n".join(hashes))).hex(). Acá no hay canonicalización, ni claves que ordenar, ni tildes que escapar: son 64 caracteres hexadecimales por línea y nada más.

Hash raíz del corte del RAT

La lista de hashes de versión que el corte selló, en el orden de la cadena.

Un corte es una foto del inventario vigente a una fecha. El hash raíz prueba que la LISTA no cambió —las mismas versiones, en el mismo orden—; que el TEXTO de cada una tampoco cambió lo prueba el hash de esa versión, que va impreso en su fila del CSV. Son dos preguntas distintas y por eso son dos sellos distintos.

El script de referencia

Sin dependencias y sin una línea de Datrus. Hace las DOS cuentas —reconoce por la forma del material si le diste una evidencia o la foto de un corte— y se ejecuta en nuestra suite de pruebas contra el mismo sellador que escribe la base: si alguna vez dejara de reproducir un sello, la suite se cae.

#!/usr/bin/env python3
# Verificador de un sello de consentimiento de Datrus.
#   cat evidencia.json | python3 verificar.py    ->  el sha256 del sello
# No importa nada de Datrus: sólo la especificación de esta página.
import hashlib, json, sys

ENTERO_MAXIMO = 2 ** 53 - 1


def texto(s):
    # ensure_ascii=False: la tilde va tal cual. Con el valor por omision (True)
    # este script falla en la primera tilde, y ese es el error clasico.
    try:
        s.encode('utf-8')
    except UnicodeEncodeError:
        raise ValueError('texto con un sustituto suelto: no es verificable')
    return json.dumps(s, ensure_ascii=False)


def numero(n):
    if isinstance(n, bool) or not isinstance(n, int) or abs(n) > ENTERO_MAXIMO:
        raise ValueError('numero fuera del rango verificable: rechazar, no adivinar')
    return str(n)


def canonico(v):
    if v is None:
        return 'null'
    if isinstance(v, bool):
        return 'true' if v else 'false'
    if isinstance(v, str):
        return texto(v)
    if isinstance(v, (int, float)):
        return numero(v)
    if isinstance(v, list):
        return '[' + ','.join(canonico(x) for x in v) + ']'
    if isinstance(v, dict):
        # Orden por UNIDAD DE CODIGO UTF-16, que es el de JavaScript.
        claves = sorted(v, key=lambda k: k.encode('utf-16-be'))
        return '{' + ','.join(texto(k) + ':' + canonico(v[k]) for k in claves) + '}'
    raise ValueError('tipo no verificable')


def sello(v):
    return hashlib.sha256(canonico(v).encode('utf-8')).hexdigest()


def sello_de_corte(hashes):
    # EL CORTE DEL RAT SE SELLA CON OTRA CUENTA: el sha256 de los hashes de
    # version unidos por un salto de linea, en el orden de la cadena. No hay
    # canonicalizacion acá. Aplicarle la de arriba da otro hash y declara
    # adulterado un RAT intacto.
    if not hashes:
        raise ValueError('un corte vacio no existe: se esperan sha256 en hexadecimal')
    for h in hashes:
        if not isinstance(h, str) or len(h) != 64 or any(c not in '0123456789abcdefABCDEF' for c in h):
            raise ValueError('la foto de un corte son sha256 en hexadecimal: rechazar, no adivinar')
    return hashlib.sha256('\n'.join(hashes).encode('utf-8')).hexdigest()


if __name__ == '__main__':
    dato = json.loads(sys.stdin.read())
    # Un sobre de exportacion de Datrus trae el sello adentro.
    if isinstance(dato, dict) and isinstance(dato.get('sello'), dict):
        dato = dato['sello']
    # La forma del material dice cual de los dos sellos es: la foto de un corte
    # es una lista de hashes, la de una evidencia no.
    if isinstance(dato, dict) and isinstance(dato.get('hashes'), list):
        print(sello_de_corte(dato['hashes']))
    else:
        print(sello(dato))

El límite declarado

La canonicalización escribe cada número con la notación de JavaScript, y para magnitudes extremas esa notación no es igual entre lenguajes: dos textos distintos dan dos sha256 distintos sin que nadie haya tocado nada. Hoy no es alcanzable —los sellos de este módulo sólo llevan enteros chicos y las fechas van como texto ISO—, pero se declara igual, porque un límite que no está escrito reaparece como un hash que no cuadra y nadie sabe por qué. Un verificador correcto, el nuestro incluido, RECHAZA un número así en vez de adivinar su notación.

Verificar un documento sellado — Datrus