diff --git a/Dockerfile b/Dockerfile index 8d48620..0049951 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,7 +65,9 @@ # ZKCOINS_BLOSSOM_ALLOWED_OPS # Comma-separated lowercase-hex 32-byte op pubkeys allowed to upload. # Variable required when store is set; empty string allowed -# (surface up, every upload 403). Source: ENV_BLOSSOM_ALLOWED_OPS. +# (surface up, every upload 403). A sole * token allows any +# verified kind-24242 (test nodes). Mixing * with hex is a start +# error. Source: ENV_BLOSSOM_ALLOWED_OPS. # # Optional (logging only — not process config): # diff --git a/docs/rest-surface.md b/docs/rest-surface.md index 3e6d7d2..e709219 100644 --- a/docs/rest-surface.md +++ b/docs/rest-surface.md @@ -234,4 +234,4 @@ Sibling-Vergleich mit `zk-coins/node` ist optional/lokal, kein CI-Gate. Client |---|---| | `ZKCOINS_BLOSSOM_STORE` | Wurzelverzeichnis des inhaltsadressierten Blob-Stores. **Abwesend** ⇒ die drei Blossom-Keys (`get`/`head`/`upload`) bleiben unbeworben und unmontiert. **Kein Default-Pfad**, kein `/tmp`-Rückfall. Leer gesetzt → Startfehler. | | `ZKCOINS_BLOSSOM_MAX_BLOB_BYTES` | Pflicht-Begleiter wenn der Store gesetzt ist: ausgewiesene Upload-Obergrenze (`> 0`). Body darüber → `413 payload_too_large`. | -| `ZKCOINS_BLOSSOM_ALLOWED_OPS` | Pflicht-Begleiter wenn der Store gesetzt ist: komma-separierte lowercase-hex-32B-`op`-Pubkeys (gepaarte Konten + Replikations-Peers). Darf leer sein (dann ist jeder Upload `403`). | +| `ZKCOINS_BLOSSOM_ALLOWED_OPS` | Pflicht-Begleiter wenn der Store gesetzt ist: komma-separierte lowercase-hex-32B-`op`-Pubkeys (gepaarte Konten + Replikations-Peers). Darf leer sein (dann ist jeder Upload `403`). Ein alleinstehendes `*` akzeptiert jedes bereits verifizierte Kind-24242 (Test-Nodes). `*` gemischt mit Hex-Keys ist ein Startfehler. | diff --git a/src/blossom/mod.rs b/src/blossom/mod.rs index 36df9a0..b2c9a5d 100644 --- a/src/blossom/mod.rs +++ b/src/blossom/mod.rs @@ -47,6 +47,8 @@ pub struct BlossomState { pub max_blob_bytes: u64, /// `op` keys allowed to upload (paired accounts + replication peers). pub allowed_upload_ops: Arc>, + /// When true, any verified kind-24242 may upload (test nodes). + pub allow_any_verified_op: bool, } impl BlossomState { @@ -56,6 +58,7 @@ impl BlossomState { store: Arc::new(store), max_blob_bytes: cfg.max_blob_bytes, allowed_upload_ops: Arc::new(cfg.allowed_upload_ops.clone()), + allow_any_verified_op: cfg.allow_any_verified_op, }) } } @@ -179,7 +182,7 @@ pub async fn upload_blob( let verified = verify_blossom_auth(auth_header, RequiredAction::Upload, &body_hash, now)?; // ACL: op must be a paired account or configured replication peer. - if !blossom.allowed_upload_ops.contains(&verified.op_pubkey) { + if !blossom.allow_any_verified_op && !blossom.allowed_upload_ops.contains(&verified.op_pubkey) { return Err(ApiError::scope_exceeded( "upload op key is neither a paired account nor a configured replication peer", )); @@ -297,6 +300,7 @@ mod tests { store, max_blob_bytes: 1, allowed_upload_ops: Arc::new(BTreeSet::new()), + allow_any_verified_op: false, }); let mut headers = HeaderMap::new(); headers.insert( @@ -316,6 +320,103 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + fn sample_sk_pk() -> (bitcoin::secp256k1::SecretKey, [u8; 32]) { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let sk = bitcoin::secp256k1::SecretKey::from_slice(&[0x7au8; 32]).expect("secret"); + let kp = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + fn temp_blossom(allow_any: bool) -> (AppState, std::path::PathBuf) { + let root = std::env::temp_dir().join(format!( + "zkcoins-blossom-acl-{}-{}-{}", + allow_any, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let store = Arc::new(BlobStore::open(&root).expect("temp blossom store")); + let mut state = dummy_state(); + state.blossom = Some(BlossomState { + store, + max_blob_bytes: 1024, + allowed_upload_ops: Arc::new(BTreeSet::new()), + allow_any_verified_op: allow_any, + }); + (state, root) + } + + #[tokio::test] + async fn upload_allow_any_accepts_unlisted_verified_op() { + let (state, root) = temp_blossom(true); + let body = axum::body::Bytes::from_static(b"fixture-blob"); + let x = blob_id_of(&body); + let (sk, pk) = sample_sk_pk(); + let now = unix_now().expect("clock"); + let b64 = sign_auth_event_base64(&sk, &pk, AuthAction::Upload, &x, now, now + 60); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Nostr {b64}")).expect("auth header"), + ); + let result = upload_blob(State(state), headers, LimitedBytes(body)).await; + let resp = result.expect("allow-any upload"); + assert_eq!(resp.status(), StatusCode::OK); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn upload_allow_any_still_requires_verified_auth() { + let (state, root) = temp_blossom(true); + let body = axum::body::Bytes::from_static(b"fixture-blob"); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + let result = upload_blob(State(state), headers, LimitedBytes(body)).await; + assert!(result.is_err(), "missing auth must fail before ACL"); + if let Err(err) = result { + assert_eq!(err.status, StatusCode::UNAUTHORIZED); + assert_eq!(err.body.error, "unauthorized"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn upload_empty_acl_without_allow_any_is_403() { + let (state, root) = temp_blossom(false); + let body = axum::body::Bytes::from_static(b"fixture-blob"); + let x = blob_id_of(&body); + let (sk, pk) = sample_sk_pk(); + let now = unix_now().expect("clock"); + let b64 = sign_auth_event_base64(&sk, &pk, AuthAction::Upload, &x, now, now + 60); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Nostr {b64}")).expect("auth header"), + ); + let result = upload_blob(State(state), headers, LimitedBytes(body)).await; + assert!(result.is_err(), "empty ACL must deny unlisted op"); + if let Err(err) = result { + assert_eq!(err.status, StatusCode::FORBIDDEN); + assert_eq!(err.body.error, "scope_exceeded"); + } + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn require_octet_stream_missing_content_type_is_malformed() { let headers = HeaderMap::new(); diff --git a/src/config.rs b/src/config.rs index 0478230..1f9e637 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,8 @@ //! - `ZKCOINS_BLOSSOM_MAX_BLOB_BYTES` — advertised upload size limit (`> 0`) //! - `ZKCOINS_BLOSSOM_ALLOWED_OPS` — comma-separated lowercase-hex 32-byte //! `op` pubkeys allowed to upload (paired accounts + replication peers; -//! may be empty ⇒ every upload is `403`) +//! may be empty ⇒ every upload is `403`). A sole `*` token allows any +//! verified kind-24242 (dedicated test nodes). use std::collections::BTreeSet; use std::env; @@ -82,8 +83,12 @@ pub struct BlossomConfig { /// Advertised maximum upload body size in bytes (`> 0`). pub max_blob_bytes: u64, /// `op` pubkeys (32 raw bytes) allowed to PUT/POST — paired accounts and - /// configured replication peers. Empty set ⇒ every upload is `403`. + /// configured replication peers. Empty set ⇒ every upload is `403`, + /// unless `allow_any_verified_op` is set. pub allowed_upload_ops: BTreeSet<[u8; 32]>, + /// When true (`ZKCOINS_BLOSSOM_ALLOWED_OPS=*`), any kind-24242 that + /// verifies is accepted. For dedicated test nodes only. + pub allow_any_verified_op: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -265,12 +270,14 @@ where let ops_raw = require_present(get, ENV_BLOSSOM_ALLOWED_OPS)?; // Empty string is allowed: surface is up, but every upload is 403. - let allowed_upload_ops = parse_allowed_ops(&ops_raw)?; + // A sole `*` token allows any verified kind-24242 (test nodes). + let (allowed_upload_ops, allow_any_verified_op) = parse_allowed_ops(&ops_raw)?; Ok(Some(BlossomConfig { store_root: PathBuf::from(store_raw), max_blob_bytes, allowed_upload_ops, + allow_any_verified_op, })) } @@ -302,13 +309,23 @@ fn parse_max_blob_bytes(raw: &str) -> Result { }) } -fn parse_allowed_ops(raw: &str) -> Result, ConfigError> { +fn parse_allowed_ops(raw: &str) -> Result<(BTreeSet<[u8; 32]>, bool), ConfigError> { + let tokens: Vec<&str> = raw + .split(',') + .map(str::trim) + .filter(|t| !t.is_empty()) + .collect(); + if tokens == ["*"] { + return Ok((BTreeSet::new(), true)); + } + if tokens.contains(&"*") { + return Err(ConfigError::InvalidBlossomAllowedOp { + value: "*".to_string(), + reason: "wildcard must be the sole ZKCOINS_BLOSSOM_ALLOWED_OPS token".to_string(), + }); + } let mut out = BTreeSet::new(); - for part in raw.split(',') { - let token = part.trim(); - if token.is_empty() { - continue; - } + for token in tokens { // Lowercase hex only — uppercase is rejected (no silent fold). if token.len() != 64 { return Err(ConfigError::InvalidBlossomAllowedOp { @@ -336,7 +353,7 @@ fn parse_allowed_ops(raw: &str) -> Result, ConfigError> { } out.insert(key); } - Ok(out) + Ok((out, false)) } fn hex_nibble(b: u8) -> u8 { @@ -436,6 +453,24 @@ mod tests { ); assert_eq!(blossom.max_blob_bytes, 1_048_576); assert_eq!(blossom.allowed_upload_ops.len(), 1); + assert!(!blossom.allow_any_verified_op); + } + + #[test] + fn blossom_allowed_ops_empty_does_not_allow_any() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, "/var/lib/zkcoins/blossom"), + (ENV_BLOSSOM_MAX_BLOB_BYTES, "1048576"), + (ENV_BLOSSOM_ALLOWED_OPS, ""), + ])); + let cfg = Config::from_getter(&mut get).expect("empty ops"); + let blossom = cfg.blossom.expect("blossom configured"); + assert!(!blossom.allow_any_verified_op); + assert!(blossom.allowed_upload_ops.is_empty()); } #[test] @@ -805,6 +840,44 @@ mod tests { )); } + #[test] + fn blossom_allowed_ops_star_allows_any_verified_op() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, "/var/lib/zkcoins/blossom"), + (ENV_BLOSSOM_MAX_BLOB_BYTES, "1048576"), + (ENV_BLOSSOM_ALLOWED_OPS, "*"), + ])); + let cfg = Config::from_getter(&mut get).expect("star ops"); + let blossom = cfg.blossom.expect("blossom configured"); + assert!(blossom.allow_any_verified_op); + assert!(blossom.allowed_upload_ops.is_empty()); + } + + #[test] + fn blossom_allowed_ops_star_mixed_with_hex_is_error() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, "/var/lib/zkcoins/blossom"), + (ENV_BLOSSOM_MAX_BLOB_BYTES, "1048576"), + ( + ENV_BLOSSOM_ALLOWED_OPS, + "*,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ])); + let err = Config::from_getter(&mut get).expect_err("mixed star"); + assert!(matches!( + &err, + ConfigError::InvalidBlossomAllowedOp { value, .. } if value == "*" + )); + } + /// Reads the real process env only (no set_var/remove_var — races other tests). #[test] fn from_env_without_zkcoins_vars_is_missing_env() { diff --git a/src/routes.rs b/src/routes.rs index d376c35..c13bc6f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -8195,6 +8195,7 @@ mod tests { store_root: root, max_blob_bytes: max, allowed_upload_ops: ops, + allow_any_verified_op: false, }), }; build_router(cfg, Arc::new(UnreachableKernel)).expect("router") @@ -8221,6 +8222,7 @@ mod tests { )), max_blob_bytes: 1024, allowed_upload_ops: BTreeSet::new(), + allow_any_verified_op: false, }), }; // Create a *file* at store_root so open fails "not a directory". @@ -8853,6 +8855,7 @@ mod tests { store_root: root.clone(), max_blob_bytes: 1024, allowed_upload_ops: BTreeSet::new(), + allow_any_verified_op: false, }), }; let app = build_router(cfg, Arc::new(UnreachableKernel)).expect("router"); @@ -8906,6 +8909,7 @@ mod tests { store_root: root.clone(), max_blob_bytes: 1024, allowed_upload_ops: BTreeSet::new(), + allow_any_verified_op: false, }), }; let app = build_router(cfg, Arc::new(UnreachableKernel)).expect("router"); diff --git a/src/startup.rs b/src/startup.rs index c6e3aac..dd3fd43 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -147,6 +147,7 @@ mod tests { store_root: path.clone(), max_blob_bytes: 1024, allowed_upload_ops: BTreeSet::new(), + allow_any_verified_op: false, }), ); let code = run_with_config(config).await;