diff --git a/.gitignore b/.gitignore index 4dabd6a..f2127e8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ build/ .worktrees/ .superpowers/ + +# Local stamp debug-trace runs and editor backups +debug-trace/ +*.bak +*.bak-* +index.html.bak* diff --git a/docs/api.md b/docs/api.md index 13bc57e..0778e39 100644 --- a/docs/api.md +++ b/docs/api.md @@ -34,7 +34,7 @@ Scope rank: local < shared < global. | `org_id` | empty | Filter when set | | `want_scope` | empty | Drop receipts below this rank | -Returns `{ "receipts": [ ... ] }` from the local file. Global-only certificates are not listed. +Returns `{ "receipts": [ ... ] }`. `want_scope=global` queries VeraeTime `GET /api/hash/{sha256}` (Carfax). Empty `want_scope` merges the local file ledger with the central history. ## login diff --git a/docs/standards.md b/docs/standards.md index 27cf627..3deadd2 100644 --- a/docs/standards.md +++ b/docs/standards.md @@ -24,7 +24,7 @@ This process is an MCP server (Model Context Protocol). Clients are assistants a ## Global history -`hash_report` reads the local file only. VeraeTime is not queried for a digest history. A global stamp can verify by certificate id and still be absent from `hash_report`. An empty list means "not in the local file," not "VeraeTime has no record." +`hash_report` with `want_scope=global` calls VeraeTime `GET /api/hash/{digest}` (Carfax). Without that scope it reads the local file and also merges central history when available. An empty list means no receipts were found for that digest in the queried ledgers. ## Credentials diff --git a/scripts/nats_stamp.py b/scripts/nats_stamp.py new file mode 100644 index 0000000..ce3fa4c --- /dev/null +++ b/scripts/nats_stamp.py @@ -0,0 +1,158 @@ +"""Stamp digests over core NATS. Subscribe to verae.ts.completed before requesting. + +No status poll and no retry sleep. A known digest returns its certificate on the +request reply. A new digest returns a jobId, then verae.ts.completed carries the +certificate. + +Usage: + python scripts/nats_stamp.py <64-hex-or-text> [...] + VERAE_NATS_URL=nats://127.0.0.1:4222 python scripts/nats_stamp.py "Hello World!" +""" + +from __future__ import annotations + +import hashlib +import json +import os +import socket +import sys +import time + +SUBJECT_REQUEST = "verae.ts.request" +SUBJECT_COMPLETED = "verae.ts.completed" + + +def digest_of(text: str) -> str: + raw = text.strip().lower() + if raw.startswith("sha256:"): + raw = raw[7:] + if len(raw) == 64 and all(c in "0123456789abcdef" for c in raw): + return raw + return hashlib.sha256(text.encode()).hexdigest() + + +class Conn: + def __init__(self, url: str) -> None: + host_port = url.split("://", 1)[-1] + host, port_s = host_port.rsplit(":", 1) + self.sock = socket.create_connection((host, int(port_s)), timeout=5) + self.buf = b"" + info = self._read_line() + if not info.startswith("INFO "): + raise RuntimeError(info) + self.queued: dict[str, list[dict]] = {} + self._send( + 'CONNECT {"verbose":false,"pedantic":false,"lang":"verae-stamp","version":"1","protocol":1}\r\n' + "PING\r\n" + ) + self._expect_pong() + + def _send(self, text: str) -> None: + self.sock.sendall(text.encode()) + + def _read_line(self) -> str: + while b"\r\n" not in self.buf: + chunk = self.sock.recv(65536) + if not chunk: + raise RuntimeError("nats closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\r\n", 1) + return line.decode() + + def _expect_pong(self) -> None: + while True: + line = self._read_line() + if line == "PONG" or line.startswith("+OK"): + if line == "PONG": + return + continue + if line.startswith("-ERR"): + raise RuntimeError(line) + + def subscribe(self, subject: str, sid: str) -> None: + self._send(f"SUB {subject} {sid}\r\n") + + def request(self, subject: str, payload: dict, inbox: str, sid: str) -> dict: + raw = json.dumps(payload).encode() + self.subscribe(inbox, sid) + self._send(f"PUB {subject} {inbox} {len(raw)}\r\n") + self._send(raw.decode() + "\r\n") + return self._wait_sid(sid, 30.0) + + def _read_msg(self) -> tuple[str, dict]: + while True: + line = self._read_line() + if line.startswith("PING"): + self._send("PONG\r\n") + continue + if line.startswith("PONG") or line.startswith("+OK"): + continue + if not line.startswith("MSG "): + continue + parts = line.split() + size = int(parts[-1]) + msg_sid = parts[2] + while len(self.buf) < size + 2: + chunk = self.sock.recv(65536) + if not chunk: + raise RuntimeError("nats closed") + self.buf += chunk + body = self.buf[:size] + self.buf = self.buf[size + 2 :] + return msg_sid, json.loads(body.decode()) + + def _wait_sid(self, sid: str, timeout: float) -> dict: + queued = self.queued.get(sid) or [] + if queued: + return queued.pop(0) + deadline = time.time() + timeout + while time.time() < deadline: + self.sock.settimeout(max(0.1, deadline - time.time())) + msg_sid, body = self._read_msg() + if msg_sid == sid: + return body + self.queued.setdefault(msg_sid, []).append(body) + raise TimeoutError(sid) + + def next_completed(self, timeout: float) -> dict: + return self._wait_sid("done", timeout) + + +def main(argv: list[str]) -> int: + if not argv: + print("usage: nats_stamp.py [...]", file=sys.stderr) + return 2 + url = os.environ.get("VERAE_NATS_URL", "nats://127.0.0.1:4222") + digests = [digest_of(item) for item in argv] + conn = Conn(url) + conn.subscribe(SUBJECT_COMPLETED, "done") + pending: dict[str, str] = {} + receipts: dict[str, str] = {} + t0 = time.perf_counter() + for i, digest in enumerate(digests): + started = time.perf_counter() + body = conn.request(SUBJECT_REQUEST, {"hash": digest}, f"_INBOX.{i}", f"inbox{i}") + if body.get("error"): + print(json.dumps({"hash": digest, "error": body["error"]})) + return 1 + elapsed = (time.perf_counter() - started) * 1000 + if body.get("receipt_id"): + receipts[digest] = body["receipt_id"] + print(json.dumps({"hash": digest, "receipt_id": body["receipt_id"], "existing": body.get("existing"), "ms": round(elapsed, 3)})) + else: + pending[body["jobId"]] = digest + print(json.dumps({"hash": digest, "jobId": body.get("jobId"), "existing": False, "ms": round(elapsed, 3)})) + while pending: + msg = conn.next_completed(30) + job_id = msg.get("jobId") + digest = pending.pop(job_id, None) + if digest is None: + continue + receipts[digest] = msg.get("receipt_id") or "" + print(json.dumps({"hash": digest, "jobId": job_id, "receipt_id": receipts[digest], "via": "verae.ts.completed"})) + print(json.dumps({"n": len(digests), "receipts": len(receipts), "wall_ms": round((time.perf_counter() - t0) * 1000, 3)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/testflight-enroll/README.md b/scripts/testflight-enroll/README.md new file mode 100644 index 0000000..ba39769 --- /dev/null +++ b/scripts/testflight-enroll/README.md @@ -0,0 +1,30 @@ +# TestFlight enroll service + +HTTP helper that adds an Apple ID email to the Verae Timestamping TestFlight group via App Store Connect. + +## Install + +```sh +# On ns1: secrets at /SSD2/secrets/asc/ (AuthKey_*.p8 + testflight-enroll.env, mode 600) +# Service unit: verae-testflight-enroll.service (venv under /SSD2/services/verae-testflight-enroll) +cd scripts/testflight-enroll +python -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || true +``` + +Public invite endpoint (behind Caddy): `POST https://mcp.verae.ai/api/testflight-invite` with JSON `{"email":"you@apple-id.example","note":"..."}`. + +## Usage + +```sh +sudo -u marchon bash -lc 'set -a; source /SSD2/secrets/asc/testflight-enroll.env; set +a; /SSD2/services/verae-testflight-enroll/venv/bin/python enroll_cli.py you@example.com' +``` + +Env keys: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH`, optional app/group ids. Do not commit `.p8` keys or env files. + +## Contributing + +Questions: marchon@gmail.com or the Verae research workspace. PRs coordinated in-workspace first. + +## License + +UNLICENSED © George Lambert diff --git a/scripts/testflight-enroll/enroll_cli.py b/scripts/testflight-enroll/enroll_cli.py new file mode 100644 index 0000000..943199b --- /dev/null +++ b/scripts/testflight-enroll/enroll_cli.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""CLI for Grok Bot / operators: enroll one Apple ID into external TestFlight.""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from enroll_lib import enroll + + +def main(): + p = argparse.ArgumentParser(description="Enroll macOS TestFlight external tester via ASC API") + p.add_argument("email", help="Apple ID email") + p.add_argument("--note", default="", help="optional note (ignored by ASC; logged locally only if you redirect)") + args = p.parse_args() + try: + result = enroll(args.email, note=args.note) + except Exception as e: + print(json.dumps({"ok": False, "error": str(e)})) + sys.exit(1) + print(json.dumps(result)) + sys.exit(0 if result.get("ok") else 2) + + +if __name__ == "__main__": + main() diff --git a/scripts/testflight-enroll/enroll_lib.py b/scripts/testflight-enroll/enroll_lib.py new file mode 100644 index 0000000..eca990d --- /dev/null +++ b/scripts/testflight-enroll/enroll_lib.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""App Store Connect TestFlight external-tester enrollment helpers. + +Secrets come from env (never hard-coded): + ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH +Optional: + ASC_APP_ID (default 6817018890) + ASC_BETA_GROUP_ID (default fcfa572e-6494-4781-97b2-728f73e5b3ae) +""" +from __future__ import annotations + +import json +import os +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$") + +DEFAULT_APP_ID = "6817018890" +DEFAULT_GROUP_ID = "fcfa572e-6494-4781-97b2-728f73e5b3ae" +ASC_BASE = "https://api.appstoreconnect.apple.com" + + +def validate_email(email: str) -> str: + email = (email or "").strip() + if not email or len(email) > 254 or not EMAIL_RE.match(email): + raise ValueError("invalid_email") + return email.lower() + + +def _require_env() -> tuple[str, str, str]: + key_id = os.environ.get("ASC_KEY_ID", "").strip() + issuer = os.environ.get("ASC_ISSUER_ID", "").strip() + path = os.environ.get("ASC_KEY_PATH", "").strip() + if not key_id or not issuer or not path: + raise RuntimeError("missing_asc_env") + if not os.path.isfile(path): + raise RuntimeError("missing_asc_key_file") + return key_id, issuer, path + + +def make_token() -> str: + """ES256 JWT for App Store Connect API (max ~20 min).""" + import jwt # PyJWT + + key_id, issuer, path = _require_env() + with open(path, "r", encoding="utf-8") as f: + private_key = f.read() + now = int(time.time()) + payload = { + "iss": issuer, + "iat": now, + "exp": now + 15 * 60, + "aud": "appstoreconnect-v1", + } + return jwt.encode( + payload, + private_key, + algorithm="ES256", + headers={"kid": key_id, "typ": "JWT"}, + ) + + +def _request(method: str, path: str, body: dict | None = None, token: str | None = None) -> tuple[int, Any]: + token = token or make_token() + url = path if path.startswith("http") else ASC_BASE + path + data = None if body is None else json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Bearer " + token) + req.add_header("Accept", "application/json") + if body is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=45) as resp: + raw = resp.read().decode("utf-8") or "{}" + return resp.status, json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + parsed = {"raw": raw} + return e.code, parsed + + +def find_tester_by_email(email: str, token: str) -> dict | None: + q = urllib.parse.urlencode({ + "filter[email]": email, + "filter[apps]": os.environ.get("ASC_APP_ID", DEFAULT_APP_ID), + "limit": "5", + }) + status, data = _request("GET", f"/v1/betaTesters?{q}", token=token) + if status != 200: + return None + items = data.get("data") or [] + return items[0] if items else None + + +def create_tester(email: str, token: str, first: str = "Verae", last: str = "Tester") -> tuple[int, Any]: + app_id = os.environ.get("ASC_APP_ID", DEFAULT_APP_ID) + group_id = os.environ.get("ASC_BETA_GROUP_ID", DEFAULT_GROUP_ID) + body = { + "data": { + "type": "betaTesters", + "attributes": { + "email": email, + "firstName": first[:30] or "Verae", + "lastName": last[:30] or "Tester", + }, + "relationships": { + "betaGroups": { + "data": [{"type": "betaGroups", "id": group_id}] + }, + "apps": { + "data": [{"type": "apps", "id": app_id}] + }, + }, + } + } + return _request("POST", "/v1/betaTesters", body=body, token=token) + + +def add_tester_to_group(tester_id: str, token: str) -> tuple[int, Any]: + group_id = os.environ.get("ASC_BETA_GROUP_ID", DEFAULT_GROUP_ID) + body = { + "data": [{"type": "betaTesters", "id": tester_id}] + } + return _request( + "POST", + f"/v1/betaGroups/{group_id}/relationships/betaTesters", + body=body, + token=token, + ) + + +def resend_invitation(tester_id: str, token: str) -> tuple[int, Any]: + # App Store Connect: POST /v1/betaTesterInvitations + app_id = os.environ.get("ASC_APP_ID", DEFAULT_APP_ID) + body = { + "data": { + "type": "betaTesterInvitations", + "relationships": { + "betaTester": {"data": {"type": "betaTesters", "id": tester_id}}, + "app": {"data": {"type": "apps", "id": app_id}}, + }, + } + } + return _request("POST", "/v1/betaTesterInvitations", body=body, token=token) + + +def enroll(email: str, note: str = "") -> dict: + """Validate + create/add external tester + resend invite. Returns public result dict.""" + email = validate_email(email) + token = make_token() + existing = find_tester_by_email(email, token) + created = False + tester_id = None + steps: list[str] = [] + + if existing: + tester_id = existing["id"] + steps.append("found_existing") + st, _ = add_tester_to_group(tester_id, token) + # 204 success; 409 often already in group + if st in (204, 409): + steps.append("group_ok" if st == 204 else "already_in_group") + else: + steps.append(f"group_status_{st}") + else: + st, data = create_tester(email, token) + if st in (201, 200): + created = True + tester_id = (data.get("data") or {}).get("id") + steps.append("created") + elif st == 409: + # race / already exists — look up again + steps.append("create_conflict") + existing = find_tester_by_email(email, token) + if not existing: + return { + "ok": False, + "error": "tester_conflict", + "message": "Apple reported a conflict creating this tester. Try again or email marchon@gmail.com.", + "steps": steps, + } + tester_id = existing["id"] + st2, _ = add_tester_to_group(tester_id, token) + steps.append("group_ok" if st2 in (204, 409) else f"group_status_{st2}") + else: + detail = "" + errs = (data or {}).get("errors") or [] + if errs: + detail = errs[0].get("detail") or errs[0].get("title") or "" + return { + "ok": False, + "error": "asc_create_failed", + "message": detail or f"App Store Connect create failed ({st}).", + "steps": steps, + "status": st, + } + + if tester_id: + rst, _ = resend_invitation(tester_id, token) + if rst in (201, 200, 204): + steps.append("invite_resent") + else: + steps.append(f"invite_status_{rst}") + + return { + "ok": True, + "email": email, + "created": created, + "tester_id": tester_id, + "steps": steps, + "join_url": "https://testflight.apple.com/join/dsHsyx2Z", + "beta_review_note": ( + "External builds may still be WAITING_FOR_BETA_REVIEW. " + "Enrollment succeeded; you may not see the build until Apple approves the beta." + ), + "note_received": bool((note or "").strip()), + } diff --git a/scripts/testflight-enroll/enroll_server.py b/scripts/testflight-enroll/enroll_server.py new file mode 100644 index 0000000..8f840f1 --- /dev/null +++ b/scripts/testflight-enroll/enroll_server.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Minimal HTTP service: POST /api/testflight-invite → ASC external TestFlight enroll. + +Bind 127.0.0.1 only; Caddy reverse_proxies https://mcp.verae.ai/api/testflight-invite +""" +from __future__ import annotations + +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# Allow running from installed dir +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from enroll_lib import enroll, validate_email # noqa: E402 + +HOST = os.environ.get("ENROLL_BIND", "127.0.0.1") +PORT = int(os.environ.get("ENROLL_PORT", "8011")) + + +class Handler(BaseHTTPRequestHandler): + server_version = "verae-testflight-enroll/1.0" + + def log_message(self, fmt, *args): + # Avoid logging emails/bodies + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def _cors(self): + origin = self.headers.get("Origin") or "" + allowed = { + "https://mcp.verae.ai", + "https://mcp.georgelambert.org", + "http://127.0.0.1:8000", + "http://localhost:8000", + } + if origin in allowed: + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + def _json(self, code: int, obj: dict): + raw = json.dumps(obj).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self._cors() + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_OPTIONS(self): + self.send_response(204) + self._cors() + self.end_headers() + + def do_GET(self): + if self.path.split("?")[0] in ("/healthz", "/api/testflight-invite/healthz"): + self._json(200, {"ok": True, "service": "verae-testflight-enroll"}) + return + self._json(404, {"ok": False, "error": "not_found"}) + + def do_POST(self): + path = self.path.split("?")[0] + if path not in ("/api/testflight-invite", "/"): + self._json(404, {"ok": False, "error": "not_found"}) + return + length = int(self.headers.get("Content-Length") or "0") + if length > 8192: + self._json(413, {"ok": False, "error": "too_large", "message": "Request too large."}) + return + raw = self.rfile.read(length) if length else b"{}" + try: + body = json.loads(raw.decode("utf-8") or "{}") + except json.JSONDecodeError: + self._json(400, {"ok": False, "error": "invalid_json", "message": "Expected JSON body."}) + return + email = body.get("email") or body.get("apple_id") or "" + note = body.get("note") or "" + try: + validate_email(email) + except ValueError: + self._json(400, { + "ok": False, + "error": "invalid_email", + "message": "Enter a valid Apple ID email address.", + }) + return + try: + result = enroll(email, note=str(note)[:500]) + except RuntimeError as e: + code = str(e) + self._json(503, { + "ok": False, + "error": code, + "message": "Enrollment service is not configured. Email marchon@gmail.com with your Apple ID.", + }) + return + except Exception: + self._json(500, { + "ok": False, + "error": "internal", + "message": "Enrollment failed unexpectedly. Email marchon@gmail.com with your Apple ID.", + }) + return + self._json(200 if result.get("ok") else 502, result) + + +def main(): + httpd = ThreadingHTTPServer((HOST, PORT), Handler) + print(f"verae-testflight-enroll listening on {HOST}:{PORT}", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/testflight-enroll/verae-testflight-enroll.service b/scripts/testflight-enroll/verae-testflight-enroll.service new file mode 100644 index 0000000..7837802 --- /dev/null +++ b/scripts/testflight-enroll/verae-testflight-enroll.service @@ -0,0 +1,19 @@ +[Unit] +Description=Verae TestFlight invite enrollment (ASC API) +After=network.target + +[Service] +Type=simple +User=marchon +Group=marchon +WorkingDirectory=/SSD2/services/verae-testflight-enroll +EnvironmentFile=-/SSD2/secrets/asc/testflight-enroll.env +ExecStart=/SSD2/services/verae-testflight-enroll/venv/bin/python /SSD2/services/verae-testflight-enroll/enroll_server.py +Restart=on-failure +RestartSec=3 +# Do not leak secrets into journal via argv +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/site/app.html b/site/app.html new file mode 100644 index 0000000..8841080 --- /dev/null +++ b/site/app.html @@ -0,0 +1,1151 @@ + + + + + +Verae Timestamping — Browser App + + + + + + + + + +
+ Verae + +
+
+

