diff --git a/README.md b/README.md index 30257b1..1c840df 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Verae MCP timestamping server. Hash-only stamps via leaf locald/NATS. -The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. Bearer token: `VERAE_GLOBAL_TOKEN` or `~/.verae/auth.properties`. +The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. `signup` creates a `user` account with no existing session. `login` stores the VeraeTime JWT on this host. `session` checks it. `create_user` adds an account when that JWT is an admin. ## Install @@ -40,10 +40,10 @@ uv run python scripts/transaction_log_test.py open debug-trace/2026-09-20T143541Z-verae-debug-test/transaction-log-test.html ``` -Global stamp (central VeraeTime). Token lives at `~/.verae/auth.properties` (mode 600): +Global stamp (central VeraeTime). `login` writes `~/.verae/auth.properties` (mode 600) and does not return the JWT: ```sh -# refresh: POST https://api.veraetime.net/auth/login → access_token= +# MCP tools: signup, login, session, create_user # or: export VERAE_GLOBAL_TOKEN=... # then stamp with scope=global ``` diff --git a/docs/api.md b/docs/api.md index 9e8411d..097fee5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,6 +36,42 @@ Scope rank: local < shared < global. Returns `{ "receipts": [ ... ] }` from the local file. Global-only certificates are not listed. +## login + +| Argument | Default | Meaning | +| --- | --- | --- | +| `username` | required | VeraeTime account | +| `password` | required | VeraeTime password | + +`POST /auth/login`. Stores `access_token`, `id_token`, `auth_token`, `expires_at`, `username`, and `password` in the auth properties file (mode `0600`) and replaces the in-memory JWT. Returns `username`, `role`, and `expires_at`. The JWT is not returned. + +## session + +No arguments. `GET /auth/validate` with the stored JWT. + +Returns `valid`, `username`, `role`, and `expires_at`. + +## signup + +| Argument | Default | Meaning | +| --- | --- | --- | +| `username` | required | New account | +| `password` | required | New password | + +`POST /auth/signup` with no bearer token. The timestamping service creates the account with role `user`. Returns `id`, `username`, and `role`. Does not replace the JWT stored for `stamp`. A new account calls `login` to become the stored session. `create_user` remains the admin path and can set `admin`. + +## create_user + +| Argument | Default | Meaning | +| --- | --- | --- | +| `username` | required | New account | +| `password` | required | New password | +| `role` | required | `user` or `admin` | + +`POST /auth/users` with the stored JWT. Refuses unless `session` reports role `admin`. Returns `id`, `username`, and `role`. There is no public signup; the first admin already exists on the timestamping service. + +A stored login is refreshed when `stamp` or `verify` runs and `expires_at` is missing or within 5 minutes, if `username` and `password` are in the auth file. + ## Environment | Name | Default | diff --git a/docs/html/api.html b/docs/html/api.html index c8a6f1e..61836ce 100644 --- a/docs/html/api.html +++ b/docs/html/api.html @@ -201,6 +201,104 @@ when the stored scope ranks lower

Returns { "receipts": [ ... ] } from the local file. Global-only certificates are not listed.

+

login

+ + + + + + + + + + + + + + + + + + + + +
ArgumentDefaultMeaning
usernamerequiredVeraeTime account
passwordrequiredVeraeTime password
+

POST /auth/login. Stores access_token, +id_token, auth_token, expires_at, +username, and password in the auth properties +file (mode 0600) and replaces the in-memory JWT. Returns +username, role, and expires_at. +The JWT is not returned.

+

session

+

No arguments. GET /auth/validate with the stored +JWT.

+

Returns valid, username, role, +and expires_at.

+

signup

+ + + + + + + + + + + + + + + + + + + + +
ArgumentDefaultMeaning
usernamerequiredNew account
passwordrequiredNew password
+

POST /auth/signup with no bearer token. The timestamping +service creates the account with role user. Returns +id, username, and role. Does not +replace the JWT stored for stamp. A new account calls +login to become the stored session. +create_user remains the admin path and can set +admin.

+

create_user

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ArgumentDefaultMeaning
usernamerequiredNew account
passwordrequiredNew password
rolerequireduser or admin
+

POST /auth/users with the stored JWT. Refuses unless +session reports role admin. Returns +id, username, and role. There is +no public signup; the first admin already exists on the timestamping +service.

+

A stored login is refreshed when stamp or +verify runs and expires_at is missing or +within 5 minutes, if username and password are +in the auth file.

Environment

diff --git a/docs/html/operator-guide.html b/docs/html/operator-guide.html index 677ed10..148073b 100644 --- a/docs/html/operator-guide.html +++ b/docs/html/operator-guide.html @@ -112,9 +112,14 @@ site/index.html on the same machine, or put TLS and an access control in front of a non-loopback bind you choose yourself.

Global token

-
# POST https://api.veraetime.net/auth/login and store access_token=
-# in ~/.verae/auth.properties (mode 600), or:
-export VERAE_GLOBAL_TOKEN=...
+

signup creates a role user account with no +bearer token. Call login with that username and password to +store the JWT in ~/.verae/auth.properties (mode 600). The +server uses it for scope=global without a restart. +session checks that JWT. create_user adds an +account when the stored role is admin.

+
# or skip the tool and export a token yourself:
+export VERAE_GLOBAL_TOKEN=...

Tests

uv run pytest

Stamp traces (live VeraeTime) are a separate script. They write diff --git a/docs/html/standards.html b/docs/html/standards.html index 94f16ab..1228b78 100644 --- a/docs/html/standards.html +++ b/docs/html/standards.html @@ -90,6 +90,17 @@ means “not in the local file,” not “VeraeTime has no record.”

access_token / id_token / auth_token in ~/.verae/auth.properties (VERAE_AUTH_PROPERTIES overrides the path). +
  • login writes that file (mode 0600), +including username, password, and +expires_at. The tool result does not include the JWT.
  • +
  • signup calls POST /auth/signup with no +bearer token. The account role is user. It does not replace +the stored JWT.
  • +
  • create_user calls POST /auth/users only +when the stored token’s role is admin.
  • +
  • Before a global stamp or verify, a stored username and password are +used to log in again when expires_at is missing or within 5 +minutes.
  • Do not commit tokens. .env is gitignored.
  • The local receipt file is mode 0600 after each save.
  • diff --git a/docs/operator-guide.md b/docs/operator-guide.md index c8e9cd0..7a47a93 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -31,9 +31,10 @@ A page served from the public internet still cannot call `127.0.0.1` from curren ## Global token +`signup` creates a role `user` account with no bearer token. Call `login` with that username and password to store the JWT in `~/.verae/auth.properties` (mode 600). The server uses it for `scope=global` without a restart. `session` checks that JWT. `create_user` adds an account when the stored role is `admin`. + ```sh -# POST https://api.veraetime.net/auth/login and store access_token= -# in ~/.verae/auth.properties (mode 600), or: +# or skip the tool and export a token yourself: export VERAE_GLOBAL_TOKEN=... ``` diff --git a/docs/pdf/api.pdf b/docs/pdf/api.pdf index b7ce20e..84eac75 100644 Binary files a/docs/pdf/api.pdf and b/docs/pdf/api.pdf differ diff --git a/docs/pdf/architecture.pdf b/docs/pdf/architecture.pdf index 19b61e3..ed2f271 100644 Binary files a/docs/pdf/architecture.pdf and b/docs/pdf/architecture.pdf differ diff --git a/docs/pdf/operator-guide.pdf b/docs/pdf/operator-guide.pdf index e262c2f..8ae4214 100644 Binary files a/docs/pdf/operator-guide.pdf and b/docs/pdf/operator-guide.pdf differ diff --git a/docs/pdf/standards.pdf b/docs/pdf/standards.pdf index 68d9803..3c38a76 100644 Binary files a/docs/pdf/standards.pdf and b/docs/pdf/standards.pdf differ diff --git a/docs/standards.md b/docs/standards.md index 6b1db11..27cf627 100644 --- a/docs/standards.md +++ b/docs/standards.md @@ -29,6 +29,10 @@ This process is an MCP server (Model Context Protocol). Clients are assistants a ## Credentials - Token: `VERAE_GLOBAL_TOKEN`, else `access_token` / `id_token` / `auth_token` in `~/.verae/auth.properties` (`VERAE_AUTH_PROPERTIES` overrides the path). +- `login` writes that file (mode `0600`), including `username`, `password`, and `expires_at`. The tool result does not include the JWT. +- `signup` calls `POST /auth/signup` with no bearer token. The account role is `user`. It does not replace the stored JWT. +- `create_user` calls `POST /auth/users` only when the stored token's role is `admin`. +- Before a global stamp or verify, a stored username and password are used to log in again when `expires_at` is missing or within 5 minutes. - Do not commit tokens. `.env` is gitignored. - The local receipt file is mode `0600` after each save. diff --git a/src/verae_mcp_timestamping_server/global_client.py b/src/verae_mcp_timestamping_server/global_client.py index 42becab..0192aee 100644 --- a/src/verae_mcp_timestamping_server/global_client.py +++ b/src/verae_mcp_timestamping_server/global_client.py @@ -9,12 +9,16 @@ from __future__ import annotations import asyncio import json import os +import re import time import urllib.error import urllib.request +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Protocol +from pydantic import BaseModel + from verae_mcp_timestamping_server.ledger import ( Receipt, VerifyResult, @@ -22,6 +26,8 @@ from verae_mcp_timestamping_server.ledger import ( ) DEFAULT_GLOBAL_URL = "https://api.veraetime.net" +# Refresh a stored login this long before expires_at. +TOKEN_REFRESH_SKEW = timedelta(minutes=5) class HttpTransport(Protocol): @@ -81,20 +87,86 @@ class UrllibHttp: return _UrllibResp(status, body, raw, resp_headers) +class LoginResult(BaseModel): + username: str + role: str + expires_at: str + + +class SessionResult(BaseModel): + valid: bool = False + username: str = "" + role: str = "" + expires_at: str = "" + + +class CreateUserResult(BaseModel): + id: str = "" + username: str + role: str + + +def default_auth_path() -> Path: + override = os.environ.get("VERAE_AUTH_PROPERTIES") + if override: + return Path(override) + return Path.home() / ".verae" / "auth.properties" + + +def read_properties(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + props: dict[str, str] = {} + for line in path.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + props[key.strip()] = value.strip() + return props + + +def write_properties(path: Path, props: dict[str, str]) -> None: + path.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + merged = read_properties(path) + merged.update(props) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text("".join(f"{key}={value}\n" for key, value in merged.items())) + os.chmod(tmp, 0o600) + tmp.replace(path) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def parse_expiry(value: str) -> datetime | None: + text = value.strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + match = re.match(r"^(.*T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2})?$", text) + if match is None: + return None + frac = match.group(2) or "" + if frac: + frac = "." + frac[1:7] + zone = match.group(3) or "+00:00" + try: + parsed = datetime.fromisoformat(match.group(1) + frac + zone) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + def load_auth_token() -> str: env = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip() if env: return env - path = Path(os.environ.get("VERAE_AUTH_PROPERTIES", Path.home() / ".verae" / "auth.properties")) - if not path.is_file(): - return "" - props: dict[str, str] = {} - for line in path.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - props[key.strip()] = value.strip() + props = read_properties(default_auth_path()) return props.get("access_token") or props.get("id_token") or props.get("auth_token") or "" @@ -104,16 +176,161 @@ class GlobalClient: base_url: str = DEFAULT_GLOBAL_URL, token: str = "", http: HttpTransport | None = None, + auth_path: Path | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.token = token self.http = http or UrllibHttp() + self.auth_path = Path(auth_path) if auth_path is not None else None + self.expires_at: datetime | None = None + self._username = "" + self._password = "" + if self.auth_path is not None: + self._load_stored(keep_token=bool(token)) @classmethod def from_env(cls) -> GlobalClient: + env_token = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip() return cls( base_url=os.environ.get("VERAE_GLOBAL_URL", DEFAULT_GLOBAL_URL), - token=load_auth_token(), + token=env_token, + auth_path=default_auth_path(), + ) + + def _load_stored(self, keep_token: bool) -> None: + if self.auth_path is None: + return + props = read_properties(self.auth_path) + if not keep_token: + self.token = ( + props.get("access_token") + or props.get("id_token") + or props.get("auth_token") + or self.token + ) + self._username = props.get("username") or "" + self._password = props.get("password") or "" + self.expires_at = parse_expiry(props.get("expires_at") or "") + + def needs_refresh(self) -> bool: + if not self._username or not self._password: + return False + if not self.token or self.expires_at is None: + return True + return self.expires_at <= datetime.now(timezone.utc) + TOKEN_REFRESH_SKEW + + def ensure_token(self) -> None: + if self.needs_refresh(): + self.login(self._username, self._password) + + def login(self, username: str, password: str) -> LoginResult: + if not username or not password: + raise ValueError("username and password are required") + res = self.http.request( + "POST", + f"{self.base_url}/auth/login", + payload={"username": username, "password": password}, + headers={"Content-Type": "application/json"}, + ) + parsed = res.json() if hasattr(res, "json") else {} + if not isinstance(parsed, dict): + parsed = {} + if res.status_code >= 300: + detail = str(parsed.get("error") or res.text or "").strip() + raise ValueError(f"login HTTP {res.status_code}: {detail}") + token = str(parsed.get("token") or "") + if not token: + raise ValueError("login response missing token") + user = parsed.get("user") if isinstance(parsed.get("user"), dict) else {} + expires_at = str(parsed.get("expiresAt") or "") + self.token = token + self._username = username + self._password = password + self.expires_at = parse_expiry(expires_at) + path = self.auth_path or default_auth_path() + self.auth_path = path + write_properties( + path, + { + "access_token": token, + "id_token": token, + "auth_token": token, + "expires_at": expires_at, + "username": username, + "password": password, + }, + ) + return LoginResult( + username=str(user.get("username") or username), + role=str(user.get("role") or ""), + expires_at=expires_at, + ) + + def session(self) -> SessionResult: + if not self.token: + return SessionResult() + res = self.http.request( + "GET", + f"{self.base_url}/auth/validate", + payload={}, + headers=self._headers(), + ) + parsed = res.json() if hasattr(res, "json") else {} + if not isinstance(parsed, dict) or res.status_code >= 300 or not parsed.get("valid"): + return SessionResult() + return SessionResult( + valid=True, + username=str(parsed.get("username") or ""), + role=str(parsed.get("role") or ""), + expires_at=str(parsed.get("expiresAt") or ""), + ) + + def signup(self, username: str, password: str) -> CreateUserResult: + """Create a role=user account. Does not replace this process's JWT.""" + if not username or not password: + raise ValueError("username and password are required") + kept = self.token + res = self.http.request( + "POST", + f"{self.base_url}/auth/signup", + payload={"username": username, "password": password}, + headers={"Content-Type": "application/json"}, + ) + self.token = kept + parsed = res.json() if hasattr(res, "json") else {} + if not isinstance(parsed, dict): + parsed = {} + if res.status_code >= 300: + detail = str(parsed.get("error") or res.text or "").strip() + raise ValueError(f"signup HTTP {res.status_code}: {detail}") + return CreateUserResult( + id=str(parsed.get("id") or ""), + username=str(parsed.get("username") or username), + role=str(parsed.get("role") or "user"), + ) + + def create_user(self, username: str, password: str, role: str) -> CreateUserResult: + if not username or not password or not role: + raise ValueError("username, password, and role are required") + current = self.session() + if not current.valid or current.role != "admin": + raise ValueError("admin login required") + res = self.http.request( + "POST", + f"{self.base_url}/auth/users", + payload={"username": username, "password": password, "role": role}, + headers=self._headers(), + ) + parsed = res.json() if hasattr(res, "json") else {} + if not isinstance(parsed, dict): + parsed = {} + if res.status_code >= 300: + detail = str(parsed.get("error") or res.text or "").strip() + raise ValueError(f"create user HTTP {res.status_code}: {detail}") + return CreateUserResult( + id=str(parsed.get("id") or ""), + username=str(parsed.get("username") or username), + role=str(parsed.get("role") or role), ) def _headers(self) -> dict[str, str]: @@ -123,6 +340,7 @@ class GlobalClient: return headers def submit(self, digest: str, cube_id: str = "") -> Receipt: + self.ensure_token() url = f"{self.base_url}/api/timestamp" res = self.http.request( "POST", @@ -132,13 +350,18 @@ class GlobalClient: "digest": digest, "hashAlg": "SHA256", "restamp": False, - "wait": True, }, headers=self._headers(), ) parsed = res.json() if hasattr(res, "json") else {} - if res.status_code == 200 and isinstance(parsed, dict): - cid = str(parsed.get("certificate") or parsed.get("jobId") or "") + if not isinstance(parsed, dict): + parsed = {} + # 200 + certificate is the first CID for a digest already in the queue. + # jobId on that response is the original job, not the CID. + if res.status_code == 200 and ( + parsed.get("certificate") or parsed.get("existing") + ): + cid = str(parsed.get("certificate") or "") if cid: return Receipt( receipt_id=cid, @@ -189,6 +412,7 @@ class GlobalClient: ) def verify_certificate(self, receipt_id: str) -> VerifyResult: + self.ensure_token() url = f"{self.base_url}/api/verify" res = self.http.request( "POST", diff --git a/src/verae_mcp_timestamping_server/server.py b/src/verae_mcp_timestamping_server/server.py index 16d078b..00afa25 100644 --- a/src/verae_mcp_timestamping_server/server.py +++ b/src/verae_mcp_timestamping_server/server.py @@ -2,12 +2,19 @@ from __future__ import annotations +import asyncio import os from mcp.server import MCPServer from mcp.server.mcpserver.exceptions import ToolError -from verae_mcp_timestamping_server.global_client import GlobalClient, GlobalLedger +from verae_mcp_timestamping_server.global_client import ( + CreateUserResult, + GlobalClient, + GlobalLedger, + LoginResult, + SessionResult, +) from verae_mcp_timestamping_server.guard import reject_content_keys, strict_arg_model from verae_mcp_timestamping_server.ledger import ( FileLedger, @@ -20,15 +27,25 @@ from verae_mcp_timestamping_server.ledger import ( mcp = MCPServer("verae-timestamping") _ledger: Ledger = MemoryLedger() +_global_client: GlobalClient = GlobalClient() -def default_ledger() -> Ledger: +def default_ledger(client: GlobalClient | None = None) -> Ledger: + held = client or GlobalClient.from_env() return RoutingLedger( local=FileLedger(), - global_ledger=GlobalLedger(GlobalClient.from_env()), + global_ledger=GlobalLedger(held), ) +def _client_of(ledger: Ledger) -> GlobalClient | None: + inner = getattr(ledger, "global_ledger", None) + client = getattr(inner, "client", None) + if isinstance(client, GlobalClient): + return client + return None + + _HARDENED_TOOLS: set[int] = set() @@ -57,9 +74,18 @@ def _harden_hash_only_tools(server: MCPServer) -> None: _HARDENED_TOOLS.add(tid) -def build_mcp(ledger: Ledger | None = None) -> MCPServer: - global _ledger - _ledger = ledger or default_ledger() +def build_mcp( + ledger: Ledger | None = None, + global_client: GlobalClient | None = None, +) -> MCPServer: + global _ledger, _global_client + if ledger is None: + client = global_client or GlobalClient.from_env() + _ledger = default_ledger(client) + _global_client = client + else: + _ledger = ledger + _global_client = global_client or _client_of(ledger) or GlobalClient() _harden_hash_only_tools(mcp) return mcp @@ -72,7 +98,7 @@ def _tool_error(exc: ValueError) -> ToolError: async def stamp( sha256: str, scope: str = "local", cube_id: str = "", org_id: str = "" ) -> Receipts: - """Commit a SHA-256. scope=local is the leaf file ledger; scope=global is api.veraetime.net.""" + """Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID.""" try: receipts = await _ledger.stamp( sha256, scope=scope, cube_id=cube_id, org_id=org_id @@ -88,6 +114,41 @@ async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult: return await _ledger.verify(receipt_id, want_scope=want_scope) +@mcp.tool() +async def login(username: str, password: str) -> LoginResult: + """Log in to api.veraetime.net. Stores the JWT on this host and does not return it.""" + try: + return await asyncio.to_thread(_global_client.login, username, password) + except ValueError as exc: + raise _tool_error(exc) from exc + + +@mcp.tool() +async def session() -> SessionResult: + """Validate the stored VeraeTime JWT. Returns valid, username, role, and expires_at.""" + return await asyncio.to_thread(_global_client.session) + + +@mcp.tool() +async def signup(username: str, password: str) -> CreateUserResult: + """Create a VeraeTime user account. Role is user. Does not replace the stored JWT.""" + try: + return await asyncio.to_thread(_global_client.signup, username, password) + except ValueError as exc: + raise _tool_error(exc) from exc + + +@mcp.tool() +async def create_user(username: str, password: str, role: str) -> CreateUserResult: + """Create a VeraeTime user. The stored JWT must belong to an admin.""" + try: + return await asyncio.to_thread( + _global_client.create_user, username, password, role + ) + except ValueError as exc: + raise _tool_error(exc) from exc + + @mcp.tool() async def hash_report( sha256: str, org_id: str = "", want_scope: str = "" @@ -103,7 +164,8 @@ async def hash_report( def main() -> None: - build_mcp(default_ledger()) + client = GlobalClient.from_env() + build_mcp(default_ledger(client), global_client=client) transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio") if transport == "streamable-http": host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1") @@ -115,7 +177,7 @@ def main() -> None: h.strip() for h in os.environ.get( "VERAE_MCP_ALLOWED_HOSTS", - "mcp.georgelambert.org,localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,10.1.10.128,10.1.10.128:8000", + "mcp.veraetime.net,mcp.georgelambert.org,localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,10.1.10.128,10.1.10.128:8000", ).split(",") if h.strip() ] diff --git a/tests/test_global_client.py b/tests/test_global_client.py index 0a0f74e..2502755 100644 --- a/tests/test_global_client.py +++ b/tests/test_global_client.py @@ -64,7 +64,8 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt(): assert payload["digest"] == SHA256_EMPTY assert payload["hashAlg"] == "SHA256" assert payload["restamp"] is False - assert payload["wait"] is True + assert "wait" not in payload + assert "digest" in payload assert headers["Authorization"] == "Bearer secret" assert rec.scope == "global" assert rec.receipt_id == "job-1" @@ -72,6 +73,49 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt(): assert rec.cube_id == "cube-1" +async def test_existing_digest_returns_first_certificate_without_status_poll(): + class ExistingHttp(FakeHttp): + def request(self, method, url, payload, headers): + self.calls.append((method, url, payload, headers)) + if url.endswith("/api/timestamp"): + return _Resp( + 200, + { + "jobId": "28df9952-094b-45a9-b6e5-e3ff81389bdd", + "existing": True, + "certificate": "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA", + }, + ) + raise AssertionError(f"unexpected {method} {url}") + + http = ExistingHttp() + client = GlobalClient("https://api.veraetime.net", http=http) + rec = client.submit(SHA256_EMPTY) + assert rec.receipt_id == "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA" + assert http.calls[0][2]["digest"] == SHA256_EMPTY + assert http.calls[0][2]["restamp"] is False + assert len(http.calls) == 1 + + +async def test_existing_without_certificate_polls_job(): + class PendingExisting(FakeHttp): + def request(self, method, url, payload, headers): + self.calls.append((method, url, payload, headers)) + if url.endswith("/api/timestamp"): + return _Resp(200, {"jobId": "job-9", "existing": True}) + if url.endswith("/api/status/job-9"): + return _Resp( + 200, + {"id": "job-9", "status": "completed", "result": "QmPending"}, + ) + return _Resp(404, {"error": "nope"}) + + http = PendingExisting() + rec = GlobalClient("https://api.veraetime.net", http=http).submit(SHA256_EMPTY) + assert rec.receipt_id == "QmPending" + assert http.calls[1][0] == "GET" + + async def test_global_verify_posts_certificate(): http = FakeHttp() ledger = GlobalLedger(GlobalClient("https://api.veraetime.net", http=http)) @@ -106,3 +150,159 @@ async def test_routing_sends_only_global_scope_to_central(): weak = await routed.verify(local_rec.receipt_id, want_scope="global") assert weak.valid is False + + +def _auth_http(role: str = "admin"): + class AuthHttp(FakeHttp): + def request(self, method, url, payload, headers): + self.calls.append((method, url, payload, headers)) + if url.endswith("/auth/login"): + return _Resp( + 200, + { + "token": "fresh-token", + "expiresAt": "2099-01-01T00:00:00Z", + "user": {"id": "u1", "username": "admin", "role": "admin"}, + }, + ) + if url.endswith("/auth/validate"): + if headers.get("Authorization") != "Bearer fresh-token": + return _Resp(401, {"error": "Unauthorized"}) + return _Resp( + 200, + { + "valid": True, + "userId": "u1", + "username": "admin", + "role": role, + "expiresAt": "2099-01-01T00:00:00Z", + }, + ) + if url.endswith("/auth/signup"): + assert "Authorization" not in headers + return _Resp( + 201, + {"id": "u3", "username": payload["username"], "role": "user"}, + ) + if url.endswith("/auth/users"): + return _Resp( + 201, + {"id": "u2", "username": payload["username"], "role": payload["role"]}, + ) + if url.endswith("/api/timestamp"): + return _Resp( + 200, + { + "jobId": "job-1", + "existing": True, + "certificate": "QmFresh", + }, + ) + return _Resp(404, {"error": "nope"}) + + return AuthHttp() + + +async def test_login_stores_token_and_hides_it_from_result(tmp_path): + http = _auth_http() + path = tmp_path / "auth.properties" + client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path) + result = client.login("admin", "secret-pass") + assert result.username == "admin" + assert result.role == "admin" + assert result.expires_at == "2099-01-01T00:00:00Z" + assert "fresh-token" not in result.model_dump().values() + stored = path.read_text() + assert "access_token=fresh-token" in stored + assert "username=admin" in stored + assert "password=secret-pass" in stored + assert path.stat().st_mode & 0o777 == 0o600 + assert client.token == "fresh-token" + assert http.calls[0][2] == {"username": "admin", "password": "secret-pass"} + + +async def test_session_and_create_user_require_admin(tmp_path): + http = _auth_http(role="user") + client = GlobalClient( + "https://api.veraetime.net", + token="fresh-token", + http=http, + auth_path=tmp_path / "auth.properties", + ) + seen = client.session() + assert seen.valid is True + assert seen.role == "user" + with pytest.raises(ValueError, match="admin login required"): + client.create_user("newuser", "secret-pass", "user") + assert not any(url.endswith("/auth/users") for _, url, _, _ in http.calls) + + +async def test_create_user_posts_when_admin(tmp_path): + http = _auth_http(role="admin") + client = GlobalClient( + "https://api.veraetime.net", + token="fresh-token", + http=http, + auth_path=tmp_path / "auth.properties", + ) + created = client.create_user("newuser", "secret-pass", "user") + assert created.id == "u2" + assert created.username == "newuser" + assert created.role == "user" + assert "secret-pass" not in created.model_dump().values() + method, url, payload, headers = http.calls[-1] + assert method == "POST" + assert url.endswith("/auth/users") + assert payload["password"] == "secret-pass" + assert headers["Authorization"] == "Bearer fresh-token" + + +async def test_signup_creates_user_without_replacing_token(tmp_path): + http = _auth_http() + client = GlobalClient( + "https://api.veraetime.net", + token="service-token", + http=http, + auth_path=tmp_path / "auth.properties", + ) + created = client.signup("newcomer", "secret-pass") + assert created == type(created)(id="u3", username="newcomer", role="user") + assert client.token == "service-token" + method, url, payload, headers = http.calls[0] + assert method == "POST" + assert url.endswith("/auth/signup") + assert payload == {"username": "newcomer", "password": "secret-pass"} + assert "Authorization" not in headers + assert "role" not in payload + + +async def test_submit_refreshes_expired_stored_login(tmp_path): + path = tmp_path / "auth.properties" + path.write_text( + "access_token=old-token\n" + "expires_at=2020-01-01T00:00:00Z\n" + "username=admin\n" + "password=secret-pass\n" + ) + http = _auth_http() + client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path) + rec = client.submit(SHA256_EMPTY) + assert rec.receipt_id == "QmFresh" + assert http.calls[0][1].endswith("/auth/login") + assert http.calls[1][3]["Authorization"] == "Bearer fresh-token" + + +async def test_submit_skips_login_when_token_is_fresh(tmp_path): + path = tmp_path / "auth.properties" + path.write_text( + "access_token=still-good\n" + "expires_at=2099-01-01T00:00:00Z\n" + "username=admin\n" + "password=secret-pass\n" + ) + http = _auth_http() + client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path) + rec = client.submit(SHA256_EMPTY) + assert rec.receipt_id == "QmFresh" + assert http.calls[0][1].endswith("/api/timestamp") + assert http.calls[0][3]["Authorization"] == "Bearer still-good" diff --git a/tests/test_server.py b/tests/test_server.py index 09e788e..4c8e8ed 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,6 +5,7 @@ import base64 import pytest from mcp import Client +from verae_mcp_timestamping_server.global_client import GlobalClient from verae_mcp_timestamping_server.guard import FORBIDDEN_CONTENT_KEYS from verae_mcp_timestamping_server.ledger import SHA256_EMPTY, MemoryLedger from verae_mcp_timestamping_server.server import build_mcp @@ -123,3 +124,60 @@ async def test_verify_and_hash_report_tools(): report = await client.call_tool("hash_report", {"sha256": SHA256_EMPTY}) items = report.structured_content["receipts"] assert items[0]["receipt_id"] == receipt_id + + +class _ToolHttp: + def __init__(self): + self.calls = [] + + def request(self, method, url, payload, headers): + self.calls.append((method, url, payload, headers)) + + class Resp: + status_code = 200 + text = "" + headers = {"Content-Type": "application/json"} + + def json(self): + if url.endswith("/auth/login"): + return { + "token": "tool-token", + "expiresAt": "2099-01-01T00:00:00Z", + "user": {"id": "u1", "username": "admin", "role": "admin"}, + } + if url.endswith("/auth/validate"): + return { + "valid": True, + "username": "admin", + "role": "admin", + "expiresAt": "2099-01-01T00:00:00Z", + } + return {} + + return Resp() + + +async def test_login_tool_does_not_return_token(tmp_path): + http = _ToolHttp() + held = GlobalClient( + "https://api.veraetime.net", + http=http, + auth_path=tmp_path / "auth.properties", + ) + mcp = build_mcp(MemoryLedger(), global_client=held) + async with Client(mcp, raise_exceptions=True) as client: + listed = await client.list_tools() + login = next(tool for tool in listed.tools if tool.name == "login") + assert login.input_schema.get("additionalProperties") is False + assert "password" in login.input_schema["properties"] + result = await client.call_tool( + "login", {"username": "admin", "password": "secret-pass"} + ) + data = result.structured_content + assert data["username"] == "admin" + assert data["role"] == "admin" + assert "token" not in data + assert "tool-token" not in str(data) + session = await client.call_tool("session", {}) + assert session.structured_content["valid"] is True + assert session.structured_content["role"] == "admin"