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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion crates/auths-witness-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ axum = { version = "0.8" }
clap = { version = "4", features = ["derive", "env"] }
tower = { version = "0.5", features = ["limit"] }
tower-http = { version = "0.6", features = ["timeout"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "sync"] }
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.
flate2 = "1"
# Durable per-seed anchor state — the workspace's single `links = sqlite3` owner.
sqlite = { version = "0.32", features = ["bundled"] }
# The cosign role's signed-note signing primitive (as in auths-transparency).
Expand Down
62 changes: 56 additions & 6 deletions crates/auths-witness-node/src/bin/witness_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,11 @@ fn probe_health(url: &str) -> Result<(), String> {
#[derive(Parser)]
struct ServeArgs {
/// Roles to serve (comma-separated): `anchor` (spend anchors), `kel`
/// (KERI receipt witnessing), `cosign` (checkpoint cosigning). All three
/// share one identity seed, one data dir, and one hardening envelope.
/// (KERI receipt witnessing), `cosign` (checkpoint cosigning), `registry`
/// (serve the held `refs/auths/*` read-only over git smart-HTTP, so the node
/// is its own resolution surface). anchor/kel/cosign share one identity seed,
/// one data dir, and one tight hardening envelope; `registry` carries its own
/// (larger bodies, longer timeout) for git transfers.
#[arg(long, value_delimiter = ',', default_value = "anchor,kel,cosign")]
roles: Vec<String>,

Expand Down Expand Up @@ -242,14 +245,26 @@ async fn main() -> std::process::ExitCode {
Command::Serve(args) => {
for role in &args.roles {
match role.as_str() {
"anchor" | "kel" | "cosign" => {}
"anchor" | "kel" | "cosign" | "registry" => {}
other => {
eprintln!("witness-node: unknown role `{other}`");
return std::process::ExitCode::FAILURE;
}
}
}
let has = |role: &str| args.roles.iter().any(|r| r == role);

// Roles that read (anchor: key resolution) or serve (registry) the
// party registry need it to be an openable repo. Ensure it exists —
// an empty registry is a valid new-witness state, and this lets the
// first node in a network (no peer to sync from) bootstrap cleanly.
if (has("anchor") || has("registry"))
&& let Err(e) = auths_witness_node::sync::ensure_registry(&args.registry)
{
eprintln!("witness-node: registry init: {e:#}");
return std::process::ExitCode::FAILURE;
}

let mut app = Router::new();

if has("anchor") {
Expand Down Expand Up @@ -285,9 +300,9 @@ async fn main() -> std::process::ExitCode {
}
}

// One hardening envelope over every role — the same posture the
// standalone KEL witness shipped with.
let app = app
// The anchor/kel/cosign roles share one tight hardening envelope —
// the posture the standalone KEL witness shipped with.
let core = app
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(ConcurrencyLimitLayer::new(
auths_witness_node::MAX_CONCURRENT_REQUESTS,
Expand All @@ -296,6 +311,41 @@ async fn main() -> std::process::ExitCode {
axum::http::StatusCode::REQUEST_TIMEOUT,
auths_witness_node::REQUEST_TIMEOUT,
));

// The registry role serves git transfers — legitimately larger
// bodies over longer timeouts than an anchor POST — so it gets its
// own envelope, merged after `core` is sealed so the two never mix.
// Fail closed (I-DEPLOY-6) if its repo or the `git` binary is absent.
let app = if has("registry") {
if let Err(e) = registry_ready(&args.registry) {
eprintln!(
"witness-node: registry role: {e} (registry path: {})",
args.registry.display()
);
return std::process::ExitCode::FAILURE;
}
if !auths_witness_node::serve_registry::git_available().await {
eprintln!(
"witness-node: registry role: the `git` binary is required to serve \
refs/auths/* but was not found on PATH"
);
return std::process::ExitCode::FAILURE;
}
let registry = auths_witness_node::serve_registry::registry_router(&args.registry)
.layer(DefaultBodyLimit::max(
auths_witness_node::serve_registry::REGISTRY_MAX_BODY_BYTES,
))
.layer(ConcurrencyLimitLayer::new(
auths_witness_node::serve_registry::REGISTRY_MAX_CONCURRENT_REQUESTS,
))
.layer(TimeoutLayer::with_status_code(
axum::http::StatusCode::REQUEST_TIMEOUT,
auths_witness_node::serve_registry::REGISTRY_TIMEOUT,
));
core.merge(registry)
} else {
core
};
let listener = match tokio::net::TcpListener::bind(&args.bind).await {
Ok(listener) => listener,
Err(e) => {
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 @@ -43,6 +43,7 @@ pub mod engine;
pub mod hardening;
pub mod receipt;
pub mod registry;
pub mod serve_registry;
pub mod signer;
pub mod sqlite_store;
pub mod standup;
Expand Down
266 changes: 266 additions & 0 deletions crates/auths-witness-node/src/serve_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//! Read-only git smart-HTTP over the witness's held KEL store.
//!
//! A witness already holds the party KELs it receipts (under `refs/auths/*` in
//! the `--registry` repo). Exposing them read-only over git smart-HTTP makes the
//! node its own resolution surface — a verifier or a peer witness runs a plain
//! `git fetch +refs/auths/*:refs/auths/*` against it (exactly what
//! [`crate::sync`] does), with no separate registry server anywhere. This is the
//! "serve what you witness" leg of `docs/plans/network/network-auths-dev.md`.
//!
//! Only `git-upload-pack` (fetch) is ever invoked. There is no `receive-pack`
//! code path, so the surface is **read-only by construction** — not by config
//! that could be flipped. Writes are a separate, signed ingest (the KEL
//! receipting role), never an anonymous push.
//!
//! The heavy lifting — pkt-line framing, protocol negotiation, packfile
//! generation — is delegated to the `git` binary's own `upload-pack`, the
//! reference implementation, rather than reimplemented. The node passes the
//! client's `Git-Protocol` through so protocol v2 (which avoids advertising
//! every ref) works, and decompresses gzip request bodies, so any standard git
//! client interoperates.

use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;

use axum::Router;
use axum::body::Bytes;
use axum::extract::{RawQuery, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;

/// The only git service this node speaks. `receive-pack` is deliberately absent.
const SERVICE: &str = "git-upload-pack";

/// Smart-HTTP media types for the upload-pack service (RFC-less, but stable git
/// protocol strings). Kept as `&'static str` so a response's header pair is one
/// concrete type.
const ADVERTISEMENT_CONTENT_TYPE: &str = "application/x-git-upload-pack-advertisement";
const RESULT_CONTENT_TYPE: &str = "application/x-git-upload-pack-result";

/// Each response is a per-fetch computation — never a cacheable fixed body.
const NO_CACHE: &str = "no-cache, max-age=0, must-revalidate";

/// Request-body cap for the fetch negotiation. A client's want/have list is
/// small (KB) even for large repos, but generous here so a deep fetch of a
/// many-ref registry is never truncated. Far above the anchor role's cap; the
/// registry routes carry their own envelope for exactly this reason.
pub const REGISTRY_MAX_BODY_BYTES: usize = 32 * 1024 * 1024;

/// Concurrent `upload-pack` subprocesses to allow. Each fetch forks `git`, so
/// this bounds fork pressure independently of the anchor role's limit.
pub const REGISTRY_MAX_CONCURRENT_REQUESTS: usize = 32;

/// Per-request timeout for the registry routes. A pack transfer legitimately
/// takes longer than an anchor POST, so it gets its own, larger budget.
pub const REGISTRY_TIMEOUT: Duration = Duration::from_secs(120);

/// The read-only git-smart-HTTP router over `registry` — the repo whose
/// `refs/auths/*` this node serves. Mounted at the node root, so the git URL is
/// simply the node's base (`git fetch <base> +refs/auths/*:refs/auths/*`).
///
/// Args:
/// * `registry`: path to the local registry repo (the `--registry` dir).
///
/// Usage:
/// ```ignore
/// let app = app.merge(serve_registry::registry_router(&args.registry));
/// ```
pub fn registry_router(registry: &Path) -> Router {
let repo = Arc::new(registry.to_path_buf());
Router::new()
.route("/info/refs", get(info_refs))
.route("/git-upload-pack", post(upload_pack))
.with_state(repo)
}

/// Whether the `git` binary is invocable — the registry role's hard dependency.
/// The node calls this at startup and refuses the role (I-DEPLOY-6, fail closed)
/// rather than 500-ing every fetch later.
pub async fn git_available() -> bool {
Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}

/// `GET /info/refs?service=git-upload-pack` — the smart-HTTP ref advertisement.
async fn info_refs(
State(repo): State<Arc<PathBuf>>,
headers: HeaderMap,
RawQuery(query): RawQuery,
) -> Response {
// Read-only: advertise nothing but upload-pack. A client asking for
// receive-pack (a push) is refused here — there is no write surface.
if query.as_deref() != Some(&format!("service={SERVICE}")) {
return (
StatusCode::FORBIDDEN,
format!("only service={SERVICE} is served (read-only registry)\n"),
)
.into_response();
}

let mut cmd = Command::new("git");
cmd.args(["upload-pack", "--stateless-rpc", "--advertise-refs"])
.arg(repo.as_path());
apply_git_protocol(&mut cmd, &headers);

let out = match cmd.output().await {
Ok(out) if out.status.success() => out.stdout,
Ok(out) => return git_failed("upload-pack --advertise-refs", &out.stderr),
Err(e) => return spawn_failed(e),
};

// Smart-HTTP wraps the advertisement in the service pkt-line + a flush.
let mut body = pkt_line(format!("# service={SERVICE}\n").as_bytes());
body.extend_from_slice(b"0000");
body.extend_from_slice(&out);

(
[
(header::CONTENT_TYPE, ADVERTISEMENT_CONTENT_TYPE),
(header::CACHE_CONTROL, NO_CACHE),
],
body,
)
.into_response()
}

/// `POST /git-upload-pack` — the fetch negotiation + packfile response.
async fn upload_pack(
State(repo): State<Arc<PathBuf>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// git clients may gzip the request body; upload-pack expects it raw.
let input = if is_gzip(&headers) {
match gunzip(&body) {
Ok(decoded) => decoded,
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("gzip decode: {e}\n")).into_response();
}
}
} else {
body.to_vec()
};

let mut cmd = Command::new("git");
cmd.args(["upload-pack", "--stateless-rpc"])
.arg(repo.as_path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
apply_git_protocol(&mut cmd, &headers);

let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => return spawn_failed(e),
};

// Feed the negotiation to git on its own task while we drain stdout, so a
// large request can never deadlock against a filling stdout pipe.
if let Some(mut stdin) = child.stdin.take() {
tokio::spawn(async move {
let _ = stdin.write_all(&input).await;
// Dropping `stdin` closes it, signalling end-of-input to git.
});
}

let out = match child.wait_with_output().await {
Ok(out) if out.status.success() => out.stdout,
Ok(out) => return git_failed("upload-pack", &out.stderr),
Err(e) => return spawn_failed(e),
};

(
[
(header::CONTENT_TYPE, RESULT_CONTENT_TYPE),
(header::CACHE_CONTROL, NO_CACHE),
],
out,
)
.into_response()
}

/// Frame `payload` as a single git pkt-line (4-hex length prefix + payload).
fn pkt_line(payload: &[u8]) -> Vec<u8> {
let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
line.extend_from_slice(payload);
line
}

/// Pass the client's negotiated protocol version through to `git` so protocol
/// v2 (ref-filtered, essential at scale) is honoured; absent, git uses v0.
fn apply_git_protocol(cmd: &mut Command, headers: &HeaderMap) {
if let Some(proto) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) {
cmd.env("GIT_PROTOCOL", proto);
}
}

fn is_gzip(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("gzip"))
}

fn gunzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut decoder = flate2::read::GzDecoder::new(data);
let mut out = Vec::new();
decoder.read_to_end(&mut out)?;
Ok(out)
}

fn git_failed(what: &str, stderr: &[u8]) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("git {what} failed: {}\n", String::from_utf8_lossy(stderr)),
)
.into_response()
}

fn spawn_failed(e: std::io::Error) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("could not run git: {e}\n"),
)
.into_response()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn pkt_line_prefixes_hex_length() {
// "# service=git-upload-pack\n" is 26 bytes → 26 + 4 = 30 = 0x1e.
let line = pkt_line(b"# service=git-upload-pack\n");
assert_eq!(&line[..4], b"001e");
assert_eq!(&line[4..], b"# service=git-upload-pack\n");
}

#[test]
fn gunzip_roundtrips() {
use std::io::Write;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(b"0011command=ls-refs\n0000").unwrap();
let compressed = encoder.finish().unwrap();
assert_eq!(gunzip(&compressed).unwrap(), b"0011command=ls-refs\n0000");
}

#[test]
fn only_upload_pack_service_is_accepted() {
// A defensive check that the constant is the fetch service, never
// receive-pack — the read-only guarantee depends on it.
assert_eq!(SERVICE, "git-upload-pack");
}
}
Loading
Loading