Browser client · WASM hash · Mac-parity

+

Verae timestamping

+

Full browser client matching the Mac app: hash files locally (WASM, WebCrypto fallback), Register or Verify digests via MCP, green/red status lights, auth defaults, and CSV / JSON / PDF / text export. File bytes never leave this device.

+ +
+
+ Docs (brief — same ideas as Mac Docs) +
Verae Timestamping (Browser)
+
+This page hashes files in your browser (SHA-256 WASM or Web Crypto), then
+calls the Verae MCP with digests only — file bytes never upload.
+
+• Register = stamp digests via MCP and save a local receipt map in
+  localStorage (keyed by sha256), similar to ~/.verae/receipts on Mac.
+• Verify = look up local receipt → MCP verify by receipt_id; or use a
+  pasted receipt_id. Does NOT stamp. If no receipt is known → Not found.
+• Green check = found / valid / registered · Red octagon = not found /
+  invalid / error. SHA-256 is always shown when hashed.
+• Defaults + auth persist in localStorage (verae.client.defaults,
+  verae.auth). Browser storage is less safe than Keychain — useful for
+  demos; prefer a short-lived token in shared machines.
+• MCP sends Authorization: Bearer <token> when a token is present.
+
+MCP default: https://mcp.verae.ai/mcp
+Auth API default: https://api.veraetime.net
+
+
+ +
+

