fix(security): bind seq into mesh signatures (v2 preimage), guard DID slice, cfg-gate dev password

- mesh: verify_signature accepts a v2 preimage (t,v,ts,seq) alongside
  legacy v1 (t,v,ts); signed_with_seq() is the v2 sender path, not yet
  wired — senders stay v1 until the fleet verifies v2 (receivers
  hard-drop bad sigs, so flipping send-side first would break
  mixed-fleet alerts). Tests: v2 verify, v2 seq-tamper rejection,
  v1 sign-then-set-seq compat.
- mesh listener: malformed radio-supplied DID shorter than the
  'did🔑' prefix can no longer panic advert_name (slice -> .get()).
- auth: the pre-setup password123 dev login and the constant itself are
  now #[cfg(debug_assertions)] — no release binary carries the bypass,
  whatever its runtime config says.
- orchestrator: canned host-facts under #[cfg(test)] — awaiting real
  subprocesses under tokio's paused test clock deadlocks against
  auto-advanced timers (the old blocking detection only worked by never
  yielding).
- drop two now-unused std::process::Command imports left by 4c75bb3d.

Tests: mesh 110/110 (incl. 2 new), api 68/68, container 159/159,
archipelago-container check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-04 17:49:52 -04:00
parent 291f2d7186
commit 2c8c99fd28
7 changed files with 145 additions and 37 deletions

View File

