From 28a92fcc9b9790840f84ebb76ed5d36b906a8384 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 8 Oct 2026 09:12:40 -0400 Subject: [PATCH] feat: integrate local Blossom, reviewed nsites and scoped app access --- app-catalog/catalog.json | 13 + apps/blossom/README.md | 88 +++++ apps/blossom/manifest.yml | 87 +++++ apps/home-assistant/manifest.yml | 1 + apps/immich/manifest.yml | 1 + apps/jellyfin/manifest.yml | 1 + apps/nextcloud/manifest.yml | 1 + apps/photoprism/manifest.yml | 1 + apps/strfry/manifest.yml | 3 + core/Cargo.lock | 1 + core/archipelago/src/api/rpc/dispatcher.rs | 6 + core/archipelago/src/api/rpc/publishing.rs | 360 +++++++++++++++++- core/archipelago/src/appgate/identity.rs | 37 ++ core/archipelago/src/appgate/listener.rs | 16 +- core/archipelago/src/appgate/mod.rs | 191 +++++++++- core/archipelago/src/device_tokens.rs | 168 +++++++- core/archipelago/src/fips/app_ports.rs | 1 + core/archipelago/src/publishing/mod.rs | 143 ++++++- core/archipelago/src/publishing/nsite.rs | 130 +++++++ core/archipelago/src/rate_limit.rs | 6 + core/publishing-tests/Cargo.toml | 1 + docker/blossom/Dockerfile | 13 + docker/blossom/patch.ts | 32 ++ docker/blossom/startup.ts | 21 + docker/blossom/ui/app.ts | 80 ++++ docker/blossom/ui/index.html | 1 + docs/app-manifest-spec.md | 16 + docs/external-access-and-websites.md | 58 ++- .../public/assets/img/app-icons/blossom.svg | 1 + neode-ui/public/catalog.json | 13 + .../__tests__/nsitePublishing.test.ts | 137 +++++++ neode-ui/src/services/nsitePublishing.ts | 151 ++++++++ neode-ui/src/services/publishing.ts | 9 +- .../src/views/appSession/appSessionConfig.ts | 1 + .../appSession/generatedAppSessionConfig.ts | 2 + .../src/views/publishing/PublishingSetup.vue | 198 +++++++++- .../__tests__/PublishingSetup.test.ts | 45 ++- tests/apps/blossom/protocol.ts | 36 ++ tests/apps/blossom/ui-smoke.cjs | 40 ++ 39 files changed, 2060 insertions(+), 50 deletions(-) create mode 100644 apps/blossom/README.md create mode 100644 apps/blossom/manifest.yml create mode 100644 core/archipelago/src/publishing/nsite.rs create mode 100644 docker/blossom/Dockerfile create mode 100644 docker/blossom/patch.ts create mode 100644 docker/blossom/startup.ts create mode 100644 docker/blossom/ui/app.ts create mode 100644 docker/blossom/ui/index.html create mode 100644 neode-ui/public/assets/img/app-icons/blossom.svg create mode 100644 neode-ui/src/services/__tests__/nsitePublishing.test.ts create mode 100644 neode-ui/src/services/nsitePublishing.ts create mode 100644 tests/apps/blossom/protocol.ts create mode 100644 tests/apps/blossom/ui-smoke.cjs diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index b37c4146..348529ff 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -673,6 +673,19 @@ "tier": "optional", "icon": "/assets/img/app-icons/angor-green.png", "repoUrl": "https://github.com/hoytech/strfry" + }, + { + "id": "blossom", + "author": "hzrd149 / Archipelago", + "requires": [], + "tier": "optional", + "title": "Blossom", + "version": "6.4.1-archy.1", + "description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.", + "dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1", + "category": "data", + "repoUrl": "https://github.com/hzrd149/blossom-server", + "icon": "/assets/img/app-icons/blossom.svg" } ] } diff --git a/apps/blossom/README.md b/apps/blossom/README.md new file mode 100644 index 00000000..8e20c22c --- /dev/null +++ b/apps/blossom/README.md @@ -0,0 +1,88 @@ +# Blossom on Archipelago + +Candidate package, not a published catalogue release. Follow +[`docs/app-developer-guide.md`](../../docs/app-developer-guide.md) and +[`docs/candidate-catalog-qualification.md`](../../docs/candidate-catalog-qualification.md) +for lifecycle and catalogue acceptance. + +## Package contract + +- MIT upstream `hzrd149/blossom-server` 6.4.1, source commit + `a492dc61c4a581bbd0992546b2aec6f9aa543f75`. The Dockerfile verifies the source + archive SHA-256 and uses upstream's frozen dependency lock for the server. +- Manifest-owned local build; the runtime payload must include `docker/blossom`. + No unpublished registry image is advertised. Initial installation needs access + to the open-source build dependencies; normal startup uses cached dependencies. +- Rootless container, read-only root, no capabilities, no new privileges, + explicit `slirp4netns`. Host port 8191 binds IPv4 loopback behind AppGate. + Keep that private backend binding: any FIPS/IPv6 ingress belongs at the gate. +- Persistent data and SQLite under `/var/lib/archipelago/blossom/data`. + Preserve this directory on uninstall. No automatic expiry/pruning, automatic + mirroring, media conversion, or upstream administration dashboard. +- Uploads require BUD-11 signatures from the profile identities supplied by + `{{NODE_IDENTITY_PUBKEYS}}`. No profile means startup fails closed. Changes to + that allowlist take effect on restart, including revocation of removed profiles. + The appliance identity is excluded. Listing requires the owner's signature. +- The custom local UI loads the canonical, host-managed `nostr-provider.js` + through the documented lifecycle hook. A missing provider fails verification. + The UI uses the platform identity chooser and ordinary NIP-07 signing; there + is no generated browser key, nsec input, or second consent modal. +- Upload authorization is scoped to the file hash, actual server hostname and + five-minute expiry. Local upload does not send a public Nostr announcement. +- Uploaded files are returned as sandboxed attachments. Untrusted HTML/SVG must + not acquire this app's origin or signer access. Published website rendering + needs the separate website origin, not a relaxation of this policy. + +AppGate protects reads as well as the UI. Blossom itself is content-addressed, +not an encrypted per-user vault: other authorized node users who know a hash can +retrieve its bytes. Do not open the whole app gate to publish one website. Public +asset serving must authorize exact selected hashes; external replication requires +its own explicit content/destination review. An inaccessible local URL is not a +working public Blossom endpoint. + +## Qualification evidence — 2026-10-08 + +- Manifest preflight: 16 passed, no warnings. Generated catalogue drift: zero. +- Candidate built and started on Framework with read-only root and the declared + resource/security constraints. All protocol tests use synthetic identities and + files; no public relay or external Blossom server is contacted. +- `tests/apps/blossom/protocol.ts` passed against the candidate: authenticated + upload/readback, exact hash/size/bytes, wrong identity/server/expired/anonymous + upload rejection, owner-only listing, disabled mirror, canonical provider and + health endpoint. HTML response has sandbox CSP and attachment headers. +- Fixture survived container recreation with the same data directory and restart + with explicit slirp4netns. An earlier test using Podman's default pasta hit a + transient port teardown conflict; that is not the package's configured network. +- Canonical Rust parser: all shipped manifests parse in the isolated test runner. +- Setup/source tests: 13 passed, dashboard typecheck passed including the final + receipt-review presentation changes. +- Packaged UI passed a real Chromium test at mobile width: explicit identity + chooser, consent before upload, signer refusal blocks upload, hash/host-scoped + upload, consent reset and no external requests. Signer and upload transport + were mocked for this UI test; live protocol checks above are separate. +- Framework's normal installer succeeded after the operator temporarily disabled + dashboard 2FA. Candidate manifest and build context are staged in the runtime + payload. The app is healthy, with its canonical bridge installed by the hook, + read-only root, slirp4netns and a loopback backend behind AppGate. Anonymous + HTTPS access on port 8191 returns the gate's 401 sign-in page. +- Real HTTPS tab signer acceptance passed: profile chooser, refusal prevents any + upload, and an approved BUD-11 authorization stores a synthetic local file. + No real identity key was exported or public Nostr event sent. Existing native + Bitcoin/LND processes retained their original start times during installation. +- No signed catalogue, source proposal, public Nostr event, OTA or ISO published. + +- Real HTTP tab signing also passed. Normal app stop/start, restart with a new + container, uninstall with `preserve_data:true`, reinstall, and management restart + all preserved the uploaded synthetic file, verified by hash. Native Bitcoin/LND + processes retained their original start times. +- Local website archive integration is implemented in source: an explicit action + signs a hash/server-scoped upload, stores the saved draft through the local + manifest-owned Blossom backend, verifies exact readback and records a receipt. + It does not announce or replicate anything. Its backend RPC is not yet deployed. + +Still required before release: cross-profile identity switch (only one profile +was available), HTTP/HTTPS iframe and physical companion validation, arranged +reboot, integrated website archive acceptance, then reviewed source/mirror parity +and signed catalogue gates. Selective public asset routes remain separate work; +the authenticated app address must never be advertised as a public Blossom URL. +Restore dashboard 2FA with the operator after live testing. diff --git a/apps/blossom/manifest.yml b/apps/blossom/manifest.yml new file mode 100644 index 00000000..30ff7127 --- /dev/null +++ b/apps/blossom/manifest.yml @@ -0,0 +1,87 @@ +app: + id: blossom + name: Blossom + version: 6.4.1-archy.1 + upstream: + kind: github + repo: hzrd149/blossom-server + description: Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice. + category: data + container: + network: slirp4netns + build: + context: /opt/archipelago/docker/blossom + dockerfile: Dockerfile + tag: localhost/archipelago-blossom:6.4.1-archy.1 + derived_env: + - key: ARCHY_BLOSSOM_PUBKEYS + template: '{{NODE_IDENTITY_PUBKEYS}}' + dependencies: + - storage: 1Gi + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: isolated + seccomp_profile: default + ports: + - host: 8191 + container: 3000 + protocol: tcp + bind: 127.0.0.1 + auth: gated + volumes: + - type: bind + source: /var/lib/archipelago/blossom/data + target: /data + options: [rw] + - type: bind + source: /var/lib/archipelago/blossom/bridge + target: /bridge + options: [rw] + - type: bind + source: /var/lib/archipelago/blossom/config.json + target: /config/config.json + options: [ro] + - type: tmpfs + target: /tmp + options: [rw, nosuid, nodev, size=64m] + files: + - path: /var/lib/archipelago/blossom/config.json + overwrite: false + content: '{}' + hooks: + post_install: + - copy_from_host: + src: web-ui/nostr-provider.js + dest: /bridge/nostr-provider.js + - exec: [sh, -c, 'test -s /bridge/nostr-provider.js'] + health_check: + type: http + endpoint: http://127.0.0.1:3000 + path: /healthz + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + interfaces: + main: + name: Local files + type: ui + port: 8191 + protocol: http + path: / + metadata: + author: hzrd149 / Archipelago + tier: optional + icon: /assets/img/app-icons/blossom.svg + license: MIT + repo: https://github.com/hzrd149/blossom-server + tags: [nostr, blossom, storage, websites] + launch: + open_in_new_tab: false + requires_host_frame: false diff --git a/apps/home-assistant/manifest.yml b/apps/home-assistant/manifest.yml index 22a08337..6811f822 100644 --- a/apps/home-assistant/manifest.yml +++ b/apps/home-assistant/manifest.yml @@ -67,6 +67,7 @@ app: path: / metadata: + guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply. icon: /assets/img/app-icons/homeassistant.png category: home author: Home Assistant diff --git a/apps/immich/manifest.yml b/apps/immich/manifest.yml index 414dfefb..54f9f025 100644 --- a/apps/immich/manifest.yml +++ b/apps/immich/manifest.yml @@ -84,5 +84,6 @@ app: path: / metadata: + guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply. launch: open_in_new_tab: true diff --git a/apps/jellyfin/manifest.yml b/apps/jellyfin/manifest.yml index 110bccb2..1c7410c4 100644 --- a/apps/jellyfin/manifest.yml +++ b/apps/jellyfin/manifest.yml @@ -63,6 +63,7 @@ app: path: / metadata: + guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply. icon: /assets/img/app-icons/jellyfin.webp category: data author: Jellyfin diff --git a/apps/nextcloud/manifest.yml b/apps/nextcloud/manifest.yml index 914bd721..15bc2522 100644 --- a/apps/nextcloud/manifest.yml +++ b/apps/nextcloud/manifest.yml @@ -59,6 +59,7 @@ app: path: / metadata: + guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply. icon: /assets/img/app-icons/nextcloud.webp category: data author: Nextcloud diff --git a/apps/photoprism/manifest.yml b/apps/photoprism/manifest.yml index 8ef97ef7..d5738af8 100644 --- a/apps/photoprism/manifest.yml +++ b/apps/photoprism/manifest.yml @@ -60,6 +60,7 @@ app: path: / metadata: + guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply. icon: /assets/img/app-icons/photoprism.svg category: data author: PhotoPrism diff --git a/apps/strfry/manifest.yml b/apps/strfry/manifest.yml index 9eaef6df..b3f12ff4 100644 --- a/apps/strfry/manifest.yml +++ b/apps/strfry/manifest.yml @@ -219,3 +219,6 @@ app: nostr_integration: relay_type: public monetization_enabled: true + + metadata: + guest_access: true diff --git a/core/Cargo.lock b/core/Cargo.lock index 690dd75a..0748933f 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -237,6 +237,7 @@ dependencies = [ "hyper 0.14.32", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "tokio", "uuid", diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index c8280e20..14b4da31 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -12,9 +12,15 @@ impl RpcHandler { ) -> Result { 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, diff --git a/core/archipelago/src/api/rpc/publishing.rs b/core/archipelago/src/api/rpc/publishing.rs index e7894cd9..51ef0cad 100644 --- a/core/archipelago/src/api/rpc/publishing.rs +++ b/core/archipelago/src/api/rpc/publishing.rs @@ -5,6 +5,322 @@ use serde::Deserialize; use serde_json::json; impl RpcHandler { + pub(super) async fn handle_publishing_verify_https( + &self, + params: Option, + ) -> Result { + #[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, + ) -> Result { + #[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, + ) -> Result { + #[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, + ) -> Result { + #[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, + ) -> Result { + 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 { 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, ) -> Result { - 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, + ) -> Result { + #[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, diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index 269ef7a2..5ae37e4a 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -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 diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 6689e63f..5d18ee9e 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -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(stream: S, peer: SocketAddr, gate: Arc, app: GatedPort) -where +async fn serve_http( + stream: S, + peer: SocketAddr, + gate: Arc, + 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| { + 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) } diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 76ba2268..6a57f5cd 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -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 ` — 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::() + .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(
-
"#, + +{guest_form}"#, logo = logo_markup(), spinner = SPINNER_SVG, icon = icon_markup(app), @@ -1263,6 +1337,14 @@ fn login_page( .map(|e| format!(r#"
{}
"#, esc(e))) .unwrap_or_default(), prefix = gate_url(mount_prefix, ""), + guest_form = if app.guest_access && app.auth_enabled { + format!( + r#"
Have an app-only access token?