Defaults & auth

+
+ + + +
+

Auth

+
+ + +
+
+ +
+

Token hint: (none) + · Expires: — + · Session: —

+
+ + + +
+

+

Warning: username, password, and token are stored in localStorage. That is convenient but less safe than the Mac Keychain / ~/.verae/auth.properties (0600). Clear auth on shared browsers.

+
+ +
+

Mode

+
+ + +
+ +
+
+
Export formats
+
+ + + + +
+
+
+
+ +
+

Documents

+
+ Drop files here + or click to choose · multiple files supported · digests only leave the browser + +
+
+ + + +
+

Waiting for files…

+
+ + + + + + + + + + + + + +
+

Lookup URL (PDF QR):

+
+
+ + + + diff --git a/site/assets/windows/gui-hash-web.png b/site/assets/windows/gui-hash-web.png new file mode 100644 index 0000000..9a8a4ed Binary files /dev/null and b/site/assets/windows/gui-hash-web.png differ diff --git a/site/assets/windows/gui-hash.png b/site/assets/windows/gui-hash.png new file mode 100644 index 0000000..272f2c5 Binary files /dev/null and b/site/assets/windows/gui-hash.png differ diff --git a/site/assets/windows/gui-ready-web.png b/site/assets/windows/gui-ready-web.png new file mode 100644 index 0000000..05f56b0 Binary files /dev/null and b/site/assets/windows/gui-ready-web.png differ diff --git a/site/assets/windows/gui-ready.png b/site/assets/windows/gui-ready.png new file mode 100644 index 0000000..aee4680 Binary files /dev/null and b/site/assets/windows/gui-ready.png differ diff --git a/site/assets/windows/gui-register-web.png b/site/assets/windows/gui-register-web.png new file mode 100644 index 0000000..4d4778e Binary files /dev/null and b/site/assets/windows/gui-register-web.png differ diff --git a/site/assets/windows/gui-register.png b/site/assets/windows/gui-register.png new file mode 100644 index 0000000..db4af6b Binary files /dev/null and b/site/assets/windows/gui-register.png differ diff --git a/site/assets/windows/gui-verify-web.png b/site/assets/windows/gui-verify-web.png new file mode 100644 index 0000000..c88247b Binary files /dev/null and b/site/assets/windows/gui-verify-web.png differ diff --git a/site/assets/windows/gui-verify.png b/site/assets/windows/gui-verify.png new file mode 100644 index 0000000..3d2ec9c Binary files /dev/null and b/site/assets/windows/gui-verify.png differ diff --git a/site/assets/windows/samples/hash-only.txt b/site/assets/windows/samples/hash-only.txt new file mode 100644 index 0000000..cbf75ee --- /dev/null +++ b/site/assets/windows/samples/hash-only.txt @@ -0,0 +1 @@ +c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c /tmp/verae-windows-docs-45782/sample/sample-contract.txt diff --git a/site/assets/windows/samples/verae-register.csv b/site/assets/windows/samples/verae-register.csv new file mode 100644 index 0000000..8442c59 --- /dev/null +++ b/site/assets/windows/samples/verae-register.csv @@ -0,0 +1,2 @@ +path,sha256,scope,receipt_id,created_at,merkle,status +/tmp/verae-windows-docs-45782/sample/sample-contract.txt,c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c,global,QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc,,,ok diff --git a/site/assets/windows/samples/verae-register.json b/site/assets/windows/samples/verae-register.json new file mode 100644 index 0000000..301a385 --- /dev/null +++ b/site/assets/windows/samples/verae-register.json @@ -0,0 +1,19 @@ +[ + { + "receipts": [ + { + "receipt_id": "QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc", + "hash": "c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c", + "scope": "global", + "cube_id": "", + "org_id": "", + "created_at": "", + "merkle": "", + "restamp_of": "" + } + ], + "receipt_file": "/Users/marchon/.verae/receipts/c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c.json", + "sha256": "c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c", + "path": "/tmp/verae-windows-docs-45782/sample/sample-contract.txt" + } +] diff --git a/site/assets/windows/samples/verae-register.txt b/site/assets/windows/samples/verae-register.txt new file mode 100644 index 0000000..9249331 --- /dev/null +++ b/site/assets/windows/samples/verae-register.txt @@ -0,0 +1,7 @@ +File: /tmp/verae-windows-docs-45782/sample/sample-contract.txt +SHA-256: c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c +Scope: global +Receipt: QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc +Original timestamp: 2026-09-28T17:15:39.073480469Z +Merkle: (none) +Status: ok diff --git a/site/assets/windows/samples/verae-verify.csv b/site/assets/windows/samples/verae-verify.csv new file mode 100644 index 0000000..ba25a35 --- /dev/null +++ b/site/assets/windows/samples/verae-verify.csv @@ -0,0 +1,2 @@ +path,sha256,scope,receipt_id,created_at,merkle,status +/tmp/verae-windows-docs-45782/sample/sample-contract.txt,c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c,global,QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc,2026-09-28T17:15:38.657720898Z,113072,valid diff --git a/site/assets/windows/samples/verae-verify.json b/site/assets/windows/samples/verae-verify.json new file mode 100644 index 0000000..8dadcd7 --- /dev/null +++ b/site/assets/windows/samples/verae-verify.json @@ -0,0 +1,30 @@ +[ + { + "sha256": "c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c", + "scope": "global", + "path": "/tmp/verae-windows-docs-45782/sample/sample-contract.txt", + "valid": true, + "error": null, + "receipt": { + "receipt_id": "QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc", + "hash": "", + "scope": "global", + "cube_id": "", + "org_id": "", + "created_at": "2026-09-28T17:15:38.657720898Z", + "merkle": "113072", + "restamp_of": "" + }, + "receipt_id": "QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc", + "receipts": [ + { + "receipt_id": "QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc", + "hash": "c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c", + "scope": "global", + "created_at": "2026-09-28T17:15:38.657720898Z", + "merkle": "113072" + } + ], + "status": "valid" + } +] diff --git a/site/assets/windows/samples/verae-verify.txt b/site/assets/windows/samples/verae-verify.txt new file mode 100644 index 0000000..bb59f84 --- /dev/null +++ b/site/assets/windows/samples/verae-verify.txt @@ -0,0 +1,7 @@ +File: /tmp/verae-windows-docs-45782/sample/sample-contract.txt +SHA-256: c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c +Scope: global +Receipt: QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc +Original timestamp: 2026-09-28T17:15:38.657720898Z +Merkle: 113072 +Status: valid diff --git a/site/downloads/index.html b/site/downloads/index.html new file mode 100644 index 0000000..d6fcb34 --- /dev/null +++ b/site/downloads/index.html @@ -0,0 +1,113 @@ + + + + + +Downloads — Verae timestamps + + + + + + + + + +
+ Verae + +
+
+

