Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/auths-witness-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ git2 = { version = "0.21.0", default-features = false, features = ["vendored-lib
axum = { version = "0.8" }
clap = { version = "4", features = ["derive", "env"] }
tower = { version = "0.5", features = ["limit"] }
tower-http = { version = "0.6", features = ["timeout"] }
tower-http = { version = "0.6", features = ["timeout", "cors"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "sync", "process", "io-util"] }
# Decompress gzip-encoded git-upload-pack request bodies (the `registry` role's
# git smart-HTTP surface); standard git clients gzip the fetch negotiation.
Expand Down
32 changes: 32 additions & 0 deletions crates/auths-witness-node/src/bin/witness_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ use auths_witness_node::signer::{FileSigner, Signer as _};
use auths_witness_node::sqlite_store::SqliteAnchorStore;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::Method;
use clap::{Parser, Subcommand};
use tower::limit::ConcurrencyLimitLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::timeout::TimeoutLayer;

/// A witness request body is one anchor plus the party naming — a few KiB at
Expand Down Expand Up @@ -286,6 +288,22 @@ async fn main() -> std::process::ExitCode {

let mut app = Router::new();

// W0.1 — the public status page (`GET /`), served regardless of the
// role mix. The member key is derived from the seed best-effort: a
// registry-only node started without one simply renders "—".
let member_did = parse_seed(&args.seed).ok().map(|seed| {
let signer = FileSigner::from_seed(args.witness_name.clone(), seed);
auths_crypto::did_key_encode(auths_crypto::CurveType::Ed25519, &signer.public_key())
});
app = app.merge(auths_witness_node::status::status_router(
auths_witness_node::status::StatusState {
witness_name: args.witness_name.clone(),
member_did,
roles: args.roles.clone(),
registry: args.registry.clone(),
},
));

if has("anchor") {
let state = match build_state(&args) {
Ok(state) => state,
Expand Down Expand Up @@ -362,6 +380,20 @@ async fn main() -> std::process::ExitCode {
} else {
core
};

// W0.2 — CORS on the public GET reads, so the explorer's
// browser-direct mode (and any third-party lens) can query this
// witness straight from a browser without a proxy. Scoped to GET
// with no credentials: a cross-origin write is a non-simple request,
// so its preflight fails against `allow_methods([GET])` and the
// browser blocks it — the wildcard ACAO never enables a cross-site
// POST. Mirrors auths-mcp-server's opt-in CORS, tightened to reads.
let app = app.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET])
.allow_headers(Any),
);
// Housekeeping for the write path: appends create loose git
// objects; a periodic `git gc --auto` keeps the served registry
// packed (bench-measured ~45 KB/identity unpacked otherwise).
Expand Down
1 change: 1 addition & 0 deletions crates/auths-witness-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub mod serve_registry;
pub mod signer;
pub mod sqlite_store;
pub mod standup;
pub mod status;
pub mod sync;
pub mod vocabulary;

Expand Down
165 changes: 165 additions & 0 deletions crates/auths-witness-node/src/status.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//! The node's public status page (`GET /`) — epic W0.1.
//!
//! One embedded-HTML handler: no JS, no build step, no dependency the node
//! doesn't already link. It renders the facts a human — often a would-be witness
//! operator — wants when they point a browser at `https://<node>`: the node's
//! name, its member key, the roles it serves, how many members it holds, and the
//! one-liners to mirror its registry and add it to a principal's declared set.
//!
//! It is deliberately the WHOLE web surface the node has. Growth pressure never
//! goes into making the node bigger (network-auths-dev §6): everything richer —
//! browsing KELs, re-verifying receipts, inspecting anchors — lives in the
//! untrusted explorer at `explorer.auths.dev`, off the audited node binary. A
//! stranger's conformant witness gets this same page for free from the same
//! code.

use std::path::PathBuf;

use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::Html;
use axum::routing::get;
use axum::Router;

/// The slice of node identity the status page renders. Cloneable so it can back
/// a dedicated `/` router independent of any role's typed state.
#[derive(Clone)]
pub struct StatusState {
/// This node's public name, as carried in its cosignatures.
pub witness_name: String,
/// The node's member verifying key (`did:key:…`), or `None` when the seed
/// couldn't be read (a registry-only node started without one).
pub member_did: Option<String>,
/// The roles this node serves (`anchor`, `kel`, `cosign`, `registry`).
pub roles: Vec<String>,
/// The party registry, read live to count members held.
pub registry: PathBuf,
}

/// A `/`-only router serving the status page. Merge it into the node's app so
/// `GET /` answers regardless of role mix.
pub fn status_router(state: StatusState) -> Router {
Router::new().route("/", get(status_page)).with_state(state)
}

async fn status_page(State(state): State<StatusState>, headers: HeaderMap) -> Html<String> {
// Members held: a flat ref-enumeration index lookup, never a KEL walk.
let member_count = auths_sdk::storage::PerPrefixKelStore::open(state.registry.as_path())
.list_prefixes()
.map(|p| p.len())
.unwrap_or(0);
let base_url = base_url(&headers);
Html(render(&state, member_count, &base_url))
}

/// Reconstruct the node's own public base URL from the request, so the shown
/// commands are copy-pasteable. Honors `x-forwarded-proto` (the node sits behind
/// TLS-terminating proxies in every shipped deploy); falls back to a scheme
/// inferred from the host.
fn base_url(headers: &HeaderMap) -> String {
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("network.auths.dev");
let scheme = headers
.get("x-forwarded-proto")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| {
if host.starts_with("127.0.0.1") || host.starts_with("localhost") {
"http".to_string()
} else {
"https".to_string()
}
});
format!("{scheme}://{host}")
}

/// Minimal HTML entity escaping for the untrusted-ish dynamic fields (node name,
/// member key, roles all originate from local config, but escaping keeps the
/// page robust if that ever changes).
fn escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

fn render(state: &StatusState, member_count: usize, base_url: &str) -> String {
let name = escape(&state.witness_name);
let member_key = state.member_did.as_deref().unwrap_or("—");
let member_key_esc = escape(member_key);
let roles = state
.roles
.iter()
.map(|r| escape(r))
.collect::<Vec<_>>()
.join(" · ");
let base = escape(base_url);
let add_witness = format!("auths witness add --url {base}");
let fetch = format!("git fetch '{base}' '+refs/auths/kel/*:refs/auths/kel/*'");

format!(
r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{name} — Auths witness</title>
<style>
:root {{ --paper:#faf8f4; --ink:#1c1814; --soft:#5b5348; --faint:#8a8074; --rule:#e5ddd0; --seal:#b5502a; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--paper); color:var(--ink); font:16px/1.6 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }}
.wrap {{ max-width:44rem; margin:0 auto; padding:4rem 1.5rem 5rem; }}
.kicker {{ font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.2em; text-transform:uppercase; color:var(--faint); }}
h1 {{ font-size:2.4rem; font-weight:600; letter-spacing:-.02em; margin:.6rem 0 0; }}
.meta {{ margin-top:.4rem; color:var(--soft); font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }}
.card {{ border:1px solid var(--rule); border-radius:4px; background:#fff; padding:1rem 1.25rem; margin-top:1.5rem; }}
.row {{ display:flex; gap:1rem; padding:.55rem 0; border-bottom:1px solid var(--rule); font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }}
.row:last-child {{ border-bottom:0; }}
.row .k {{ width:9rem; flex:0 0 auto; color:var(--faint); text-transform:uppercase; letter-spacing:.08em; font-size:11px; }}
.row .v {{ min-width:0; word-break:break-all; }}
h2 {{ font-size:1.1rem; margin:2.5rem 0 .5rem; }}
pre {{ background:#15130f; color:#d6d0c8; border-radius:6px; padding:.9rem 1rem; overflow:auto; font:12.5px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; }}
.comment {{ color:#9a948c; }}
a {{ color:var(--seal); text-decoration:none; border-bottom:1px solid rgba(181,80,42,.35); }}
a:hover {{ border-bottom-color:var(--seal); }}
.links {{ margin-top:2rem; display:flex; flex-wrap:wrap; gap:1.25rem; font:13px/1 ui-monospace,SFMono-Regular,Menlo,monospace; }}
.foot {{ margin-top:3rem; color:var(--faint); font-size:12px; }}
</style>
</head>
<body>
<div class="wrap">
<p class="kicker">Auths witness node</p>
<h1>{name}</h1>
<p class="meta">a small, dumb signer you check — not a service you trust</p>

<div class="card">
<div class="row"><span class="k">member key</span><span class="v">{member_key_esc}</span></div>
<div class="row"><span class="k">roles</span><span class="v">{roles}</span></div>
<div class="row"><span class="k">members held</span><span class="v">{member_count}</span></div>
<div class="row"><span class="k">liveness</span><span class="v">up · see <a href="/health">/health</a></span></div>
</div>

<h2>Mirror this node’s key histories</h2>
<pre><span class="comment"># pull every member’s KEL over plain git — no auth, no account</span>
{fetch}</pre>

<h2>Witness a principal with this node</h2>
<pre><span class="comment"># add this witness to your identity’s declared set</span>
{add_witness}
<span class="comment"># then the principal anchors the set (name + curve + member key) in their own key history</span></pre>

<div class="links">
<a href="https://explorer.auths.dev/w/{name}">Browse it in the explorer ↗</a>
<a href="https://auths.dev/network">The witness directory ↗</a>
<a href="https://docs.auths.dev/mcp/witness-network/run-a-witness">Run your own ↗</a>
</div>

<p class="foot">Everything that proves here is open code: the node, the verifier, the conformance harness. This page is a convenience over evidence anyone can re-check.</p>
</div>
</body>
</html>
"##
)
}
62 changes: 52 additions & 10 deletions packages/auths-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,40 @@ export declare const enum PresentationStatus {
Unknown = 'Unknown'
}

/**
* Read a member's Key Event Log out of a `fetchRegistry` mirror as JSON.
*
* Transport only — this does NOT verify. It emits the member's events plus,
* for each event, its CESR attachment **hex-encoded** (controller indexed
* signatures, and any delegation source seal), in exactly the shape
* `@auths-dev/verifier`'s `validateKelJson(kelJson, attachmentsJson)` consumes:
* `attachments[i]` is the hex attachment for `events[i]`, strictly equal
* length and order. The caller (a browser) re-verifies; a compromised mirror
* can withhold or truncate here, but the client recompute catches that — it
* cannot forge.
*
* The registry mirror only carries what rides under `refs/auths/*`, so the
* attachments are the per-event controller/delegation groups actually stored;
* witness receipts (a derived index) are served separately by the node's
* `/witness/{prefix}/receipt/{said}` surface, not here.
*
* Args:
* * `registry_dir`: a directory previously populated by `fetchRegistry`.
* * `prefix`: the member AID (a bare KERI prefix, no `did:keri:`).
*
* Returns JSON `{ prefix, events, attachments, tip, source }`. Throws when this
* registry does not hold the prefix.
*
* Usage:
* ```ignore
* fetchRegistry(witnessGitUrl, dir);
* const { events, attachments } = JSON.parse(readKelJson(dir, prefix));
* const keyState = JSON.parse(await validateKelJson(
* JSON.stringify(events), JSON.stringify(attachments)));
* ```
*/
export declare function readKelJson(registryDir: string, prefix: string): string

/** The embedded JSON schema for the `receipts/v1` wire contract. */
export declare function receiptsV1Schema(): string

Expand Down Expand Up @@ -570,14 +604,6 @@ export declare function signCommit(data: Buffer, identityKeyAlias: string, repoP
*/
export declare function verifyActionEnvelope(envelopeJson: string, publicKeyHex: string, curve?: string | undefined | null): NapiVerificationResult

/** Options for {@link verifyActivityAttestation}. */
export interface VerifyActivityOpts {
/** Fail the whole document when there is no verified witness anchor. */
requireWitness?: boolean
/** An independently-known witness tip index; a tip past the document's count marks the anchor stale. */
witnessTipIndex?: number
}

/**
* Verify a published `activity/v1` attestation against a fetched registry copy:
* resolve the agent's current keys from the KEL (identity resolution ONLY —
Expand All @@ -593,10 +619,12 @@ export interface VerifyActivityOpts {
* seller's own claims. `freshness` is `"fresh" | "unknown" | "stale"`;
* `headBound` is `true` only when a verified witness anchor cosigns the head.
*
* `cumulativeCents` is a SIGNED CLAIM, not a re-derived fact: the per-call log is
* structurally private, so the magnitude is provable only when `anchor` is a
* `cumulativeCents` is a SIGNED CLAIM, not a re-derived fact: the per-call log
* is structurally private, so the magnitude is provable only when `anchor` is a
* non-stale `witness` summary. A consumer MUST NOT present `cumulativeCents` as
* verified earnings unless `anchor?.tier === "witness" && anchor.stale === false`.
* At first-seen (`anchor === null`) the honest label is "seller-claimed,
* unwitnessed".
*
* Usage:
* ```ignore
Expand All @@ -606,6 +634,20 @@ export interface VerifyActivityOpts {
*/
export declare function verifyActivityAttestation(attestationJson: string, registryPath: string, opts?: VerifyActivityOpts | undefined | null): string

/** Options for [`verify_activity_attestation`]. */
export interface VerifyActivityOpts {
/**
* Fail the whole document when there is no verified witness anchor — promotes
* the anchor from an additive assurance tier to a required gate.
*/
requireWitness?: boolean
/**
* An independently-known witness tip index for this seed, if the caller has
* one. A tip greater than the document's count marks the anchor `stale`.
*/
witnessTipIndex?: number
}

export declare function verifyAttestation(attestationJson: string, issuerPkHex: string): Promise<NapiVerificationResult>

export declare function verifyAtTime(attestationJson: string, issuerPkHex: string, atRfc3339: string): Promise<NapiVerificationResult>
Expand Down
Loading
Loading