This opens only this app, without a dashboard login. The app may also require its own account.

"#, + 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 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(), diff --git a/core/archipelago/src/device_tokens.rs b/core/archipelago/src/device_tokens.rs index 6701556a..39142f04 100644 --- a/core/archipelago/src/device_tokens.rs +++ b/core/archipelago/src/device_tokens.rs @@ -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>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, } 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 { + load_strict(data_dir).await.unwrap_or_default() +} + +async fn load_strict(data_dir: &Path) -> Result> { 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>, ) -> Result { + create_scoped_expiring(data_dir, name, apps, None).await +} + +pub async fn create_scoped_expiring( + data_dir: &Path, + name: &str, + apps: Option>, + expires_at: Option, +) -> Result { + 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 { 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 { + 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 { 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 { /// Remove the token minted for `name`. Returns whether one existed. pub async fn remove(data_dir: &Path, name: &str) -> Result { - 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 { #[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() { diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 21b122eb..d6d58bc4 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -29,6 +29,7 @@ pub const APP_LAUNCH_PORTS: &[u16] = &[ 8175, 8176, 8187, + 8191, 8240, 8334, 8336, diff --git a/core/archipelago/src/publishing/mod.rs b/core/archipelago/src/publishing/mod.rs index 0790520f..1a7a9ad7 100644 --- a/core/archipelago/src/publishing/mod.rs +++ b/core/archipelago/src/publishing/mod.rs @@ -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, #[serde(default)] pub tor_publication: Option, + #[serde(default)] + pub nsite_receipt: Option, + #[serde(default)] + pub local_archive: Option, +} + +#[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, }, @@ -158,7 +182,7 @@ fn name(value: &str) -> Result { 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> { impl State { pub fn apply(&mut self, change: Change) -> Result> { 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( + 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 = "

