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
co-authored by Claude Fable 5
parent 291f2d7186
commit 2c8c99fd28
7 changed files with 145 additions and 37 deletions
+3 -1
View File
@@ -563,7 +563,9 @@ pub(super) async fn handle_identity_received(
// Update peer record
let peer = MeshPeer {
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()),
pubkey_hex: Some(ed_pubkey_hex.to_string()),
// The advert signature was verified above, so this is an authenticated
+77 -8
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> {
let Some(sig_bytes) = &self.sig else {
return Ok(false);
@@ -266,13 +290,14 @@ impl TypedEnvelope {
let signature =
ed25519_dalek::Signature::from_slice(sig_bytes).context("Invalid signature bytes")?;
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());
if verifying_key
.verify_strict(&self.signing_preimage_v2(), &signature)
.is_ok()
{
return Ok(true);
}
verifying_key
.verify_strict(&sign_data, &signature)
.verify_strict(&self.signing_preimage_v1(), &signature)
.context("Signature verification failed")?;
Ok(true)
}
@@ -284,12 +309,25 @@ impl TypedEnvelope {
/// Set the outbound sequence number. Called by the send path after the
/// 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 {
self.seq = seq;
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].
pub fn to_wire(&self) -> Result<Vec<u8>> {
let mut buf = Vec::new();
@@ -816,6 +854,37 @@ mod tests {
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]
fn test_invoice_payload_roundtrip() {
let invoice = InvoicePayload {