@ -1,4 +1,6 @@
use super::{RpcHandler, DEV_DEFAULT_PASSWORD}; use super::RpcHandler;
#[cfg(debug_assertions)]
use super::DEV_DEFAULT_PASSWORD;
use anyhow::Result; use anyhow::Result;
impl RpcHandler { impl RpcHandler {
@ -14,7 +16,10 @@ impl RpcHandler {
let is_setup = self.auth_manager.is_setup().await?; let is_setup = self.auth_manager.is_setup().await?;
if !is_setup { if !is_setup {
// Dev mode: allow default password so UI can log in without running setup // Dev BUILDS only: allow the default password so the UI can log
// in without running setup. cfg-gated so no release binary can
// carry the bypass, whatever its runtime config says.
#[cfg(debug_assertions)]
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD { if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
tracing::info!("[onboarding] login via dev default password"); tracing::info!("[onboarding] login via dev default password");
return Ok(serde_json::Value::Null); return Ok(serde_json::Value::Null);

View File

@ -62,6 +62,9 @@ pub use middleware::PeerAddr;
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse}; use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Default dev password when no user is set up (matches mock-backend). /// Default dev password when no user is set up (matches mock-backend).
/// Dev builds only — the pre-setup login bypass that reads this is
/// cfg-gated out of release binaries.
#[cfg(debug_assertions)]
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123"; pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
pub struct RpcHandler { pub struct RpcHandler {

View File

@ -32,7 +32,6 @@ use async_trait::async_trait;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::os::unix::fs::FileTypeExt; use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
@ -2725,6 +2724,20 @@ impl ProdContainerOrchestrator {
} }
async fn detect_host_facts(&self) -> HostFacts { async fn detect_host_facts(&self) -> HostFacts {
// Unit tests run under tokio's paused clock; awaiting a real
// subprocess there deadlocks against auto-advanced timers (the old
// BLOCKING detection only worked by never yielding). Canned facts.
#[cfg(test)]
{
return HostFacts {
host_ip: "127.0.0.1".to_string(),
host_mdns: "test.local".to_string(),
disk_gb: self.test_disk_gb.unwrap_or(1000),
bitcoin_host: "bitcoin-knots".to_string(),
};
}
#[allow(unreachable_code)]
{
let host_ip = Self::detect_host_ip() let host_ip = Self::detect_host_ip()
.await .await
.unwrap_or_else(|| "127.0.0.1".to_string()); .unwrap_or_else(|| "127.0.0.1".to_string());
@ -2740,15 +2753,21 @@ impl ProdContainerOrchestrator {
bitcoin_host: "bitcoin-knots".to_string(), bitcoin_host: "bitcoin-knots".to_string(),
} }
} }
}
/// 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).
async fn bitcoin_host(&self) -> String { async fn bitcoin_host(&self) -> String {
// No real podman under the tests' paused clock (see detect_host_facts).
#[cfg(test)] #[cfg(test)]
if let Some(host) = &self.test_bitcoin_host { {
return host.clone(); return self
.test_bitcoin_host
.clone()
.unwrap_or_else(|| "bitcoin-knots".to_string());
} }
#[allow(unreachable_code)]
// Mirrors api::rpc::package::dependencies (the legacy install path); // Mirrors api::rpc::package::dependencies (the legacy install path);
// both Bitcoin node variants are reachable on archy-net by name. // both Bitcoin node variants are reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"]; const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
@ -2839,12 +2858,16 @@ impl ProdContainerOrchestrator {
} }
async fn disk_gb(&self) -> u64 { async fn disk_gb(&self) -> u64 {
// No real df under the tests' paused clock (see detect_host_facts).
#[cfg(test)] #[cfg(test)]
if let Some(disk_gb) = self.test_disk_gb { {
return disk_gb; return self.test_disk_gb.unwrap_or(1000);
} }
#[allow(unreachable_code)]
{
Self::detect_disk_gb().await Self::detect_disk_gb().await
} }
}
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin /// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required /// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required

View File

@ -563,7 +563,9 @@ pub(super) async fn handle_identity_received(
// Update peer record // Update peer record
let peer = MeshPeer { let peer = MeshPeer {
contact_id, contact_id,
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]), // .get(): a malformed DID shorter than the "did:key:" prefix must
// not panic the listener on a radio-supplied string.
advert_name: format!("Archy-{}", did.get(8..16.min(did.len())).unwrap_or(did)),
did: Some(did.to_string()), did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.to_string()), pubkey_hex: Some(ed_pubkey_hex.to_string()),
// The advert signature was verified above, so this is an authenticated // The advert signature was verified above, so this is an authenticated

View File

@ -258,7 +258,31 @@ impl TypedEnvelope {
} }
} }
/// Verify signature if present. /// Signing preimage v2: binds the anti-replay `seq` so a radio MITM
/// can't reorder/replay a signed message under a different sequence
/// number. v1 (legacy) covers only (t, v, ts).
fn signing_preimage_v2(&self) -> Vec<u8> {
let mut sign_data = Vec::with_capacity(1 + self.v.len() + 4 + 8);
sign_data.push(self.t);
sign_data.extend_from_slice(&self.v);
sign_data.extend_from_slice(&self.ts.to_le_bytes());
sign_data.extend_from_slice(&self.seq.to_le_bytes());
sign_data
}
fn signing_preimage_v1(&self) -> Vec<u8> {
let mut sign_data = Vec::with_capacity(1 + self.v.len() + 4);
sign_data.push(self.t);
sign_data.extend_from_slice(&self.v);
sign_data.extend_from_slice(&self.ts.to_le_bytes());
sign_data
}
/// Verify signature if present. Accepts the seq-binding v2 preimage OR
/// the legacy (t, v, ts) preimage — senders still emit v1 until the
/// whole fleet verifies v2 (receivers hard-drop bad signatures, so
/// flipping the send side first would break mixed-fleet alerts). The
/// seq-tampering window closes only when the v1 arm is removed.
pub fn verify_signature(&self, verifying_key: &ed25519_dalek::VerifyingKey) -> Result<bool> { pub fn verify_signature(&self, verifying_key: &ed25519_dalek::VerifyingKey) -> Result<bool> {
let Some(sig_bytes) = &self.sig else { let Some(sig_bytes) = &self.sig else {
return Ok(false); return Ok(false);
@ -266,13 +290,14 @@ impl TypedEnvelope {
let signature = let signature =
ed25519_dalek::Signature::from_slice(sig_bytes).context("Invalid signature bytes")?; ed25519_dalek::Signature::from_slice(sig_bytes).context("Invalid signature bytes")?;
let mut sign_data = Vec::with_capacity(1 + self.v.len() + 4); if verifying_key
sign_data.push(self.t); .verify_strict(&self.signing_preimage_v2(), &signature)
sign_data.extend_from_slice(&self.v); .is_ok()
sign_data.extend_from_slice(&self.ts.to_le_bytes()); {
return Ok(true);
}
verifying_key verifying_key
.verify_strict(&sign_data, &signature) .verify_strict(&self.signing_preimage_v1(), &signature)
.context("Signature verification failed")?; .context("Signature verification failed")?;
Ok(true) Ok(true)
} }
@ -284,12 +309,25 @@ impl TypedEnvelope {
/// Set the outbound sequence number. Called by the send path after the /// Set the outbound sequence number. Called by the send path after the
/// target's counter has been incremented. Safe to call AFTER `new_signed` /// target's counter has been incremented. Safe to call AFTER `new_signed`
/// because the signature covers `(t, v, ts)` — not `seq`. /// because the v1 signature covers `(t, v, ts)` — not `seq`. Once the
/// fleet is on a build whose `verify_signature` accepts the v2 preimage,
/// flip senders to sign AFTER seq allocation via `signed_with_seq`.
pub fn with_seq(mut self, seq: u64) -> Self { pub fn with_seq(mut self, seq: u64) -> Self {
self.seq = seq; self.seq = seq;
self self
} }
/// v2 sender path (NOT yet wired — see `verify_signature` for the fleet
/// migration order): set seq first, then sign binding it.
#[allow(dead_code)]
pub fn signed_with_seq(mut self, seq: u64, signing_key: &ed25519_dalek::SigningKey) -> Self {
use ed25519_dalek::Signer;
self.seq = seq;
let signature = signing_key.sign(&self.signing_preimage_v2());
self.sig = Some(signature.to_bytes().to_vec());
self
}
/// Encode to wire format: [0x02] [CBOR envelope]. /// Encode to wire format: [0x02] [CBOR envelope].
pub fn to_wire(&self) -> Result<Vec<u8>> { pub fn to_wire(&self) -> Result<Vec<u8>> {
let mut buf = Vec::new(); let mut buf = Vec::new();
@ -816,6 +854,37 @@ mod tests {
assert!(envelope.verify_signature(&key.verifying_key()).is_err()); assert!(envelope.verify_signature(&key.verifying_key()).is_err());
} }
#[test]
fn test_v2_seq_bound_signature() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec())
.signed_with_seq(42, &key);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
// v2 binds seq: replaying the signed envelope under a different
// sequence number must fail verification.
let mut replayed = envelope.clone();
replayed.seq = 43;
assert!(replayed.verify_signature(&key.verifying_key()).is_err());
}
#[test]
fn test_v1_signature_survives_seq_set_after_signing() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
// Mixed-fleet compatibility: current senders sign first (v1
// preimage, no seq) and allocate seq afterwards; verify must still
// accept that.
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key)
.with_seq(7);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
}
#[test] #[test]
fn test_invoice_payload_roundtrip() { fn test_invoice_payload_roundtrip() {
let invoice = InvoicePayload { let invoice = InvoicePayload {

View File

@ -2,7 +2,6 @@ use crate::manifest::{AppManifest, BuildConfig};
use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient}; use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use std::process::Command;
use std::time::Duration; use std::time::Duration;
use tokio::process::Command as TokioCommand; use tokio::process::Command as TokioCommand;

View File

@ -150,12 +150,19 @@ modules; production request/boot paths are essentially panic-free. The real risk
which sets `X-Real-IP $remote_addr`) — direct connections (e.g. the FIPS peer which sets `X-Real-IP $remote_addr`) — direct connections (e.g. the FIPS peer
listener) bucket under their socket IP, so per-request header rotation no longer listener) bucket under their socket IP, so per-request header rotation no longer
defeats the login limiter. 3 unit tests. defeats the login limiter. 3 unit tests.
- [ ] 🟢 **Include `seq` in the mesh signed preimage.** `message_types.rs:245-288` signs - [x] 🟢 **Include `seq` in the mesh signed preimage.** DONE 2026-07-04 (receiver half):
`(t,v,ts)` but sets the anti-replay `seq` after signing → a radio MITM can alter ordering `verify_signature` accepts a v2 preimage `(t,v,ts,seq)` alongside legacy v1 `(t,v,ts)`;
without breaking the signature. `signed_with_seq()` is the v2 sender path, deliberately NOT yet wired — receivers
- [ ] 🟢 **Guard the short-DID slice panic** (`mesh/listener/decode.rs:566`) and gate the hard-drop bad signatures, so senders stay on v1 until the whole fleet verifies v2.
dev-mode `password123` bypass (`auth.rs:18`) behind `#[cfg]` before it can reach a The seq-tampering window closes only when the v1 arm is removed (track as a
release build. post-fleet-rollout follow-up). Unit tests cover v2 verify, v2 seq-tamper rejection,
and v1 sign-then-set-seq compatibility.
- [x] 🟢 **Guard the short-DID slice panic** (`mesh/listener/decode.rs:566`) and gate the
dev-mode `password123` bypass (`auth.rs:18`) behind `#[cfg]`. DONE 2026-07-04:
advert_name uses `.get()` fallback (malformed radio-supplied DID can't panic the
listener); the pre-setup dev-password login + the constant itself are
`#[cfg(debug_assertions)]` — no release binary carries the bypass regardless of
runtime config.
- [ ] 🟢 **Apply the seccomp/apparmor profile**`security/src/container_policies.rs:71` is a - [ ] 🟢 **Apply the seccomp/apparmor profile**`security/src/container_policies.rs:71` is a
TODO; the profile is defined but never applied to podman. TODO; the profile is defined but never applied to podman.