Downloads hub

+

Download Verae clients

+

Windows binaries, hasher manifests, and macOS TestFlight. macOS notarized zip, Windows binaries, hasher, TestFlight. Full list on Ways to use.

+
+
+

Timestamp explorer

+

Verify a receipt CID or file hash; open merkle on the chain explorer.

+ +
+ +
+

macOS direct download

+

Developer ID + Apple-notarized universal .app zip. No TestFlight required.

+ +
+
+

macOS TestFlight

+

Optional App Store–style beta. Request an Apple ID invite or use the public join link.

+ +
+ +
+
+ + + diff --git a/site/downloads/macos/README.md b/site/downloads/macos/README.md new file mode 100644 index 0000000..d60427a --- /dev/null +++ b/site/downloads/macos/README.md @@ -0,0 +1,39 @@ +# Verae Timestamping — macOS direct download + +Developer ID signed, Apple-notarized universal macOS app for Verae digests-only timestamping. + +**Version:** 0.2.1 (build 7) · universal (x86_64 + arm64) · macOS 14+ · FlightLookup Inc. (`G8CKY7VMZC`) + +## Install + +```sh +# Download VeraeTimestamping-macos-universal.zip from this folder (or https://mcp.verae.ai/macos.html) +unzip VeraeTimestamping-macos-universal.zip +# optional: move to Applications +# xattr -dr com.apple.quarantine "/Applications/Verae Timestamping.app" +``` + +| File | Notes | +|------|--------| +| `VeraeTimestamping-macos-universal.zip` | Stable URL (latest) | +| `VeraeTimestamping-0.2.1-macos-universal.zip` | Versioned copy (same bytes) | +| `SHA256SUMS` | SHA-256 of both zips | + +## Usage + +```sh +open "Verae Timestamping.app" +# MCP default: https://mcp.verae.ai/mcp · scope global +``` + +Gatekeeper: notarized Developer ID. Alternatives: [TestFlight](https://mcp.verae.ai/ways.html#testflight), or CLI from the client repo (`uv run verae-timestamp …`). + +Rebuild (maintainer): `cd mac/VeraeTimestamping && ./scripts/build-direct-macos.sh` + +## Contributing + +Questions: marchon@gmail.com or the Verae research workspace. PRs coordinated in-workspace first. + +## License + +UNLICENSED © George Lambert / FlightLookup Inc. diff --git a/site/downloads/macos/SHA256SUMS b/site/downloads/macos/SHA256SUMS new file mode 100644 index 0000000..b16861c --- /dev/null +++ b/site/downloads/macos/SHA256SUMS @@ -0,0 +1,2 @@ +0067ac8767942b4b657f0856c5b9a1389aaa61a3d998459dd7ea30669bb603d8 VeraeTimestamping-macos-universal.zip +0067ac8767942b4b657f0856c5b9a1389aaa61a3d998459dd7ea30669bb603d8 VeraeTimestamping-0.2.1-macos-universal.zip diff --git a/site/downloads/macos/VeraeTimestamping-0.2.1-macos-universal.zip b/site/downloads/macos/VeraeTimestamping-0.2.1-macos-universal.zip new file mode 100644 index 0000000..4cb9c4f Binary files /dev/null and b/site/downloads/macos/VeraeTimestamping-0.2.1-macos-universal.zip differ diff --git a/site/downloads/macos/VeraeTimestamping-macos-universal.zip b/site/downloads/macos/VeraeTimestamping-macos-universal.zip new file mode 100644 index 0000000..4cb9c4f Binary files /dev/null and b/site/downloads/macos/VeraeTimestamping-macos-universal.zip differ diff --git a/site/downloads/windows/README.md b/site/downloads/windows/README.md new file mode 100644 index 0000000..3e73865 --- /dev/null +++ b/site/downloads/windows/README.md @@ -0,0 +1,42 @@ +# Verae Timestamping — Windows amd64 + +Frozen Windows amd64 CLI, GUI, and optional hasher for Verae digests-only timestamping. + +## Install + +```bat +:: Download from this folder or https://mcp.verae.ai/windows.html +:: Verify SHA256SUMS, then run: +verae-timestamp.exe +verae-timestamp-gui.exe +``` + +| File | Description | +|------|-------------| +| `verae-timestamp.exe` | Frozen CLI (PyInstaller onefile), version **0.2.0** | +| `verae-timestamp-gui.exe` | Frozen GUI (PyInstaller onefile, windowed, PySide6), version **0.2.0** | +| `verae-sha256.exe` | Optional local hasher (`x86_64-pc-windows-gnu`, hasher 0.1.2) | + +Architecture: **PE32+ x86-64 (amd64)**. Not code-signed (SmartScreen may warn). Not Microsoft Store. + +## Usage + +```bat +verae-timestamp.exe set-defaults --url https://mcp.verae.ai/mcp --scope global --output text +verae-timestamp.exe register .\contract.pdf +verae-timestamp.exe verify .\contract.pdf +verae-timestamp.exe hash .\contract.pdf +verae-timestamp-gui.exe +``` + +GUI: Add files → Hash / Register / Verify; export JSON / CSV / Text. Config/auth/receipts under `%USERPROFILE%\.verae\`. Place `verae-sha256.exe` beside the GUI (or set `VERAE_SHA256_BIN`) for faster hashing. + +Rebuild (maintainer): `./scripts/build-windows-exe.sh` in the client repo (Wine + embeddable CPython 3.12 + PyInstaller). Built 2026-09-28. + +## Contributing + +Questions: marchon@gmail.com or the Verae research workspace. PRs coordinated in-workspace first. + +## License + +UNLICENSED © George Lambert diff --git a/site/downloads/windows/SHA256SUMS b/site/downloads/windows/SHA256SUMS new file mode 100644 index 0000000..d6308ba --- /dev/null +++ b/site/downloads/windows/SHA256SUMS @@ -0,0 +1,3 @@ +1e79afbd1cd1eb86094a4c3961d382b5292b4ff452f6f07399b52c559f5e59a9 verae-timestamp.exe +b3075e5a8d9ccaecb3f05231dd09d9de136bb41c064a175fc2418409f9319550 verae-timestamp-gui.exe +43e58d969eed10265fe028671ea3fa98d068209ff489b2020e30c0179daba48a verae-sha256.exe diff --git a/site/downloads/windows/verae-sha256.exe b/site/downloads/windows/verae-sha256.exe new file mode 100644 index 0000000..2029deb Binary files /dev/null and b/site/downloads/windows/verae-sha256.exe differ diff --git a/site/downloads/windows/verae-timestamp-gui.exe b/site/downloads/windows/verae-timestamp-gui.exe new file mode 100755 index 0000000..cfc761d Binary files /dev/null and b/site/downloads/windows/verae-timestamp-gui.exe differ diff --git a/site/downloads/windows/verae-timestamp.exe b/site/downloads/windows/verae-timestamp.exe new file mode 100644 index 0000000..0ed4908 Binary files /dev/null and b/site/downloads/windows/verae-timestamp.exe differ diff --git a/site/explorer.html b/site/explorer.html new file mode 100644 index 0000000..996af00 --- /dev/null +++ b/site/explorer.html @@ -0,0 +1,316 @@ + + + + + +Explorer — verify receipt or file hash · Verae + + + + + + + + +
+ Verae + +
+ +
+