Private draft

".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 = "

Public

".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: "

Public

".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 { diff --git a/core/archipelago/src/publishing/nsite.rs b/core/archipelago/src/publishing/nsite.rs new file mode 100644 index 00000000..9f572b5d --- /dev/null +++ b/core/archipelago/src/publishing/nsite.rs @@ -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 { + 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 { + 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!("{}", 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, + 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: "

Hello 🏝

".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(" { + await next(); + if (/^\\/[a-f0-9]{64}(?:\\.[a-zA-Z0-9]+)?$/.test(c.req.path)) { + c.header('Content-Security-Policy', "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'"); + c.header('Content-Disposition', 'attachment'); + c.header('X-Content-Type-Options', 'nosniff'); + } + }); + // Static local UI and the canonical host-managed signer bridge only. + app.get('/', async c => c.html(await Deno.readTextFile('/app/archy-ui/index.html'))); + app.get('/app.js', async c => c.body(await Deno.readTextFile('/app/archy-ui/app.js'), 200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' })); + app.get('/nostr-provider.js', async c => { + try { return c.body(await Deno.readTextFile('/bridge/nostr-provider.js'), 200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache, no-store, must-revalidate' }); } + catch { return c.text('Archipelago signer bridge is not installed', 503); } + }); + app.get('/healthz', c => c.json({ ready: true, storage: 'local' })); +${marker}`); +await Deno.writeTextFile(serverPath, server); diff --git a/docker/blossom/startup.ts b/docker/blossom/startup.ts new file mode 100644 index 00000000..618825b5 --- /dev/null +++ b/docker/blossom/startup.ts @@ -0,0 +1,21 @@ +// Public profile keys only; no private keys or dashboard credentials enter this +// container. Changes to the node's identity allowlist take effect on restart. +const keys = (Deno.env.get('ARCHY_BLOSSOM_PUBKEYS') ?? '').split(',').filter(Boolean); +if (!keys.length || keys.some(k => !/^[a-f0-9]{64}$/.test(k))) throw new Error('Create a profile identity in Archipelago before starting Blossom'); +const config = JSON.parse(await Deno.readTextFile('/config/config.json')); +config.host = '0.0.0.0'; config.port = 3000; +config.database = { path: '/data/sqlite.db' }; +config.storage = { backend: 'local', local: { dir: '/data/blobs' }, removeWhenNoOwners: false, rules: [{ type: '*', expiration: '100 years', pubkeys: keys }] }; +config.upload = { enabled: true, requireAuth: true, requirePubkeyInRule: true, maxSize: 16777216, workers: 1 }; +config.delete = { requireAuth: true }; +config.list = { enabled: true, requireAuth: true, allowListOthers: false }; +config.mirror = { enabled: false, requireAuth: true }; +config.media = { enabled: false, requireAuth: true, requirePubkeyInRule: true }; +config.report = { enabled: false }; +config.landing = { enabled: false }; +config.dashboard = { enabled: false }; +// No URL/host facts are baked into the UI. BUD-11 uses the actual request host +// unless the operator explicitly configured the server's canonical domain. +await Deno.writeTextFile('/tmp/blossom-config.json', JSON.stringify(config)); +Deno.args.splice(0, Deno.args.length, '/tmp/blossom-config.json'); +await import('./main.ts'); diff --git a/docker/blossom/ui/app.ts b/docker/blossom/ui/app.ts new file mode 100644 index 00000000..bc0d387d --- /dev/null +++ b/docker/blossom/ui/app.ts @@ -0,0 +1,80 @@ +import { sha256 } from 'npm:@noble/hashes@2.0.1/sha2.js'; + +declare global { + interface Window { + nostr?: { getPublicKey(): Promise; signEvent(event: unknown): Promise> }; + archipelagoNostr?: { selectIdentity?(): Promise }; + } +} +const element = (id: string) => document.getElementById(id) as T; +const fileInput = element('file'); +const approve = element('approve'); +const upload = element('upload'); +const refresh = element('refresh'); +let pubkey = ''; +let working = false; +function update() { + upload.disabled = working || !pubkey || !approve.checked || !fileInput.files?.length; + refresh.disabled = working || !pubkey; + fileInput.disabled = working; + element('identity').disabled = working; +} +async function perform(fn: () => Promise) { + working = true; update(); element('status').textContent = ''; + try { await fn(); } catch (e) { element('status').textContent = e instanceof Error ? e.message : 'Request failed'; } + finally { working = false; update(); } +} +async function auth(action: string, hash?: string) { + if (!window.nostr) throw new Error('Archipelago signer is unavailable. Reinstall the app bridge; never enter a private key here.'); + if (await window.nostr.getPublicKey() !== pubkey) throw new Error('Identity changed. Choose your identity and review the file again.'); + const now = Math.floor(Date.now() / 1000); + const tags = [['t', action], ['expiration', String(now + 300)], ['server', location.hostname]]; + if (hash) tags.push(['x', hash]); + const signed = await window.nostr.signEvent({ kind: 24242, created_at: now, tags, content: `Authorize local Blossom ${action}` }); + if (signed.pubkey !== pubkey) throw new Error('Signer returned another identity. Nothing was sent.'); + return 'Nostr ' + btoa(JSON.stringify(signed)); +} +element('identity').onclick = () => perform(async () => { + if (!window.nostr) throw new Error('Archipelago signer is unavailable'); + await window.archipelagoNostr?.selectIdentity?.(); + const key = await window.nostr.getPublicKey(); + if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid signer identity'); + pubkey = key; approve.checked = false; + element('pubkey').textContent = key; element('files').replaceChildren(); +}); +fileInput.onchange = () => { + approve.checked = false; + const file = fileInput.files?.[0]; + element('file-review').textContent = file ? `${file.name} · ${file.size} bytes · ${file.type || 'unknown type'}` : 'Choose a file up to 16 MiB.'; + update(); +}; +approve.onchange = update; +upload.onclick = () => perform(async () => { + const file = fileInput.files?.[0]; + if (!file || !approve.checked || file.size > 16777216) throw new Error('Choose and approve a file up to 16 MiB'); + const bytes = new Uint8Array(await file.arrayBuffer()); + const hash = Array.from(sha256(bytes), b => b.toString(16).padStart(2, '0')).join(''); + const authorization = await auth('upload', hash); + const response = await fetch('/upload', { method: 'PUT', headers: { Authorization: authorization, 'Content-Type': file.type || 'application/octet-stream' }, body: bytes, credentials: 'same-origin', redirect: 'error' }); + if (!response.ok) throw new Error(`Local upload failed (${response.status}). No external copy was requested.`); + const descriptor = await response.json(); + if (descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw new Error('Storage returned an unexpected file descriptor'); + approve.checked = false; + element('status').textContent = `Stored on this node. SHA-256: ${hash}. No Nostr announcement was published.`; +}); +refresh.onclick = () => perform(async () => { + const authorization = await auth('list'); + const response = await fetch(`/list/${pubkey}?limit=100`, { headers: { Authorization: authorization }, credentials: 'same-origin', redirect: 'error' }); + if (!response.ok) throw new Error(`Could not list files (${response.status})`); + const files = await response.json(); + if (!Array.isArray(files)) throw new Error('Unexpected file list'); + const list = element('files'); list.replaceChildren(); + for (const file of files.slice(0, 100)) { + if (!/^[a-f0-9]{64}$/.test(file.sha256)) continue; + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = '/' + file.sha256; link.download = file.sha256; + link.textContent = `${file.sha256} · ${file.size} bytes`; + item.append(link); list.append(item); + } +}); diff --git a/docker/blossom/ui/index.html b/docker/blossom/ui/index.html new file mode 100644 index 00000000..cd718201 --- /dev/null +++ b/docker/blossom/ui/index.html @@ -0,0 +1 @@ +Blossom · Archipelago

