feat: add NODE_IDENTITY_PUBKEYS derived-env placeholder

Lets an app grant the node's users owner rights, e.g. a Blossom server's
allowed uploaders. The value is the Nostr keys of the identities the app
identity picker offers for NIP-07 signing, chosen by the same rule as
NostrIdentityPicker.vue, so the node's own appliance identity is never
included. It is resolved only for manifests that template it, and an
empty set is an error rather than an empty owner list.

identity.list now shares its is_node test with the new helper.
This commit is contained in:
TheCryptoDonkey
2026-10-03 11:10:29 +02:00
parent 57729f8e18
commit 494d248356
6 changed files with 435 additions and 7 deletions
@@ -1,6 +1,8 @@
use super::*;
use crate::api::rpc::RpcHandler;
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
use crate::identity_manager::{
is_node_identity, IdentityManager, IdentityProfile, IdentityPurpose,
};
use crate::network::did_dht;
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
@@ -38,7 +40,7 @@ impl RpcHandler {
.into_iter()
.map(|id| {
let is_default = default_id.as_deref() == Some(&id.id);
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
let is_node = is_node_identity(&id, &node_pubkey_hex);
let (nostr_pubkey, nostr_npub) = if is_node {
(
node_nostr_hex.clone().or(id.nostr_pubkey),
@@ -3534,6 +3534,7 @@ impl ProdContainerOrchestrator {
host_mdns: "test.local".to_string(),
disk_gb: self.test_disk_gb.unwrap_or(1000),
bitcoin_host: "bitcoin-knots".to_string(),
node_identity_pubkeys: String::new(),
};
}
#[allow(unreachable_code)]
@@ -3551,10 +3552,53 @@ impl ProdContainerOrchestrator {
// demand (it costs a podman call) only for manifests that use
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
bitcoin_host: "bitcoin-knots".to_string(),
// Likewise filled on demand, only for manifests that use
// {{NODE_IDENTITY_PUBKEYS}}.
node_identity_pubkeys: String::new(),
}
}
}
/// Nostr public keys of the identities the app identity picker offers, for
/// the `{{NODE_IDENTITY_PUBKEYS}}` derived-env placeholder. The node
/// identity is recognised the way `identity.list` marks `is_node`: by the
/// node's ed25519 public key, read here from `identity/node_key.pub`
/// (the file `server_info.pubkey` is derived from at startup). The record
/// mirrored from the node key has a `node-` id, so the picker's prefix rule
/// hides it either way. The key is only read, never created: a missing or
/// malformed file is an error. An empty set is an error too, so an app is
/// never handed an empty owner list.
async fn node_identity_pubkeys(&self) -> Result<String> {
let node_pubkey_hex = self.node_pubkey_hex().await?;
let pubkeys = crate::identity_manager::IdentityManager::new(&self.data_dir)
.await?
.app_signable_nostr_pubkeys(&node_pubkey_hex)
.await?;
anyhow::ensure!(
!pubkeys.is_empty(),
"no user identity with a Nostr key is available for apps to sign with; \
create one under Web5 \u{2192} Identities"
);
Ok(pubkeys)
}
/// The node's ed25519 public key as lowercase hex, read from
/// `identity/node_key.pub` (raw 32 bytes, as `NodeIdentity` writes it)
/// without the logging or key creation of `NodeIdentity::load_or_create`.
async fn node_pubkey_hex(&self) -> Result<String> {
let path = self.data_dir.join("identity").join("node_key.pub");
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("reading the node public key {}", path.display()))?;
anyhow::ensure!(
bytes.len() == 32,
"node public key {} is {} bytes, expected 32",
path.display(),
bytes.len()
);
Ok(hex::encode(bytes))
}
/// Container name of the running Bitcoin node (`bitcoin-knots` or
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
/// Defaults to `bitcoin-knots` when none is running (B12).
@@ -3822,6 +3866,22 @@ impl ProdContainerOrchestrator {
{
facts.bitcoin_host = self.bitcoin_host().await;
}
// The identities' keys are read only for manifests that template them.
if manifest
.app
.container
.derived_env
.iter()
.any(|e| e.template.contains("{{NODE_IDENTITY_PUBKEYS}}"))
{
facts.node_identity_pubkeys =
self.node_identity_pubkeys().await.with_context(|| {
format!(
"resolving {{{{NODE_IDENTITY_PUBKEYS}}}} for {}",
manifest.app.id
)
})?;
}
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
@@ -6151,6 +6211,143 @@ app:
}
}
const NODE_IDENTITY_PUBKEYS_YAML: &str = "app:\n id: wildbloom-node\n name: wildbloom-node\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: WILDBLOOM_ALLOW_PUBKEYS\n template: \"{{NODE_IDENTITY_PUBKEYS}}\"\n";
/// Writes `pubkey_hex` as the node public key, in `NodeIdentity`'s format.
async fn write_node_pubkey(orch: &ProdContainerOrchestrator, pubkey_hex: &str) {
let dir = orch.data_dir().join("identity");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("node_key.pub"), hex::decode(pubkey_hex).unwrap())
.await
.unwrap();
}
#[tokio::test]
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
// The owners must be exactly the identities the app signer offers:
// the user identities, never the node's own identity.
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
let mgr = crate::identity_manager::IdentityManager::new(orch.data_dir())
.await
.unwrap();
let mut expected = Vec::new();
for name in ["Personal", "Business"] {
let r = mgr
.create(
name.to_string(),
crate::identity_manager::IdentityPurpose::Personal,
)
.await
.unwrap();
expected.push(r.nostr_pubkey.unwrap().to_ascii_lowercase());
}
expected.sort();
// The node key is held by an identity with a uuid id and an ordinary
// name, so only the `is_node` match, through the key read from
// node_key.pub, can keep it out.
let laptop = mgr
.create(
"Laptop".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
)
.await
.unwrap();
assert!(!laptop.id.starts_with("node-"));
let laptop_nostr = laptop.nostr_pubkey.clone().unwrap();
write_node_pubkey(&orch, &laptop.pubkey_hex).await;
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
let env = &manifest.app.environment;
let want = format!("WILDBLOOM_ALLOW_PUBKEYS={}", expected.join(","));
assert!(env.iter().any(|e| e == &want), "env was {env:?}");
assert!(
!env.iter().any(|e| e.contains(&laptop_nostr)),
"node identity leaked into {env:?}"
);
}
#[tokio::test]
async fn node_identity_pubkeys_placeholder_refuses_an_empty_set() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
// A node key with only the node's own identity: nothing is signable.
let node = crate::identity::NodeIdentity::load_or_create(&orch.data_dir().join("identity"))
.await
.unwrap();
crate::identity_manager::IdentityManager::new(orch.data_dir())
.await
.unwrap()
.create_from_signing_key(
"Node".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
node.signing_key().clone(),
)
.await
.unwrap();
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("NODE_IDENTITY_PUBKEYS"),
"unexpected error: {msg}"
);
assert!(msg.contains("no user identity"), "unexpected error: {msg}");
assert!(
!manifest
.app
.environment
.iter()
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")),
"an empty owner list must never render"
);
}
#[tokio::test]
async fn node_identity_pubkeys_placeholder_needs_the_node_key_and_never_creates_it() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
crate::identity_manager::IdentityManager::new(orch.data_dir())
.await
.unwrap()
.create(
"Personal".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
)
.await
.unwrap();
let identity_dir = orch.data_dir().join("identity");
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
assert!(
format!("{err:#}").contains("node public key"),
"unexpected error: {err:#}"
);
assert!(
!identity_dir.join("node_key").exists(),
"a node key was created"
);
assert!(!identity_dir.join("node_key.pub").exists());
// A malformed key file is refused, not reinterpreted.
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
tokio::fs::write(identity_dir.join("node_key.pub"), "ab".repeat(32))
.await
.unwrap();
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
assert!(
format!("{err:#}").contains("expected 32"),
"unexpected error: {err:#}"
);
assert!(!manifest
.app
.environment
.iter()
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")));
}
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
/// generated secret plus a secret_env that reads it, which is what makes
/// the credential participate in secret_env_hash.
+172
View File
@@ -115,6 +115,23 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
norm(a) == norm(b)
}
/// True when `record` is the node's own identity: the one whose ed25519 key
/// is the node key (`server_info.pubkey`). `identity.list` reports this as
/// `is_node`, and clients must never offer it as an app signer.
pub fn is_node_identity(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
!node_pubkey_hex.is_empty() && record.pubkey_hex == node_pubkey_hex
}
/// True when the app identity picker hides `record`, mirroring
/// `NostrIdentityPicker.vue`'s filter exactly: the node identity
/// (`is_node`), any `node-*` id and any identity named "Node".
pub(crate) fn is_hidden_from_app_signer(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
// Rust's `str::trim` keeps U+FEFF, which JS `trim()` strips.
is_node_identity(record, node_pubkey_hex)
|| record.id.trim().to_lowercase().starts_with("node-")
|| record.name.trim().to_lowercase() == "node"
}
impl IdentityManager {
pub async fn new(data_dir: &Path) -> Result<Self> {
let identities_dir = data_dir.join(IDENTITIES_DIR);
@@ -150,6 +167,25 @@ impl IdentityManager {
Ok((identities, default_id))
}
/// Nostr public keys of the identities an app may sign with through the
/// NIP-07 bridge, as sorted, de-duplicated, comma-joined lowercase hex.
///
/// Leaves out what the identity picker hides (`is_hidden_from_app_signer`)
/// and identities without a Nostr key (they cannot sign). Empty when no
/// identity qualifies.
pub async fn app_signable_nostr_pubkeys(&self, node_pubkey_hex: &str) -> Result<String> {
let (identities, _) = self.list().await?;
let mut pubkeys: Vec<String> = identities
.iter()
.filter(|r| !is_hidden_from_app_signer(r, node_pubkey_hex))
.filter_map(|r| r.nostr_pubkey.as_deref())
.map(str::to_ascii_lowercase)
.collect();
pubkeys.sort();
pubkeys.dedup();
Ok(pubkeys.join(","))
}
/// Create a new identity.
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
let signing_key = SigningKey::generate(&mut OsRng);
@@ -966,6 +1002,142 @@ mod tests {
assert_ne!(default_id, Some(r1.id));
}
fn record(id: &str, name: &str, pubkey_hex: &str) -> IdentityRecord {
IdentityRecord {
id: id.to_string(),
name: name.to_string(),
purpose: IdentityPurpose::Personal,
pubkey_hex: pubkey_hex.to_string(),
did: String::new(),
dht_did: None,
created_at: String::new(),
nostr_pubkey: None,
nostr_npub: None,
profile: None,
}
}
#[test]
fn is_node_identity_matches_only_the_node_pubkey() {
let node = "ab".repeat(32);
let other = "cd".repeat(32);
assert!(is_node_identity(&record("uuid-1", "Laptop", &node), &node));
assert!(!is_node_identity(
&record("uuid-1", "Laptop", &other),
&node
));
// An unknown node key matches nothing, not the records without a key.
assert!(!is_node_identity(&record("uuid-1", "Laptop", ""), ""));
// The id and name rules belong to the picker filter, not to `is_node`.
assert!(!is_node_identity(
&record("node-abc", "Node", &other),
&node
));
}
#[test]
fn is_hidden_from_app_signer_mirrors_the_picker_rules() {
let node = "ab".repeat(32);
let other = "cd".repeat(32);
let hidden = |id: &str, name: &str, pk: &str| {
is_hidden_from_app_signer(&record(id, name, pk), &node)
};
// is_node: matched by key alone, whatever the id and name.
assert!(hidden("uuid-1", "Laptop", &node));
// node-* id, any case, surrounding whitespace ignored.
assert!(hidden("node-0123456789abcdef", "Laptop", &other));
assert!(hidden(" NODE-x ", "Laptop", &other));
assert!(hidden("Node-x", "Laptop", &other));
// The name "Node", any case, surrounding whitespace ignored.
assert!(hidden("uuid-1", "Node", &other));
assert!(hidden("uuid-1", " nODe\t", &other));
// Near misses stay visible.
assert!(!hidden("uuid-1", "Laptop", &other));
assert!(!hidden("my-node-1", "Node 2", &other));
assert!(!hidden("nodes", "Nodes", &other));
}
#[tokio::test]
async fn app_signable_nostr_pubkeys_mirror_the_identity_picker() {
let dir = tempdir().unwrap();
let mgr = IdentityManager::new(dir.path()).await.unwrap();
let personal = mgr
.create("Personal".to_string(), IdentityPurpose::Personal)
.await
.unwrap();
let business = mgr
.create("Business".to_string(), IdentityPurpose::Business)
.await
.unwrap();
// The node identity as mirrored at startup: a `node-` id named
// "Node", given a Nostr key so only the id and name rules hide it.
let mirrored_key = SigningKey::generate(&mut OsRng);
let mirrored = mgr
.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, mirrored_key)
.await
.unwrap();
assert!(mirrored.id.starts_with("node-"));
mgr.create_nostr_key(&mirrored.id).await.unwrap();
// A user-created identity named "Node" is hidden by the picker too.
let named_node = mgr
.create(" node ".to_string(), IdentityPurpose::Anonymous)
.await
.unwrap();
// The node key belongs to an identity with a uuid id and an ordinary
// name, so only the `is_node` match can hide it.
let laptop = mgr
.create("Laptop".to_string(), IdentityPurpose::Personal)
.await
.unwrap();
assert!(!laptop.id.starts_with("node-"));
let (all, _) = mgr.list().await.unwrap();
assert!(all
.iter()
.any(|r| r.id == mirrored.id && r.nostr_pubkey.is_some()));
assert!(all.iter().any(|r| r.id == named_node.id));
assert!(all
.iter()
.any(|r| r.id == laptop.id && r.nostr_pubkey.is_some()));
let mut expected = vec![
personal.nostr_pubkey.unwrap().to_ascii_lowercase(),
business.nostr_pubkey.unwrap().to_ascii_lowercase(),
];
expected.sort();
assert_eq!(
mgr.app_signable_nostr_pubkeys(&laptop.pubkey_hex)
.await
.unwrap(),
expected.join(",")
);
// Without the node key, the same identity is offered like any other.
let mut with_laptop = expected.clone();
with_laptop.push(laptop.nostr_pubkey.unwrap().to_ascii_lowercase());
with_laptop.sort();
assert_eq!(
mgr.app_signable_nostr_pubkeys("").await.unwrap(),
with_laptop.join(",")
);
}
#[tokio::test]
async fn app_signable_nostr_pubkeys_is_empty_with_only_the_node_identity() {
let dir = tempdir().unwrap();
let mgr = IdentityManager::new(dir.path()).await.unwrap();
let node_key = SigningKey::generate(&mut OsRng);
let node_pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
mgr.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, node_key)
.await
.unwrap();
assert_eq!(
mgr.app_signable_nostr_pubkeys(&node_pubkey_hex)
.await
.unwrap(),
""
);
}
#[tokio::test]
async fn test_delete_default_shifts() {
let dir = tempdir().unwrap();
+60 -3
View File
@@ -263,7 +263,8 @@ pub struct ContainerConfig {
/// Derived-env entry. The template is rendered against `HostFacts` at
/// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported
/// fact name is allowed (host_ip, host_mdns, disk_gb).
/// fact name is allowed (host_ip, host_mdns, disk_gb, bitcoin_host,
/// node_identity_pubkeys).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DerivedEnv {
pub key: String,
@@ -1428,6 +1429,13 @@ pub struct HostFacts {
/// right host. Both are reachable on archy-net by their container name;
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
pub bitcoin_host: String,
/// Nostr public keys of the node's identities that the app identity
/// picker offers for signing (the node's own appliance key excluded),
/// as comma-joined, sorted, lowercase 64-char hex. Lets an app grant
/// the node's users owner rights (e.g. a Blossom server's allowed
/// uploaders). Empty unless a manifest templates it; the orchestrator
/// resolves it on demand and refuses to render an empty set.
pub node_identity_pubkeys: String,
}
impl HostFacts {
@@ -1439,13 +1447,20 @@ impl HostFacts {
host_mdns: "test-node.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-knots".to_string(),
node_identity_pubkeys: "1111111111111111111111111111111111111111111111111111111111111111,2222222222222222222222222222222222222222222222222222222222222222".to_string(),
}
}
}
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
/// with `HostFacts`. Centralized so validation and rendering agree.
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
const DERIVED_PLACEHOLDERS: &[&str] = &[
"HOST_IP",
"HOST_MDNS",
"DISK_GB",
"BITCOIN_HOST",
"NODE_IDENTITY_PUBKEYS",
];
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
@@ -1529,7 +1544,8 @@ impl ContainerConfig {
.replace("{{HOST_IP}}", &facts.host_ip)
.replace("{{HOST_MDNS}}", &facts.host_mdns)
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host)
.replace("{{NODE_IDENTITY_PUBKEYS}}", &facts.node_identity_pubkeys);
format!("{}={}", e.key, value)
})
.collect()
@@ -2396,6 +2412,46 @@ app:
);
}
#[test]
fn node_identity_pubkeys_placeholder_is_accepted() {
let yaml = r#"
app:
id: wildbloom-node
name: Wildbloom Node
version: 0.2.2
container:
image: ghcr.io/forgesworn/wildbloom-node:0.2.2
derived_env:
- key: WILDBLOOM_ALLOW_PUBKEYS
template: "{{NODE_IDENTITY_PUBKEYS}}"
"#;
AppManifest::parse(yaml).expect("NODE_IDENTITY_PUBKEYS is a supported placeholder");
}
#[test]
fn resolve_derived_env_renders_node_identity_pubkeys() {
let yaml = r#"
app:
id: wildbloom-node
name: Wildbloom Node
version: 0.2.2
container:
image: ghcr.io/forgesworn/wildbloom-node:0.2.2
derived_env:
- key: WILDBLOOM_ALLOW_PUBKEYS
template: "{{NODE_IDENTITY_PUBKEYS}}"
"#;
let manifest = AppManifest::parse(yaml).unwrap();
let facts = HostFacts::sample();
assert_eq!(
manifest.app.container.resolve_derived_env(&facts),
vec![format!(
"WILDBLOOM_ALLOW_PUBKEYS={}",
facts.node_identity_pubkeys
)]
);
}
#[test]
fn path_traversal_secret_file_is_rejected() {
let yaml = r#"
@@ -2451,6 +2507,7 @@ app:
host_mdns: "test-node.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-core".to_string(),
node_identity_pubkeys: String::new(),
};
let out = c.resolve_derived_env(&facts);