Lookup · digests only

+

Verify a receipt or a file hash

+

Public timestamp explorer for Verae. Paste a receipt CID, a SHA-256, or drop a file (hashed in-browser — bytes never upload). Results come from the MCP at https://mcp.verae.ai/mcp. On-chain merkle / block proofs open in the Verae blockchain explorer.

+
+
+ +
+
+
+

Lookup

+

Provide a receipt id, a hex digest, and/or a file. File bytes stay in this tab.

+ + +
Drop a file to hash locally, or click to choose
+ +

+ +

Deep link: ?receipt=… and/or ?sha256=…

+
+
+

Result Idle

+
Awaiting input…
+ +
+
+ +

How this relates to the chain explorer

+
+

+ This page checks VeraeTime timestamp certificates (receipt CIDs) and digests via MCP (verify / hash_report).
+ explorer.verae.ai is the Substrate blockchain explorer (blocks, extrinsics, accounts). When a receipt includes a merkle / block reference (e.g. 113072), use View merkle / block on chain to open it there. +

+
+
+ + + + + + diff --git a/site/hasher.html b/site/hasher.html index a41180b..06649f4 100644 --- a/site/hasher.html +++ b/site/hasher.html @@ -4,17 +4,105 @@ Verae WASM hasher + + + + + -

Local WASM hasher

-

Hash a file in this browser. Bytes never leave the device. Paste the digest into sample.html or your MCP client.

- -
Waiting for a file…
+
+ Verae + +
+
+

Local WASM · digests only

+

Local WASM hasher

+

Hash a file in this browser. Bytes never leave the device. Paste the digest into sample.html, the browser app, or your MCP client. More options: Ways to use.

+ +
Waiting for a file…
+
+ + + + +
+ Verae + +
+
+

Browser embed · no AI required · Global ledger

+

Timestamp registration

+

This page is a plain web app calling the Verae MCP over HTTPS — no AI chat involved. The same pattern embeds in your own site or runs from a local program. Drop one or many documents; each is hashed here (bytes never upload), then checked with scope = global. You get the original timestamp, the receipt, and CSV / JSON / PDF / text exports — including an official registration PDF with QR lookup.

+

Need Register and Verify with auth defaults? Use the full browser app (Mac-parity client).

+ +
+

How it works

+
    +
  1. No assistant required — this is an embedded browser client of the MCP. Files stay on your device; only the SHA-256 digest is sent to /mcp.
  2. +
  3. Default scope is global. Calling stamp is idempotent: an existing certificate is returned with its original time.
  4. +
  5. Each row shows the original created_at, receipt id (CID), and optional merkle / block hash.
  6. +
  7. Export selected formats. PDF is a formal Verae Timestamp Registration Document (one page per file) with logo, ledger location, and a QR code that opens the public lookup for that digest.
  8. +
+
+ +
+

Options

+
+ + +
+
+
+
Return / export formats
+
+ + + + +
+
+
+
+ +
+

Documents

+
+ Drop files here + or click to choose · multiple files supported + +
+
+ + + +
+

Waiting for files…

+
+ + + + + + + + + + + + +
+

Lookup URL pattern (also encoded in each PDF QR):

+
+
+ + + + diff --git a/site/sample.html b/site/sample.html index 3a46f6f..d66712d 100644 --- a/site/sample.html +++ b/site/sample.html @@ -4,28 +4,108 @@ Verae timestamping MCP sample + + + + + -

Verae timestamping MCP sample

-

This page calls a Model Context Protocol (MCP) server. The three letters are MCP. The server name is verae-timestamping. It stamps a SHA-256 digest. It does not upload a file.

-

This page defaults to the public MCP on this host. You can also point it at a local uvx server on 127.0.0.1.

-
VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server
+
+ Verae + +
+
+

MCP sample · no AI required

+

Verae timestamping MCP sample

+

This page calls a Model Context Protocol (MCP) server. It stamps a SHA-256 digest. It does not upload a file. See all clients on Ways to use.

+

Multi-document registration: register.html. Full Register + Verify client: app.html.

+

Defaults to the public MCP on this host. You can also point it at a local uvx server on 127.0.0.1.

+
VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server
- +
+ + diff --git a/site/windows.html b/site/windows.html new file mode 100644 index 0000000..74dbf9e --- /dev/null +++ b/site/windows.html @@ -0,0 +1,439 @@ + + + + + +Windows downloads — Verae Timestamping + + + + + + + + +
+ Verae + +
+ +
+
Windows amd64 · v0.2.0 · 2026-09-28
+

Verae Timestamping for Windows

+

Standalone GUI, CLI, and optional local hasher. Hash on the PC, register or verify digests against https://mcp.verae.ai/mcp. File bytes never leave the machine — only SHA-256 digests are sent.

+ +

Stable URLs under /downloads/windows/ · PE32+ x86-64 · not code-signed · not in the Microsoft Store

+
+ +
+
+ +
+ Windows SmartScreen / Defender caveat. + These builds are not code-signed. First launch will often show “Windows protected your PC.” Click More info → Run anyway, or right-click the EXE → Properties → check Unblock → Apply. This is expected for ad-hoc research builds. Always verify the SHA-256 below before running. +
+ +

Artifacts

+

All three EXEs are frozen one-file binaries (GUI/CLI via PyInstaller; hasher is a small native Rust build). Place them in the same folder so the GUI can find verae-sha256.exe.

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
FileSizeSHA-256What it is
verae-timestamp-gui.exe65.4 MiBb3075e5a8d9ccaecb3f05231dd09d9de136bb41c064a175fc2418409f9319550Qt GUI (windowed). Add files → Hash / Register / Verify → Export.
verae-timestamp.exe21.7 MiB1e79afbd1cd1eb86094a4c3961d382b5292b4ff452f6f07399b52c559f5e59a9Console CLI. Same register/verify/hash pipeline as the GUI.
verae-sha256.exe0.4 MiB43e58d969eed10265fe028671ea3fa98d068209ff489b2020e30c0179daba48aOptional fast local hasher. Also published under /hasher/0.1.2/.
+

Checksum file: /downloads/windows/SHA256SUMS · package notes: README.md

+
+ +

Install & first run

+
    +
  1. Download the three EXEs (or at least the GUI) into a folder such as %USERPROFILE%\Verae\.
  2. +
  3. Verify checksums, e.g. in PowerShell: +
    Get-FileHash .\verae-timestamp-gui.exe -Algorithm SHA256
    +Get-Content .\SHA256SUMS
    +
  4. +
  5. If SmartScreen blocks: More info → Run anyway, or unblock via Properties.
  6. +
  7. Double-click verae-timestamp-gui.exe (first launch can be slow while the onefile bundle extracts to %TEMP%\_MEI…).
  8. +
  9. Confirm MCP URL is https://mcp.verae.ai/mcp, scope global, then Save defaults (writes %USERPROFILE%\.verae\client.json).
  10. +
  11. Optional auth for global stamps that need a VeraeTime account — use the CLI login / set-auth helpers; tokens live in %USERPROFILE%\.verae\auth.properties (mode 600 on Unix; ACL-protected on Windows where possible).
  12. +
+ +

GUI: Register · Verify · Hash · Export

+

Status colors: green = PASS (registered / valid), red = FAIL, blue = hashed. Results table columns: Status, File, SHA-256, Timestamp, Receipt, Scope. Export JSON / CSV / Text, or Copy output.

+ +
+
+ Verae Timestamping GUI ready with a sample file added +
+ Ready + Defaults pointed at mcp.verae.ai · sample file added · Hash / Verify / Register awaiting action. +
+
+
+ GUI after Hash — blue hashed status and SHA-256 +
+ Hash + Local SHA-256 only (no network). Status shows hashed in blue. +
+
+
+ GUI after Register — green PASS Registered with receipt +
+ Register + Digest stamped via MCP. Green PASS · Registered with receipt id. +
+
+
+ GUI after Verify — green PASS Valid with timestamp and merkle +
+ Verify + Lookup against global ledger. Green PASS · Valid with original timestamp and merkle. +
+
+
+

Screenshots are the real PySide6 Qt GUI shipped inside verae-timestamp-gui.exe (same gui_qt.py), captured in a dry run against https://mcp.verae.ai/mcp on 2026-09-28. Window chrome may differ slightly on a native Windows desktop; layout and results are identical.

+ +

CLI usage

+
verae-timestamp.exe
+verae-timestamp.exe set-defaults --url https://mcp.verae.ai/mcp --scope global --output text
+verae-timestamp.exe hash .\sample-contract.txt
+verae-timestamp.exe register .\sample-contract.txt
+verae-timestamp.exe verify .\sample-contract.txt
+verae-timestamp.exe verify --output json --output-dir .\out .\sample-contract.txt
+verae-timestamp.exe verify --output csv --output-dir .\out .\sample-contract.txt
+

Receipts also land under %USERPROFILE%\.verae\receipts\<sha256>.json unless --no-persist is set.

+ +

Example outputs (real dry run)

+

Captured 2026-09-28 against the live MCP with a local sample file. Paths below reflect the capture host; on Windows you would see a C:\… path instead. Digests, receipt ids, and timestamps are authentic.

+ +
+ + + + + +
+ +
+
c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c  sample-contract.txt
+
+ + + + +

Raw capture files (full paths as written by the client): + verae-verify.json · + verae-verify.csv · + verae-register.txt · + hash-only.txt +

+ +

Runtime notes

+ +
+ + + + + + diff --git a/src/verae_mcp_timestamping_server/global_client.py b/src/verae_mcp_timestamping_server/global_client.py index af399d8..5d2ae81 100644 --- a/src/verae_mcp_timestamping_server/global_client.py +++ b/src/verae_mcp_timestamping_server/global_client.py @@ -486,6 +486,70 @@ class GlobalClient: ) + def hash_report(self, digest: str) -> list[Receipt]: + """Carfax: GET /api/hash/{digest} -> receipts for one SHA-256 (read-only).""" + override = request_token.get() + saved = self.token + if override: + self.token = override + else: + self.ensure_token() + try: + return self._hash_report(digest) + finally: + if override: + self.token = saved + + def _hash_report(self, digest: str) -> list[Receipt]: + url = f"{self.base_url}/api/hash/{digest}" + res = self.http.request("GET", url, payload={}, headers=self._headers()) + if res.status_code == 404: + return [] + if res.status_code >= 300: + detail = "" + parsed_err = res.json() if hasattr(res, "json") else {} + if isinstance(parsed_err, dict): + detail = str(parsed_err.get("error") or "").strip() + if not detail: + detail = (res.text or "").strip() + raise ValueError(f"hash report HTTP {res.status_code}: {detail}") + parsed = res.json() if hasattr(res, "json") else {} + if not isinstance(parsed, dict): + return [] + refs = parsed.get("receipts") or [] + if not isinstance(refs, list): + return [] + out: list[Receipt] = [] + for ref in refs: + if not isinstance(ref, dict): + continue + cid = str(ref.get("certificate") or ref.get("receipt_id") or "") + if not cid: + continue + created = str(ref.get("completedAt") or ref.get("created_at") or "") + restamp = str(ref.get("restampOf") or ref.get("restamp_of") or "") + hash_val = str(ref.get("digest") or ref.get("hash") or digest) + merkle = "" + try: + verified = self._verify_certificate(cid) + except Exception: + verified = None + if verified is not None and verified.valid and verified.receipt is not None: + created = verified.receipt.created_at or created + merkle = verified.receipt.merkle or merkle + hash_val = verified.receipt.hash or hash_val + out.append( + Receipt( + receipt_id=cid, + hash=hash_val or digest, + scope="global", + created_at=created, + merkle=str(merkle), + restamp_of=restamp, + ) + ) + return out + class GlobalLedger: def __init__(self, client: GlobalClient) -> None: self.client = client @@ -509,5 +573,6 @@ class GlobalLedger: async def hash_report( self, sha256: str, org_id: str = "", want_scope: str = "" ) -> list[Receipt]: - del sha256, org_id, want_scope - return [] + del org_id, want_scope + digest = validate_sha256(sha256) + return await asyncio.to_thread(self.client.hash_report, digest) diff --git a/src/verae_mcp_timestamping_server/ledger.py b/src/verae_mcp_timestamping_server/ledger.py index 3fe9fe4..8cfab22 100644 --- a/src/verae_mcp_timestamping_server/ledger.py +++ b/src/verae_mcp_timestamping_server/ledger.py @@ -237,6 +237,25 @@ class RoutingLedger: async def hash_report( self, sha256: str, org_id: str = "", want_scope: str = "" ) -> list[Receipt]: - return await self.local.hash_report( + if want_scope == "global": + return await self.global_ledger.hash_report( + sha256, org_id=org_id, want_scope=want_scope + ) + local = await self.local.hash_report( sha256, org_id=org_id, want_scope=want_scope ) + if want_scope: + return local + try: + central = await self.global_ledger.hash_report( + sha256, org_id=org_id, want_scope=want_scope + ) + except Exception: + return local + seen = {rec.receipt_id for rec in local} + merged = list(local) + for rec in central: + if rec.receipt_id not in seen: + merged.append(rec) + seen.add(rec.receipt_id) + return merged diff --git a/tests/test_global_client.py b/tests/test_global_client.py index d4bb27d..e3f2cdb 100644 --- a/tests/test_global_client.py +++ b/tests/test_global_client.py @@ -35,9 +35,33 @@ class FakeHttp: "blockHash": "0xdead", }, ) + if "/api/hash/" in url: + digest = url.rsplit("/", 1)[-1] + return _Resp( + 200, + { + "digest": digest, + "receipts": [ + { + "jobId": "job-1", + "certificate": "job-1", + "digest": digest, + "completedAt": "2026-08-15T00:00:00Z", + } + ], + }, + ) if url.endswith("/api/verify"): if payload.get("certificate") == "job-1": - return _Resp(200, {"valid": True, "hash": SHA256_EMPTY}) + return _Resp( + 200, + { + "valid": True, + "hash": SHA256_EMPTY, + "timestamp": "2026-08-15T00:00:00Z", + "blockIndex": 113072, + }, + ) return _Resp(200, {"valid": False}) if "/api/status/" in url: return _Resp( @@ -178,7 +202,11 @@ async def test_routing_sends_only_global_scope_to_central(): assert http.calls[0][1].endswith("/api/timestamp") cached = await routed.hash_report(SHA256_EMPTY, want_scope="global") - assert cached == [] + assert len(cached) == 1 + assert cached[0].receipt_id == "job-1" + assert cached[0].scope == "global" + assert cached[0].merkle == "113072" + assert any("/api/hash/" in c[1] for c in http.calls) weak = await routed.verify(local_rec.receipt_id, want_scope="global") assert weak.valid is False @@ -338,3 +366,17 @@ async def test_submit_skips_login_when_token_is_fresh(tmp_path): assert rec.receipt_id == "QmFresh" assert http.calls[0][1].endswith("/api/timestamp") assert http.calls[0][3]["Authorization"] == "Bearer still-good" + + +async def test_global_hash_report_uses_api_hash(): + http = FakeHttp() + ledger = GlobalLedger(GlobalClient("https://api.veraetime.net", token="secret", http=http)) + recs = await ledger.hash_report(SHA256_EMPTY, want_scope="global") + assert len(recs) == 1 + assert recs[0].receipt_id == "job-1" + assert recs[0].hash == SHA256_EMPTY + assert recs[0].merkle == "113072" + methods_urls = [(m, u) for m, u, _, _ in http.calls] + assert methods_urls[0] == ("GET", f"https://api.veraetime.net/api/hash/{SHA256_EMPTY}") + assert methods_urls[1][0] == "POST" + assert methods_urls[1][1].endswith("/api/verify")