# 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. """calendar_client.py: OpenTimestamps Calendar Server protocol. The profile does not specify this protocol; it imports it from [OTS] (Appendix D, preamble). Implemented from the pinned release: - submission: POST of the raw digest to {url}/digest, with header Accept: application/vnd.opentimestamps.v1; the response is a serialised Timestamp relative to the submitted value ([OTS] calendar.py:47-76) - upgrade: GET {url}/timestamp/{hex(commitment)}; 200 gives a serialised Timestamp relative to the commitment, any other status means not yet available ([OTS] calendar.py:78-100) Not security-critical: compromise of a calendar is addressed in profile Section 6.3, and verification never contacts a calendar (Section 3.2). Inline comments below are in Portuguese, the implementer's working language. """ import urllib.request import ots_wire HEADERS = {'Accept': 'application/vnd.opentimestamps.v1', 'User-Agent': 'interop-fassbender-03'} MAX_RESPONSE = 10000 # calendar.py:71-73 def _parse_timestamp(resp_bytes: bytes) -> ots_wire.Node: """A resposta do calendário é um Timestamp sem cabeçalho de ficheiro: só a estrutura recursiva (timestamp.py:130-183).""" r = ots_wire.Reader(resp_bytes) node = ots_wire.Node.deserialize(r) if not r.at_eof(): raise ots_wire.WireError("lixo após a resposta do calendário") return node def submit(url: str, digest: bytes, timeout=30) -> ots_wire.Node: """HTTP-POST(url, digest) do D.1 passo 4 / D.3 passo 4.""" req = urllib.request.Request(url.rstrip('/') + '/digest', data=digest, headers=HEADERS) with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status != 200: raise IOError(f"calendário devolveu {resp.status}") body = resp.read(MAX_RESPONSE + 1) if len(body) > MAX_RESPONSE: raise IOError("resposta do calendário excede o limite") return _parse_timestamp(body) def get_timestamp(url: str, commitment: bytes, timeout=30): """Pedido de upgrade: devolve Node ou None se ainda não houver caminho Bitcoin (OTS-UPGRADE devolve NULL nesse caso, §1.4).""" req = urllib.request.Request( url.rstrip('/') + '/timestamp/' + commitment.hex(), headers=HEADERS) try: with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status != 200: return None body = resp.read(MAX_RESPONSE + 1) if len(body) > MAX_RESPONSE: return None return _parse_timestamp(body) except Exception: return None