Blossom on your node

Store files with your Archipelago identity. Files stay on this node. Uploading here does not publish a Nostr event or send a copy to another server.

Your identity

Choose a profile identity to manage its files.

After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.

Store a file

Choose a file up to 16 MiB. Review it before storing.

External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.

Your files

    diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index ce3b0d2d..39e57970 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -173,6 +173,22 @@ override wins over the manifest in both directions and applies on the next request — your app cannot assume the gate is or isn't in front of it, so it must always enforce its own authorization for sensitive operations. +## Optional guest access + +`metadata.guest_access: true` opts an application into Setup's expiring, +revocable app-only access credentials. It requires an explicitly declared gated +port, an enabled AppGate, and no `session_passthrough`. The signed catalog remains +authoritative for catalog apps; a disk manifest cannot override its policy. +Wallets, signing surfaces and node administration apps must not opt in. + +A guest credential opens only the selected application, never dashboard login or +RPC. The application must still enforce its own accounts and permissions. Guest +credentials expire after the operator-selected interval (one hour to 30 days) +and can be revoked. Every subsequent HTTP request checks current scope, expiry +and revocation; an already established stream or WebSocket is not disconnected +by this first implementation. AppGate strips guest credentials before proxying. +Do not treat the guest gate as authorization for an application's internal API. + ## Launch metadata `metadata.launch` is consumed by catalog generation and the dashboard diff --git a/docs/external-access-and-websites.md b/docs/external-access-and-websites.md index 7433b16b..4c8e523e 100644 --- a/docs/external-access-and-websites.md +++ b/docs/external-access-and-websites.md @@ -37,8 +37,11 @@ FIPS/onion addresses do not require a purchased domain. No automatic purchases. AIUI creates node-owned static website projects with isolated previews, revisions, download, publish, rollback and unpublish. Local/open model operation is supported; no silent fallback to a proprietary model. A published website has a separate -origin from management and cannot receive dashboard cookies, signing authority or -RPC access. Public copies may survive unpublishing from Nostr/Blossom. +origin from management and cannot read dashboard cookies or access signing +authority or RPC. Direct FIPS ports share a hostname, so a browser may send +host cookies to the trusted static handler; it neither reflects nor forwards +them, and published HTML runs under a script-blocking sandbox policy. Public +copies may survive unpublishing from Nostr/Blossom. The user also requested removal of the File Browser Setup card because the app is already bundled in the ISO. Keep the installed app and launcher unchanged. @@ -59,13 +62,60 @@ is already bundled in the ISO. Keep the installed app and launcher unchanged. - [ ] Framework acceptance with confirmed identity, access and release coordination. - [ ] ngit review and exact accepted-commit mirror parity before any release. -The user authorized Framework as a test node if deployment is needed. Access and -current release-agent reservation must be confirmed before live work. Preserve all +The user authorized Framework as a free test node and a separate test proxy route +on Yaya. Access has been verified on both actual nodes. Preserve all wallet/channel/app data. Source tests are not node acceptance. Backend unit tests run only through `scripts/test-backend-isolated.sh`; use a worktree-local target. +### Current integration checkpoint + +Blossom is installed and healthy on Framework through the normal app installer. +Protocol, real HTTP/HTTPS tab signing and lifecycle/data-preservation evidence is +recorded in `apps/blossom/README.md`. The combined dashboard/backend candidate has +not yet been deployed. No public Nostr test events or external file replicas have +been created. The temporary public proxy route and certificate were removed after +their standalone acceptance checks. + +New source work includes local Blossom website archives, explicit app-only guest +credentials, and an on-demand HTTPS check against exact published page bytes. +Guest tokens cannot authenticate to node login; scope/expiry are checked on each +request, and revocation affects subsequent requests, not established streams. +Only opted-in gated app manifests expose guest access. Persistent credentials use +serialized, atomic 0600 writes and refuse corruption/capacity without evicting an +existing device. HTTPS checks pin validated public DNS addresses, validate TLS, +refuse redirects/proxies and bound response reads. They are point-in-time checks +from the node, not proof of outside-device access or future certificate renewal. + +Public-web projects can explicitly publish a FIPS upstream for an existing proxy +without selecting FIPS again. The confirmation still explains its FIPS visibility. +Automated frp enrollment/end-to-node TLS and selective local public Blossom assets +remain unfinished. Source validation and standalone routes must not be described +as acceptance of those features or of the complete dashboard journey. + +The current dashboard production build and supported AIUI build both pass and +are staged separately on Framework. The original backend and full web tree are +backed up for rollback; the live dashboard has not been switched. The selected +dashboard suite passed 36 tests; subsequent HTTPS UI coverage passed six tests, +and tightened Nostr signing/receipt coverage passed 12 tests. The latest combined +18-test run, TypeScript check and dashboard rebuild passed. Catalog drift is zero +(37 catalog entries, 64 manifests). Full isolated backend validation now passes +1,699 tests, zero failures and four explicit ignores. The focused app-gate run +passes 53 tests, and all three credential tests pass. The deployable backend build +is still pending at this checkpoint; passing tests is not live-node acceptance. + ## Development evidence (2026-10-08, not release acceptance) +Latest addition: [Blossom candidate package and acceptance ledger](../apps/blossom/README.md). +Setup offers catalogue installation and skips that prompt for installed Blossom. +The candidate is built and protocol-tested on Framework, and normal installation +and the real HTTPS tab signer work. Further lifecycle acceptance is in progress. +The operator temporarily disabled dashboard 2FA for tests; restore it afterwards. +Nostr publication now includes a local +preparation/review step showing exact HTML, hash, identity, manifest and destinations; +upload and announcement require explicit consent. No public Nostr events or external +Blossom uploads have been performed. Local Blossom website-asset integration remains +outstanding. Earlier evidence below records its own point in development. + The isolated branch now contains versioned node-owned projects, multi-route preferences, both Setup screens, local Ollama draft generation, sandboxed static previews, revision restore and FIPS-only static publication/revocation. AIUI can diff --git a/neode-ui/public/assets/img/app-icons/blossom.svg b/neode-ui/public/assets/img/app-icons/blossom.svg new file mode 100644 index 00000000..d1792614 --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/blossom.svg @@ -0,0 +1 @@ + diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index b37c4146..348529ff 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -673,6 +673,19 @@ "tier": "optional", "icon": "/assets/img/app-icons/angor-green.png", "repoUrl": "https://github.com/hoytech/strfry" + }, + { + "id": "blossom", + "author": "hzrd149 / Archipelago", + "requires": [], + "tier": "optional", + "title": "Blossom", + "version": "6.4.1-archy.1", + "description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.", + "dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1", + "category": "data", + "repoUrl": "https://github.com/hzrd149/blossom-server", + "icon": "/assets/img/app-icons/blossom.svg" } ] } diff --git a/neode-ui/src/services/__tests__/nsitePublishing.test.ts b/neode-ui/src/services/__tests__/nsitePublishing.test.ts new file mode 100644 index 00000000..e6f353d6 --- /dev/null +++ b/neode-ui/src/services/__tests__/nsitePublishing.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) +vi.mock('../publishing', () => ({ publishing: { status: vi.fn(), update: vi.fn() } })) +import { rpcClient } from '@/api/rpc-client' +import { publishing } from '../publishing' +import { namedNsiteUrl, prepareNsite, publishNsite, relayAddresses, retryNsite, requestNsiteDeletion, nsiteIdentities, storeLocalWebsite } from '../nsitePublishing' +import type { NsiteReceipt, SignedNsiteEvent } from '../nsitePublishing' +const identity = { id: 'profile', name: 'Me', nostr_pubkey: 'a'.repeat(64), is_node: false } +const event: SignedNsiteEvent = { id: 'b'.repeat(64), pubkey: identity.nostr_pubkey, kind: 35128, created_at: 1, tags: [['d', 'website123']], content: '', sig: 'c'.repeat(128) } +const receipt: NsiteReceipt = { identity_id: identity.id, server: 'https://blossom.example', event, accepted_relays: [], deletion_requested: false } +let accept = true +class Socket { + onopen?: () => void + onmessage?: (event: { data: string }) => void + onerror?: () => void + onclose?: () => void + constructor() { queueMicrotask(() => this.onopen?.()) } + send(raw: string) { + const sent = JSON.parse(raw)[1] + queueMicrotask(() => { + this.onmessage?.({ data: JSON.stringify(['OK', 'unrelated-id', true]) }) + this.onmessage?.({ data: JSON.stringify(['OK', sent.id, accept]) }) + }) + } + close() {} +} +const prepared = { html: '

    Hello 🏝

    ', sha256: 'd'.repeat(64), server: receipt.server, identifier: 'website123', authorization: { kind: 24242, tags: [['expiration', String(Math.floor(Date.now() / 1000) + 300)]] }, manifest: { ...event } } +async function publishReviewed(html: string) { + const p = await prepareNsite('project', 4, receipt.server, html) + return publishNsite('project', 4, identity, ['wss://relay.example'], p) +} +beforeEach(() => { + vi.clearAllMocks(); accept = true + vi.stubGlobal('WebSocket', Socket) + vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4 } } as never) + vi.mocked(publishing.update).mockResolvedValue({} as never) + vi.mocked(rpcClient.call).mockImplementation(async request => { + if (request.method === 'publishing.nsite-prepare') return prepared as never + if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never + if (request.method === 'identity.list') return { identities: [identity, { ...identity, id: 'node', is_node: true }] } as never + throw new Error('Unexpected RPC') + }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }), { status: 201 })).mockResolvedValueOnce(new Response(prepared.html))) +}) +describe('named nsite publishing', () => { + it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => { + const socket = vi.fn() + vi.stubGlobal('WebSocket', socket) + vi.mocked(rpcClient.call).mockImplementation(async request => { + if (request.method === 'publishing.blossom-prepare') return { authorization: prepared.authorization } as never + if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never + if (request.method === 'publishing.blossom-store') return {} as never + throw new Error('Unexpected RPC') + }) + await storeLocalWebsite('project', 4, identity) + expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.blossom-prepare', 'identity.nostr-sign', 'publishing.blossom-store']) + expect(fetch).not.toHaveBeenCalled() + expect(socket).not.toHaveBeenCalled() + expect(publishing.update).not.toHaveBeenCalled() + await expect(storeLocalWebsite('project', 4, { ...identity, is_node: true })).rejects.toThrow('profile identity') + }) + it('uses profile identities and rejects insecure relay URLs', async () => { + expect(await nsiteIdentities()).toEqual([identity]) + expect(relayAddresses('wss://relay.example wss://relay.example')).toEqual(['wss://relay.example/']) + for (const value of ['ws://relay.example', 'wss://user:secret@relay.example', 'wss://relay.example/#key']) expect(() => relayAddresses(value)).toThrow() + }) + it('preparation never signs, uploads or broadcasts', async () => { + await prepareNsite('project', 4, receipt.server, '

    Private draft

    ') + expect(fetch).not.toHaveBeenCalled() + expect(publishing.update).not.toHaveBeenCalled() + expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.nsite-prepare']) + }) + it('refuses stale reviews before signing or uploading', async () => { + await expect(publishNsite('project', 3, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed after review') + expect(fetch).not.toHaveBeenCalled() + expect(rpcClient.call).not.toHaveBeenCalled() + }) + it('uploads exact UTF-8 bytes, scopes signing to the chosen profile, and records delivery', async () => { + const result = await publishReviewed( '

    Draft

    ') + expect(result.accepted_relays).toEqual(['wss://relay.example']) + const prep = vi.mocked(rpcClient.call).mock.calls[0]![0].params as { html: string } + expect(prep.html).not.toContain('Draft') + expect(fetch).toHaveBeenNthCalledWith(1, `${receipt.server}/upload`, expect.objectContaining({ method: 'PUT', credentials: 'omit', redirect: 'error', body: prepared.html })) + expect(publishing.update).toHaveBeenCalledTimes(2) + expect(vi.mocked(publishing.update).mock.calls[0]![1]).toMatchObject({ receipt: { accepted_relays: [] } }) + expect(vi.mocked(publishing.update).mock.calls[1]![1]).toMatchObject({ receipt: { accepted_relays: ['wss://relay.example'] } }) + const signs = vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'identity.nostr-sign') + expect(signs.every(([r]) => r.params?.id === identity.id)).toBe(true) + }) + it('never pays or announces when storage requires payment', async () => { + vi.mocked(fetch).mockReset().mockResolvedValue(new Response('', { status: 402 })) + await expect(publishReviewed( '

    Draft

    ')).rejects.toThrow('No payment was made') + expect(publishing.update).not.toHaveBeenCalled() + }) + it('rejects altered signer output before upload or relay delivery', async () => { + vi.mocked(rpcClient.call).mockImplementation(async request => { + if (request.method === 'identity.nostr-sign') return { ...event, ...prepared.authorization, tags: [['t', 'upload']] } as never + throw new Error('Unexpected RPC') + }) + await expect(publishNsite('project', 4, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed the reviewed event') + expect(fetch).not.toHaveBeenCalled() + expect(publishing.update).not.toHaveBeenCalled() + }) + it('bounds untrusted upload receipts before announcing', async () => { + vi.mocked(fetch).mockReset().mockResolvedValue(new Response(' '.repeat(8193))) + await expect(publishReviewed('

    Draft

    ')).rejects.toThrow('size limit') + expect(publishing.update).not.toHaveBeenCalled() + }) + it('does not announce if the server changes uploaded bytes', async () => { + vi.mocked(fetch).mockReset().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }))).mockResolvedValueOnce(new Response('different')) + await expect(publishReviewed( '

    Draft

    ')).rejects.toThrow('different website bytes') + expect(publishing.update).not.toHaveBeenCalled() + }) + it('retains a pending manifest on rejection and retries without signing or uploading', async () => { + accept = false + await expect(publishReviewed( '

    Draft

    ')).rejects.toThrow('No relay accepted') + vi.clearAllMocks(); accept = true + const result = await retryNsite('project', receipt, ['wss://relay.example']) + expect(result.accepted_relays).toHaveLength(1) + expect(fetch).not.toHaveBeenCalled() + expect(rpcClient.call).not.toHaveBeenCalled() + }) + it('requests deletion with the publishing identity without deleting shared blobs', async () => { + await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example']) + expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { id: identity.id, event: expect.objectContaining({ kind: 5, tags: expect.arrayContaining([['e', event.id], ['a', `35128:${event.pubkey}:website123`]]) }) } })) + expect(fetch).not.toHaveBeenCalled() + expect(publishing.update).toHaveBeenCalledWith(4, expect.objectContaining({ receipt: expect.objectContaining({ deletion_requested: true }) })) + }) + it('builds a portable named-site gateway URL without overwriting the root site', () => { + const url = new URL(namedNsiteUrl(receipt, 'https://gateway.example')) + expect(url.hostname.split('.')[0]).toHaveLength(50 + 'website123'.length) + expect(url.hostname).toContain('website123.gateway.example') + expect(() => namedNsiteUrl(receipt, 'http://gateway.example')).toThrow() + }) +}) diff --git a/neode-ui/src/services/nsitePublishing.ts b/neode-ui/src/services/nsitePublishing.ts new file mode 100644 index 00000000..b1599408 --- /dev/null +++ b/neode-ui/src/services/nsitePublishing.ts @@ -0,0 +1,151 @@ +import DOMPurify from 'dompurify' +import { rpcClient } from '@/api/rpc-client' +import { publishing } from './publishing' + +export interface SignedNsiteEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string } +export interface NsiteReceipt { identity_id: string; server: string; event: SignedNsiteEvent; accepted_relays: string[]; deletion_requested: boolean } +export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean } +export interface PreparedNsite { html: string; sha256: string; server: string; identifier: string; authorization: Record; manifest: Record } + +export function relayAddresses(raw: string): string[] { + const list = [...new Set(raw.split(/[\s,]+/).filter(Boolean).map(value => { + const url = new URL(value) + if (url.protocol !== 'wss:' || url.username || url.password || url.hash || value.length > 300) throw new Error('Use secure wss:// relay URLs without credentials or fragments') + return url.href + }))] + if (!list.length || list.length > 8) throw new Error('Choose between one and eight relays') + return list +} +export async function nsiteIdentities(): Promise { + const data = await rpcClient.call<{ identities: NsiteIdentity[] }>({ method: 'identity.list', maxRetries: 0 }) + return data.identities.filter(i => !i.is_node && i.nostr_pubkey) +} +async function sign(identity: NsiteIdentity, event: Record): Promise { + const signed = await rpcClient.call({ method: 'identity.nostr-sign', params: { id: identity.id, event }, maxRetries: 0 }) + if (signed.pubkey !== identity.nostr_pubkey || signed.kind !== event.kind) throw new Error('Signer returned a different identity or event kind') + for (const key of ['created_at', 'content', 'tags'] as const) { + if (event[key] !== undefined && JSON.stringify(signed[key]) !== JSON.stringify(event[key])) throw new Error('Signer changed the reviewed event; this event will not be sent') + } + return signed +} +export async function storeLocalWebsite(projectId: string, version: number, identity: NsiteIdentity): Promise { + if (identity.is_node) throw new Error('Choose a profile identity for local files') + const prepared = await rpcClient.call<{ authorization: Record }>({ method: 'publishing.blossom-prepare', params: { id: projectId, version }, maxRetries: 0 }) + const authorization = await sign(identity, prepared.authorization) + await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization }, timeout: 70000, maxRetries: 0 }) +} +export function sendToRelay(url: string, event: SignedNsiteEvent): Promise { + return new Promise(resolve => { + let socket: WebSocket + try { socket = new WebSocket(url) } catch { resolve(false); return } + let settled = false + const finish = (ok: boolean) => { if (settled) return; settled = true; clearTimeout(timer); socket.close(); resolve(ok) } + const timer = setTimeout(() => finish(false), 15000) + socket.onopen = () => socket.send(JSON.stringify(['EVENT', event])) + socket.onerror = () => finish(false) + socket.onclose = () => finish(false) + socket.onmessage = message => { + if (typeof message.data !== 'string' || message.data.length > 65536) return + try { + const reply = JSON.parse(message.data) + if (reply[0] === 'OK' && reply[1] === event.id) finish(reply[2] === true) + } catch { /* Ignore unrelated relay messages. */ } + } + }) +} +async function broadcast(event: SignedNsiteEvent, relays: string[]): Promise { + const result = await Promise.all(relays.map(async relay => ({ relay, ok: await sendToRelay(relay, event) }))) + return result.filter(r => r.ok).map(r => r.relay) +} +async function record(projectId: string, receipt: NsiteReceipt): Promise { + // Persist this operation's receipt without overwriting a newer draft or other + // transport. Only retry the optimistic conflict, never upload/sign/broadcast. + for (let attempt = 0; attempt < 3; attempt++) { + const status = await publishing.status() + try { await publishing.update(status.state.version, { action: 'record-nsite', id: projectId, receipt }); return } + catch (error) { + if (attempt === 2 || !(error instanceof Error) || !error.message.includes('changed')) throw error + } + } +} +async function readback(response: Response, expected: Uint8Array): Promise { + if (!response.ok || !response.body) throw new Error('Uploaded website could not be fetched back') + const reader = response.body.getReader() + let offset = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (offset + value.length > expected.length || value.some((byte, index) => byte !== expected[offset + index])) throw new Error('Blossom returned different website bytes') + offset += value.length + } + if (offset !== expected.length) throw new Error('Blossom returned an incomplete website') + } finally { await reader.cancel() } +} +async function uploadDescriptor(response: Response): Promise<{ sha256: string; size: number }> { + if (!response.body) throw new Error('Blossom upload receipt is empty') + const reader = response.body.getReader() + const bytes = new Uint8Array(8192) + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (size + value.length > bytes.length) throw new Error('Blossom upload receipt exceeds the size limit') + bytes.set(value, size); size += value.length + } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size))) + } finally { await reader.cancel() } +} +export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string): Promise { + return await rpcClient.call({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 }) +} +export async function publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise { + if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity') + const current = await publishing.status() + if (current.state.version !== version) throw new Error('The project changed after review. Review the publication again.') + const expiry = (p.authorization.tags as string[][] | undefined)?.find(t => t[0] === 'expiration')?.[1] + if (!expiry || Number(expiry) <= Date.now() / 1000) throw new Error('The review expired. Prepare and review the publication again.') + const authorization = await sign(identity, p.authorization) + const bytes = new TextEncoder().encode(p.html) + const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization)))) + const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html }) + if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.') + if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`) + const descriptor = await uploadDescriptor(uploaded) + if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.') + await readback(await fetch(`${p.server}/${p.sha256}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), bytes) + const event = await sign(identity, p.manifest) + const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false } + // Save the exact signed manifest before network delivery, for recovery. + await record(projectId, receipt) + const delivered = { ...receipt, accepted_relays: await broadcast(event, relays) } + await record(projectId, delivered) + if (!delivered.accepted_relays.length) throw new Error('No relay accepted the manifest. The upload and signed manifest were retained; retry delivery from this project.') + return delivered +} +export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise { + if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request') + const accepted = await broadcast(receipt.event, relays) + const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] } + await record(projectId, next) + if (!accepted.length) throw new Error('No relay accepted this delivery attempt') + return next +} +export async function requestNsiteDeletion(projectId: string, receipt: NsiteReceipt, identity: NsiteIdentity, relays: string[]): Promise { + if (identity.id !== receipt.identity_id || identity.nostr_pubkey !== receipt.event.pubkey) throw new Error('Choose the identity that published this nsite') + const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1] + if (!identifier) throw new Error('Missing named-site identifier') + const event = await sign(identity, { kind: 5, created_at: Math.floor(Date.now() / 1000), content: 'Remove this website manifest', tags: [['e', receipt.event.id], ['a', `35128:${receipt.event.pubkey}:${identifier}`], ['k', '35128']] }) + const accepted = await broadcast(event, [...new Set([...receipt.accepted_relays, ...relays])]) + if (!accepted.length) throw new Error('No relay accepted the deletion request. The nsite may remain available.') + await record(projectId, { ...receipt, deletion_requested: true }) +} +export function namedNsiteUrl(receipt: NsiteReceipt, gateway: string): string { + const url = new URL(gateway) + if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.pathname !== '/') throw new Error('Enter the gateway HTTPS origin without a path') + const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1] ?? '' + if (!/^[a-z0-9-]{1,13}$/.test(identifier) || identifier.endsWith('-') || !/^[a-f0-9]{64}$/.test(receipt.event.pubkey)) throw new Error('Invalid named nsite') + const author = BigInt(`0x${receipt.event.pubkey}`).toString(36).padStart(50, '0') + return `https://${author}${identifier}.${url.hostname}/` +} diff --git a/neode-ui/src/services/publishing.ts b/neode-ui/src/services/publishing.ts index 004f0073..8a5861e4 100644 --- a/neode-ui/src/services/publishing.ts +++ b/neode-ui/src/services/publishing.ts @@ -1,4 +1,5 @@ import { rpcClient } from '@/api/rpc-client' +import type { NsiteReceipt } from './nsitePublishing' export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr' export interface PublishDomain { hostname: string; destination: string | null } @@ -8,6 +9,8 @@ export interface WebsiteProject { draft: string; revisions: WebsiteRevision[] fips_publication?: { port: number; html: string; created_at: string } | null tor_publication?: { port: number; html: string; created_at: string } | null + nsite_receipt?: NsiteReceipt | null + local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null } export interface PublishingState { schema: number; version: number; connections: PublishRoute[]; projects: Record @@ -16,12 +19,15 @@ export interface PublishingStatus { state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[] onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[] - apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean }[] + nostr_relays?: string[] + apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[] + grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[] } export interface DnsPlan { records: { record_type: string; name: string; value: string; ttl: number }[] verified: boolean; notes: string[]; instructions_url: string } +export interface HttpsCheck { hostname: string; sha256: string; checked_at: string } export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [ { id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' }, { id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' }, @@ -30,6 +36,7 @@ export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: str ] export const publishing = { status: () => rpcClient.call({ method: 'publishing.status', maxRetries: 1 }), + verifyHttps: (id: string, version: number) => rpcClient.call({ method: 'publishing.verify-https', params: { id, version }, timeout: 30000, maxRetries: 0 }), update: (version: number, change: Record) => rpcClient.call<{state: PublishingState; project_id: string | null}>({ method: 'publishing.update', params: { version, change }, maxRetries: 0, }), diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 675996c3..8acc733f 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -81,6 +81,7 @@ export const HTTPS_PROXY_PATHS: Record = { */ const PRE_CATALOG_GATED_PORTS: Record = { 'archipelago-source': 8337, + 'blossom': GENERATED_APP_PORTS.blossom, } export function appPortIsGateFronted(appId: string, port: number | string): boolean { diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index b9f70e0e..5964ec81 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -7,6 +7,7 @@ export const GENERATED_APP_PORTS: Record = { "archy-mempool-web": 4080, "archy-nbxplorer": 32838, "bitcoin-ui": 8334, + "blossom": 8191, "botfights": 9100, "btcpay-server": 23000, "cuprate-ui": 18091, @@ -53,6 +54,7 @@ export const GENERATED_APP_TITLES: Record = { "bitcoin-core": "Bitcoin Core", "bitcoin-knots": "Bitcoin Knots", "bitcoin-ui": "Bitcoin UI", + "blossom": "Blossom", "botfights": "BotFights", "btcpay-server": "BTCPay Server", "core-lightning": "Core Lightning (CLN)", diff --git a/neode-ui/src/views/publishing/PublishingSetup.vue b/neode-ui/src/views/publishing/PublishingSetup.vue index ddcae4fd..e5f3a95f 100644 --- a/neode-ui/src/views/publishing/PublishingSetup.vue +++ b/neode-ui/src/views/publishing/PublishingSetup.vue @@ -1,11 +1,17 @@

    Blossom qualification, synthetic data only.

    '; +const bytes = new TextEncoder().encode(body); +const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), x => x.toString(16).padStart(2,'0')).join(''); +function auth(action: string, secret=key, server='127.0.0.1', expires=300) { + const now = Math.floor(Date.now()/1000); + return 'Nostr ' + btoa(JSON.stringify(finalizeEvent({ kind:24242,created_at:now,content:'Local synthetic qualification only',tags:[['t',action],['x',hash],['server',server],['expiration',String(now+expires)]]},secret))); +} +async function check(label: string, expected: number, path: string, init={}) { + const r=await fetch(base+path,init); + if(r.status!==expected) throw new Error(`${label}: expected ${expected}, got ${r.status}: ${await r.text()}`); + console.log(`PASS ${label}: ${r.status}`);return r; +} +const upload=(token?:string)=>({method:'PUT',headers:{'content-type':'text/html',...(token?{authorization:token}:{})},body}); +await check('unauthenticated upload denied',401,'/upload',upload()); +await check('unlisted identity denied',401,'/upload',upload(auth('upload',other))); +await check('wrong host denied',401,'/upload',upload(auth('upload',key,'wrong.invalid'))); +await check('expired token denied',401,'/upload',upload(auth('upload',key,'127.0.0.1',-300))); +const stored=await (await check('signed profile upload',201,'/upload',upload(auth('upload')))).json(); +if(stored.sha256!==hash || stored.size!==bytes.length) throw new Error('Wrong descriptor'); +const read=await check('read stored bytes',200,'/'+hash); +if(await read.text()!==body) throw new Error('Stored bytes differ'); +if(!read.headers.get('content-security-policy')?.includes('sandbox') || read.headers.get('content-disposition')!=='attachment') throw new Error('Active content not sandboxed'); +console.log('PASS exact bytes and sandboxed attachment'); +await check('anonymous list denied',401,'/list/'+getPublicKey(key)); +await check('other identity cannot list owner',403,'/list/'+getPublicKey(key),{headers:{authorization:auth('list',other)}}); +await check('owner list',200,'/list/'+getPublicKey(key),{headers:{authorization:auth('list')}}); +await check('mirror disabled',403,'/mirror',{method:'PUT',headers:{authorization:auth('upload')}}); +await check('canonical signer provider',200,'/nostr-provider.js'); +await check('health',200,'/healthz'); +console.log('PRESERVE_HASH '+hash); diff --git a/tests/apps/blossom/ui-smoke.cjs b/tests/apps/blossom/ui-smoke.cjs new file mode 100644 index 00000000..42c74a73 --- /dev/null +++ b/tests/apps/blossom/ui-smoke.cjs @@ -0,0 +1,40 @@ +// Run against the disposable packaged UI forwarded to 127.0.0.1:48191. +// The signer and upload transport are mocked; no real keys or public endpoints. +const { chromium } = require('../../../neode-ui/node_modules/@playwright/test'); +const { createHash } = require('node:crypto'); +(async () => { + const browser = await chromium.launch({headless:true}); + const page = await browser.newPage({viewport:{width:390,height:844}}); + page.on('console',m=>console.log('browser:',m.text())); page.on('pageerror',e=>console.log('page error:',e.message)); + const outgoing=[]; let uploads=0; + await page.route('**/*', async route => { + const u=new URL(route.request().url()); + if(u.origin!=='http://127.0.0.1:48191'){outgoing.push(u.origin);return route.abort();} + if(u.pathname==='/nostr-provider.js')return route.fulfill({contentType:'application/javascript',body:`window.signCalls=[];window.chooseCalls=0;window.deny=true;window.archipelagoNostr={selectIdentity:async()=>{window.chooseCalls++}};window.nostr={getPublicKey:async()=>'${'a'.repeat(64)}',signEvent:async e=>{window.signCalls.push(e);if(window.deny)throw new Error('User declined signing');return {...e,pubkey:'${'a'.repeat(64)}',id:'${'b'.repeat(64)}',sig:'${'c'.repeat(128)}'}}};`}); + if(u.pathname==='/upload'){ + uploads++; const body=route.request().postDataBuffer(); + const token=JSON.parse(Buffer.from(route.request().headers().authorization.slice(6),'base64').toString()); + const hash=createHash('sha256').update(body).digest('hex'); + if(!token.tags.some(t=>t[0]==='x'&&t[1]===hash)||!token.tags.some(t=>t[0]==='server'&&t[1]==='127.0.0.1'))throw Error('Auth scope mismatch'); + return route.fulfill({contentType:'application/json',body:JSON.stringify({sha256:hash,size:body.length})}); + } + return route.continue(); + }); + await page.goto('http://127.0.0.1:48191/'); + await page.waitForFunction(()=>typeof window.nostr==='object'); + if(!await page.locator('#upload').isDisabled())throw Error('Upload enabled before consent'); + await page.locator('#identity').click(); + await page.waitForFunction(()=>document.querySelector('#pubkey').textContent==='a'.repeat(64)); + await page.locator('#file').setInputFiles({name:'local-fixture.txt',mimeType:'text/plain',buffer:Buffer.from('Synthetic local file')}); + await page.locator('#approve').check(); await page.locator('#upload').click(); + await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('User declined')); + if(uploads!==0)throw Error('Uploaded despite signing refusal'); + await page.evaluate(()=>window.deny=false); + await page.locator('#upload').click(); + await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Stored on this node')); + if(uploads!==1 || outgoing.length)throw Error('Unexpected upload or external request'); + if(await page.locator('#approve').isChecked())throw Error('Approval was retained after upload'); + if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Mobile horizontal overflow'); + console.log('PASS packaged local UI: identity chooser, explicit consent, signer denial, scoped upload, consent reset, mobile width, no external requests (mock signer/transport; real signer still pending)'); + await browser.close(); +})().catch(e=>{console.error(e);process.exit(1)});