diff --git a/core/archipelago/src/api/rpc/identity/handlers.rs b/core/archipelago/src/api/rpc/identity/handlers.rs index 5d936af0..1d289d56 100644 --- a/core/archipelago/src/api/rpc/identity/handlers.rs +++ b/core/archipelago/src/api/rpc/identity/handlers.rs @@ -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), diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 66c5c3c9..d15c3657 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -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 { + 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 { + 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. diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 02c836c4..23d6b104 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -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 { 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 { + let (identities, _) = self.list().await?; + let mut pubkeys: Vec = 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 { 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(); diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index f9ffe101..a51bf076 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -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); diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index b364e509..12c09369 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -108,7 +108,7 @@ app: | `app.container.pull_policy` | Pull behavior, usually `if-not-present` | | `app.container.network` | Podman network setting such as `archy-net` or `pasta`; dangerous namespace-sharing modes are rejected | | `app.container.entrypoint` / `custom_args` | Entrypoint and command override | -| `app.container.derived_env` | Environment values rendered from host facts. The complete placeholder set is `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`; an unknown name or an unbalanced `{{` is a parse error, so typos fail loudly | +| `app.container.derived_env` | Environment values rendered from host facts. The complete placeholder set is `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`, `{{NODE_IDENTITY_PUBKEYS}}`; an unknown name or an unbalanced `{{` is a parse error, so typos fail loudly. `{{NODE_IDENTITY_PUBKEYS}}` is the Nostr public keys of the identities the app signer (the NIP-07 bridge's identity picker) offers, the node's own appliance identity excluded, as sorted, comma-joined 64-char hex. It is resolved when the app is installed or started, so changes to your identities, including removals, take effect on the app's next restart: until then a removed identity keeps its access; with no such identity the app refuses to start rather than render an empty value | | `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/`, injected as podman secrets (never visible in `podman inspect` or unit files) | | `app.container.generated_secrets` | Secrets the orchestrator creates on first use (`hex16`/`hex32`/`base64`/`bcrypt`) — self-healing, 0600, no host provisioning | | `app.container.generated_certs` | Self-signed TLS certs materialised before create; CN/SANs rendered from host facts | diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index 68ce533b..2c45edc6 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -93,7 +93,7 @@ Exactly **one** of `image` or `build` must be present (image XOR build). | `network_aliases` | list of string | Extra DNS names on `network` (podman `--network-alias`) — lets stack members answer to short baked-in hostnames (`api`, `minio`, `relay`). | | `entrypoint` | list of string | Entrypoint override. | | `custom_args` | list of string | Extra positional args appended after the image. | -| `derived_env` | list | `- { key, template }` — template rendered against host facts at apply time. The allow-list is exactly `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}` (`DERIVED_PLACEHOLDERS`); an unknown name or unbalanced `{{` fails validation. `{{BITCOIN_HOST}}` resolves to whichever Bitcoin app is running (`bitcoin-knots` or `bitcoin-core`, defaulting to knots). Never hard-code host specifics. | +| `derived_env` | list | `- { key, template }` — template rendered against host facts at apply time. The allow-list is exactly `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`, `{{NODE_IDENTITY_PUBKEYS}}` (`DERIVED_PLACEHOLDERS`); an unknown name or unbalanced `{{` fails validation. `{{BITCOIN_HOST}}` resolves to whichever Bitcoin app is running (`bitcoin-knots` or `bitcoin-core`, defaulting to knots). `{{NODE_IDENTITY_PUBKEYS}}` resolves to the Nostr public keys of the identities the app signer (the NIP-07 bridge's identity picker) offers, the node's own appliance identity excluded, as sorted, comma-joined 64-char hex. It is resolved at install and on every start, so changes to your identities, including removals, take effect on the app's next restart: until then a removed identity keeps its access; with no such identity the app refuses to start rather than render an empty value. Never hard-code host specifics. | | `secret_env` | list | `- { key, secret_file }` — value read from `/var/lib/archipelago/secrets/` and injected as a **podman secret**, so it never appears in `podman inspect` or unit files. `secret_file` must be a bare filename (no `/`, no `..`). | | `generated_secrets` | list | `- { name, kind }` — orchestrator materialises the secret on first use (0600, rootless service user, idempotent + self-healing). `kind ∈ hex16 | hex32 | base64 | bcrypt` (bcrypt writes `` = hash and `.pw` = plaintext). | | `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |