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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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):
#
Expand Down
2 changes: 1 addition & 1 deletion docs/rest-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
103 changes: 102 additions & 1 deletion src/blossom/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BTreeSet<[u8; 32]>>,
/// When true, any verified kind-24242 may upload (test nodes).
pub allow_any_verified_op: bool,
}

impl BlossomState {
Expand All @@ -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,
})
}
}
Expand Down Expand Up @@ -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",
));
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down
93 changes: 83 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}))
}

Expand Down Expand Up @@ -302,13 +309,23 @@ fn parse_max_blob_bytes(raw: &str) -> Result<u64, ConfigError> {
})
}

fn parse_allowed_ops(raw: &str) -> Result<BTreeSet<[u8; 32]>, 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 {
Expand Down Expand Up @@ -336,7 +353,7 @@ fn parse_allowed_ops(raw: &str) -> Result<BTreeSet<[u8; 32]>, ConfigError> {
}
out.insert(key);
}
Ok(out)
Ok((out, false))
}

fn hex_nibble(b: u8) -> u8 {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 4 additions & 0 deletions src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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".
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading