feat: add signup, login, session, and create_user

signup calls POST /auth/signup and leaves the stored JWT in place.
login writes the VeraeTime token to the auth properties file and
does not return it. A stored username and password refresh that
token when expires_at is missing or within five minutes.
create_user still requires an admin session.
This commit is contained in:
George Lambert 2026-09-23 11:15:11 -04:00
commit c547804835
15 changed files with 730 additions and 31 deletions

View file

@ -4,7 +4,7 @@
Verae MCP timestamping server. Hash-only stamps via leaf locald/NATS. 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 ## 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 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 ```sh
# refresh: POST https://api.veraetime.net/auth/login → access_token= # MCP tools: signup, login, session, create_user
# or: export VERAE_GLOBAL_TOKEN=... # or: export VERAE_GLOBAL_TOKEN=...
# then stamp with scope=global # then stamp with scope=global
``` ```

View file

@ -36,6 +36,42 @@ Scope rank: local < shared < global.
Returns `{ "receipts": [ ... ] }` from the local file. Global-only certificates are not listed. 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 ## Environment
| Name | Default | | Name | Default |

View file

@ -201,6 +201,104 @@ when the stored scope ranks lower</td>
</table> </table>
<p>Returns <code>{ "receipts": [ ... ] }</code> from the local file. <p>Returns <code>{ "receipts": [ ... ] }</code> from the local file.
Global-only certificates are not listed.</p> Global-only certificates are not listed.</p>
<h2 id="login">login</h2>
<table>
<thead>
<tr>
<th>Argument</th>
<th>Default</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>username</code></td>
<td>required</td>
<td>VeraeTime account</td>
</tr>
<tr>
<td><code>password</code></td>
<td>required</td>
<td>VeraeTime password</td>
</tr>
</tbody>
</table>
<p><code>POST /auth/login</code>. Stores <code>access_token</code>,
<code>id_token</code>, <code>auth_token</code>, <code>expires_at</code>,
<code>username</code>, and <code>password</code> in the auth properties
file (mode <code>0600</code>) and replaces the in-memory JWT. Returns
<code>username</code>, <code>role</code>, and <code>expires_at</code>.
The JWT is not returned.</p>
<h2 id="session">session</h2>
<p>No arguments. <code>GET /auth/validate</code> with the stored
JWT.</p>
<p>Returns <code>valid</code>, <code>username</code>, <code>role</code>,
and <code>expires_at</code>.</p>
<h2 id="signup">signup</h2>
<table>
<thead>
<tr>
<th>Argument</th>
<th>Default</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>username</code></td>
<td>required</td>
<td>New account</td>
</tr>
<tr>
<td><code>password</code></td>
<td>required</td>
<td>New password</td>
</tr>
</tbody>
</table>
<p><code>POST /auth/signup</code> with no bearer token. The timestamping
service creates the account with role <code>user</code>. Returns
<code>id</code>, <code>username</code>, and <code>role</code>. Does not
replace the JWT stored for <code>stamp</code>. A new account calls
<code>login</code> to become the stored session.
<code>create_user</code> remains the admin path and can set
<code>admin</code>.</p>
<h2 id="create_user">create_user</h2>
<table>
<thead>
<tr>
<th>Argument</th>
<th>Default</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>username</code></td>
<td>required</td>
<td>New account</td>
</tr>
<tr>
<td><code>password</code></td>
<td>required</td>
<td>New password</td>
</tr>
<tr>
<td><code>role</code></td>
<td>required</td>
<td><code>user</code> or <code>admin</code></td>
</tr>
</tbody>
</table>
<p><code>POST /auth/users</code> with the stored JWT. Refuses unless
<code>session</code> reports role <code>admin</code>. Returns
<code>id</code>, <code>username</code>, and <code>role</code>. There is
no public signup; the first admin already exists on the timestamping
service.</p>
<p>A stored login is refreshed when <code>stamp</code> or
<code>verify</code> runs and <code>expires_at</code> is missing or
within 5 minutes, if <code>username</code> and <code>password</code> are
in the auth file.</p>
<h2 id="environment">Environment</h2> <h2 id="environment">Environment</h2>
<table> <table>
<thead> <thead>

View file

