Merge opt-in app owner identity placeholder
nevent1qqs9d76qm6f5xj2vrtjfnkqz5exrc8r0s9zev4f672kqyd0wjh7wwvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcx2tvaw
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::rpc::RpcHandler;
|
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 crate::network::did_dht;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
@@ -38,7 +40,7 @@ impl RpcHandler {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|id| {
|
.map(|id| {
|
||||||
let is_default = default_id.as_deref() == Some(&id.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 {
|
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||||
(
|
(
|
||||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||||
|
|||||||
@@ -3534,6 +3534,7 @@ impl ProdContainerOrchestrator {
|
|||||||
host_mdns: "test.local".to_string(),
|
host_mdns: "test.local".to_string(),
|
||||||
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
@@ -3551,10 +3552,53 @@ impl ProdContainerOrchestrator {
|
|||||||
// demand (it costs a podman call) only for manifests that use
|
// demand (it costs a podman call) only for manifests that use
|
||||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
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
|
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||||
/// Defaults to `bitcoin-knots` when none is running (B12).
|
/// Defaults to `bitcoin-knots` when none is running (B12).
|
||||||
@@ -3822,6 +3866,22 @@ impl ProdContainerOrchestrator {
|
|||||||
{
|
{
|
||||||
facts.bitcoin_host = self.bitcoin_host().await;
|
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();
|
let mut env = manifest.app.environment.clone();
|
||||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
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
|
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
||||||
/// generated secret plus a secret_env that reads it, which is what makes
|
/// generated secret plus a secret_env that reads it, which is what makes
|
||||||
/// the credential participate in secret_env_hash.
|
/// the credential participate in secret_env_hash.
|
||||||
|
|||||||
@@ -115,6 +115,23 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
|
|||||||
norm(a) == norm(b)
|
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 {
|
impl IdentityManager {
|
||||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||||
@@ -150,6 +167,25 @@ impl IdentityManager {
|
|||||||
Ok((identities, default_id))
|
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.
|
/// Create a new identity.
|
||||||
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
||||||
let signing_key = SigningKey::generate(&mut OsRng);
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
@@ -966,6 +1002,142 @@ mod tests {
|
|||||||
assert_ne!(default_id, Some(r1.id));
|
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]
|
#[tokio::test]
|
||||||
async fn test_delete_default_shifts() {
|
async fn test_delete_default_shifts() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ pub struct ContainerConfig {
|
|||||||
|
|
||||||
/// Derived-env entry. The template is rendered against `HostFacts` at
|
/// Derived-env entry. The template is rendered against `HostFacts` at
|
||||||
/// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DerivedEnv {
|
pub struct DerivedEnv {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
@@ -1428,6 +1429,13 @@ pub struct HostFacts {
|
|||||||
/// right host. Both are reachable on archy-net by their container name;
|
/// right host. Both are reachable on archy-net by their container name;
|
||||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||||
pub bitcoin_host: String,
|
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 {
|
impl HostFacts {
|
||||||
@@ -1439,13 +1447,20 @@ impl HostFacts {
|
|||||||
host_mdns: "test-node.local".to_string(),
|
host_mdns: "test-node.local".to_string(),
|
||||||
disk_gb: 2000,
|
disk_gb: 2000,
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
node_identity_pubkeys: "1111111111111111111111111111111111111111111111111111111111111111,2222222222222222222222222222222222222222222222222222222222222222".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
/// 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> {
|
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||||
@@ -1529,7 +1544,8 @@ impl ContainerConfig {
|
|||||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
.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)
|
format!("{}={}", e.key, value)
|
||||||
})
|
})
|
||||||
.collect()
|
.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]
|
#[test]
|
||||||
fn path_traversal_secret_file_is_rejected() {
|
fn path_traversal_secret_file_is_rejected() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -2451,6 +2507,7 @@ app:
|
|||||||
host_mdns: "test-node.local".to_string(),
|
host_mdns: "test-node.local".to_string(),
|
||||||
disk_gb: 2000,
|
disk_gb: 2000,
|
||||||
bitcoin_host: "bitcoin-core".to_string(),
|
bitcoin_host: "bitcoin-core".to_string(),
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let out = c.resolve_derived_env(&facts);
|
let out = c.resolve_derived_env(&facts);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ app:
|
|||||||
| `app.container.pull_policy` | Pull behavior, usually `if-not-present` |
|
| `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.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.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/<secret_file>`, injected as podman secrets (never visible in `podman inspect` or unit files) |
|
| `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/<secret_file>`, 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_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 |
|
| `app.container.generated_certs` | Self-signed TLS certs materialised before create; CN/SANs rendered from host facts |
|
||||||
|
|||||||
@@ -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`). |
|
| `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. |
|
| `entrypoint` | list of string | Entrypoint override. |
|
||||||
| `custom_args` | list of string | Extra positional args appended after the image. |
|
| `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/<secret_file>` 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 `..`). |
|
| `secret_env` | list | `- { key, secret_file }` — value read from `/var/lib/archipelago/secrets/<secret_file>` 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 `<name>` = hash and `<name>.pw` = plaintext). |
|
| `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 `<name>` = hash and `<name>.pw` = plaintext). |
|
||||||
| `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |
|
| `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |
|
||||||
|
|||||||
Reference in New Issue
Block a user