feat: integrate local Blossom, reviewed nsites and scoped app access

This commit is contained in:
archipelago
2026-10-08 09:12:40 -04:00
parent 05e999b117
commit 28a92fcc9b
39 changed files with 2060 additions and 50 deletions
@@ -12,9 +12,15 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
match method {
"publishing.status" => self.handle_publishing_status().await,
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
"publishing.update" => self.handle_publishing_update(params).await,
"publishing.dns" => self.handle_publishing_dns(params).await,
"publishing.generate" => self.handle_publishing_generate(params).await,
"publishing.nsite-prepare" => self.handle_publishing_nsite_prepare(params).await,
"publishing.blossom-prepare" => self.handle_publishing_blossom_prepare(params).await,
"publishing.blossom-store" => self.handle_publishing_blossom_store(params).await,
"publishing.access-create" => self.handle_publishing_access_create(params).await,
"publishing.access-revoke" => self.handle_publishing_access_revoke(params).await,
"echo" => self.handle_echo(params).await,
"server.echo" => self.handle_echo(params).await,
"server.get-state" => self.handle_server_get_state().await,
+358 -2
View File
@@ -5,6 +5,322 @@ use serde::Deserialize;
use serde_json::json;
impl RpcHandler {
pub(super) async fn handle_publishing_verify_https(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
}
let request: Request =
serde_json::from_value(params.context("Missing website to verify")?)?;
let state = publishing::load(&self.config.data_dir).await?;
anyhow::ensure!(
state.version == request.version,
"Settings changed. Reload before checking"
);
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
anyhow::ensure!(
project.routes.contains(&publishing::Route::PublicWeb),
"Select public web and save first"
);
let host = publishing::hostname(
&project
.domain
.as_ref()
.context("Save a domain first")?
.hostname,
)?;
let expected = project
.fips_publication
.as_ref()
.context("Publish the website upstream first")?
.html
.as_bytes();
let addresses: Vec<_> = tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::net::lookup_host((host.as_str(), 443)),
)
.await
.context("DNS lookup timed out")?
.context("Domain DNS lookup failed")?
.collect();
anyhow::ensure!(
!addresses.is_empty() && addresses.iter().all(|a| publishing::public_ip(a.ip())),
"HTTPS checks require DNS resolving exclusively to public addresses"
);
// Pin this validated resolution: do not resolve again, follow redirects,
// inherit proxy settings, accept custom ports or relax TLS verification.
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(&host, &addresses)
.timeout(std::time::Duration::from_secs(20))
.build()?;
let mut response = client
.get(format!("https://{host}/"))
.header("Accept-Encoding", "identity")
.send()
.await
.context("HTTPS connection failed; check DNS, proxy and certificate")?;
anyhow::ensure!(
response.status() == reqwest::StatusCode::OK,
"Expected HTTP 200 from the website; received {}",
response.status()
);
let mut offset = 0;
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
offset + chunk.len() <= expected.len()
&& expected[offset..offset + chunk.len()] == chunk[..],
"The HTTPS address serves different content from this published version"
);
offset += chunk.len();
}
anyhow::ensure!(offset == expected.len(), "Website response was incomplete");
anyhow::ensure!(
publishing::load(&self.config.data_dir).await?.version == request.version,
"Settings changed during verification. Check the current version again"
);
Ok(
json!({"hostname":host,"sha256":publishing::nsite::hash(expected),
"checked_at":chrono::Utc::now().to_rfc3339()}),
)
}
pub(super) async fn handle_publishing_access_create(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
app_id: String,
label: String,
hours: u32,
}
let request: Request =
serde_json::from_value(params.context("Missing app access request")?)?;
let label = request.label.trim();
anyhow::ensure!(
!label.is_empty() && label.len() <= 64 && !label.chars().any(char::is_control),
"Enter a guest label of at most 64 characters"
);
anyhow::ensure!(
(1..=720).contains(&request.hours),
"Choose an expiry between one hour and 30 days"
);
let map = crate::appgate::identity::build_port_map();
let app = map
.gated_ports()
.find(|p| {
p.app_id == request.app_id
&& p.guest_access
&& p.declared
&& p.auth_enabled
&& !p.session_passthrough
})
.context("This app has not opted in to external guest access")?;
let id = format!("external:{}:{label}", uuid::Uuid::new_v4());
let expires = chrono::Utc::now().timestamp() as u64 + u64::from(request.hours) * 3600;
let token = crate::device_tokens::create_scoped_expiring(
&self.config.data_dir,
&id,
Some(vec![app.app_id.clone()]),
Some(expires),
)
.await?;
Ok(json!({"id":id, "token":token, "app_id":app.app_id, "expires_at":expires}))
}
pub(super) async fn handle_publishing_access_revoke(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
}
let request: Request =
serde_json::from_value(params.context("Missing access credential")?)?;
let credentials = crate::device_tokens::list(&self.config.data_dir).await;
anyhow::ensure!(
credentials.iter().any(|c| c.name == request.id
&& c.name.starts_with("external:")
&& c.apps.is_some()),
"External app access credential not found"
);
Ok(
json!({"revoked":crate::device_tokens::remove(&self.config.data_dir, &request.id).await?}),
)
}
pub(super) async fn handle_publishing_blossom_prepare(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
}
let request: Request =
serde_json::from_value(params.context("Missing local archive request")?)?;
let state = publishing::load(&self.config.data_dir).await?;
anyhow::ensure!(
state.version == request.version,
"Publishing settings changed. Reload before storing"
);
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
anyhow::ensure!(
!project.draft.trim().is_empty(),
"Save a website draft first"
);
let digest = publishing::nsite::hash(project.draft.as_bytes());
let now = chrono::Utc::now().timestamp();
Ok(
json!({ "sha256": digest, "size": project.draft.len(), "authorization": {
"kind":24242, "created_at":now, "content":"Store this website draft on my local node only",
"tags":[["t","upload"],["x",digest],["server","127.0.0.1"],["expiration",(now+300).to_string()]]
}}),
)
}
pub(super) async fn handle_publishing_blossom_store(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use base64::Engine;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
authorization: nostr_sdk::Event,
}
let request: Request =
serde_json::from_value(params.context("Missing local archive authorization")?)?;
request
.authorization
.verify()
.context("Invalid local upload signature")?;
let state = publishing::load(&self.config.data_dir).await?;
anyhow::ensure!(
state.version == request.version,
"Publishing settings changed. Reload before storing"
);
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
anyhow::ensure!(
!project.draft.trim().is_empty(),
"Save a website draft first"
);
let digest = publishing::nsite::hash(project.draft.as_bytes());
let event = serde_json::to_value(&request.authorization)?;
let tags = event["tags"]
.as_array()
.context("Missing upload authorization tags")?;
anyhow::ensure!(
event["kind"] == 24242
&& tags.contains(&json!(["t", "upload"]))
&& tags.contains(&json!(["x", digest]))
&& tags.contains(&json!(["server", "127.0.0.1"])),
"Authorization does not match this local draft upload"
);
// This is a protocol adapter, not a general URL proxy. Resolve only the
// manifest-owned Blossom backend and never send node session cookies.
let map = crate::appgate::identity::build_port_map();
let port = map
.gated_ports()
.find(|p| p.app_id == "blossom" && p.declared && p.auth_enabled)
.context("Install local Blossom with its app gate enabled first")?
.port;
let base = format!("http://127.0.0.1:{port}");
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build()?;
let auth = base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&request.authorization)?);
let mut response = client
.put(format!("{base}/upload"))
.header("Authorization", format!("Nostr {auth}"))
.header("Content-Type", "text/html; charset=utf-8")
.body(project.draft.clone())
.send()
.await
.context("Local Blossom is not responding. Start it from Apps")?;
anyhow::ensure!(
response.status().is_success(),
"Local Blossom rejected the upload ({})",
response.status()
);
let mut descriptor = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
descriptor.len() + chunk.len() <= 8192,
"Invalid local Blossom receipt"
);
descriptor.extend_from_slice(&chunk);
}
let descriptor: serde_json::Value = serde_json::from_slice(&descriptor)?;
anyhow::ensure!(
descriptor["sha256"] == digest && descriptor["size"] == project.draft.len(),
"Local Blossom returned another file receipt"
);
let mut response = client
.get(format!("{base}/{digest}"))
.send()
.await?
.error_for_status()?;
let expected = project.draft.as_bytes();
let mut offset = 0;
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
offset + chunk.len() <= expected.len()
&& expected[offset..offset + chunk.len()] == chunk[..],
"Local Blossom readback differs from the saved draft"
);
offset += chunk.len();
}
anyhow::ensure!(
offset == expected.len(),
"Local Blossom readback was incomplete"
);
let receipt = publishing::LocalArchive {
sha256: digest,
size: expected.len(),
pubkey: request.authorization.pubkey.to_hex(),
created_at: chrono::Utc::now().to_rfc3339(),
};
let (state, _) = publishing::update(
&self.config.data_dir,
publishing::Update {
version: request.version,
change: publishing::Change::RecordLocalArchive {
id: request.id,
receipt,
},
},
)
.await
.context("The local file was stored, but its project receipt could not be saved")?;
Ok(json!({"state":state}))
}
pub(super) async fn handle_publishing_status(&self) -> Result<serde_json::Value> {
let state = publishing::load(&self.config.data_dir).await?;
let gate = crate::appgate::listener::shared_status();
@@ -18,18 +334,24 @@ impl RpcHandler {
"id": p.app_id, "name": p.app_name, "port": p.port,
"authentication": if p.auth_enabled { "node-session" } else { "application" },
"listener_claimed": crate::appgate::listener::port_claimed(&gate, p.port),
"guest_access": p.guest_access && p.auth_enabled,
})
})
.collect();
apps.sort_by_key(|a| a["id"].as_str().unwrap_or_default().to_owned());
drop(gate);
let credentials = crate::device_tokens::list(&self.config.data_dir).await;
let grants: Vec<_> = credentials.iter().filter(|c| c.name.starts_with("external:") && c.apps.is_some()).map(|c| json!({"id":c.name,"label":c.name.splitn(3, ':').nth(2).unwrap_or("Guest"),"apps":c.apps,"expires_at":c.expires_at})).collect();
Ok(json!({
"state": state,
"fips_address": crate::fips::iface::fips0_ula().map(|a| a.to_string()),
"apps": apps,
"grants": grants,
"nostr_relays": self.config.nostr_relays,
"publication_enabled": true,
"listeners": publishing::serving::status().await,
"onions": publishing::tor::status().await,
"notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Automated gateways and Nostr publishing are not enabled yet. Saving choices does not change app access; external verification is separate.",
"notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Nostr publishing requires an explicit identity, Blossom server and relay selection. Automated gateway setup is not enabled yet. Saving choices does not change app access; external verification is separate.",
}))
}
@@ -37,11 +359,45 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let update = serde_json::from_value(params.context("Missing publishing settings")?)?;
let update: publishing::Update =
serde_json::from_value(params.context("Missing publishing settings")?)?;
if let publishing::Change::RecordNsite { receipt, .. } = &update.change {
let event: nostr_sdk::Event = serde_json::from_value(receipt.event.clone())?;
event.verify().context("Invalid nsite event signature")?;
}
let (state, project_id) = publishing::update(&self.config.data_dir, update).await?;
Ok(json!({ "state": state, "project_id": project_id }))
}
pub(super) async fn handle_publishing_nsite_prepare(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
server: String,
html: String,
}
let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?;
let state = publishing::load(&self.config.data_dir).await?;
if state.version != request.version {
anyhow::bail!("Publishing settings changed. Reload before preparing the nsite");
}
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
if request.html.len() > 512 * 1024 || request.html.contains('\0') {
anyhow::bail!("Prepared website exceeds the HTML limit");
}
let mut prepared = project.clone();
prepared.draft = request.html;
publishing::nsite::prepare(&prepared, &request.server)
}
pub(super) async fn handle_publishing_dns(
&self,
params: Option<serde_json::Value>,
+37
View File
@@ -19,6 +19,8 @@ use std::path::PathBuf;
/// An app port the gate is responsible for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatedPort {
/// Explicit manifest permission to offer app-only external credentials.
pub guest_access: bool,
pub port: u16,
pub app_id: String,
/// Display name for the login page. Falls back to the id when a manifest
@@ -215,10 +217,24 @@ pub fn build_port_map() -> PortMap {
map
}
#[cfg(test)]
pub(super) fn test_port_map(port: GatedPort) -> PortMap {
let mut map = PortMap::default();
map.gated.insert(port.port, port);
map
}
/// Classify one manifest's ports into the map. Split from [`build_port_map`]
/// so the catalog-overlay pass and the disk pass cannot diverge.
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
let app_id = manifest.app.id.clone();
let guest_access = manifest
.app
.extensions
.get("metadata")
.and_then(|m| m.get("guest_access"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let icon = manifest_icon(manifest);
let app_name = if manifest.app.name.trim().is_empty() {
app_id.clone()
@@ -260,6 +276,9 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
map.gated.insert(
port.host,
GatedPort {
guest_access: guest_access
&& !port.session_passthrough
&& port.auth_policy() == PortAuth::Gated,
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
@@ -309,6 +328,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
map.gated.insert(
port.host,
GatedPort {
guest_access: false,
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
@@ -372,6 +392,23 @@ app:
image: example.org/testapp:1.0
"#;
#[test]
fn guest_access_requires_explicit_gate_and_never_allows_session_passthrough() {
for (auth, passthrough, expected) in [
("gated", false, true),
("gated", true, false),
("session", false, false),
] {
let text = format!("{BASE} metadata:\n guest_access: true\n ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 0.0.0.0\n auth: {auth}\n session_passthrough: {passthrough}\n");
let mut map = PortMap::default();
classify_manifest(&manifest(&text), &mut map);
assert_eq!(map.gated(8090).unwrap().guest_access, expected);
}
let mut map = PortMap::default();
classify_manifest(&manifest(&format!("{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n")), &mut map);
assert!(!map.gated(8090).unwrap().guest_access);
}
/// `auth: gated` is the only classification allowed to redirect traffic —
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
/// `declared`. An undeclared Session port is challenged and audited but
+11 -5
View File
@@ -406,7 +406,7 @@ async fn serve_connection(
if is_tls {
match gate.tls.acceptor().await {
Some(acceptor) => match acceptor.accept(stream).await {
Ok(tls_stream) => serve_http(tls_stream, peer, gate, app).await,
Ok(tls_stream) => serve_http(tls_stream, peer, gate, app, true).await,
Err(e) => {
// Routine: a browser probing a cert it does not trust, or a
// scanner. Not operator-actionable, so debug.
@@ -424,18 +424,24 @@ async fn serve_connection(
}
}
} else {
serve_http(stream, peer, gate, app).await;
serve_http(stream, peer, gate, app, false).await;
}
}
/// The HTTP half, generic over the transport so TLS and plain share one path —
/// the gate's authentication, proxying and upgrade handling must not differ by
/// scheme, and generics make that structural rather than a thing to remember.
async fn serve_http<S>(stream: S, peer: SocketAddr, gate: Arc<AppGate>, app: GatedPort)
where
async fn serve_http<S>(
stream: S,
peer: SocketAddr,
gate: Arc<AppGate>,
app: GatedPort,
secure: bool,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let service = hyper::service::service_fn(move |req| {
let service = hyper::service::service_fn(move |mut req: hyper::Request<hyper::Body>| {
req.extensions_mut().insert(super::SecureTransport(secure));
let gate = gate.clone();
let app = app.clone();
async move { Ok::<_, std::convert::Infallible>(gate.handle(req, &app, peer.ip()).await) }
+182 -9
View File
@@ -50,6 +50,8 @@ use tokio::sync::RwLock;
/// Paths the gate serves itself rather than proxying. Namespaced so an app
/// that happens to have its own `/login` is unaffected.
const GATE_PREFIX: &str = "/__archipelago-gate/";
#[derive(Clone, Copy)]
pub(crate) struct SecureTransport(pub bool);
/// Result of examining a request's credentials.
#[derive(Debug, PartialEq, Eq)]
@@ -60,6 +62,11 @@ pub enum Authorization {
/// `Authorization: Bearer <device token>` — strip that header before the
/// app sees it, exactly as the session cookie is stripped.
AllowGateToken,
/// App-only cookie; never repair or issue a dashboard session for it.
AllowGuest,
/// Expiring external guest credential presented as an API bearer token.
/// It still requires the current port's guest opt-in and is stripped.
AllowGuestToken,
/// Serve the login page.
Challenge,
}
@@ -105,7 +112,7 @@ impl AppGate {
/// Does this request carry a credential good for `app_id`?
///
/// Two accepted forms, deliberately no others:
/// Accepted credentials retain distinct scopes:
///
/// * the node session cookie — and because a session still pending its
/// TOTP step fails `validate()`, **2FA is honoured here for free**. The
@@ -113,6 +120,8 @@ impl AppGate {
/// * an app-scoped bearer token, for machine clients that speak HTTP but
/// cannot hold a cookie or complete an interactive login (Home
/// Assistant reaching an app's API is the motivating case).
/// * a separately named app-only cookie, with live scope/expiry/revocation
/// checks and no ability to authenticate to dashboard RPC.
pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization {
if let Some(token) = crate::session::extract_session_cookie(headers) {
if self.sessions.validate(&token).await {
@@ -120,12 +129,29 @@ impl AppGate {
}
}
let guest_enabled = self
.port_map
.read()
.await
.gated_ports()
.any(|p| p.app_id == app_id && p.guest_access && p.auth_enabled);
if let Some(token) = bearer_token(headers) {
if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id)
.await
.is_some()
if let Some(credential) =
crate::device_tokens::verified_app_token(&self.data_dir, &token, app_id).await
{
return Authorization::AllowGateToken;
if !credential.name.starts_with("external:") || credential.apps.is_none() {
return Authorization::AllowGateToken;
}
if guest_enabled {
return Authorization::AllowGuestToken;
}
}
}
if guest_enabled {
if let Some(token) = cookie_value(headers, &format!("archy_app_access_{app_id}")) {
if crate::device_tokens::verify_guest(&self.data_dir, &token, app_id).await {
return Authorization::AllowGuest;
}
}
}
@@ -216,12 +242,20 @@ impl AppGate {
}
// The credential WAS the Authorization header, and it was ours.
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
Authorization::AllowGuest if app.guest_access && !app.session_passthrough => {
proxy_to_app(req, app, false).await
}
Authorization::AllowGuestToken if app.guest_access && !app.session_passthrough => {
proxy_to_app(req, app, true).await
}
// 401 rather than a redirect: a redirect to a login page is
// indistinguishable from the app itself redirecting, and machine
// clients would follow it and parse HTML as if it were their API
// response. The status says "you are not authenticated" in a way
// every client understands, and browsers still render the body.
Authorization::Challenge => {
Authorization::Challenge
| Authorization::AllowGuest
| Authorization::AllowGuestToken => {
login_page(app, None, StatusCode::UNAUTHORIZED, &mount_prefix)
}
}
@@ -269,6 +303,16 @@ impl AppGate {
// never appears in the HTML, in a `view-source`, or in a screenshot
// of the second-factor page.
let pending = crate::session::extract_session_cookie(req.headers());
let secure = req
.extensions()
.get::<SecureTransport>()
.map(|s| s.0)
.unwrap_or(false)
|| req
.headers()
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
== Some("https");
// Same limiter instance as the JSON-RPC login path, so an attacker
// cannot get a fresh budget of guesses simply by moving to an app
@@ -295,6 +339,26 @@ impl AppGate {
};
match action {
"guest" if app.guest_access && app.auth_enabled => {
let token = field(&form, "access_token").unwrap_or_default();
if !crate::device_tokens::verify_guest(&self.data_dir, &token, &app.app_id).await {
self.limiter.record_failure(client_ip).await;
return login_page(
app,
Some("App access token is invalid, expired or revoked."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
}
let mut response = redirect_to_app(mount_prefix);
// Host-only and app-specific. A token is rechecked on EVERY
// request, so revocation and its expiry apply immediately.
let suffix = if secure { "; Secure" } else { "" };
if let Ok(cookie) = header::HeaderValue::from_str(&format!("archy_app_access_{}={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600{suffix}", app.app_id)) {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
response
}
"login" => self.do_login(app, &form, client_ip, mount_prefix).await,
"totp" => {
self.do_totp(app, &form, pending, client_ip, mount_prefix)
@@ -535,6 +599,9 @@ async fn proxy_to_app(
let (mut parts, body) = req.into_parts();
parts.uri = uri;
strip_matching_cookies(&mut parts.headers, |name| {
name.starts_with("archy_app_access_")
});
// Strip the gate's own credential before it reaches the app — the app
// should never be in a position to log, echo, or forward the node
// session. But ONLY the gate's cookies: apps run their own cookie logins
@@ -662,12 +729,18 @@ fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) {
}
/// Cookie names owned by the gate/daemon, never the app's to see.
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"];
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token", "remember"];
/// Remove the gate's own cookie pairs from the Cookie header, preserving the
/// app's cookies (its login/session/prefs) untouched. Drops the header
/// entirely when nothing remains.
fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
strip_matching_cookies(headers, |name| {
GATE_COOKIE_NAMES.contains(&name) || name.starts_with("archy_app_access_")
});
}
fn strip_matching_cookies(headers: &mut hyper::HeaderMap, remove: fn(&str) -> bool) {
let Some(cookie) = headers.get(header::COOKIE) else {
return;
};
@@ -681,7 +754,7 @@ fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
.map(str::trim)
.filter(|pair| {
let name = pair.split('=').next().unwrap_or("").trim();
!GATE_COOKIE_NAMES.contains(&name)
!remove(name)
})
.filter(|pair| !pair.is_empty())
.collect();
@@ -1254,7 +1327,8 @@ fn login_page(
<form method="post" action="{prefix}login">
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
<button type="submit"><span class="idle">Sign in</span><span class="busy">{spinner}Signing in…</span></button>
</form>"#,
</form>
{guest_form}"#,
logo = logo_markup(),
spinner = SPINNER_SVG,
icon = icon_markup(app),
@@ -1263,6 +1337,14 @@ fn login_page(
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = gate_url(mount_prefix, ""),
guest_form = if app.guest_access && app.auth_enabled {
format!(
r#"<details><summary>Have an app-only access token?</summary><p class="sub">This opens only this app, without a dashboard login. The app may also require its own account.</p><form method="post" action="{}"><input type="password" name="access_token" placeholder="App access token" autocomplete="off" required><button type="submit">Open this app</button></form></details>"#,
gate_url(mount_prefix, "guest")
)
} else {
String::new()
},
);
page("Sign in", app, &body, status, mount_prefix)
}
@@ -1298,6 +1380,96 @@ fn totp_page(
#[cfg(test)]
mod tests {
#[tokio::test]
async fn guest_login_is_app_only_and_revocation_blocks_subsequent_requests() {
let gate = test_gate().await;
let mut app = app();
app.guest_access = true;
*gate.port_map.write().await = identity::test_port_map(app.clone());
let token = crate::device_tokens::create_scoped_expiring(
&gate.data_dir,
"external:test:Guest",
Some(vec![app.app_id.clone()]),
Some(u64::MAX),
)
.await
.unwrap();
let mut request = Request::post(format!("{GATE_PREFIX}guest"))
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("access_token={token}")))
.unwrap();
request.extensions_mut().insert(SecureTransport(true));
let response = gate
.handle(request, &app, "127.0.0.1".parse().unwrap())
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let cookies: Vec<_> = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|h| h.to_str().unwrap())
.collect();
assert_eq!(cookies.len(), 1);
assert!(
cookies[0].starts_with("archy_app_access_strfry=")
&& cookies[0].contains("; Secure")
&& cookies[0].contains("HttpOnly")
);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
cookies[0].split(';').next().unwrap().parse().unwrap(),
);
assert_eq!(
gate.authorize(&headers, &app.app_id).await,
Authorization::AllowGuest
);
assert_eq!(
gate.authorize(&headers, "lnd").await,
Authorization::Challenge
);
assert!(!gate.sessions.validate(&token).await);
assert!(crate::device_tokens::verify(&gate.data_dir, &token)
.await
.is_none());
let mut bearer = HeaderMap::new();
bearer.insert(
header::AUTHORIZATION,
format!("Bearer {token}").parse().unwrap(),
);
assert_eq!(
gate.authorize(&bearer, &app.app_id).await,
Authorization::AllowGuestToken
);
app.guest_access = false;
*gate.port_map.write().await = identity::test_port_map(app.clone());
assert_eq!(
gate.authorize(&bearer, &app.app_id).await,
Authorization::Challenge
);
assert_eq!(
gate.authorize(&headers, &app.app_id).await,
Authorization::Challenge
);
app.guest_access = true;
*gate.port_map.write().await = identity::test_port_map(app.clone());
crate::device_tokens::remove(&gate.data_dir, "external:test:Guest")
.await
.unwrap();
assert_eq!(
gate.authorize(&headers, &app.app_id).await,
Authorization::Challenge
);
}
#[test]
fn guest_and_remember_credentials_never_reach_the_app() {
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "session=owner; remember=master; csrf_token=csrf; archy_app_access_nextcloud=guest; own_app_session=keep".parse().unwrap());
strip_gate_cookies(&mut headers);
assert_eq!(headers[header::COOKIE], "own_app_session=keep");
}
#[test]
fn credentialless_allowlist_covers_the_manifest_that_broke_apps() {
// A <link rel="manifest"> fetch never carries the cookie, so these must
@@ -1334,6 +1506,7 @@ mod tests {
fn app() -> GatedPort {
GatedPort {
guest_access: false,
port: 8090,
app_id: "strfry".to_string(),
app_name: "Strfry Relay".to_string(),
+152 -16
View File
@@ -18,6 +18,7 @@ const TOKENS_FILE: &str = "device-tokens.json";
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
/// so this only limits the number of *distinct* device names.
const MAX_TOKENS: usize = 32;
static TOKEN_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceToken {
@@ -39,9 +40,14 @@ pub struct DeviceToken {
/// app's API should not also open every other app on the node.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
}
impl DeviceToken {
fn active(&self) -> bool {
self.expires_at.map(|end| end > now()).unwrap_or(true)
}
/// Whether this token may reach `app_id`.
pub fn allows_app(&self, app_id: &str) -> bool {
match &self.apps {
@@ -51,22 +57,49 @@ impl DeviceToken {
}
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(u64::MAX)
}
fn tokens_path(data_dir: &Path) -> PathBuf {
data_dir.join(TOKENS_FILE)
}
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
load_strict(data_dir).await.unwrap_or_default()
}
async fn load_strict(data_dir: &Path) -> Result<Vec<DeviceToken>> {
match fs::read(tokens_path(data_dir)).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Vec::new(),
Ok(bytes) => serde_json::from_slice(&bytes)
.context("Read stored access credentials; existing file preserved"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e).context("Read stored access credentials"),
}
}
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
let bytes = serde_json::to_vec_pretty(tokens)?;
fs::write(tokens_path(data_dir), bytes)
.await
.context("write device-tokens.json")
use tokio::io::AsyncWriteExt;
let tmp = data_dir.join(format!(".device-tokens-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).mode(0o600);
let mut file = options.open(&tmp).await?;
file.write_all(&bytes).await?;
file.sync_all().await?;
fs::rename(&tmp, tokens_path(data_dir)).await?;
fs::File::open(data_dir).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(tmp).await;
}
result.context("write device-tokens.json")
}
fn hash_hex(token: &str) -> String {
@@ -94,6 +127,20 @@ pub async fn create_scoped(
name: &str,
apps: Option<Vec<String>>,
) -> Result<String> {
create_scoped_expiring(data_dir, name, apps, None).await
}
pub async fn create_scoped_expiring(
data_dir: &Path,
name: &str,
apps: Option<Vec<String>>,
expires_at: Option<u64>,
) -> Result<String> {
let _guard = TOKEN_WRITE_LOCK.lock().await;
anyhow::ensure!(
expires_at.map(|end| end > now()).unwrap_or(true),
"Access expiry must be in the future"
);
// An empty list would be indistinguishable from "no restriction" to a
// careless reader while actually authorising nothing — reject it rather
// than mint a token whose behaviour nobody can predict from its record.
@@ -108,10 +155,10 @@ pub async fn create_scoped(
})?;
let token = hex::encode(token_bytes);
let mut tokens = load(data_dir).await;
let mut tokens = load_strict(data_dir).await?;
tokens.retain(|t| t.name != name);
if tokens.len() >= MAX_TOKENS {
tokens.remove(0);
anyhow::bail!("Access credential limit reached. Revoke an unused credential first");
}
tokens.push(DeviceToken {
name: name.to_string(),
@@ -121,35 +168,63 @@ pub async fn create_scoped(
.map(|d| d.as_secs())
.unwrap_or(0),
apps,
expires_at,
});
save(data_dir, &tokens).await?;
Ok(token)
}
/// Verify a candidate token. Returns the device name it was minted for.
/// Verify a node-wide login token. App-only credentials must never be exchanged
/// for an administrator session through auth.login (including its password path).
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
let candidate_hash = hash_hex(candidate);
load(data_dir)
.await
.iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
.find(|t| {
t.apps.is_none() && t.active() && ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
})
.map(|t| t.name.clone())
}
/// Verify a candidate token **for a specific app**, as the app gate does.
/// Returns the device name when the token is valid *and* in scope.
///
/// Separate from `verify` on purpose: `verify` answers "is this a real
/// token", which is the right question for node login, and would be the
/// wrong question here — a token scoped to one app would otherwise open
/// every app.
/// Node-wide companion credentials retain their existing app access; app-only
/// credentials work only for the recorded application(s), before their expiry.
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
verified_app_token(data_dir, candidate, app_id)
.await
.map(|t| t.name)
}
/// Return one verified snapshot so callers can distinguish a guest credential
/// from a node-wide device without racing a second read of the token file.
pub async fn verified_app_token(
data_dir: &Path,
candidate: &str,
app_id: &str,
) -> Option<DeviceToken> {
let candidate_hash = hash_hex(candidate);
load(data_dir)
.await
.iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()) && t.allows_app(app_id))
.map(|t| t.name.clone())
.find(|t| {
t.active()
&& ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
&& t.allows_app(app_id)
})
.cloned()
}
pub async fn verify_guest(data_dir: &Path, candidate: &str, app_id: &str) -> bool {
let candidate_hash = hash_hex(candidate);
load(data_dir).await.iter().any(|t| {
t.apps.is_some()
&& t.active()
&& t.allows_app(app_id)
&& ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
})
}
/// List stored tokens (hashes only — plaintexts are unrecoverable).
@@ -159,7 +234,8 @@ pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
/// Remove the token minted for `name`. Returns whether one existed.
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
let mut tokens = load(data_dir).await;
let _guard = TOKEN_WRITE_LOCK.lock().await;
let mut tokens = load_strict(data_dir).await?;
let before = tokens.len();
tokens.retain(|t| t.name != name);
let removed = tokens.len() != before;
@@ -172,6 +248,66 @@ pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn concurrent_grants_survive_and_capacity_never_evicts_a_device() {
let dir = tempfile::tempdir().unwrap();
let owner = create(dir.path(), "phone").await.unwrap();
let mut tasks = tokio::task::JoinSet::new();
for i in 1..MAX_TOKENS {
let path = dir.path().to_owned();
tasks.spawn(async move { create(&path, &format!("device-{i}")).await.unwrap() });
}
while let Some(result) = tasks.join_next().await {
result.unwrap();
}
assert_eq!(list(dir.path()).await.len(), MAX_TOKENS);
assert!(create(dir.path(), "overflow").await.is_err());
assert_eq!(verify(dir.path(), &owner).await.as_deref(), Some("phone"));
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(tokens_path(dir.path()))
.await
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
#[tokio::test]
async fn guest_scope_expiry_and_corruption_fail_closed_without_replacing_credentials() {
let dir = tempfile::tempdir().unwrap();
let guest = create_scoped_expiring(
dir.path(),
"guest",
Some(vec!["nextcloud".into()]),
Some(now() + 3600),
)
.await
.unwrap();
assert!(verify(dir.path(), &guest).await.is_none());
assert!(verify_guest(dir.path(), &guest, "nextcloud").await);
assert!(verify_for_app(dir.path(), &guest, "nextcloud")
.await
.is_some());
assert!(verify_for_app(dir.path(), &guest, "lnd").await.is_none());
let mut records = load(dir.path()).await;
records[0].expires_at = Some(1);
save(dir.path(), &records).await.unwrap();
assert!(!verify_guest(dir.path(), &guest, "nextcloud").await);
assert!(verify_for_app(dir.path(), &guest, "nextcloud")
.await
.is_none());
fs::write(tokens_path(dir.path()), b"broken stored credential file")
.await
.unwrap();
assert!(create(dir.path(), "phone").await.is_err());
assert!(remove(dir.path(), "guest").await.is_err());
assert_eq!(
fs::read(tokens_path(dir.path())).await.unwrap(),
b"broken stored credential file"
);
}
#[tokio::test]
async fn mint_verify_replace_remove() {
+1
View File
@@ -29,6 +29,7 @@ pub const APP_LAUNCH_PORTS: &[u16] = &[
8175,
8176,
8187,
8191,
8240,
8334,
8336,
+139 -4
View File
@@ -7,6 +7,7 @@ use std::path::Path;
use tokio::sync::Mutex;
mod firewall;
pub mod nsite;
pub mod serving;
pub mod tor;
@@ -45,6 +46,19 @@ pub struct Project {
pub fips_publication: Option<Publication>,
#[serde(default)]
pub tor_publication: Option<Publication>,
#[serde(default)]
pub nsite_receipt: Option<nsite::Receipt>,
#[serde(default)]
pub local_archive: Option<LocalArchive>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalArchive {
pub sha256: String,
pub size: usize,
pub pubkey: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -85,6 +99,16 @@ impl Default for State {
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Change {
// Only the local storage adapter can claim a verified archive receipt.
#[serde(skip_deserializing)]
RecordLocalArchive {
id: String,
receipt: LocalArchive,
},
RecordNsite {
id: String,
receipt: nsite::Receipt,
},
Connections {
routes: BTreeSet<Route>,
},
@@ -158,7 +182,7 @@ fn name(value: &str) -> Result<String> {
Ok(value.to_owned())
}
fn public_ip(ip: std::net::IpAddr) -> bool {
pub(crate) fn public_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(a) => {
let o = a.octets();
@@ -173,12 +197,15 @@ fn public_ip(ip: std::net::IpAddr) -> bool {
&& o[0] < 240
&& !(o[0] == 100 && (64..=127).contains(&o[1]))
&& !(o[0] == 198 && (o[1] == 18 || o[1] == 19))
&& !(o[0] == 192 && o[1] == 0 && o[2] == 0)
}
std::net::IpAddr::V6(a) => {
let s = a.segments();
// Only global unicast; excludes ULA/FIPS, mapped-v4, loopback,
// multicast and link-local, plus documentation allocations.
(s[0] & 0xe000) == 0x2000
&& !(s[0] == 0x2001 && s[1] < 0x200)
&& s[0] != 0x2002
&& !(s[0] == 0x2001 && s[1] == 0x0db8)
&& !(s[0] == 0x3fff && s[1] < 0x1000)
}
@@ -228,6 +255,28 @@ pub fn dns_records(domain: &Domain) -> Result<Vec<DnsRecord>> {
impl State {
pub fn apply(&mut self, change: Change) -> Result<Option<String>> {
match change {
Change::RecordLocalArchive { id, receipt } => {
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
if receipt.sha256 != nsite::hash(p.draft.as_bytes())
|| receipt.size != p.draft.len()
{
bail!("The draft changed while storing it. The stored file is retained; review the current draft");
}
p.local_archive = Some(receipt);
Ok(Some(id))
}
Change::RecordNsite { id, receipt } => {
receipt.validate(&id)?;
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
p.nsite_receipt = Some(receipt);
Ok(Some(id))
}
Change::Connections { routes } => {
self.connections = routes;
Ok(None)
@@ -249,6 +298,8 @@ impl State {
revisions: vec![],
fips_publication: None,
tor_publication: None,
nsite_receipt: None,
local_archive: None,
},
);
Ok(Some(id))
@@ -275,7 +326,10 @@ impl State {
.projects
.get_mut(&id)
.context("Website project not found")?;
if p.fips_publication.is_some() && !routes.contains(&Route::Fips) {
if p.fips_publication.is_some()
&& !routes.contains(&Route::Fips)
&& !routes.contains(&Route::PublicWeb)
{
bail!("Unpublish the FIPS website before removing its route");
}
if p.tor_publication.is_some() && !routes.contains(&Route::Tor) {
@@ -313,8 +367,10 @@ impl State {
.projects
.get_mut(&id)
.context("Website project not found")?;
if !p.routes.contains(&Route::Fips) || p.draft.trim().is_empty() {
bail!("Save a website draft and select FIPS before publishing");
if (!p.routes.contains(&Route::Fips) && !p.routes.contains(&Route::PublicWeb))
|| p.draft.trim().is_empty()
{
bail!("Save a website draft and select FIPS or public web before publishing");
}
let port = match &p.fips_publication {
Some(old) => old.port,
@@ -482,6 +538,45 @@ pub async fn update(root: &Path, request: Update) -> Result<(State, Option<Strin
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_archive_receipts_cannot_be_claimed_by_clients_or_publish_routes() {
assert!(serde_json::from_value::<Change>(
serde_json::json!({"action":"record-local-archive", "id":"x", "receipt":{}})
)
.is_err());
let mut state = State::default();
let id = state
.apply(Change::Create {
name: "Local archive".into(),
})
.unwrap()
.unwrap();
state.projects.get_mut(&id).unwrap().draft = "<p>Private draft</p>".into();
let draft = &state.projects[&id].draft;
let mut receipt = LocalArchive {
sha256: nsite::hash(draft.as_bytes()),
size: draft.len(),
pubkey: "a".repeat(64),
created_at: chrono::Utc::now().to_rfc3339(),
};
state
.apply(Change::RecordLocalArchive {
id: id.clone(),
receipt: receipt.clone(),
})
.unwrap();
let p = &state.projects[&id];
assert!(
p.routes.is_empty()
&& p.fips_publication.is_none()
&& p.tor_publication.is_none()
&& p.nsite_receipt.is_none()
);
receipt.sha256 = "b".repeat(64);
assert!(state
.apply(Change::RecordLocalArchive { id, receipt })
.is_err());
}
#[tokio::test]
async fn concurrent_edit_is_rejected_and_project_survives_reload() {
let d = tempfile::tempdir().unwrap();
@@ -545,6 +640,9 @@ mod tests {
"::1",
"192.168.1.2",
"::ffff:8.8.8.8",
"2002:7f00:1::1",
"2001::1",
"192.0.0.1",
"node.fips",
"a.onion",
"example.com; bad",
@@ -570,6 +668,43 @@ mod tests {
}
}
#[test]
fn public_web_reuses_fips_upstream_without_requiring_a_second_route_choice() {
let mut state = State::default();
state.connections.insert(Route::PublicWeb);
let id = state
.apply(Change::Create {
name: "Public site".into(),
})
.unwrap()
.unwrap();
state.projects.get_mut(&id).unwrap().draft = "<h1>Public</h1>".into();
assert!(state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: false
})
.is_err());
state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: true,
})
.unwrap();
assert!(state.projects[&id].fips_publication.is_some());
assert!(!state.projects[&id].routes.contains(&Route::Fips));
let save = |routes| Change::Save {
id: id.clone(),
name: "Public site".into(),
routes,
domain: None,
html: "<h1>Public</h1>".into(),
};
state
.apply(save([Route::PublicWeb].into_iter().collect()))
.unwrap();
assert!(state.apply(save(BTreeSet::new())).is_err());
}
#[test]
fn multiple_routes_and_restore_do_not_publish() {
let mut s = State::default();
s.apply(Change::Connections {
+130
View File
@@ -0,0 +1,130 @@
//! NIP-5A named-site preparation only. Upload and explicit identity signing use
//! the dashboard's existing signer; this module never exports or creates keys.
use super::{Project, Route};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
pub fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
pub fn server(raw: &str) -> Result<String> {
let value = raw.trim().trim_end_matches('/');
let host = value
.strip_prefix("https://")
.ok_or_else(|| anyhow::anyhow!("Enter an HTTPS Blossom server origin"))?;
Ok(format!("https://{}", super::hostname(host)?))
}
pub fn prepare(project: &Project, blossom: &str) -> Result<Value> {
if !project.routes.contains(&Route::Nostr) || project.draft.trim().is_empty() {
bail!("Save a website draft and select Nostr before publishing");
}
let server = server(blossom)?;
// The first policy remains restrictive even if generated HTML adds another
// CSP. Hosted nsites use a separate origin, without dashboard privileges.
let html = format!("<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\"><meta name=\"referrer\" content=\"no-referrer\">{}", project.draft);
let digest = hash(html.as_bytes());
let identifier: String = project.id.chars().filter(|c| *c != '-').take(13).collect();
let aggregate = hash(format!("{digest} /index.html\n").as_bytes());
let now = chrono::Utc::now().timestamp();
Ok(json!({
"html":html, "sha256":digest, "server":server, "identifier":identifier,
"authorization": { "kind":24242, "created_at":now, "content":"Upload this website's index.html", "tags":[["t","upload"],["x",digest],["server",server.trim_start_matches("https://")],["expiration",(now+300).to_string()]] },
"manifest": { "kind":35128, "created_at":now, "content":"", "tags":[["d",identifier],["path","/index.html",digest],["x",aggregate,"aggregate"],["server",server],["title",project.name]] }
}))
}
/// A client-side delivery receipt, not a claim of gateway reachability or of
/// erasure from relays. Keep the signed event so interrupted sends can be retried.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Receipt {
pub identity_id: String,
pub server: String,
pub event: Value,
pub accepted_relays: Vec<String>,
pub deletion_requested: bool,
}
impl Receipt {
pub fn validate(&self, project_id: &str) -> Result<()> {
if self.identity_id.is_empty()
|| self.identity_id.len() > 200
|| self.accepted_relays.len() > 8
|| serde_json::to_vec(&self.event)?.len() > 16 * 1024
{
bail!("Invalid nsite receipt");
}
server(&self.server)?;
for key in ["id", "pubkey"] {
let s = self.event[key].as_str().unwrap_or("");
if s.len() != 64 || !s.bytes().all(|c| c.is_ascii_hexdigit()) {
bail!("Invalid signed nsite event");
}
}
if self.event["kind"] != 35128 {
bail!("Only named nsite receipts are supported");
}
let identifier: String = project_id.chars().filter(|c| *c != '-').take(13).collect();
let tags = self.event["tags"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("Missing nsite tags"))?;
if tags.iter().filter(|t| t[0] == "d").count() != 1
|| !tags.iter().any(|t| t == &json!(["d", identifier]))
{
bail!("Nsite receipt does not belong to this project");
}
if self
.accepted_relays
.iter()
.any(|r| !r.starts_with("wss://") || r.len() > 300 || r.chars().any(char::is_control))
{
bail!("Invalid relay receipt");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::publishing::{Change, State};
#[test]
fn named_manifest_scopes_auth_and_hashes_exact_uploaded_bytes() {
let mut state = State::default();
let id = state
.apply(Change::Create {
name: "Site".into(),
})
.unwrap()
.unwrap();
state
.apply(Change::Save {
id: id.clone(),
name: "Site".into(),
routes: [Route::Nostr].into_iter().collect(),
domain: None,
html: "<h1>Hello 🏝</h1>".into(),
})
.unwrap();
let p = prepare(&state.projects[&id], "https://blossom.example.org/").unwrap();
assert_eq!(p["sha256"], hash(p["html"].as_str().unwrap().as_bytes()));
assert_eq!(p["manifest"]["kind"], 35128);
assert_eq!(p["manifest"]["tags"][0][1].as_str().unwrap().len(), 13);
assert_eq!(
p["authorization"]["tags"][2],
json!(["server", "blossom.example.org"])
);
assert!(p["html"]
.as_str()
.unwrap()
.starts_with("<!doctype html><meta http-equiv=\"Content-Security-Policy\""));
for bad in [
"http://example.org",
"https://127.0.0.1",
"https://user:secret@example.org",
"https://example.org/path",
] {
assert!(server(bad).is_err());
}
}
}
+6
View File
@@ -93,6 +93,12 @@ impl EndpointRateLimiter {
// Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Explicit publishing actions can allocate credentials or perform
// bounded network I/O. Saving/previewing never invokes these actions.
limits.insert("publishing.access-create".to_string(), (10, 60));
limits.insert("publishing.verify-https".to_string(), (10, 60));
limits.insert("publishing.blossom-store".to_string(), (10, 60));
limits.insert("publishing.generate".to_string(), (5, 300));
// Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600));
limits.insert("backup.restore".to_string(), (5, 600));