@ -112,9 +112,14 @@
<code>site/index.html</code> on the same machine, or put TLS and an <code>site/index.html</code> on the same machine, or put TLS and an
access control in front of a non-loopback bind you choose yourself.</p> access control in front of a non-loopback bind you choose yourself.</p>
<h2 id="global-token">Global token</h2> <h2 id="global-token">Global token</h2>
<div class="sourceCode" id="cb5"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># POST https://api.veraetime.net/auth/login and store access_token=</span></span> <p><code>signup</code> creates a role <code>user</code> account with no
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="co"># in ~/.verae/auth.properties (mode 600), or:</span></span> bearer token. Call <code>login</code> with that username and password to
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">VERAE_GLOBAL_TOKEN</span><span class="op">=</span>...</span></code></pre></div> store the JWT in <code>~/.verae/auth.properties</code> (mode 600). The
server uses it for <code>scope=global</code> without a restart.
<code>session</code> checks that JWT. <code>create_user</code> adds an
account when the stored role is <code>admin</code>.</p>
<div class="sourceCode" id="cb5"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># or skip the tool and export a token yourself:</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">VERAE_GLOBAL_TOKEN</span><span class="op">=</span>...</span></code></pre></div>
<h2 id="tests">Tests</h2> <h2 id="tests">Tests</h2>
<div class="sourceCode" id="cb6"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">uv</span> run pytest</span></code></pre></div> <div class="sourceCode" id="cb6"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">uv</span> run pytest</span></code></pre></div>
<p>Stamp traces (live VeraeTime) are a separate script. They write <p>Stamp traces (live VeraeTime) are a separate script. They write

View file

@ -90,6 +90,17 @@ means “not in the local file,” not “VeraeTime has no record.”</p>
<code>access_token</code> / <code>id_token</code> / <code>access_token</code> / <code>id_token</code> /
<code>auth_token</code> in <code>~/.verae/auth.properties</code> <code>auth_token</code> in <code>~/.verae/auth.properties</code>
(<code>VERAE_AUTH_PROPERTIES</code> overrides the path).</li> (<code>VERAE_AUTH_PROPERTIES</code> overrides the path).</li>
<li><code>login</code> writes that file (mode <code>0600</code>),
including <code>username</code>, <code>password</code>, and
<code>expires_at</code>. The tool result does not include the JWT.</li>
<li><code>signup</code> calls <code>POST /auth/signup</code> with no
bearer token. The account role is <code>user</code>. It does not replace
the stored JWT.</li>
<li><code>create_user</code> calls <code>POST /auth/users</code> only
when the stored token’s role is <code>admin</code>.</li>
<li>Before a global stamp or verify, a stored username and password are
used to log in again when <code>expires_at</code> is missing or within 5
minutes.</li>
<li>Do not commit tokens. <code>.env</code> is gitignored.</li> <li>Do not commit tokens. <code>.env</code> is gitignored.</li>
<li>The local receipt file is mode <code>0600</code> after each <li>The local receipt file is mode <code>0600</code> after each
save.</li> save.</li>

View file

@ -31,9 +31,10 @@ A page served from the public internet still cannot call `127.0.0.1` from curren
## Global token ## 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 ```sh
# POST https://api.veraetime.net/auth/login and store access_token= # or skip the tool and export a token yourself:
# in ~/.verae/auth.properties (mode 600), or:
export VERAE_GLOBAL_TOKEN=... export VERAE_GLOBAL_TOKEN=...
``` ```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -29,6 +29,10 @@ This process is an MCP server (Model Context Protocol). Clients are assistants a
## Credentials ## Credentials
- Token: `VERAE_GLOBAL_TOKEN`, else `access_token` / `id_token` / `auth_token` in `~/.verae/auth.properties` (`VERAE_AUTH_PROPERTIES` overrides the path). - 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. - Do not commit tokens. `.env` is gitignored.
- The local receipt file is mode `0600` after each save. - The local receipt file is mode `0600` after each save.

View file

@ -9,12 +9,16 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import re
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Protocol from typing import Any, Protocol
from pydantic import BaseModel
from verae_mcp_timestamping_server.ledger import ( from verae_mcp_timestamping_server.ledger import (
Receipt, Receipt,
VerifyResult, VerifyResult,
@ -22,6 +26,8 @@ from verae_mcp_timestamping_server.ledger import (
) )
DEFAULT_GLOBAL_URL = "https://api.veraetime.net" 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): class HttpTransport(Protocol):
@ -81,20 +87,86 @@ class UrllibHttp:
return _UrllibResp(status, body, raw, resp_headers) 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: def load_auth_token() -> str:
env = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip() env = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
if env: if env:
return env return env
path = Path(os.environ.get("VERAE_AUTH_PROPERTIES", Path.home() / ".verae" / "auth.properties")) props = read_properties(default_auth_path())
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()
return props.get("access_token") or props.get("id_token") or props.get("auth_token") or "" 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, base_url: str = DEFAULT_GLOBAL_URL,
token: str = "", token: str = "",
http: HttpTransport | None = None, http: HttpTransport | None = None,
auth_path: Path | None = None,
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.token = token self.token = token
self.http = http or UrllibHttp() 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 @classmethod
def from_env(cls) -> GlobalClient: def from_env(cls) -> GlobalClient:
env_token = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
return cls( return cls(
base_url=os.environ.get("VERAE_GLOBAL_URL", DEFAULT_GLOBAL_URL), 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]: def _headers(self) -> dict[str, str]:
@ -123,6 +340,7 @@ class GlobalClient:
return headers return headers
def submit(self, digest: str, cube_id: str = "") -> Receipt: def submit(self, digest: str, cube_id: str = "") -> Receipt:
self.ensure_token()
url = f"{self.base_url}/api/timestamp" url = f"{self.base_url}/api/timestamp"
res = self.http.request( res = self.http.request(
"POST", "POST",
@ -132,13 +350,18 @@ class GlobalClient:
"digest": digest, "digest": digest,
"hashAlg": "SHA256", "hashAlg": "SHA256",
"restamp": False, "restamp": False,
"wait": True,
}, },
headers=self._headers(), headers=self._headers(),
) )
parsed = res.json() if hasattr(res, "json") else {} parsed = res.json() if hasattr(res, "json") else {}
if res.status_code == 200 and isinstance(parsed, dict): if not isinstance(parsed, dict):
cid = str(parsed.get("certificate") or parsed.get("jobId") or "") 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: if cid:
return Receipt( return Receipt(
receipt_id=cid, receipt_id=cid,
@ -189,6 +412,7 @@ class GlobalClient:
) )
def verify_certificate(self, receipt_id: str) -> VerifyResult: def verify_certificate(self, receipt_id: str) -> VerifyResult:
self.ensure_token()
url = f"{self.base_url}/api/verify" url = f"{self.base_url}/api/verify"
res = self.http.request( res = self.http.request(
"POST", "POST",

View file

@ -2,12 +2,19 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from mcp.server import MCPServer from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError 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.guard import reject_content_keys, strict_arg_model
from verae_mcp_timestamping_server.ledger import ( from verae_mcp_timestamping_server.ledger import (
FileLedger, FileLedger,
@ -20,15 +27,25 @@ from verae_mcp_timestamping_server.ledger import (
mcp = MCPServer("verae-timestamping") mcp = MCPServer("verae-timestamping")
_ledger: Ledger = MemoryLedger() _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( return RoutingLedger(
local=FileLedger(), 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() _HARDENED_TOOLS: set[int] = set()
@ -57,9 +74,18 @@ def _harden_hash_only_tools(server: MCPServer) -> None:
_HARDENED_TOOLS.add(tid) _HARDENED_TOOLS.add(tid)
def build_mcp(ledger: Ledger | None = None) -> MCPServer: def build_mcp(
global _ledger ledger: Ledger | None = None,
_ledger = ledger or default_ledger() 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) _harden_hash_only_tools(mcp)
return mcp return mcp
@ -72,7 +98,7 @@ def _tool_error(exc: ValueError) -> ToolError:
async def stamp( async def stamp(
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = "" sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
) -> Receipts: ) -> 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: try:
receipts = await _ledger.stamp( receipts = await _ledger.stamp(
sha256, scope=scope, cube_id=cube_id, org_id=org_id 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) 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() @mcp.tool()
async def hash_report( async def hash_report(
sha256: str, org_id: str = "", want_scope: str = "" sha256: str, org_id: str = "", want_scope: str = ""
@ -103,7 +164,8 @@ async def hash_report(
def main() -> None: 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") transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
if transport == "streamable-http": if transport == "streamable-http":
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1") host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
@ -115,7 +177,7 @@ def main() -> None:
h.strip() h.strip()
for h in os.environ.get( for h in os.environ.get(
"VERAE_MCP_ALLOWED_HOSTS", "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(",") ).split(",")
if h.strip() if h.strip()
] ]

View file

@ -64,7 +64,8 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt():
assert payload["digest"] == SHA256_EMPTY assert payload["digest"] == SHA256_EMPTY
assert payload["hashAlg"] == "SHA256" assert payload["hashAlg"] == "SHA256"
assert payload["restamp"] is False 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 headers["Authorization"] == "Bearer secret"
assert rec.scope == "global" assert rec.scope == "global"
assert rec.receipt_id == "job-1" 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" 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(): async def test_global_verify_posts_certificate():
http = FakeHttp() http = FakeHttp()
ledger = GlobalLedger(GlobalClient("https://api.veraetime.net", http=http)) 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") weak = await routed.verify(local_rec.receipt_id, want_scope="global")
assert weak.valid is False 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"

View file

@ -5,6 +5,7 @@ import base64
import pytest import pytest
from mcp import Client 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.guard import FORBIDDEN_CONTENT_KEYS
from verae_mcp_timestamping_server.ledger import SHA256_EMPTY, MemoryLedger from verae_mcp_timestamping_server.ledger import SHA256_EMPTY, MemoryLedger
from verae_mcp_timestamping_server.server import build_mcp 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}) report = await client.call_tool("hash_report", {"sha256": SHA256_EMPTY})
items = report.structured_content["receipts"] items = report.structured_content["receipts"]
assert items[0]["receipt_id"] == receipt_id 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"