# Copyright (c) 2026 Tiago Pinto # SPDX-License-Identifier: MIT # # Independent producer-side implementation of # draft-fassbender-scitt-time-anchor-03, written from the published # Internet-Draft alone. See README.md and CONSTRUCTION.md. """anchor.py: producer side of profile draft-fassbender-scitt-time-anchor-03. Implements CONSTRUCT-ANCHOR (Appendix D.1) and BATCH-ANCHOR (Appendix D.3) from the published document alone. Each decision is annotated with the passage that justifies it. Side effects (clock, randomness, UUID, HTTP submission, storage) are injectable: tests run offline with deterministic values; in production the real calendar protocol is injected, whose shape the profile imports from [OTS] (Appendix D, preamble: the Calendar Server protocol and the form of the commitments "are none of them specified here; they are imported from [OTS]"). The real protocol, for reference: [OTS] calendar.py:58-77, POST of the raw digest to {url}/digest; the response is a serialised Timestamp relative to the submitted digest. Inline comments below are in Portuguese, the implementer's working language. """ import hashlib import os import re import uuid import string import secrets from datetime import datetime, timezone import ots_wire HASH_RE = re.compile(r'^(sha256:)?[0-9a-f]{64}$') # D.3 passo 1 # ---------- utilitários com nome igual ao do perfil (§1.4) ---------- def NOW(): """§1.4: UTC ISO 8601; não é fonte de tempo de segurança.""" return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') def RANDOM_BYTES(n): """§1.4: fonte aleatória criptograficamente segura.""" return secrets.token_bytes(n) def UUID_v4(): return str(uuid.uuid4()) def RANDOM_ALPHANUMERIC(n): """§1.4: [0-9a-zA-Z]; só para referência humana, não é crítico.""" alphabet = string.digits + string.ascii_letters return ''.join(secrets.choice(alphabet) for _ in range(n)) def STRIP_PREFIX(s: str) -> str: """§1.4: remove o prefixo "sha256:" se presente.""" return s[7:] if s.startswith('sha256:') else s # ---------- CONSTRUCT-ANCHOR (Apêndice D.1) ---------- def construct_anchor(hash_algorithm: str, hash_value: bytes, mode: str, storage: dict, calendars=None, submit_fn=None, now_fn=NOW, uuid_fn=UUID_v4, token_fn=lambda: RANDOM_ALPHANUMERIC(8)): """CONSTRUCT-ANCHOR(hash_algorithm, hash_value, mode) — D.1. Recebe um digest, nunca os bytes do artefacto (§1.3, AnchorDigest: "an anchoring service under this profile receives a digest and never the artifact bytes"). storage: dict com as tabelas 'origin_attestations' e 'core_ots_proofs' (D.1 usa INSERT como placeholder não normativo, §1.4: "an implementation MAY use any storage mechanism"). """ # Passo 1 — CANONICALISE THE DIGEST assert hash_algorithm == "sha256" # D.1 passo 1 (F2) assert len(hash_value) == 32 # D.1 passo 1 hash_string = "sha256:" + hash_value.hex() # forma canónica # Passo 2 — TIMESTAMP CAPTURE captured_at = now_fn() # Passo 3 — ORIGIN REGISTRATION origin_id = uuid_fn() short_token = token_fn() storage.setdefault('origin_attestations', {})[origin_id] = { 'origin_id': origin_id, 'hash': hash_string, 'hash_algo': 'sha256', 'captured_at': captured_at, 'short_token': short_token, } # Passo 4 — OTS SUBMISSION if mode == "BATCH": # D.1 passo 4: submissão diferida para BATCH-ANCHOR. "submitted" # é valor de resposta do serviço; NÃO é F9 (nenhuma prova existe # ainda — §2.4.1: F9 "has no value before a proof exists"). return {'origin_id': origin_id, 'hash_string': hash_string, 'captured_at': captured_at, 'proof_status': 'submitted'} # Modo DIRECT: submeter o hash a >=1 Calendar Server(s) [OTS]. pending_commitments = [] for cal in (calendars or []): commitment = submit_fn(cal, hash_value) pending_commitments.append(commitment) # Passo 5 — PENDING PROOF ASSEMBLY # "OTS-SERIALIZE(digest, [], commitments)": no modo direto a # sequência de operações é vazia (§1.4, OTS-SERIALIZE). root = ots_wire.Node() for c in pending_commitments: _merge_commitment(root, c) proof = ots_wire.Proof('sha256', hash_value, root) pending_proof = proof.serialize() storage.setdefault('core_ots_proofs', {})[origin_id] = { 'origin_id': origin_id, 'ots_proof': pending_proof, 'status': 'pending', 'created_at': captured_at, } # Passo 6 return {'origin_id': origin_id, 'hash_string': hash_string, 'captured_at': captured_at, 'proof_status': 'pending'} # ---------- BATCH-ANCHOR (Apêndice D.3) ---------- def batch_anchor(origin_list, storage, ots_servers, submit_fn, random_bytes=RANDOM_BYTES): """BATCH-ANCHOR(origin_list) — D.3. origin_list: lista ordenada de origin records (dicts com origin_id e hash), "the order in which the records were captured" (D.3 Input). Devolve [(origin_id, ots_bytes)]. """ # Passo 1 — VALIDATE for origin in origin_list: assert HASH_RE.match(origin['hash']), \ f"hash inválido: {origin['hash']!r}" # D.3 passo 1 # Passo 2 — DERIVE LEAVES # Folha = SHA-256(digest || nonce); nonce fresco de 16 bytes; o # nonce e o hash seguinte são as duas primeiras entradas da # sequência de operações da origem (D.3 passo 2). leaves = [] for origin in origin_list: origin['digest'] = bytes.fromhex(STRIP_PREFIX(origin['hash'])) nonce = random_bytes(16) origin['path'] = [ots_wire.Op('append', nonce), ots_wire.Op('sha256')] leaves.append(hashlib.sha256(origin['digest'] + nonce).digest()) # Passo 3 — AGGREGATE # Emparelhamento sequencial em ordem de captura; concatenação de # bytes crus; sem sort lexicográfico; sem prefixo de separação de # domínio; elemento ímpar final promovido sem alteração e sem # operação registada (D.3 passo 3). level = list(leaves) members = [[origin_list[i]] for i in range(len(leaves))] while len(level) > 1: next_level, next_members = [], [] for i in range(0, len(level) - 1, 2): for origin in members[i]: origin['path'].append(ots_wire.Op('append', level[i + 1])) origin['path'].append(ots_wire.Op('sha256')) for origin in members[i + 1]: origin['path'].append(ots_wire.Op('prepend', level[i])) origin['path'].append(ots_wire.Op('sha256')) next_level.append(hashlib.sha256(level[i] + level[i + 1]).digest()) next_members.append(members[i] + members[i + 1]) if len(level) % 2 == 1: next_level.append(level[-1]) next_members.append(members[-1]) level, members = next_level, next_members merkle_root = level[0] # Passo 4 — SUBMIT ROOT # Vários servidores é redundância; um sucesso basta; se todas as # submissões falharem, nada é persistido e o lote é retentado # (D.3 passo 4). accepted = 0 root_commitments = [] for server in ots_servers: try: commitment = submit_fn(server, merkle_root) root_commitments.append(commitment) accepted += 1 except Exception: continue if accepted == 0: return [] # Passo 5 — SERIALISE PER-ORIGIN PROOFS # Cada prova regista o caminho da própria origem; nenhuma prova # revela o hash de outra origem (D.3 passo 5). proofs = [] for origin in origin_list: node = root_node = ots_wire.Node() # encadear o caminho da origem até ao nó da raiz Merkle for op in origin['path']: child = ots_wire.Node() node.children.append((op, child)) node = child for c in root_commitments: _merge_commitment(node, c) proof = ots_wire.Proof('sha256', origin['digest'], root_node) ots_bytes = proof.serialize() proofs.append((origin['origin_id'], ots_bytes)) storage.setdefault('core_ots_proofs', {})[origin['origin_id']] = { 'origin_id': origin['origin_id'], 'ots_proof': ots_bytes, 'status': 'pending', 'created_at': NOW(), } return proofs def _merge_commitment(node: ots_wire.Node, commitment): """Anexa um compromisso de calendário ao nó do valor submetido. O compromisso é a resposta do servidor: um subárvore (Node) relativa ao valor submetido — no caso mínimo, apenas uma PendingAttestation. A forma real vem do protocolo [OTS] (calendar.py:58-77) e é deserializada pelo chamador; aqui só se agrega. """ if isinstance(commitment, ots_wire.Attestation): node.attestations.append(commitment) elif isinstance(commitment, ots_wire.Node): node.attestations.extend(commitment.attestations) node.children.extend(commitment.children) else: raise TypeError("compromisso tem de ser Attestation ou Node")