Esquema de firmaSignature scheme

Nota del traductor: este documento es informativo. Es una traducción al español del documento normativo en inglés "The DS1 token scheme". Ante cualquier discrepancia, el texto en inglés gobierna.

El esquema de token DS1

1. Alcance

Esta página especifica el formato a nivel de bytes de un token DS1:, de modo que una implementación independiente pueda decodificarlo, interpretarlo y verificarlo criptográficamente sin acceso al código fuente de este sistema. El §6 establece con precisión qué demuestra una verificación offline exitosa y qué no.

2. Gramática del token

token       = "DS1:" base45-payload
base45-payload = <codificación Base45 (RFC 9285) de deflated-cose>
deflated-cose  = <compresión DEFLATE en bruto (RFC 1951) de cose-bytes>
cose-bytes     = <codificación CBOR (RFC 8949) de una estructura COSE_Sign, etiqueta CBOR 98, según RFC 9052>
  • DS1: es un prefijo de esquema ASCII literal, fuera de la carga útil Base45 y fuera de cualquier cosa que se hashee o firme. DS identifica el esquema ("DicomServer"); 1 es la versión del esquema.
  • Un decodificador DEBE rechazar cualquier token que no comience exactamente con este prefijo, antes de intentar cualquier decodificación Base45 o inflado — fallar de forma cerrada ante una versión de esquema desconocida o futura, en lugar de intentar un análisis best-effort.
  • Después del prefijo, los caracteres restantes se decodifican como Base45 (RFC 9285) y luego se descomprimen como un flujo DEFLATE en bruto (RFC 1951 — sin encabezado ni remolque zlib o gzip).
  • Acotar el inflado. Un decodificador DEBE limitar el tamaño máximo inflado y rechazar el token si la descompresión lo superaría, en lugar de asignar un búfer sin límite. El límite de la implementación de referencia es de 8192 bytes; las cargas útiles de código de barras reales están, en la práctica, muy por debajo de 2 KiB. Un decodificador también DEBE tratar un resultado inflado que alcanza exactamente el límite como ambiguo con una carga útil truncada/bomba y rechazarlo, en lugar de aceptar un objeto posiblemente truncado.

3. Estructura del COSE_Sign

Los bytes descomprimidos son una estructura COSE_Sign codificada en CBOR (RFC 9052 §2, mensaje de "múltiples firmantes", etiqueta CBOR 98):

COSE_Sign = [
    body_protected : bstr,       ; encabezado protegido codificado en CBOR para el cuerpo
    body_unprotected : {},       ; siempre el mapa vacío
    payload : bstr,              ; cadena de bytes CBOR que contiene el documento JSON firmado
    signatures : [+ COSE_Signature]
]

COSE_Signature = [
    sign_protected : bstr,       ; encabezado protegido codificado en CBOR para esta firma
    sign_unprotected : {},       ; siempre el mapa vacío
    signature : bstr             ; ECDSA r||s en bruto de 64 bytes
]
  • body_protected decodifica al mapa CBOR { 4: kid }, donde 4 es la etiqueta de encabezado 4 de RFC 9052 §3.1 para el identificador de clave y kid es una cadena de bytes CBOR: la huella SHA-256 en bruto (32 bytes) del certificado hoja del firmante, calculada sobre la codificación DER del certificado. Ver §4 sobre cómo un verificador resuelve esta huella de vuelta a un certificado.

  • Los mapas de encabezado no protegido — tanto a nivel del cuerpo como de cada firma — son siempre el mapa CBOR vacío {} en este esquema. Ningún claim se transporta jamás sin proteger.

  • payload es una cadena de bytes CBOR cuyo contenido es un documento JSON en UTF-8, canonicalizado según JCS (RFC 8785, JSON Canonicalization Scheme) antes de envolverse en la cadena de bytes. La forma campo por campo de este payload se documenta por separado (ver la página de referencia de claims COSE publicada junto a esta).

  • signatures es un arreglo no vacío: un elemento por cada cofirmante del mismo payload, todos producidos con la misma clave de firma ES256 del hospital. El sign_protected de cada elemento decodifica a un mapa CBOR cuyo primer claim es { 1: -7 } (etiqueta de encabezado 1 de RFC 9052 §3.1 = algoritmo; -7 es ES256 según RFC 9053 §2.1), seguido de los claims de atestación de ese firmante (nombre, código de propósito de firma, correo electrónico, marca de tiempo, año, secuencia, id de registro, y los claims opcionales de cédula/designación — tabla completa en la página de referencia de claims COSE).

  • signature es la firma ECDSA en bruto de 64 bytes (r de 32 bytes concatenado con s de 32 bytes), no ASN.1 DER. Se calcula sobre la Sig_structure de RFC 9052 §4.4 para ese elemento de firma:

    Sig_structure = [
        "Signature",              ; cadena de contexto, fija
        body_protected,           ; bstr, los mismos bytes de arriba
        sign_protected,           ; bstr, el encabezado protegido de este elemento de firma
        external_aad,             ; bstr, siempre vacío (longitud cero) en este esquema
        payload                   ; bstr, los mismos bytes de arriba
    ]
    

    la codificación CBOR de ese arreglo de 5 elementos es lo que se firma con ECDSA (resumen SHA-256, curva P-256, es decir, ES256).

4. Resolución de KID a certificado

Un verificador resuelve el kid en body_protected a un certificado real de la siguiente manera:

  1. Para cada elemento de firma, lee el claim y de ese firmante (el año UTC de la firma, un entero) de su encabezado sign_protected.
  2. Descarga https://pki.dicomserver.com/leaf-bundle-<y>.pem — un paquete PEM con todos los certificados hoja que estuvieron vigentes para firmar durante ese año calendario.
  3. Calcula la huella SHA-256 de la codificación DER de cada certificado del paquete y selecciona el que coincide con kid byte a byte.
  4. Encadena ese certificado hoja a https://pki.dicomserver.com/ca-bundle.pem — el paquete de confianza acumulativo de cada Autoridad Certificadora de la que este sistema haya emitido certificados hoja alguna vez. Los certificados CA de este paquete usan ECDSA P-384/SHA-384; los certificados hoja firman con ES256 (ECDSA P-256/SHA-256).

Un verificador que no pueda alcanzar ninguno de los dos paquetes (sin red) todavía puede validar la firma aritméticamente contra un certificado hoja obtenido por otros medios (por ejemplo, suministrado directamente), pero no puede establecer confianza en ese certificado sin la cadena — ver §6.

5. El claim de propósito de firma (r)

El claim r en el encabezado protegido de cada firmante transporta un código de propósito de firma ASTM E1762-95(2013) ("Standard Guide for Electronic Authentication of Health Care Information") — el mismo vocabulario que subyace al CID 7007 de DICOM y al conjunto de valores signature-type de FHIR (urn:iso-astm:E1762-95:2013).

Por compacidad del código de barras, el claim transporta solo el segmento hoja del OID — por ejemplo, "1" para Firma de autor, "13" para Firma de revisión — bajo la raíz fija y universalmente conocida 1.2.840.10065.1.12.1. Un verificador reconstruye el OID completo anexando la hoja a la raíz; la raíz misma nunca se transmite.

El vocabulario completo de 18 códigos, y el subconjunto que la interfaz de firma de este sistema realmente ofrece a un firmante humano, se publica como datos estructurados (astm-e1762-codes.json) y se renderiza como tabla en este sitio. Un valor del claim r que no aparece en esa tabla es un rol heredado en texto libre, anterior a la adopción del vocabulario ASTM por parte de este sistema; dichos valores se muestran al lector literalmente, sin resolverse a una etiqueta.

6. Declaración de alcance — qué demuestra la verificación offline y qué no

Una verificación offline exitosa de un token DS1: — decodificación Base45, inflado, análisis del COSE_Sign, resolución de KID, verificación ECDSA por firmante y (si hay red) validación de la cadena contra un paquete CA de confianza — establece:

  • Quién atestiguó los bytes exactos del payload transportado en este token (el nombre, código de propósito de firma, correo electrónico y marca de tiempo de cada firmante), vinculado criptográficamente a esos bytes.
  • Que esos bytes exactos del payload — los identificadores de hallazgo, los hashes de contenido, las referencias de estudio/paciente y el nombre del hospital incrustados en el payload — fueron firmados, sin modificaciones desde la firma.
  • En qué carácter actuó cada firmante, mediante el claim r (§5).

No establece:

  • Que el contenido firmado sea la versión vigente/más reciente del informe. El finding.version y el finding.sha256 del payload son una instantánea al momento de la firma; si el documento vigente ha sido editado, corregido o reemplazado desde entonces solo se puede saber consultando la página de validación en línea de ese registro, que vuelve a leer la base de datos vigente y recalcula un hash fresco para comparar. Un verificador offline no tiene ningún canal hacia esa información.
  • El estado de revocación del certificado firmante. Este esquema no define ni consulta una CRL ni un respondedor OCSP; que una cadena valide estructuralmente contra el paquete CA no garantiza por ello que el certificado involucrado no haya sido revocado desde entonces.

Para certeza sobre vigencia o revocación, usar la página de validación en línea: el token offline es una prueba de atestación e integridad para los bytes que transporta, no un oráculo del documento en vivo.

The DS1 token scheme

Normative. Reference specification for the DS1: offline signature token printed on report footers (and its QR rendering). An informative Spanish translation is published alongside it; where the two disagree, this English text governs.

1. Scope

This page specifies the byte-level format of a DS1: token so that an independent implementation can decode, parse, and cryptographically verify one without access to this system's source code. §6 states precisely what a successful offline verification proves, and what it does not.

2. Token grammar

token       = "DS1:" base45-payload
base45-payload = <Base45 (RFC 9285) encoding of deflated-cose>
deflated-cose  = <raw DEFLATE (RFC 1951) compression of cose-bytes>
cose-bytes     = <CBOR (RFC 8949) encoding of a COSE_Sign structure, CBOR tag 98, per RFC 9052>
  • DS1: is a literal ASCII scheme prefix, outside the Base45 payload and outside anything that is ever hashed or signed. DS identifies the scheme ("DicomServer"); 1 is the scheme version.
  • A decoder MUST reject any token that does not start with exactly this prefix, before attempting any Base45 decoding or inflation — fail closed on an unrecognized or future scheme version rather than attempt best-effort parsing.
  • After the prefix, the remaining characters are decoded as Base45 (RFC 9285), then decompressed as a raw DEFLATE stream (RFC 1951 — no zlib or gzip header/trailer).
  • Bound the inflation. A decoder MUST cap the maximum inflated size and reject the token if decompression would exceed it, rather than allocating an unbounded buffer. The reference implementation's cap is 8192 bytes; actual barcode payloads are well under 2 KiB in practice. A decoder MUST also treat an inflated result that reaches exactly the cap as ambiguous with a truncated/bomb payload and reject it, rather than accept a possibly-truncated object.

3. COSE_Sign layout

The decompressed bytes are a CBOR-encoded COSE_Sign structure (RFC 9052 §2, "multiple signer" message, CBOR tag 98):

COSE_Sign = [
    body_protected : bstr,       ; CBOR-encoded protected header for the body
    body_unprotected : {},       ; always the empty map
    payload : bstr,              ; CBOR byte string containing the signed JSON document
    signatures : [+ COSE_Signature]
]

COSE_Signature = [
    sign_protected : bstr,       ; CBOR-encoded protected header for this signature
    sign_unprotected : {},       ; always the empty map
    signature : bstr             ; 64-byte raw ECDSA r||s
]
  • body_protected decodes to the CBOR map { 4: kid }, where 4 is the RFC 9052 §3.1 header label for the key identifier and kid is a CBOR byte string: the raw SHA-256 fingerprint (32 bytes) of the signer's leaf certificate, computed over the certificate's DER encoding. See §4 for how a verifier resolves this fingerprint back to a certificate.

  • Unprotected header maps — both at the body level and at each signature level — are always the empty CBOR map {} in this scheme. No claims are ever carried unprotected.

  • payload is a CBOR byte string whose contents are a UTF-8 JSON document, canonicalized per JCS (RFC 8785, JSON Canonicalization Scheme) before being wrapped in the byte string. The field-by-field shape of this payload is documented separately (see the COSE claims reference page published alongside this one).

  • signatures is a non-empty array: one element per co-signer of the same payload, all produced by the same hospital's ES256 signing key. Each element's sign_protected decodes to a CBOR map whose first claim is { 1: -7 } (RFC 9052 §3.1 header label 1 = algorithm; -7 is ES256 per RFC 9053 §2.1), followed by that signer's attestation claims (name, purpose-of-signature code, email, timestamp, year, sequence, record id, and the optional license/designation claims — full table on the COSE claims reference page).

  • signature is the raw 64-byte ECDSA signature (32-byte r concatenated with 32-byte s), not ASN.1 DER. It is computed over the RFC 9052 §4.4 Sig_structure for that signature element:

    Sig_structure = [
        "Signature",              ; context string, fixed
        body_protected,           ; bstr, same bytes as above
        sign_protected,           ; bstr, this signature element's protected header
        external_aad,             ; bstr, always empty (zero-length) in this scheme
        payload                   ; bstr, same bytes as above
    ]
    

    the CBOR encoding of that 5-element array is what gets ECDSA-signed (SHA-256 digest, P-256 curve, i.e. ES256).

4. KID-to-certificate resolution

A verifier resolves the kid in body_protected to an actual certificate as follows:

  1. For each signature element, read that signer's y claim (the UTC year of signing, an integer) from its sign_protected header.
  2. Fetch https://pki.dicomserver.com/leaf-bundle-<y>.pem — a PEM bundle of every leaf certificate that was live for signing during that calendar year.
  3. Compute the SHA-256 fingerprint of each certificate's DER encoding in the bundle and select the one that matches kid byte-for-byte.
  4. Chain that leaf certificate to https://pki.dicomserver.com/ca-bundle.pem — the cumulative trust bundle of every Certificate Authority this system has ever issued leaves from. CA certificates in this bundle use ECDSA P-384/SHA-384; leaf certificates sign with ES256 (ECDSA P-256/SHA-256).

A verifier that cannot reach either bundle (no network) can still validate the signature arithmetically against a leaf certificate obtained by other means (e.g. supplied directly), but cannot establish trust in that certificate without the chain — see §6.

5. The purpose-of-signature claim (r)

Claim r in each signer's protected header carries an ASTM E1762-95(2013) "Standard Guide for Electronic Authentication of Health Care Information" purpose-of-signature code — the same vocabulary underlying DICOM CID 7007 and the FHIR signature-type value set (urn:iso-astm:E1762-95:2013).

For barcode compactness, the claim carries only the leaf segment of the OID — e.g. "1" for Author's Signature, "13" for Review Signature — under the fixed and universally known root 1.2.840.10065.1.12.1. A verifier reconstructs the full OID by appending the leaf to the root; the root itself is never transmitted.

The full 18-code vocabulary, and which subset this system's signing UI actually offers to a human signer, is published as structured data (astm-e1762-codes.json) and rendered as a table on this site. A value of claim r that does not appear in that table is a legacy free-text role from before this system adopted the ASTM vocabulary; such values are displayed to a viewer verbatim, not resolved to a label.

6. Scope statement — what offline verification proves, and does not

A successful offline verification of a DS1: token — Base45 decode, inflate, COSE_Sign parse, KID resolution, per-signer ECDSA verification, and (network permitting) chain validation to a trusted CA bundle — establishes:

  • Who attested the exact payload bytes carried in this token (each signer's name, purpose-of-signature code, email, and timestamp), cryptographically bound to those bytes.
  • That these exact payload bytes — the finding identifiers, content hashes, study/patient references, and hospital name embedded in the payload — were signed, unmodified since signing.
  • In what capacity each signer acted, via the r claim (§5).

It does not establish:

  • That the signed content is the current/latest version of the report. The payload's finding.version and finding.sha256 are a snapshot as of signing time; whether the live document has since been edited, corrected, or superseded is knowable only by consulting the online validation page for that record, which re-reads the live database and recomputes a fresh hash for comparison. An offline verifier has no channel to that information.
  • Revocation status of the signing certificate. This scheme does not define or consult a CRL or OCSP responder; a chain that validates structurally against the CA bundle is not thereby guaranteed to involve a certificate that has not since been revoked.

For freshness or revocation certainty, use the online validation page: the offline token is an attestation-and-integrity proof for the bytes it carries, not a live-document oracle.

Claims COSECOSE claims

Encabezados COSE

clavetiporequeridosemántica
4bstr (binary)síIdentificador de clave (kid), RFC 9052 §3.1, etiqueta de encabezado 4. Se transporta en body_protected como un mapa CBOR { 4: kid }. El valor es la huella SHA-256 en bruto (32 bytes) del certificado hoja del firmante, calculada sobre la codificación DER del certificado.

Claims por firmante

clavetiporequeridosemántica
1int (COSE algorithm label)síAlgoritmo de firma, RFC 9052 §3.1, etiqueta de encabezado 1 (reservada por COSE; por eso el resto de las reclamaciones usa claves de cadena cortas). Valor fijo -7 (ES256, ECDSA P-256 con SHA-256) según RFC 9053 §2.1. Los verificadores deben tratar cualquier otro valor como inválido — este perfil nunca despacha según alg.
ntstr (UTF-8 text)síNombre impreso del firmante, tal como debe figurar junto a esta firma en cualquier lugar donde se muestre (pie de página, página de validación).
rtstr (ASTM E1762 leaf code, decimal digits)síCódigo de propósito de la firma: el segmento HOJA (por ejemplo, "1", "13") de un OID ASTM E1762-95(2013) bajo la raíz 1.2.840.10065.1.12.1. Ver la tabla de 18 códigos para el vocabulario completo. Un valor fuera de esa tabla es un rol heredado en texto libre y se muestra literalmente en lugar de resolverse a una etiqueta.
etstr (UTF-8 text)síDirección de correo electrónico del firmante, tal como quedó registrada por el proveedor de identidad en el momento de la firma.
ttstr (UTC timestamp)síMarca de tiempo de la firma, en UTC.
yint (4-digit year)síAño UTC de la firma. Un verificador lo usa para elegir qué leaf-bundle-<año>.pem anual descargar al resolver el kid a un certificado. No existe compatibilidad retroactiva: un encabezado protegido sin esta reclamación falla de forma cerrada (rechazo por defecto).
sintsíNúmero de secuencia del registro de firma al que pertenece esta atestación, dentro de la cadena de firmas del diagnóstico.
rectstr (ULID)síEl identificador propio del registro de firma (un ULID), incorporado a los bytes firmados antes de firmar, de modo que el registro no pueda sustituirse después.
ctstr (UTF-8 text)noNúmero de cédula/licencia profesional del firmante, cuando el firmante tiene uno registrado. Se omite por completo del mapa CBOR cuando no existe — nunca se codifica como un valor nulo.
dtstr (UTF-8 text)noDesignación o título profesional del firmante, cuando existe. Se omite por completo del mapa CBOR cuando no existe — nunca se codifica como un valor nulo.

Campos del payload

clavetiporequeridosemántica
payload_fields_versionintsíVersión del conjunto de campos de este payload. Actualmente 1; modificable in situ (pueden añadirse nuevos campos opcionales bajo la misma versión) hasta la primera firma de producción, después de lo cual un cambio de forma requiere en su lugar un nuevo número de versión.
diagnostic_idintsíIdentificador interno del informe diagnóstico al que pertenece este hallazgo.
study_uidtstr (DICOM UID)síUID de instancia de estudio DICOM (Study Instance UID) del estudio de imagen que cubre el informe.
accessiontstrnoNúmero de acceso (accession number), cuando el estudio tiene uno. Se omite por completo del objeto JSON cuando es nulo — nunca se emite como una clave con valor nulo — de modo que la cofirma compara los bytes del payload almacenado de forma exacta y los payloads previos a este campo conservan paridad de bytes.
hospital.nametstr (UTF-8 text)síNombre del hospital/inquilino (tenant) que emite el informe, anidado bajo "hospital".
patient.idintsíIdentificador interno de paciente, anidado bajo "patient". Es deliberadamente el único campo identificatorio de paciente: nombre, identificación nacional, correo electrónico y fecha de nacimiento nunca viajan en un código escaneable (minimización de PHI/HIPAA).
finding.idintsíIdentificador interno del hallazgo firmado, anidado bajo "finding".
finding.versionintsíNúmero de versión del contenido del hallazgo en el momento de la firma, usado por la verificación en línea para detectar que el contenido vigente cambió desde entonces ("desactualizado" frente a "firmado").
finding.updated_bytstr | nullsíIdentidad de quien actualizó por última vez el contenido del hallazgo antes de la firma, o null. Siempre está presente como clave, incluso cuando es null (a diferencia de accession/text_sha256).
finding.sha256tstr (lowercase hex, 64 chars)síSHA-256 sobre los bytes HTML en bruto tal como se almacenó el hallazgo — el ancla principal de integridad de contenido. La verificación en línea lo recalcula a partir del contenido vigente; no es derivable a partir de una impresión (un lector que transcribe la página impresa no puede reproducir HTML exacto byte a byte).
finding.text_sha256tstr (lowercase hex, 64 chars) | omittednoSHA-256 sobre el texto canónico DS Text Profile v1 del hallazgo — un resumen (digest) complementario derivable de la impresión que quien solo tiene la página impresa puede recalcular por transcripción (ver la página de DS Text Profile v1). Se omite por completo del objeto JSON cuando es nulo — nunca se emite como una clave con valor nulo — por la misma razón de paridad de bytes en la cofirma que accession.

COSE headers

keytyperequiredsemantics
4bstr (binary)yesKey identifier (kid), RFC 9052 §3.1 header label 4. Carried in body_protected as a CBOR map { 4: kid }. Value is the raw SHA-256 fingerprint (32 bytes) of the signer's leaf certificate, computed over the DER encoding of the certificate.

Per-signer claims

keytyperequiredsemantics
1int (COSE algorithm label)yesSignature algorithm, RFC 9052 §3.1 header label 1 (reserved by COSE, hence every other claim below uses a short string key instead). Fixed value -7 (ES256, ECDSA P-256 with SHA-256) per RFC 9053 §2.1. Verifiers must treat any other value as invalid — this profile never dispatches on alg.
ntstr (UTF-8 text)yesSigner's printed name, as it must appear alongside this signature wherever the signature is displayed (footer, validation page).
rtstr (ASTM E1762 leaf code, decimal digits)yesPurpose-of-signature code: the LEAF segment (e.g. "1", "13") of an ASTM E1762-95(2013) OID under root 1.2.840.10065.1.12.1. See the 18-code table for the full vocabulary. A value outside that table is a legacy free-text role and is displayed verbatim rather than resolved to a label.
etstr (UTF-8 text)yesSigner's email address, as recorded by the identity provider at signing time.
ttstr (UTC timestamp)yesSigning timestamp, UTC.
yint (4-digit year)yesUTC year of signing. Used by a verifier to select which annual leaf-bundle-<year>.pem to fetch when resolving the kid to a certificate. There is no legacy fallback: a protected header without this claim fails closed.
sintyesSequence number of the signature record this attestation belongs to, within the diagnostic's signature chain.
rectstr (ULID)yesThe signature record's own identifier (a ULID), bound into the signed bytes before signing so the record cannot be substituted after the fact.
ctstr (UTF-8 text)noSigner's professional license/ID number ("cédula"), when the signer has one on file. Omitted entirely from the CBOR map when absent — never encoded as a null value.
dtstr (UTF-8 text)noSigner's professional designation/title, when present. Omitted entirely from the CBOR map when absent — never encoded as a null value.

Payload fields

keytyperequiredsemantics
payload_fields_versionintyesVersion of this payload's field set. Currently 1; amendable in-place (new optional fields may be added under the same version) until the first production signature, after which a shape change requires a new version number instead.
diagnostic_idintyesInternal identifier of the diagnostic report this finding belongs to.
study_uidtstr (DICOM UID)yesDICOM Study Instance UID of the imaging study the report covers.
accessiontstrnoAccession number, when the study has one. Omitted entirely from the JSON object when null — never emitted as a null-valued key — so co-signing compares stored payload bytes exactly and pre-field payloads keep byte parity.
hospital.nametstr (UTF-8 text)yesName of the hospital/tenant issuing the report, nested under "hospital".
patient.idintyesInternal patient identifier, nested under "patient". Deliberately the only patient-identifying field: name, national ID, email and date of birth never ride in a scannable code (HIPAA/PHI minimization).
finding.idintyesInternal identifier of the signed finding, nested under "finding".
finding.versionintyesVersion number of the finding's content at signing time, used by online verification to detect that the live content has since changed ("outdated" vs. "signed").
finding.updated_bytstr | nullyesIdentity of whoever last updated the finding's content before signing, or null. Always present as a key, even when null (unlike accession/text_sha256).
finding.sha256tstr (lowercase hex, 64 chars)yesSHA-256 over the raw stored HTML bytes of the finding — the primary content-integrity anchor. Recomputed from live content by online verification; not print-derivable (a reader transcribing the printed page cannot reproduce byte-exact HTML).
finding.text_sha256tstr (lowercase hex, 64 chars) | omittednoSHA-256 over the DS Text Profile v1 canonical text of the finding — a print-derivable companion digest an offline holder of only the printed page can recompute by transcription (see the DS Text Profile v1 page). Omitted entirely from the JSON object when null — never emitted as a null-valued key — for the same co-signing byte-parity reason as accession.

Nota del traductor: este documento es informativo. Es una traducción al español del documento normativo en inglés "DS Text Profile v1 — canonical text algorithm". Ante cualquier discrepancia, el texto en inglés gobierna.

Regla de verdad de referencia: una implementación que falle cualquier vector publicado (§3) es no conforme. Una discrepancia significa que la implementación está mal — nunca significa que el vector está mal.

DS Text Profile v1 — algoritmo de texto canónico

1. Propósito

finding.text_sha256 es un resumen (digest) complementario al hash de bytes en bruto del payload, finding.sha256, calculado a partir de una extracción de texto canónico del mismo contenido — reproducible por un lector que transcribe la página impresa (a mano o vía OCR) y hashea el resultado para compararlo contra el valor transportado en el token.

Versionado por diseño: cualquier cambio futuro a este algoritmo se publica como un nuevo nombre de campo (text_sha256_v2, …), nunca como una redefinición silenciosa de text_sha256.

2. Algoritmo de texto canónico

El perfil compone estándares NOMBRADOS, cada uno citado para que una reimplementación independiente pueda converger en los mismos bytes sin leer el código fuente de ninguna implementación de referencia en particular. Los pasos se ejecutan en este orden exacto:

  1. Analizar (parse) — un análisis HTML5 permisivo del contenido almacenado del hallazgo. Permisivo: el marcado malformado nunca lanza una excepción, se analiza de la mejor manera posible como HTML5, tal como lo haría un navegador. Una reimplementación DEBE usar un analizador HTML5 igualmente permisivo y conforme a la especificación (por ejemplo, el propio DOMParser de un navegador, o una biblioteca que implemente el algoritmo de construcción de árbol HTML5 de WHATWG) — un analizador estricto/XML que rechace HTML malformado del mundo real es no conforme.

  2. Extraer — una extracción INSPIRADA en innerText, definida normativamente por ESTE perfil y sus vectores de prueba trabajados (§3) — NO por la implementación de innerText de ningún navegador. NO reutilizar el getter innerText de un navegador, ni una API DOM equivalente a un navegador, para reimplementar este paso; seguir las reglas de abajo exactamente, ya que los vectores publicados son la verdad de referencia para la conformidad, no el comportamiento real (dependiente del layout) de innerText.

    Recorrer el árbol analizado en orden de documento, emitiendo el contenido de los nodos de texto. Cada límite de nivel de bloque emite exactamente un separador de espacio ASCII (no un salto de línea — la salida del perfil es una única línea unida por espacios, colapsada aún más en el paso 6). La lista de elementos de bloque implementada es:

    p, li, td, th, tr, br, h1–h6, más div, ul, ol, table (defensivo — estos rara vez llevan texto directo, pero son límites si lo hacen).

    Todos los demás elementos (span, strong, em, a en línea, etc.) son en línea: no se emite ningún separador en su límite, de modo que fragmentos en línea adyacentes se unen sin espacio forzado. Los subárboles script y style se excluyen por completo — su contenido de texto nunca llega al paso de extracción. Los elementos img no contribuyen nada — tampoco el texto alt — según la regla de derivabilidad desde impresión: solo lo que un lector puede transcribir del papel participa en el resumen.

    Divergencias respecto al innerText del navegador (normativas — no es un comportamiento equivalente a innerText):

    • El separador de bloque se emite solo DESPUÉS del contenido de nivel de bloque, nunca según la posición de renderizado — este perfil no consulta el layout.
    • El CSS/estilo se ignora por completo: el texto con display:none / visibility:hidden sigue participando en el resumen, a diferencia del innerText real de un navegador, consciente del layout, que lo excluye.
    • Las imágenes y su texto alt se excluyen por completo.
  3. Depurar UTF-8 inválido — aplicar una conversión UTF-8-a-UTF-8 que "ignora bytes inválidos" (implementación de referencia: iconv('UTF-8', 'UTF-8//IGNORE', $text) de PHP). Una reimplementación DEBE descartar por completo las secuencias de bytes inválidas, nunca sustituirlas por un carácter de reemplazo — independientemente de cualquier configuración de carácter sustituto por servidor, de modo que la misma entrada inválida canonicaliza de forma idéntica en cualquier servidor. Un fallo total de esta conversión se trata como cadena vacía. Si la normalización NFKC (paso 4) todavía devuelve un fallo después de esto, el resumen es null.

  4. Normalización Unicode: NFKC (Unicode Standard Annex #15) — implementación de referencia: Normalizer::normalize($text, Normalizer::FORM_KC) (ext-intl de PHP). El plegado de compatibilidad maximiza la robustez de ida-y-vuelta impresión/OCR (²→2, ligaduras, formas de ancho completo).

  5. Plegado de mayúsculas/minúsculas: Unicode Default Case Folding (completo), independiente de locale — implementación de referencia: mb_convert_case($text, MB_CASE_FOLD, 'UTF-8') (ext-mbstring de PHP). Es necesario porque el estilo text-transform de CSS puede hacer que las mayúsculas/minúsculas impresas difieran de los bytes almacenados. La independencia de locale es deliberada: la İ mayúscula con punto turca se pliega a "i" + punto combinante superior (U+0307) según el plegado de mayúsculas/minúsculas por defecto de Unicode, no según la regla turca de i sin punto. Una reimplementación DEBE usar un plegado de mayúsculas/minúsculas Unicode independiente de locale/por defecto, no una función de mayúsculas/minúsculas consciente de locale, o divergirá en entradas en turco/alemán/etc. El orden plegado-después-de-NFKC es cerrado/idempotente según "Character Model for the World Wide Web: String Matching" (charmod-norm) del W3C.

  6. Colapso de espacios en blanco — colapsar cada racha que coincida con la propiedad Unicode White_Space (PCRE /[\p{White_Space}]+/u, incluye &nbsp;/U+00A0, tabulaciones, saltos de línea) a un único espacio ASCII; recortar el espacio inicial/final. Una reimplementación DEBE usar la propiedad White_Space completa, no una clase de espacio en blanco solo-ASCII o la que trae por defecto un lenguaje de programación (ver la nota de portabilidad de clase de espacio en blanco en §4).

  7. Resumen (digest) — hash('sha256', $canonicalText) (PHP) sobre los bytes UTF-8 del resultado del paso 6, en hexadecimal en minúsculas — el mismo primitivo usado para el hash de contenido en bruto finding.sha256.

Manejo de null/vacío (exacto a la implementación, no solo una convención)

  • digest(null) → null (no hay contenido de hallazgo alguno que resumir).
  • Una entrada vacía o solo-espacios-en-blanco (no nula) → el hash SHA-256 de la cadena vacía: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Esto es deliberadamente distinto del caso null: significa "el hallazgo existe y su contenido de texto está vacío", un hecho diferente de "no hay hallazgo".

Orden de linealización de tablas

El orden de linealización de tablas (el orden en que las celdas/filas se serializan en el flujo de texto) DEBE coincidir con el orden de lectura renderizado (por filas) — una tabla linealizada por columnas, o en cualquier orden específico del analizador, haría que text_sha256 divergiera entre dos analizadores HTML5 conformes aunque un lector humano vea el mismo texto. Cualquier implementación DEBE incluir fixtures explícitos de tablas en su suite de pruebas (ver el vector #2 y el vector #11 en §3).

3. Corpus de vectores — la verdad de referencia para la conformidad

La única fuente de verdad para la conformidad es un archivo legible por máquina, vectors.json, publicado junto a esta página: 14 vectores, cada uno { label, input_b64, canonical_text, sha256 }, donde input_b64 es la codificación en base64 de los bytes de entrada en bruto (de modo que el vector de UTF-8 inválido sobreviva el transporte como JSON). Cualquier implementación independiente de DS Text Profile v1 — incluido el propio validador offline de este sitio — DEBE reproducir el canonical_text y el sha256 de cada vector byte a byte dado el mismo input_b64.

El corpus se divide en 5 vectores mínimos por característica (una regla aislada por vector: separadores de bloque, linealización de tabla por filas, exclusión de script/style, normalización NFKC, manejo de UTF-8 inválido) y 9 vectores con la forma de la salida real del editor de texto enriquecido de este producto (marcas en línea anidadas, tablas, una imagen con data: URI, estilos, espaciadores).

La depuración de UTF-8 inválido (paso 3) es un no-op para los catorce vectores: todos los input_b64 ya son UTF-8 válido, o — para el vector dedicado de UTF-8 inválido — ya reducido a U+FFFD por el análisis del paso 1.

4. Requisito del modelo de analizador

Los vectores del corpus son todos marcado bien formado. La entrada malformada (<p>/<li>/<td> sin cerrar, </br> suelto, texto reubicado ("foster-parented") fuera de una <table>) es donde divergen las implementaciones: un analizador HTML5 de construcción de árbol WHATWG completo resuelve <p>Foo<p>Bar</p> a foo bar, coincidiendo con el orden de lectura humano; un analizador de solo tokenizador (basado en eventos, sin construcción de árbol) lo resuelve a foobar — un separador faltante, no solo una diferencia de espacio en blanco: el resumen mismo cambia.

Consecuencia: cualquier implementación de este perfil, incluido un verificador offline que consuma esta página, DEBE reimplementar §2 sobre un analizador HTML5 de construcción de árbol real — uno que ejecute el algoritmo de construcción de árbol de WHATWG, no un mero tokenizador/analizador basado en eventos — o divergirá silenciosamente en marcado malformado (pero común, del mundo real). Una implementación de solo tokenizador es útil solo como verificación cruzada portable contra los vectores bien formados, nunca como oráculo de conformidad para HTML almacenado arbitrario.

Dos matices de portabilidad:

  • Clase de espacio en blanco. La comprobación integrada de "esto es espacio en blanco" de la mayoría de los lenguajes de programación no equivale exactamente a la propiedad Unicode White_Space (las diferencias incluyen caracteres de control, NEL U+0085 y U+FEFF según el entorno de ejecución). Una implementación debería codificar explícitamente los 25 puntos de código White_Space (U+0009–000D, 0020, 0085, 00A0, 1680, 2000–200A, 2028, 2029, 202F, 205F,
    1. en lugar de depender del atajo nativo de un lenguaje.
  • Plegado de mayúsculas/minúsculas completo. La función de conversión de mayúsculas/minúsculas por defecto de algunos entornos de ejecución es solo plegado simple de mayúsculas/minúsculas (por ejemplo, la ß alemana permanece como ß en lugar de plegarse hacia ss), no el plegado completo por defecto de Unicode que este perfil requiere (paso 5). Una implementación cuya función nativa de mayúsculas/minúsculas sea solo simple necesita una tabla o biblioteca explícita de plegado completo de mayúsculas/minúsculas.

DS Text Profile v1 — canonical text algorithm

Normative. The canonical text algorithm ("DS Text Profile v1") behind the finding.text_sha256 claim carried in a signed COSE payload (see the COSE claims reference). An informative Spanish translation is published alongside it; where the two disagree, this English text governs.

Ground-truth rule: an implementation that fails any published vector (§3) is nonconforming. A mismatch means the implementation is wrong — it never means a vector is wrong.

1. Purpose

finding.text_sha256 is a companion digest to the payload's raw-bytes finding.sha256, computed from a canonical text extraction of the same content — reproducible by a reader transcribing the printed page (by hand or via OCR) and hashing the result to compare against the value carried in the token.

Versioned by design: any future change to this algorithm is published as a new field name (text_sha256_v2, …), never a silent redefinition of text_sha256.

2. Canonical text algorithm

The profile composes NAMED standards, each cited so that an independent re-implementation can converge on the same bytes without reading any single reference implementation's source. Steps run in this exact order:

  1. Parse — a lenient HTML5 parse of the stored finding content. Lenient: malformed markup never throws, it parses as best-effort HTML5, the way a browser would. A re-implementation MUST use an equivalently lenient, spec-compliant HTML5 parser (e.g. a browser's own DOMParser, or a library implementing the WHATWG HTML5 tree-construction algorithm) — a strict/XML parser that rejects real-world malformed HTML is nonconforming.

  2. Extract — an innerText-INSPIRED extraction, normatively defined by THIS profile and its worked test vectors (§3) — NOT by any browser's innerText implementation. Do NOT reuse a browser's innerText getter, or a browser-equivalent DOM API, to re-implement this step; follow the rules below exactly, since the published vectors are the ground truth for conformance, not innerText's actual (layout-dependent) behavior.

    Walk the parsed tree in document order, emitting text node contents. Block-level boundaries each emit exactly one ASCII space separator (not a newline — the profile's output is a single space-joined line, collapsed further in step 6). The implemented block-element list is:

    p, li, td, th, tr, br, h1–h6, plus div, ul, ol, table (defensive — these rarely carry direct text but are boundaries if they do).

    Every other element (span, strong, em, inline a, etc.) is inline: no separator is emitted at its boundary, so adjacent inline runs join with no forced space. script and style subtrees are excluded entirely — their text content never reaches the extraction step. img elements contribute nothing — no alt text either — per the print-derivability rule: only what a reader can transcribe from paper participates in the digest.

    Divergences from browser innerText (normative — not innerText-equivalent behavior):

    • The block separator is emitted only AFTER block-level content, never based on rendered layout position — this profile does not consult layout.
    • CSS/styling is entirely ignored: display:none / visibility:hidden text still participates in the digest, unlike a real browser's layout-aware innerText, which excludes it.
    • Images and their alt text are excluded entirely.
  3. Scrub invalid UTF-8 — apply an "ignore invalid bytes" UTF-8-to-UTF-8 conversion (reference implementation: PHP's iconv('UTF-8', 'UTF-8//IGNORE', $text)). A re-implementation MUST drop invalid byte sequences entirely, never substitute them with a replacement character — independent of any per-server substitution-character configuration, so the same invalid input canonicalizes identically on any server. A total failure of this conversion is treated as the empty string. If NFKC normalization (step 4) still returns a failure afterward, the digest is null.

  4. Unicode normalization: NFKC (Unicode Standard Annex #15) — reference implementation: Normalizer::normalize($text, Normalizer::FORM_KC) (PHP ext-intl). Compatibility folding maximizes print/OCR round-trip robustness (²→2, ligatures, full-width forms).

  5. Case folding: Unicode Default Case Folding (full), locale-independent — reference implementation: mb_convert_case($text, MB_CASE_FOLD, 'UTF-8') (PHP ext-mbstring). Required because CSS text-transform styling can make the printed case differ from the stored bytes. Locale-independent is deliberate: Turkish dotted capital İ folds to "i" + combining dot above (U+0307) per Unicode default case folding, not the Turkish-locale dotless-i rule. A re-implementation MUST use locale-independent/default Unicode case folding, not a locale-aware casing function, or it will diverge on Turkish/German/etc. input. Casefold-after-NFKC ordering is closed/idempotent per the W3C's "Character Model for the World Wide Web: String Matching" (charmod-norm).

  6. Whitespace collapse — collapse every run matching the Unicode White_Space property (PCRE /[\p{White_Space}]+/u, includes &nbsp;/U+00A0, tabs, newlines) to a single ASCII space; trim leading/trailing space. A re-implementation MUST use the full White_Space property, not an ASCII-only or language-runtime-default whitespace class (see §4's whitespace-class portability note).

  7. Digest — hash('sha256', $canonicalText) (PHP) over the UTF-8 bytes of the result from step 6, rendered as lowercase hexadecimal — the same primitive used for the raw-bytes finding.sha256 content hash.

Null/empty handling (implementation-exact, not just a convention)

  • digest(null) → null (there is no finding content to digest at all).
  • An empty-or-whitespace-only input (non-null) → the SHA-256 hash of the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. This is deliberately distinct from the null case: it means "the finding exists and its text content is empty," which is a different fact than "there is no finding."

Table linearization order

Table linearization order (the order cells/rows serialize into the text stream) MUST match the rendered reading order (row-major) — a table linearized column-major, or in any parser-specific order, would make text_sha256 diverge between two conformant HTML5 parsers even though a human reader sees identical text. Any implementation MUST include explicit table fixtures in its test suite (see vector #2 and vector #11 in §3).

3. Vector corpus — the ground truth for conformance

The single source of truth for conformance is a machine-readable file, vectors.json, published alongside this page: 14 vectors, each { label, input_b64, canonical_text, sha256 }, where input_b64 is the base64 encoding of the raw input bytes (so the invalid-UTF-8 vector survives transport as JSON). Any independent implementation of DS Text Profile v1 — including this site's own offline validator — MUST reproduce every vector's canonical_text and sha256 byte-for-byte given the same input_b64.

The corpus splits into 5 minimal per-feature vectors (one rule isolated per vector: block separators, table row-major linearization, script/style exclusion, NFKC normalization, invalid-UTF-8 handling) and 9 vectors shaped like this product's actual rich-text editor output (nested inline marks, tables, an image with a data: URI, styling, spacers).

The invalid-UTF-8 scrub (step 3) is a no-op for all fourteen vectors: every input_b64 is already valid UTF-8, or — for the dedicated invalid-UTF-8 vector — already reduced to U+FFFD by the step-1 parse.

4. Parser-model requirement

The corpus vectors are all well-formed markup. Malformed input (unclosed <p>/<li>/<td>, stray </br>, text foster-parented out of a <table>) is where implementations diverge: a full WHATWG tree-construction HTML5 parser resolves <p>Foo<p>Bar</p> to foo bar, matching human reading order; a tokenizer-only parser (event-based, no tree construction) resolves it to foobar — a missing separator, not just a whitespace difference: the digest itself changes.

Consequence: any implementation of this profile, including an offline verifier that consumes this page, MUST re-implement §2 on a real HTML5 tree-construction parser — one that performs the WHATWG tree-construction algorithm, not a bare tokenizer/event parser — or it will silently diverge on malformed (but common, real-world) markup. A tokenizer-only implementation is useful only as a portable cross-check against the well-formed vectors, never as a conformance oracle for arbitrary stored HTML.

Two portability nuances:

  • Whitespace class. Most language runtimes' built-in "is this whitespace" check does not equal the Unicode White_Space property exactly (differences include control characters, U+0085 NEL, and U+FEFF depending on runtime). An implementation should hard-code the 25 White_Space code points (U+0009–000D, 0020, 0085, 00A0, 1680, 2000–200A, 2028, 2029, 202F, 205F, 3000) rather than rely on a language's native shortcut.
  • Full case folding. Some language runtimes' default lowercase/case-conversion function is simple case mapping only (e.g. German ß stays ß instead of folding toward ss), not the Unicode full default case fold this profile requires (step 5). An implementation whose native casing function is simple-only needs an explicit full-case-folding table or library.

Códigos de propósito de firma (ASTM E1762)Purpose-of-signature codes (ASTM E1762)

códigoOIDdescripciónfirmable
11.2.840.10065.1.12.1.1Firma de autorsí
21.2.840.10065.1.12.1.2Firma de coautorsí
31.2.840.10065.1.12.1.3Firma de coparticipanteno
41.2.840.10065.1.12.1.4Firma de transcriptorsí
51.2.840.10065.1.12.1.5Firma de verificaciónsí
61.2.840.10065.1.12.1.6Firma de validaciónsí
71.2.840.10065.1.12.1.7Firma de consentimientono
81.2.840.10065.1.12.1.8Firma de testigo de firmano
91.2.840.10065.1.12.1.9Firma de testigo del eventono
101.2.840.10065.1.12.1.10Firma de testigo de identidadno
111.2.840.10065.1.12.1.11Firma de testigo de consentimientono
121.2.840.10065.1.12.1.12Firma de intérpreteno
131.2.840.10065.1.12.1.13Firma de revisiónsí
141.2.840.10065.1.12.1.14Firma de fuenteno
151.2.840.10065.1.12.1.15Firma de adendasí
161.2.840.10065.1.12.1.16Firma de modificaciónsí
171.2.840.10065.1.12.1.17Firma administrativa (error/edición)sí
181.2.840.10065.1.12.1.18Firma de sello de tiempono
leafOIDdescriptionsignable
11.2.840.10065.1.12.1.1Author's Signatureyes
21.2.840.10065.1.12.1.2Coauthor's Signatureyes
31.2.840.10065.1.12.1.3Co-participant's Signatureno
41.2.840.10065.1.12.1.4Transcriptionist/Recorder Signatureyes
51.2.840.10065.1.12.1.5Verification Signatureyes
61.2.840.10065.1.12.1.6Validation Signatureyes
71.2.840.10065.1.12.1.7Consent Signatureno
81.2.840.10065.1.12.1.8Signature Witness Signatureno
91.2.840.10065.1.12.1.9Event Witness Signatureno
101.2.840.10065.1.12.1.10Identity Witness Signatureno
111.2.840.10065.1.12.1.11Consent Witness Signatureno
121.2.840.10065.1.12.1.12Interpreter Signatureno
131.2.840.10065.1.12.1.13Review Signatureyes
141.2.840.10065.1.12.1.14Source Signatureno
151.2.840.10065.1.12.1.15Addendum Signatureyes
161.2.840.10065.1.12.1.16Modification Signatureyes
171.2.840.10065.1.12.1.17Administrative (Error/Edit) Signatureyes
181.2.840.10065.1.12.1.18Timestamp Signatureno

Vectores de pruebaTest vectors

Datos completos: vectors.jsonFull data: vectors.json

labelinput_b64canonical_textsha256
block_separators_no_word_joinPHVsPjxsaT5PbmU8L2xpPjxsaT5Ud288L2xpPjwvdWw+PHRhYmxlPjx0cj48dGQ+…one two three four113f69ccf08f80a63beba2f49218eaa72a183f374065e6cb56ebe9bd68eb79cc
table_linearization_reading_orderPHRhYmxlPjx0cj48dGQ+QTE8L3RkPjx0ZD5CMTwvdGQ+PC90cj48dHI+PHRkPkEy…a1 b1 a2 b20dd50f887e01a0167563f70c81191a7693c92e83b93eaae377dfbd4500961bfc
script_and_style_excludedPHA+VmlzaWJsZTwvcD48c2NyaXB0PmFsZXJ0KDEpPC9zY3JpcHQ+PHN0eWxlPi5h…visibled42ef1497900bc6e542c641a896c88694d15069b8a11247f66ba7342b6c21cd9
unicode_nfkc_normalizationPHA+eCZzdXAyOyAmI3hGQjAxO2xlICYjeEZGMjE7PC9wPg==x2 file a6ce1e5e3d795ea083e129512b424f84adf75766f8a0018cbd56c231a9ba392bc
invalid_utf8_replacementPHA+SGVsbG8g//5Xb3JsZDwvcD4=hello ��worldae94afc5ac8fa9605adefbd355a4ab4469f95cca8e364a8a5e021170f5c960d4
tiptap_empty_paragraph_spacersPHA+UHJpbWVybzwvcD48cD48L3A+PHAgc3R5bGU9InRleHQtYWxpZ246IGNlbnRl…primero segundo terceroe88677f999ec011c47a7b60fedfa8aaf5c9496b66d5f64f540d451669464c5de
tiptap_headings_and_text_alignPGgxPkluZm9ybWUgUmFkaW9sb2dpY288L2gxPjxoMj5Ub3JheDwvaDI+PGgzPkhh…informe radiologico torax hallazgos alineado a la derecha.de161ffd02feac9bd8e4e0b9c616a16532c1cdc9c74550bc2570c0e0a46f858a
tiptap_nested_marks_inlinePHA+RXN0byBlcyA8c3Ryb25nPjxlbT5uZWdyaXRhcyB5IGN1cnNpdmE8L2VtPjwv…esto es negritas y cursiva y subrayado y tachado en una linea.41f86eb4e7fc6954ba450068e9614a7ce9855936890f15db16abb1134fa224da
tiptap_case_transform_spanPHA+PHNwYW4gc3R5bGU9InRleHQtdHJhbnNmb3JtOiB1cHBlcmNhc2U7Ij5IYWxs…hallazgo relevanteb06ed3248a68f5cc54650cf64e7e748e2bf2f2bc5d8de5f00c69c2dfdc1f0569
tiptap_list_items_wrap_paragraphPHVsPjxsaT48cD5DYW1wb3MgcHVsbW9uYXJlcyBsaW1waW9zPC9wPjwvbGk+PGxp…campos pulmonares limpios sin efusion pleural73c278558634358b31c75dbcf229efe49a4d8637c83eaba8d672fb4bcc210163
tiptap_table_colgroup_cell_paragraphsPHRhYmxlIHN0eWxlPSJtaW4td2lkdGg6IDc1cHg7Ij48Y29sZ3JvdXA+PGNvbCBz…region estado silueta cardiaca normaleba099a84f96b6911362f1f75f11330a6826afe7f0ffa03969c0c031a5d37cd8
tiptap_figure_image_large_data_uriPHA+QW50ZXMgZGUgbGEgaW1hZ2VuLjwvcD48ZmlndXJlIHN0eWxlPSJ0ZXh0LWFs…antes de la imagen. despues de la imagen.8c1d06b977ff4c8ac3db32b09159268e52d795a961294e805ead12390dc10829
tiptap_br_and_nbsp_combinationsPHA+UHVsbW9uZXM6IHNpbiBhbHRlcmFjaW9uZXMuPGJyPkNvcmF6b246Jm5ic3A7…pulmones: sin alteraciones. corazon: tamano normal. impresion: sin hallazgos agudos.6f78e0e5da4c3ad37218060bafc34a9789b913140cc573775b9cf26650d945c1
tiptap_full_realistic_findingPGgyPlJhZGlvZ3JhZmlhIGRlIFRvcmF4PC9oMj48cD48L3A+PHAgc3R5bGU9InRl…radiografia de torax estudio realizado con tecnica estandar. campos pulmonares limpios sin efusion pleural region estado silueta cardiaca normal impresion: sin hallazgos agudos. correlacionar clinicamente.487830530ca209a1fda8f71df9c1992c5f975e683d5f78e7a87f6c6d324fd188

Nota del traductor: este documento es informativo. Es una traducción al español del documento normativo en inglés "Examiner transcription rules". Ante cualquier discrepancia, el texto en inglés gobierna.

Reglas de transcripción para el examinador

1. Flujo forense offline

Verificar el contenido de texto de un informe impreso contra su text_sha256 firmado sigue cinco pasos:

  1. Escanear el código QR COSE offline del informe y ejecutar la verificación estructural/criptográfica del esquema DS1 (decodificar → inflar → analizar COSE → resolver KID → verificar la firma, y encadenar a un paquete CA de confianza cuando hay conexión).
  2. Leer el valor de finding.text_sha256 transportado en el payload COSE verificado, junto al veredicto automático.
  3. Transcribir o aplicar OCR al texto del hallazgo impreso.
  4. Aplicar los pasos de canonicalización publicados de DS Text Profile v1 (ver la página complementaria) al texto transcrito.
  5. Hashear el resultado y compararlo contra el text_sha256 mostrado. Una coincidencia significa que las palabras de la página son, salvo la normalización declarada del perfil, el texto exacto que fue firmado. Una discrepancia significa "verificar en línea o volver a transcribir" — nunca un veredicto automático de falsificación por sí solo (ver la regla 5 más abajo).

2. Reglas de transcripción del examinador

Estas cinco reglas rigen cómo un examinador humano (o una herramienta de OCR complementaria) debe transcribir el texto de un hallazgo impreso antes de aplicar DS Text Profile v1 y hashearlo.

  1. Transcribir solo el texto propio del informe — párrafos, encabezados, listas y tablas — en orden de lectura (izquierda a derecha, arriba hacia abajo). Ejemplo: una tabla se renderiza como filas y columnas en la página impresa; transcribirla fila por fila, de izquierda a derecha, de arriba hacia abajo (Region | Estado / Silueta cardiaca | Normal → "Region Estado Silueta cardiaca Normal"), nunca por columnas.

  2. Omitir viñetas y marcadores de numeración de listas — copiar solo el texto del elemento. Ejemplo: un elemento de lista impreso con viñeta "• Una lista" se transcribe como "una lista", no como "e una lista" — un glifo de viñeta mal leído que filtra un carácter inicial espurio no debe sobrevivir a la entrada del hash.

  3. Nunca transcribir texto que aparezca dentro de una imagen o figura — las imágenes se excluyen del resumen de texto por diseño. Ejemplo: el texto de un pie de figura o icono no forma parte de la prosa propia del hallazgo; el equivalente en el mundo real son las anotaciones DICOM grabadas ("burned-in") — superposiciones de paciente/lateralidad, pies de página incrustados — horneadas en una imagen diagnóstica — nada de eso participa en text_sha256 y nada de eso debería tipearse.

  4. Copiar letra por letra exactamente lo que está impreso: conservar los acentos exactamente como se muestran (presentes o ausentes), no corregir la ortografía, y escribir superíndices/subíndices como caracteres ordinarios en el orden de lectura. Ejemplos: "x²" impreso se tipea como "x2" (la normalización NFKC del perfil pliega el punto de código de superíndice almacenado al mismo dígito plano en el lado firmado, de modo que firmante y examinador convergen); una "radiografia" impresa, deliberadamente sin acento, se transcribe como "radiografia", sin autocorregirla a "radiografía" — el examinador debe copiar exactamente lo que está impreso, con o sin acento, no la ortografía de diccionario.

  5. Si se usa OCR automático, revisar su salida carácter por carácter contra la página impresa antes de hashear — un solo carácter mal leído cambia el hash. Una discrepancia significa "verificar en línea o volver a transcribir", nunca un veredicto automático de falsificación por sí solo.

Qué demuestra una coincidencia: las palabras de la página impresa son, salvo la normalización declarada del perfil (NFKC + plegado de mayúsculas/minúsculas + colapso de espacios en blanco), el texto exacto que fue firmado.

Qué permanece atestación-solamente offline, incluso con una coincidencia del resumen de texto: las imágenes (excluidas del resumen por diseño) y el formato/diseño (negrita, color, estilo de tabla, fuente) — una coincidencia del resumen de texto demuestra quién atestiguó el hallazgo y que esas palabras fueron firmadas, nunca que esta representación visual exacta fue firmada. El recálculo exacto byte a byte, imágenes incluidas, todavía requiere la página de validación en línea.

Nota operativa: las plantillas de impresión desactivan la separación silábica (hyphenation) en CSS — no aparece ninguna regla CSS hyphens en ninguna hoja de estilo de informe — así que la división de palabras a mitad de línea no es una preocupación al transcribir una página impresa.

Examiner transcription rules

Normative. The offline transcription procedure and examiner rules for computing finding.text_sha256 from a printed report — companion to the DS Text Profile v1 algorithm page. An informative Spanish translation is published alongside it; where the two disagree, this English text governs.

1. Offline forensic flow

Verifying a printed report's text content against its signed text_sha256 follows five steps:

  1. Scan the report's offline COSE QR code and run the DS1 scheme's structural/cryptographic verification (decode → inflate → parse COSE → resolve KID → verify signature, and chain to a trusted CA bundle when online).
  2. Read the finding.text_sha256 value carried in the verified COSE payload alongside the automated verdict.
  3. Transcribe or OCR the printed finding's text.
  4. Apply the published DS Text Profile v1 canonicalization steps (see the companion page) to the transcribed text.
  5. Hash the result and compare it against the displayed text_sha256. A match means the words on the page are, modulo the profile's declared normalization, the exact text that was signed. A mismatch means "verify online or re-transcribe" — never an automatic forgery verdict on its own (see rule 5 below).

2. Examiner transcription rules

These five rules govern how a human examiner (or a companion OCR tool) must transcribe a printed finding's text before applying DS Text Profile v1 and hashing it.

  1. Transcribe only the report's own text — paragraphs, headings, lists, and tables — in reading order (left-to-right, top-to-bottom). Example: a table renders as rows and columns on the printed page; transcribe it row by row, left-to-right, top-to-bottom (Region | Estado / Silueta cardiaca | Normal → "Region Estado Silueta cardiaca Normal"), never column-major.

  2. Omit list bullets and numbering markers — copy only the item text. Example: a printed bulleted item "• Una lista" is transcribed as "una lista", not "e una lista" — a misread bullet glyph that leaks a stray leading character must not survive into the hash input.

  3. Never transcribe text that appears inside an image or figure — images are excluded from the text digest by design. Example: a figure's caption or icon text is not part of the finding's own prose; the real-world equivalent is burned-in DICOM annotations (patient/laterality overlays, embedded captions) baked into a diagnostic image — none of that participates in text_sha256 and none of it should be typed in.

  4. Copy letter-for-letter exactly what is printed: keep accents exactly as shown (present or absent), do not correct spelling, and write superscripts/subscripts as ordinary characters in reading order. Examples: printed "x²" is typed as "x2" (the profile's NFKC normalization folds the stored superscript codepoint to the same plain digit on the signed side, so signer and examiner converge); a printed, deliberately unaccented "radiografia" is transcribed as "radiografia", not autocorrected to "radiografía" — copy exactly what is printed, accent or no accent, not the dictionary spelling.

  5. If machine OCR is used, proofread its output character-by-character against the printed page before hashing — a single misread character changes the hash. A mismatch means "verify online or re-transcribe," never an automatic forgery verdict on its own.

What a match proves: the words on the printed page are, modulo the profile's declared normalization (NFKC + case fold + whitespace collapse), the exact text that was signed.

What stays attestation-only offline, even with a text-digest match: images (excluded from the digest by design) and formatting/layout (bold, color, table styling, font) — a text-digest match proves who attested the finding and that these words were signed, never that this exact visual rendering was signed. Byte-exact recomputation, images included, still requires the online validation page.

Operational note: print templates disable CSS hyphenation — no hyphens rule appears in any report stylesheet — so mid-word line-break splitting is not a concern when transcribing a printed page.

Nota del traductor: este documento es informativo. Es una traducción al español del documento normativo en inglés "Manual validation walkthrough". Ante cualquier discrepancia, el texto en inglés gobierna. Los bloques de código (Python/bash) se mantienen idénticos al original en inglés para que puedan copiarse y ejecutarse literalmente.

Recorrido de validación manual

Este recorrido verifica un token DS1: a mano, usando solo python3 (biblioteca estándar más los paquetes base45 y cbor2) y el CLI de openssl. Cada comando de abajo es literal y está pensado para ejecutarse tal cual contra un token real; ajustar solo las rutas de marcador de posición (token.txt, el año del leaf-bundle, los nombres de host).

Los bloques de Python de abajo también se publican como un solo archivo: manual-validation.py.

Prerrequisitos:

python3 -m pip install base45 cbor2
openssl version   # cualquier OpenSSL reciente 1.1.1+/3.x funciona

Poner el texto del token escaneado/impreso en un archivo, por ejemplo:

printf '%s' 'DS1:6BF...' > token.txt

Paso 1 — Quitar el prefijo DS1:

token = open('token.txt').read().strip()

PREFIX = 'DS1:'
if not token.startswith(PREFIX):
    raise SystemExit(f'reject: missing/unknown scheme prefix (expected {PREFIX!r})')

b45_payload = token[len(PREFIX):]

Paso 2 — Decodificar Base45 (RFC 9285)

Usando el paquete base45:

import base45

deflated = base45.b45decode(b45_payload)

Alternativa sin dependencias (por si el paquete no está disponible — mismo alfabeto y misma regla de agrupamiento que RFC 9285: grupos de 3 caracteres decodifican a 2 bytes, un grupo final de 2 caracteres decodifica a 1 byte; un resto final de 1 carácter siempre es inválido):

_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
_REVERSE = {c: i for i, c in enumerate(_ALPHABET)}


def base45_decode(s: str) -> bytes:
    if len(s) % 3 == 1:
        raise ValueError('invalid base45 length (remainder of 1 is never valid)')

    out = bytearray()
    for i in range(0, len(s), 3):
        group = s[i:i + 3]
        try:
            values = [_REVERSE[c] for c in group]
        except KeyError as exc:
            raise ValueError(f'character not in base45 alphabet: {exc}') from None

        if len(group) == 3:
            value = values[0] + values[1] * 45 + values[2] * 2025
            if value > 0xFFFF:
                raise ValueError('base45 value out of range for a 2-byte group')
            out += value.to_bytes(2, 'big')
        else:
            value = values[0] + values[1] * 45
            if value > 0xFF:
                raise ValueError('base45 value out of range for a 1-byte group')
            out.append(value)

    return bytes(out)


# deflated = base45_decode(b45_payload)

Paso 3 — Inflar DEFLATE en bruto, con límite de tamaño (RFC 1951)

El flujo es DEFLATE en bruto — sin encabezado ni remolque zlib (RFC 1950) o gzip — de ahí wbits=-15. Acotar la salida para que un token manipulado no pueda actuar como bomba de descompresión; el límite de referencia es de 8192 bytes, y un resultado que alcanza el límite exactamente se trata como indistinguible de una carga útil truncada/bomba y se rechaza, no se acepta.

import zlib

MAX_INFLATED_BYTES = 8192


def bounded_raw_inflate(data: bytes, cap: int = MAX_INFLATED_BYTES) -> bytes:
    decompressor = zlib.decompressobj(wbits=-15)
    out = decompressor.decompress(data, cap)

    if len(out) >= cap:
        raise ValueError('reject: inflated payload reached the cap (bomb/truncation-ambiguous)')
    if decompressor.unconsumed_tail:
        raise ValueError('reject: inflated payload exceeds the size cap')
    if not decompressor.eof:
        raise ValueError('reject: truncated or corrupt DEFLATE stream')

    return out


cose_bytes = bounded_raw_inflate(deflated)

Paso 4 — Decodificar la estructura COSE_Sign (etiqueta CBOR 98, RFC 9052)

import json
import cbor2
from cbor2 import CBORTag

decoded = cbor2.loads(cose_bytes)
if not (isinstance(decoded, CBORTag) and decoded.tag == 98):
    raise SystemExit('reject: not a COSE_Sign (CBOR tag 98) object')

body_protected_bytes, body_unprotected, payload_bytes, signatures = decoded.value
if body_unprotected != {}:
    raise SystemExit('reject: unexpected non-empty unprotected body header')

body_protected = cbor2.loads(body_protected_bytes)
kid = body_protected[4]  # RFC 9052 §3.1 header label 4 = key identifier
print('kid (hex):', kid.hex())

payload = json.loads(payload_bytes.decode('utf-8'))
print(json.dumps(payload, indent=2, ensure_ascii=False))

parsed_signatures = []
for sign_protected_bytes, sign_unprotected, signature in signatures:
    if sign_unprotected != {}:
        raise SystemExit('reject: unexpected non-empty unprotected signature header')

    claims = cbor2.loads(sign_protected_bytes)
    if claims.get(1) != -7:
        raise SystemExit('reject: unexpected signature algorithm, expected ES256 (-7)')

    print('signer claims:', claims)
    parsed_signatures.append(
        {
            'sign_protected_bytes': sign_protected_bytes,
            'claims': claims,
            'signature': signature,
        }
    )

claims para cada firmante incluye n (nombre), r (código hoja de propósito de firma ASTM E1762 — ver la página del esquema DS1 §5 y la tabla de 18 códigos), e (correo electrónico), t (marca de tiempo de la firma), y (año UTC de la firma), s (secuencia), rec (id de registro), y opcionalmente c (cédula/licencia) y d (designación).

Paso 5 — Ubicar el certificado hoja por el KID

Descargar el paquete hoja anual para el año en el claim y del firmante, por ejemplo:

curl -sSfLo leaf-bundle-2026.pem https://pki.dicomserver.com/leaf-bundle-2026.pem

Verificación manual de un solo certificado con el CLI (huella de un certificado candidato):

openssl x509 -in candidate.pem -outform DER | sha256sum

Comparar ese resumen hexadecimal contra kid.hex() impreso en el paso 4. Para automatizar el escaneo de cada certificado del paquete:

import re
import subprocess

year = parsed_signatures[0]['claims']['y']
bundle_path = f'leaf-bundle-{year}.pem'

bundle_text = open(bundle_path).read()
cert_blocks = re.findall(
    r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
    bundle_text,
    re.S,
)

leaf_pem = None
for i, cert_pem in enumerate(cert_blocks):
    der = subprocess.run(
        ['openssl', 'x509', '-outform', 'DER'],
        input=cert_pem.encode('ascii'),
        capture_output=True,
        check=True,
    ).stdout
    fingerprint = subprocess.run(
        ['openssl', 'dgst', '-sha256', '-binary'],
        input=der,
        capture_output=True,
        check=True,
    ).stdout

    if fingerprint == kid:
        leaf_pem = cert_pem
        print(f'matched leaf certificate #{i} in {bundle_path}')
        break

if leaf_pem is None:
    raise SystemExit(f'reject: no certificate in {bundle_path} matches kid {kid.hex()}')

with open('leaf.pem', 'w') as f:
    f.write(leaf_pem)

Paso 6 — Encadenar la hoja al paquete de confianza

curl -sSfLo ca-bundle.pem https://pki.dicomserver.com/ca-bundle.pem
openssl verify -CAfile ca-bundle.pem leaf.pem

Se espera leaf.pem: OK. Los certificados CA de este paquete son ECDSA P-384/SHA-384; la hoja firma con ES256 (ECDSA P-256/SHA-256).

Paso 7 — Reconstruir la Sig_structure y verificar la firma

Reconstruir los bytes exactos que fueron firmados con ECDSA (Sig_structure de RFC 9052 §4.4) para el primer firmante, y luego verificar con openssl:

signer = parsed_signatures[0]

sig_structure_bytes = cbor2.dumps(
    [
        'Signature',
        body_protected_bytes,
        signer['sign_protected_bytes'],
        b'',  # external_aad — always empty in this scheme
        payload_bytes,
    ]
)

with open('sigstructure.bin', 'wb') as f:
    f.write(sig_structure_bytes)

Convertir la firma en bruto de 64 bytes r||s a ASN.1 DER, ya que openssl dgst -verify espera DER:

def _encode_der_length(length: int) -> bytes:
    if length < 0x80:
        return bytes([length])
    encoded = b''
    while length > 0:
        encoded = bytes([length & 0xFF]) + encoded
        length >>= 8
    return bytes([0x80 | len(encoded)]) + encoded


def _encode_der_integer(magnitude: bytes) -> bytes:
    magnitude = magnitude.lstrip(b'\x00') or b'\x00'
    if magnitude[0] & 0x80:
        magnitude = b'\x00' + magnitude  # keep it a positive INTEGER in DER
    return b'\x02' + _encode_der_length(len(magnitude)) + magnitude


def raw_to_der(raw: bytes) -> bytes:
    if len(raw) != 64:
        raise ValueError('expected a 64-byte raw r||s ECDSA P-256 signature')
    r, s = raw[:32], raw[32:]
    body = _encode_der_integer(r) + _encode_der_integer(s)
    return b'\x30' + _encode_der_length(len(body)) + body


sig_der = raw_to_der(signer['signature'])
with open('sig.der', 'wb') as f:
    f.write(sig_der)

Extraer la clave pública de la hoja y verificar:

openssl x509 -pubkey -noout -in leaf.pem > pub.pem
openssl dgst -sha256 -verify pub.pem -signature sig.der sigstructure.bin

Se espera Verified OK. Repetir el paso 7 para cada elemento de parsed_signatures cuando hay más de un cofirmante.

Paso 8 (opcional) — Recalcular text_sha256 a partir de una transcripción

Si el payload transporta un finding.text_sha256 (DS Text Profile v1, ver la página complementaria), compararlo contra un hash del texto impreso transcrito. Una transcripción — tecleada a mano o corregida de OCR según las reglas de transcripción del examinador — es texto plano, así que solo aplican los pasos de normalización de texto del perfil: NFKC, case folding completo, colapso de White_Space, SHA-256. No hay HTML que analizar. (Reproducir los vectores publicados, cuyas entradas son HTML en bruto, requiere además el paso de extracción del perfil sobre un analizador de construcción de árbol WHATWG — eso es prueba de conformidad, fuera del alcance de este recorrido.)

Coloca la transcripción en transcription.txt (UTF-8) y, solo con la biblioteca estándar:

import hashlib
import re
import unicodedata

# The 25 Unicode White_Space code points - do not substitute a runtime's
# native whitespace class (see the DS Text Profile v1 page's portability note).
_WHITE_SPACE_RE = re.compile(
    '[\t\n\x0b\x0c\r\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+'
)

transcribed = open('transcription.txt', encoding='utf-8').read()

normalized = unicodedata.normalize('NFKC', transcribed)
folded = normalized.casefold()  # Python's casefold() is full, locale-independent
collapsed = _WHITE_SPACE_RE.sub(' ', folded).strip()
computed = hashlib.sha256(collapsed.encode('utf-8')).hexdigest()
expected = payload['finding'].get('text_sha256')

print('computed:', computed)
print('expected:', expected)
print('MATCH' if computed == expected else 'MISMATCH — verify online or re-transcribe')

Los pasos 1 a 7 son la verificación criptográfica obligatoria; el paso 8 es opcional y solo aplica cuando hay un text_sha256 presente.

Manual validation walkthrough

Normative for the procedure it describes: the reference manual validation path for a DS1: token, independent of any tool this site itself provides. An informative Spanish translation is published alongside it; where the two disagree, this English text governs.

This walkthrough verifies a DS1: token by hand, using only python3 (standard library plus the base45 and cbor2 packages) and the openssl CLI. Every command below is literal and meant to be run as-is against a real token; adjust only the placeholder paths (token.txt, the leaf-bundle year, hostnames).

The Python blocks below are also published as a single file: manual-validation.py.

Prerequisites:

python3 -m pip install base45 cbor2
openssl version   # any recent OpenSSL 1.1.1+/3.x works

Put the scanned/printed token text in a file, e.g.:

printf '%s' 'DS1:6BF...' > token.txt

Step 1 — Strip the DS1: prefix

token = open('token.txt').read().strip()

PREFIX = 'DS1:'
if not token.startswith(PREFIX):
    raise SystemExit(f'reject: missing/unknown scheme prefix (expected {PREFIX!r})')

b45_payload = token[len(PREFIX):]

Step 2 — Base45-decode (RFC 9285)

Using the base45 package:

import base45

deflated = base45.b45decode(b45_payload)

Dependency-free fallback (in case the package is unavailable — same alphabet and grouping rule as RFC 9285: groups of 3 characters decode to 2 bytes, a trailing group of 2 characters decodes to 1 byte; a trailing remainder of 1 character is always invalid):

_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
_REVERSE = {c: i for i, c in enumerate(_ALPHABET)}


def base45_decode(s: str) -> bytes:
    if len(s) % 3 == 1:
        raise ValueError('invalid base45 length (remainder of 1 is never valid)')

    out = bytearray()
    for i in range(0, len(s), 3):
        group = s[i:i + 3]
        try:
            values = [_REVERSE[c] for c in group]
        except KeyError as exc:
            raise ValueError(f'character not in base45 alphabet: {exc}') from None

        if len(group) == 3:
            value = values[0] + values[1] * 45 + values[2] * 2025
            if value > 0xFFFF:
                raise ValueError('base45 value out of range for a 2-byte group')
            out += value.to_bytes(2, 'big')
        else:
            value = values[0] + values[1] * 45
            if value > 0xFF:
                raise ValueError('base45 value out of range for a 1-byte group')
            out.append(value)

    return bytes(out)


# deflated = base45_decode(b45_payload)

Step 3 — Raw-DEFLATE inflate, size-bounded (RFC 1951)

The stream is raw DEFLATE — no zlib (RFC 1950) or gzip header/trailer — hence wbits=-15. Bound the output so a crafted token cannot act as a decompression bomb; the reference cap is 8192 bytes, and a result that reaches the cap exactly is treated as indistinguishable from a truncated/bomb payload and rejected, not accepted.

import zlib

MAX_INFLATED_BYTES = 8192


def bounded_raw_inflate(data: bytes, cap: int = MAX_INFLATED_BYTES) -> bytes:
    decompressor = zlib.decompressobj(wbits=-15)
    out = decompressor.decompress(data, cap)

    if len(out) >= cap:
        raise ValueError('reject: inflated payload reached the cap (bomb/truncation-ambiguous)')
    if decompressor.unconsumed_tail:
        raise ValueError('reject: inflated payload exceeds the size cap')
    if not decompressor.eof:
        raise ValueError('reject: truncated or corrupt DEFLATE stream')

    return out


cose_bytes = bounded_raw_inflate(deflated)

Step 4 — Decode the COSE_Sign structure (CBOR tag 98, RFC 9052)

import json
import cbor2
from cbor2 import CBORTag

decoded = cbor2.loads(cose_bytes)
if not (isinstance(decoded, CBORTag) and decoded.tag == 98):
    raise SystemExit('reject: not a COSE_Sign (CBOR tag 98) object')

body_protected_bytes, body_unprotected, payload_bytes, signatures = decoded.value
if body_unprotected != {}:
    raise SystemExit('reject: unexpected non-empty unprotected body header')

body_protected = cbor2.loads(body_protected_bytes)
kid = body_protected[4]  # RFC 9052 §3.1 header label 4 = key identifier
print('kid (hex):', kid.hex())

payload = json.loads(payload_bytes.decode('utf-8'))
print(json.dumps(payload, indent=2, ensure_ascii=False))

parsed_signatures = []
for sign_protected_bytes, sign_unprotected, signature in signatures:
    if sign_unprotected != {}:
        raise SystemExit('reject: unexpected non-empty unprotected signature header')

    claims = cbor2.loads(sign_protected_bytes)
    if claims.get(1) != -7:
        raise SystemExit('reject: unexpected signature algorithm, expected ES256 (-7)')

    print('signer claims:', claims)
    parsed_signatures.append(
        {
            'sign_protected_bytes': sign_protected_bytes,
            'claims': claims,
            'signature': signature,
        }
    )

claims for each signer includes n (name), r (ASTM E1762 purpose-of-signature leaf code — see the DS1 scheme page §5 and the 18-code table), e (email), t (signed-at timestamp), y (UTC signing year), s (sequence), rec (record id), and optionally c (license/cédula) and d (designation).

Step 5 — Locate the leaf certificate by KID

Fetch the annual leaf bundle for the year in the signer's y claim, e.g.:

curl -sSfLo leaf-bundle-2026.pem https://pki.dicomserver.com/leaf-bundle-2026.pem

Manual, single-certificate check with the CLI (fingerprint one candidate certificate):

openssl x509 -in candidate.pem -outform DER | sha256sum

Compare that hex digest against kid.hex() printed in step 4. To automate scanning every certificate in the bundle:

import re
import subprocess

year = parsed_signatures[0]['claims']['y']
bundle_path = f'leaf-bundle-{year}.pem'

bundle_text = open(bundle_path).read()
cert_blocks = re.findall(
    r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
    bundle_text,
    re.S,
)

leaf_pem = None
for i, cert_pem in enumerate(cert_blocks):
    der = subprocess.run(
        ['openssl', 'x509', '-outform', 'DER'],
        input=cert_pem.encode('ascii'),
        capture_output=True,
        check=True,
    ).stdout
    fingerprint = subprocess.run(
        ['openssl', 'dgst', '-sha256', '-binary'],
        input=der,
        capture_output=True,
        check=True,
    ).stdout

    if fingerprint == kid:
        leaf_pem = cert_pem
        print(f'matched leaf certificate #{i} in {bundle_path}')
        break

if leaf_pem is None:
    raise SystemExit(f'reject: no certificate in {bundle_path} matches kid {kid.hex()}')

with open('leaf.pem', 'w') as f:
    f.write(leaf_pem)

Step 6 — Chain the leaf to the trust bundle

curl -sSfLo ca-bundle.pem https://pki.dicomserver.com/ca-bundle.pem
openssl verify -CAfile ca-bundle.pem leaf.pem

Expect leaf.pem: OK. CA certificates in this bundle are ECDSA P-384/SHA-384; the leaf signs with ES256 (ECDSA P-256/SHA-256).

Step 7 — Rebuild the Sig_structure and verify the signature

Rebuild the exact bytes that were ECDSA-signed (RFC 9052 §4.4 Sig_structure) for the first signer, then verify with openssl:

signer = parsed_signatures[0]

sig_structure_bytes = cbor2.dumps(
    [
        'Signature',
        body_protected_bytes,
        signer['sign_protected_bytes'],
        b'',  # external_aad — always empty in this scheme
        payload_bytes,
    ]
)

with open('sigstructure.bin', 'wb') as f:
    f.write(sig_structure_bytes)

Convert the raw 64-byte r||s signature to ASN.1 DER, since openssl dgst -verify expects DER:

def _encode_der_length(length: int) -> bytes:
    if length < 0x80:
        return bytes([length])
    encoded = b''
    while length > 0:
        encoded = bytes([length & 0xFF]) + encoded
        length >>= 8
    return bytes([0x80 | len(encoded)]) + encoded


def _encode_der_integer(magnitude: bytes) -> bytes:
    magnitude = magnitude.lstrip(b'\x00') or b'\x00'
    if magnitude[0] & 0x80:
        magnitude = b'\x00' + magnitude  # keep it a positive INTEGER in DER
    return b'\x02' + _encode_der_length(len(magnitude)) + magnitude


def raw_to_der(raw: bytes) -> bytes:
    if len(raw) != 64:
        raise ValueError('expected a 64-byte raw r||s ECDSA P-256 signature')
    r, s = raw[:32], raw[32:]
    body = _encode_der_integer(r) + _encode_der_integer(s)
    return b'\x30' + _encode_der_length(len(body)) + body


sig_der = raw_to_der(signer['signature'])
with open('sig.der', 'wb') as f:
    f.write(sig_der)

Extract the leaf's public key and verify:

openssl x509 -pubkey -noout -in leaf.pem > pub.pem
openssl dgst -sha256 -verify pub.pem -signature sig.der sigstructure.bin

Expect Verified OK. Repeat step 7 for every element of parsed_signatures when there is more than one co-signer.

Step 8 (optional) — Recompute text_sha256 from a transcription

If the payload carries a finding.text_sha256 (DS Text Profile v1, see the companion page), compare it against a hash of the transcribed printed text. A transcription — typed by hand or OCR-corrected per the examiner transcription rules — is plain text, so only the profile's text-normalization steps apply: NFKC, full case folding, White_Space collapse, SHA-256. There is no HTML to parse. (Reproducing the published vectors, whose inputs are raw HTML, additionally requires the profile's extraction step on a WHATWG tree-construction parser — that is conformance testing, out of scope for this walkthrough.)

Put the transcription in transcription.txt (UTF-8) and, stdlib-only:

import hashlib
import re
import unicodedata

# The 25 Unicode White_Space code points - do not substitute a runtime's
# native whitespace class (see the DS Text Profile v1 page's portability note).
_WHITE_SPACE_RE = re.compile(
    '[\t\n\x0b\x0c\r\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+'
)

transcribed = open('transcription.txt', encoding='utf-8').read()

normalized = unicodedata.normalize('NFKC', transcribed)
folded = normalized.casefold()  # Python's casefold() is full, locale-independent
collapsed = _WHITE_SPACE_RE.sub(' ', folded).strip()
computed = hashlib.sha256(collapsed.encode('utf-8')).hexdigest()
expected = payload['finding'].get('text_sha256')

print('computed:', computed)
print('expected:', expected)
print('MATCH' if computed == expected else 'MISMATCH — verify online or re-transcribe')

Steps 1 through 7 are the mandatory cryptographic verification; step 8 is optional and applies only when a text_sha256 is present.