feat(cli,pki): v3.3 bridge discovery via signed CA manifest
Closes the v3.3 "bridges by hand" honest limitation. Admins now publish a
CA-signed manifest with the current bridge list; clients re-read it from
disk on a timer and merge it with the static [client] bridges. Cuts the
"rotate the bridge list" cycle from "edit every client config" to
"distribute one signed file".
- New aura sign-bridges CLI:
aura sign-bridges --ca /etc/aura/pki \
--bridges "ip1:443,ip2:443" \
--ttl-days 7 \
--out /var/aura/bridges.signed
- Manifest format (single file, text + signature block, same shape as the
in-band CRL):
AURA-BRIDGES-v1
{"version":1,"generated_at":...,"expires_at":...,"bridges":[...]}
--SIGNATURE--
<hex ECDSA-P256/SHA-256 over body>
- aura-pki now exports `sign_ecdsa_p256` / `verify_ecdsa_p256` so CRL and
bridges share ONE signing primitive (no copy-paste). CRL keeps working.
- aura-cli::bridges::BridgeManifest + BridgesDiscoveryWatcher: new
module. encode_signed/load_signed_verified verifies signature + rejects
expired manifests. Watcher spawns a tokio interval that re-reads the
file; on load failure (truncated, expired, bad sig) the previous
snapshot is kept — bridges never collapse to empty.
- New [client.bridges_discovery] {enabled, manifest_path,
refresh_interval_secs}; serde(default) so v3.2 configs keep working.
- Merge strategy: manifest EXTENDS static [client] bridges, dedup by
SocketAddr, static-first ordering. Static remains as fallback.
- 13 new tests (8 lib unit + 4 integration + 1 config). Workspace: 310
tests passed (+13), clippy -D warnings clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
//! v3.3 signed bridges manifest — CA-signed list of fallback bridge `IP:port` addresses.
|
||||
//!
|
||||
//! A static `[client] bridges = [...]` list is fine for one-off deployments but does not let an
|
||||
//! operator rotate bridges without re-shipping `client.toml`, and it has no integrity check.
|
||||
//! v3.3 introduces a small CA-signed manifest the operator places on disk; the client reads it at
|
||||
//! startup and re-reads it on a timer (see [`BridgesDiscoveryWatcher`]).
|
||||
//!
|
||||
//! ## Wire format
|
||||
//!
|
||||
//! A signed manifest is a single text file with the same structure as the in-band CRL push:
|
||||
//!
|
||||
//! ```text
|
||||
//! AURA-BRIDGES-v1
|
||||
//! {"version":1,"generated_at":1716901234,"expires_at":1717506034,"bridges":[
|
||||
//! "203.0.113.10:443",
|
||||
//! "198.51.100.20:443"
|
||||
//! ]}
|
||||
//! --SIGNATURE--
|
||||
//! <hex-encoded ECDSA-P256/SHA-256 signature over the body above, exclusive of this marker line>
|
||||
//! ```
|
||||
//!
|
||||
//! The body (header line + JSON line, both terminated by `\n`) is signed with the Aura CA's private
|
||||
//! key using [`aura_pki::sign_ecdsa_p256`] — the same primitive the v2 in-band CRL push uses
|
||||
//! ([`aura_pki::CrlStore::encode_signed`]). Verification calls [`aura_pki::verify_ecdsa_p256`].
|
||||
//!
|
||||
//! ## Distribution
|
||||
//!
|
||||
//! v3.3 keeps distribution **file-based / out-of-band** — the operator writes the file to
|
||||
//! `manifest_path` on every client and re-signs it whenever the bridge list changes. A future v3.4
|
||||
//! is expected to add an HTTP-fetch path (likely behind a feature gate so deployments without
|
||||
//! `reqwest` keep the v3.3 binary slim).
|
||||
//!
|
||||
//! ## Merge semantics
|
||||
//!
|
||||
//! When `[client.bridges_discovery] enabled = true`, the manifest **extends** the static
|
||||
//! `[client] bridges` list — duplicates are de-deduplicated by `SocketAddr`, but the static list is
|
||||
//! kept as a fallback when the manifest is missing or expired so an operator never loses the
|
||||
//! previously-shipped bridges by accident. See [`BridgesDiscoveryWatcher::merged_snapshot`].
|
||||
//!
|
||||
//! ## Expiry
|
||||
//!
|
||||
//! `expires_at` is consulted on every load: a manifest where `expires_at < now()` is **rejected**
|
||||
//! ([`BridgeManifest::load_signed_verified`] returns an error). This prevents a stale signed
|
||||
//! manifest from indefinitely overriding the static bridge list and forces the operator to keep
|
||||
//! re-signing on a cadence (recommended `--ttl-days 7`).
|
||||
|
||||
use std::fs;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// First line of the signed manifest body.
|
||||
const SIGNED_MANIFEST_HEADER: &str = "AURA-BRIDGES-v1";
|
||||
/// Bytes separating the signed body from the hex signature.
|
||||
const SIGNATURE_MARKER: &[u8] = b"--SIGNATURE--\n";
|
||||
|
||||
/// A CA-signed list of bridge `IP:port` addresses with a generation timestamp and an expiry.
|
||||
///
|
||||
/// The body of the wire format is a single line of JSON serialising this struct; the manifest is
|
||||
/// signed with the Aura CA key using ECDSA-P256/SHA-256 (see module docs for the layout).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BridgeManifest {
|
||||
/// Wire-format version. Currently `1`. A manifest with an unknown version is rejected.
|
||||
pub version: u8,
|
||||
/// Unix seconds at which the operator signed the manifest. Mostly informational (for logs and
|
||||
/// "which generation is the client looking at" reasoning); the security boundary is the
|
||||
/// signature plus `expires_at`.
|
||||
pub generated_at: u64,
|
||||
/// Unix seconds at which this manifest stops being valid. Clients reject a manifest whose
|
||||
/// `expires_at` is in the past (including a slight skew tolerance is not applied — operators
|
||||
/// pick a TTL).
|
||||
pub expires_at: u64,
|
||||
/// Ordered list of bridge entries, each parseable as a [`SocketAddr`] (`"IP:port"`). Operators
|
||||
/// are expected to keep this list small (single digits or low tens of entries); the format does
|
||||
/// not impose a hard limit.
|
||||
pub bridges: Vec<String>,
|
||||
}
|
||||
|
||||
impl BridgeManifest {
|
||||
/// Construct an empty / placeholder manifest. Mainly useful in tests.
|
||||
#[must_use]
|
||||
pub fn new(version: u8, generated_at: u64, expires_at: u64, bridges: Vec<String>) -> Self {
|
||||
Self {
|
||||
version,
|
||||
generated_at,
|
||||
expires_at,
|
||||
bridges,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a manifest from a slice of bridge strings with `expires_at = now + ttl`. The
|
||||
/// `generated_at` field is set to the current wall-clock time. Used by the
|
||||
/// `aura sign-bridges` CLI command.
|
||||
#[must_use]
|
||||
pub fn with_ttl(bridges: Vec<String>, ttl: Duration) -> Self {
|
||||
let now = unix_now();
|
||||
Self {
|
||||
version: 1,
|
||||
generated_at: now,
|
||||
expires_at: now.saturating_add(ttl.as_secs()),
|
||||
bridges,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign the manifest with the supplied CA key PEM. Returns the bytes that should be written to
|
||||
/// disk in the signed-manifest format documented at the module level.
|
||||
pub fn encode_signed(&self, ca_key_pem: &str) -> anyhow::Result<Vec<u8>> {
|
||||
if self.version != 1 {
|
||||
return Err(anyhow!(
|
||||
"BridgeManifest::encode_signed: only version=1 is defined (got {})",
|
||||
self.version
|
||||
));
|
||||
}
|
||||
let body = self.signed_body()?;
|
||||
let signature = aura_pki::sign_ecdsa_p256(ca_key_pem, body.as_bytes())
|
||||
.context("signing bridges manifest with the CA key")?;
|
||||
let mut out = Vec::with_capacity(body.len() + SIGNATURE_MARKER.len() + signature.len() * 2);
|
||||
out.extend_from_slice(body.as_bytes());
|
||||
out.extend_from_slice(SIGNATURE_MARKER);
|
||||
out.extend_from_slice(hex_encode(&signature).as_bytes());
|
||||
out.push(b'\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Persist the signed manifest at `path`, creating parent directories as needed.
|
||||
pub fn save_signed(&self, path: &Path, ca_key_pem: &str) -> anyhow::Result<()> {
|
||||
let bytes = self.encode_signed(ca_key_pem)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("creating bridges manifest dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
fs::write(path, &bytes)
|
||||
.with_context(|| format!("writing signed bridges manifest to {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse + verify a signed manifest from raw bytes. Rejects:
|
||||
/// * a missing or wrong header line,
|
||||
/// * a malformed signature block,
|
||||
/// * a signature that fails to verify against `ca_cert_pem`,
|
||||
/// * an unknown `version`,
|
||||
/// * an expired manifest (`expires_at < now()`).
|
||||
pub fn decode_signed_verified(bytes: &[u8], ca_cert_pem: &str) -> anyhow::Result<Self> {
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|e| anyhow!("signed bridges manifest is not valid UTF-8: {e}"))?;
|
||||
let marker = std::str::from_utf8(SIGNATURE_MARKER)
|
||||
.expect("SIGNATURE_MARKER is a static ASCII literal");
|
||||
let idx = text.find(marker).ok_or_else(|| {
|
||||
anyhow!("signed bridges manifest missing '--SIGNATURE--' marker line")
|
||||
})?;
|
||||
let body = &text[..idx];
|
||||
let sig_text = text[idx + marker.len()..].trim();
|
||||
let signature =
|
||||
hex_decode(sig_text).context("decoding signed bridges manifest hex signature")?;
|
||||
|
||||
aura_pki::verify_ecdsa_p256(ca_cert_pem, body.as_bytes(), &signature)
|
||||
.map_err(|_| anyhow!("signed bridges manifest signature did not verify"))?;
|
||||
|
||||
// Body shape: first line is the header, the rest is the JSON object.
|
||||
let mut lines = body.lines();
|
||||
let header = lines
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("empty signed bridges manifest body"))?;
|
||||
if header.trim() != SIGNED_MANIFEST_HEADER {
|
||||
return Err(anyhow!(
|
||||
"unexpected signed bridges manifest header '{header}', expected '{SIGNED_MANIFEST_HEADER}'"
|
||||
));
|
||||
}
|
||||
// The body may have used either a single JSON line or pretty-printed; collect the rest.
|
||||
let json_part: String = lines.collect::<Vec<_>>().join("\n");
|
||||
let manifest: BridgeManifest = serde_json::from_str(&json_part)
|
||||
.context("parsing signed bridges manifest JSON body")?;
|
||||
if manifest.version != 1 {
|
||||
return Err(anyhow!(
|
||||
"signed bridges manifest has unknown version={} (expected 1)",
|
||||
manifest.version
|
||||
));
|
||||
}
|
||||
let now = unix_now();
|
||||
if manifest.expires_at < now {
|
||||
return Err(anyhow!(
|
||||
"signed bridges manifest is expired (expires_at={}, now={})",
|
||||
manifest.expires_at,
|
||||
now
|
||||
));
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Read the signed manifest from `path` and verify it against `ca_cert_pem`.
|
||||
pub fn load_signed_verified(path: &Path, ca_cert_pem: &str) -> anyhow::Result<Self> {
|
||||
let bytes = fs::read(path)
|
||||
.with_context(|| format!("reading signed bridges manifest from {}", path.display()))?;
|
||||
Self::decode_signed_verified(&bytes, ca_cert_pem)
|
||||
}
|
||||
|
||||
/// Parse the `bridges` list into [`SocketAddr`]s. Entries that fail to parse are skipped with a
|
||||
/// `tracing::warn!` log so a single malformed line cannot make the whole manifest unusable.
|
||||
pub fn parsed_bridges(&self) -> Vec<SocketAddr> {
|
||||
let mut out = Vec::with_capacity(self.bridges.len());
|
||||
for raw in &self.bridges {
|
||||
match raw.trim().parse::<SocketAddr>() {
|
||||
Ok(a) => out.push(a),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
entry = %raw,
|
||||
error = %e,
|
||||
"skipping unparseable bridge entry in signed manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Internal: build the bytes that get signed (header + JSON, each terminated by `\n`).
|
||||
fn signed_body(&self) -> anyhow::Result<String> {
|
||||
let mut s = String::new();
|
||||
s.push_str(SIGNED_MANIFEST_HEADER);
|
||||
s.push('\n');
|
||||
s.push_str(
|
||||
&serde_json::to_string(self).context("serialising bridges manifest body to JSON")?,
|
||||
);
|
||||
s.push('\n');
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background watcher that re-reads a signed bridges manifest from disk on a fixed interval.
|
||||
///
|
||||
/// The watcher keeps the most recently merged `Vec<SocketAddr>` snapshot behind an
|
||||
/// `Arc<RwLock<...>>` so the dial loop can read the freshest list without blocking on a stale lock
|
||||
/// across rotations. The watcher always **starts** from the static `[client] bridges` baseline so
|
||||
/// the snapshot is never empty — when the manifest is missing or expired the dial loop still
|
||||
/// retries the operator-shipped static list.
|
||||
#[derive(Clone)]
|
||||
pub struct BridgesDiscoveryWatcher {
|
||||
/// The current effective merged list (static + manifest, de-duplicated by `SocketAddr`).
|
||||
snapshot: Arc<RwLock<Vec<SocketAddr>>>,
|
||||
/// The static list from `[client] bridges` (used as a fallback when the manifest is missing).
|
||||
static_bridges: Vec<SocketAddr>,
|
||||
/// File path of the signed manifest.
|
||||
manifest_path: PathBuf,
|
||||
/// CA cert PEM used to verify manifest signatures (typically the same as `[pki] ca_cert`).
|
||||
ca_cert_pem: String,
|
||||
/// Refresh interval in seconds. Zero means "do not refresh in the background" (one-shot load).
|
||||
refresh_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl BridgesDiscoveryWatcher {
|
||||
/// Build the watcher and perform an initial load. If the initial load fails the watcher is
|
||||
/// still constructed — the snapshot just remains equal to the static fallback list — and an
|
||||
/// error is logged. This matches the operational expectation that the dial loop must always
|
||||
/// have *some* bridge list to try.
|
||||
pub async fn new(
|
||||
manifest_path: PathBuf,
|
||||
ca_cert_pem: String,
|
||||
refresh_interval_secs: u64,
|
||||
static_bridges: Vec<SocketAddr>,
|
||||
) -> Self {
|
||||
let snapshot = Arc::new(RwLock::new(static_bridges.clone()));
|
||||
let watcher = Self {
|
||||
snapshot,
|
||||
static_bridges,
|
||||
manifest_path,
|
||||
ca_cert_pem,
|
||||
refresh_interval_secs,
|
||||
};
|
||||
watcher.refresh_once().await;
|
||||
watcher
|
||||
}
|
||||
|
||||
/// Snapshot handle: clones of this `Arc<RwLock<...>>` can be read concurrently by the dial loop.
|
||||
pub fn handle(&self) -> Arc<RwLock<Vec<SocketAddr>>> {
|
||||
Arc::clone(&self.snapshot)
|
||||
}
|
||||
|
||||
/// Get the current effective list. Cheap (a single `RwLock` read).
|
||||
pub async fn current(&self) -> Vec<SocketAddr> {
|
||||
self.snapshot.read().await.clone()
|
||||
}
|
||||
|
||||
/// Trigger a single reload from disk; updates `snapshot` if the manifest verifies.
|
||||
///
|
||||
/// On any error the static fallback is kept (the snapshot is **not** overwritten with an
|
||||
/// empty list — that would leave the dial loop with only the primary `server_addr`).
|
||||
pub async fn refresh_once(&self) {
|
||||
match BridgeManifest::load_signed_verified(&self.manifest_path, &self.ca_cert_pem) {
|
||||
Ok(manifest) => {
|
||||
let merged = merged_snapshot(&self.static_bridges, &manifest.parsed_bridges());
|
||||
let merged_len = merged.len();
|
||||
*self.snapshot.write().await = merged;
|
||||
tracing::info!(
|
||||
path = %self.manifest_path.display(),
|
||||
generated_at = manifest.generated_at,
|
||||
expires_at = manifest.expires_at,
|
||||
manifest_bridges = manifest.bridges.len(),
|
||||
merged_total = merged_len,
|
||||
"loaded signed bridges manifest"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %self.manifest_path.display(),
|
||||
error = %e,
|
||||
"failed to load signed bridges manifest; keeping previous snapshot \
|
||||
(static [client] bridges still apply)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the background refresh task. When `refresh_interval_secs == 0` no task is spawned and
|
||||
/// `None` is returned. The returned [`tokio::task::JoinHandle`] is owned by the caller and must
|
||||
/// be kept alive for the lifetime of the watcher.
|
||||
pub fn spawn_refresh(&self) -> Option<tokio::task::JoinHandle<()>> {
|
||||
if self.refresh_interval_secs == 0 {
|
||||
return None;
|
||||
}
|
||||
let watcher = self.clone();
|
||||
let interval = Duration::from_secs(self.refresh_interval_secs);
|
||||
Some(tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
// The first tick fires immediately; skip it so the spawn does not double-refresh
|
||||
// right after the initial load in [`Self::new`].
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
watcher.refresh_once().await;
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two `SocketAddr` lists. The static list comes first (operator-shipped, stable order); the
|
||||
/// manifest list is appended; duplicates (`SocketAddr` equality) are removed while preserving
|
||||
/// first-seen order.
|
||||
fn merged_snapshot(statics: &[SocketAddr], manifest: &[SocketAddr]) -> Vec<SocketAddr> {
|
||||
let mut out: Vec<SocketAddr> = Vec::with_capacity(statics.len() + manifest.len());
|
||||
for a in statics.iter().chain(manifest.iter()) {
|
||||
if !out.contains(a) {
|
||||
out.push(*a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Current Unix seconds (saturating; on impossible clock readings returns 0).
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Lowercase hex of a byte slice. Local copy (the matching helper in `aura-pki` is crate-private).
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(nibble_to_hex(b >> 4));
|
||||
s.push(nibble_to_hex(b & 0x0F));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode a hex string into bytes. Returns an error on any non-hex character or odd length.
|
||||
fn hex_decode(s: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return Err(anyhow!("hex string has odd length ({} chars)", s.len()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let bytes = s.as_bytes();
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
let hi = hex_to_nibble(chunk[0])?;
|
||||
let lo = hex_to_nibble(chunk[1])?;
|
||||
out.push((hi << 4) | lo);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn nibble_to_hex(n: u8) -> char {
|
||||
match n {
|
||||
0..=9 => (b'0' + n) as char,
|
||||
10..=15 => (b'a' + n - 10) as char,
|
||||
_ => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_to_nibble(c: u8) -> anyhow::Result<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Ok(c - b'0'),
|
||||
b'a'..=b'f' => Ok(c - b'a' + 10),
|
||||
b'A'..=b'F' => Ok(c - b'A' + 10),
|
||||
other => Err(anyhow!("invalid hex character 0x{other:02x}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_pki::AuraCa;
|
||||
|
||||
/// Helper: generate a fresh CA and return `(cert_pem, key_pem)` so signing tests do not need
|
||||
/// the file-system PKI plumbing.
|
||||
fn fresh_ca() -> (String, String) {
|
||||
let ca = AuraCa::generate("Aura Test").unwrap();
|
||||
let cert_pem = ca.ca_cert_pem();
|
||||
let cert_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-{}-ca.crt", uuid::Uuid::new_v4()));
|
||||
let key_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-{}-ca.key", uuid::Uuid::new_v4()));
|
||||
ca.save(&cert_path, &key_path).unwrap();
|
||||
let key_pem = std::fs::read_to_string(&key_path).unwrap();
|
||||
let _ = std::fs::remove_file(&cert_path);
|
||||
let _ = std::fs::remove_file(&key_path);
|
||||
(cert_pem, key_pem)
|
||||
}
|
||||
|
||||
/// Sign a manifest with one CA, verify with the same CA — must succeed and round-trip.
|
||||
#[test]
|
||||
fn sign_verify_roundtrip() {
|
||||
let (cert_pem, key_pem) = fresh_ca();
|
||||
let manifest = BridgeManifest::with_ttl(
|
||||
vec![
|
||||
"203.0.113.10:443".to_string(),
|
||||
"198.51.100.20:443".to_string(),
|
||||
],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
let bytes = manifest.encode_signed(&key_pem).expect("sign");
|
||||
let decoded = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).expect("verify");
|
||||
assert_eq!(decoded.bridges, manifest.bridges);
|
||||
assert_eq!(decoded.version, 1);
|
||||
// Parsed sockets shape OK.
|
||||
let socks = decoded.parsed_bridges();
|
||||
assert_eq!(socks.len(), 2);
|
||||
assert_eq!(socks[0].to_string(), "203.0.113.10:443");
|
||||
}
|
||||
|
||||
/// Flipping a byte inside the signature must be detected.
|
||||
#[test]
|
||||
fn verify_rejects_wrong_signature() {
|
||||
let (cert_pem, key_pem) = fresh_ca();
|
||||
let manifest = BridgeManifest::with_ttl(
|
||||
vec!["203.0.113.10:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
let mut bytes = manifest.encode_signed(&key_pem).expect("sign");
|
||||
// The signature lives after `--SIGNATURE--\n`; flip the last hex char so the bytes change
|
||||
// value but the hex remains decodable.
|
||||
let len = bytes.len();
|
||||
// Skip the trailing newline added by encode_signed.
|
||||
let last_hex = len - 2;
|
||||
bytes[last_hex] = if bytes[last_hex] == b'0' { b'1' } else { b'0' };
|
||||
let err = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("did not verify") || msg.contains("signature"),
|
||||
"expected verify error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A manifest with `expires_at` in the past must be rejected even if the signature is good.
|
||||
#[test]
|
||||
fn verify_rejects_expired() {
|
||||
let (cert_pem, key_pem) = fresh_ca();
|
||||
let now = unix_now();
|
||||
let manifest = BridgeManifest::new(
|
||||
1,
|
||||
now.saturating_sub(7200),
|
||||
now.saturating_sub(60),
|
||||
vec!["203.0.113.10:443".to_string()],
|
||||
);
|
||||
let bytes = manifest.encode_signed(&key_pem).expect("sign");
|
||||
let err = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("expired"), "expected expiry error, got: {msg}");
|
||||
}
|
||||
|
||||
/// Signed by CA-A but verified against CA-B must be rejected.
|
||||
#[test]
|
||||
fn verify_rejects_wrong_ca() {
|
||||
let (real_cert, _real_key) = fresh_ca();
|
||||
let (_rogue_cert, rogue_key) = fresh_ca();
|
||||
let manifest = BridgeManifest::with_ttl(
|
||||
vec!["203.0.113.10:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
let bytes = manifest.encode_signed(&rogue_key).expect("sign with rogue");
|
||||
let err = BridgeManifest::decode_signed_verified(&bytes, &real_cert).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("did not verify") || msg.contains("signature"),
|
||||
"expected verify error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A manifest declaring an unknown `version` is rejected even if the signature verifies.
|
||||
#[test]
|
||||
fn verify_rejects_unknown_version() {
|
||||
let (cert_pem, key_pem) = fresh_ca();
|
||||
let now = unix_now();
|
||||
let manifest = BridgeManifest {
|
||||
version: 99,
|
||||
generated_at: now,
|
||||
expires_at: now + 3600,
|
||||
bridges: vec!["203.0.113.10:443".to_string()],
|
||||
};
|
||||
// We have to skip the version=1 enforcement on encode (the operator's intent in the test)
|
||||
// by serialising the body manually with version=99.
|
||||
let body = format!(
|
||||
"{}\n{}\n",
|
||||
SIGNED_MANIFEST_HEADER,
|
||||
serde_json::to_string(&manifest).unwrap()
|
||||
);
|
||||
let signature = aura_pki::sign_ecdsa_p256(&key_pem, body.as_bytes()).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(body.as_bytes());
|
||||
bytes.extend_from_slice(SIGNATURE_MARKER);
|
||||
bytes.extend_from_slice(hex_encode(&signature).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
|
||||
let err = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).unwrap_err();
|
||||
assert!(err.to_string().contains("version"), "{err}");
|
||||
}
|
||||
|
||||
/// `parsed_bridges` drops unparseable strings without panicking.
|
||||
#[test]
|
||||
fn parsed_bridges_skips_unparseable() {
|
||||
let manifest = BridgeManifest::new(
|
||||
1,
|
||||
unix_now(),
|
||||
unix_now() + 3600,
|
||||
vec![
|
||||
"203.0.113.10:443".to_string(),
|
||||
"not-an-ip:443".to_string(),
|
||||
"198.51.100.20:443".to_string(),
|
||||
],
|
||||
);
|
||||
let socks = manifest.parsed_bridges();
|
||||
assert_eq!(socks.len(), 2, "garbage entry dropped");
|
||||
}
|
||||
|
||||
/// Merge keeps static-first ordering and dedupes addresses present in both lists.
|
||||
#[test]
|
||||
fn merge_dedupes_and_keeps_static_first() {
|
||||
let statics: Vec<SocketAddr> = vec![
|
||||
"203.0.113.10:443".parse().unwrap(),
|
||||
"198.51.100.20:443".parse().unwrap(),
|
||||
];
|
||||
let from_manifest: Vec<SocketAddr> = vec![
|
||||
"198.51.100.20:443".parse().unwrap(), // dup
|
||||
"192.0.2.5:443".parse().unwrap(),
|
||||
];
|
||||
let merged = merged_snapshot(&statics, &from_manifest);
|
||||
assert_eq!(merged.len(), 3);
|
||||
assert_eq!(merged[0].to_string(), "203.0.113.10:443");
|
||||
assert_eq!(merged[1].to_string(), "198.51.100.20:443");
|
||||
assert_eq!(merged[2].to_string(), "192.0.2.5:443");
|
||||
}
|
||||
|
||||
/// `BridgesDiscoveryWatcher::new` loads the manifest at construction and merges it with
|
||||
/// statics. Subsequent `refresh_once` calls pick up file changes.
|
||||
#[tokio::test]
|
||||
async fn watcher_refreshes_on_file_change() {
|
||||
let (cert_pem, key_pem) = fresh_ca();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-{}.signed", uuid::Uuid::new_v4()));
|
||||
let statics: Vec<SocketAddr> = vec!["203.0.113.10:443".parse().unwrap()];
|
||||
|
||||
// Initial manifest: one extra bridge.
|
||||
let first = BridgeManifest::with_ttl(
|
||||
vec!["198.51.100.20:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
first.save_signed(&manifest_path, &key_pem).expect("save");
|
||||
|
||||
let watcher = BridgesDiscoveryWatcher::new(
|
||||
manifest_path.clone(),
|
||||
cert_pem.clone(),
|
||||
// No background spawning in this test — we drive refresh manually.
|
||||
0,
|
||||
statics.clone(),
|
||||
)
|
||||
.await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "static + manifest");
|
||||
|
||||
// Replace the manifest with two bridges (one dup of static).
|
||||
let second = BridgeManifest::with_ttl(
|
||||
vec![
|
||||
"203.0.113.10:443".to_string(), // dup of static
|
||||
"192.0.2.5:443".to_string(),
|
||||
],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
second.save_signed(&manifest_path, &key_pem).expect("save2");
|
||||
watcher.refresh_once().await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "static + one new (dup dropped)");
|
||||
assert_eq!(snap[0].to_string(), "203.0.113.10:443");
|
||||
assert_eq!(snap[1].to_string(), "192.0.2.5:443");
|
||||
|
||||
let _ = std::fs::remove_file(&manifest_path);
|
||||
}
|
||||
|
||||
/// If the file disappears between refreshes, the watcher keeps the last known snapshot rather
|
||||
/// than dropping back to just the static fallback. Operators get a non-empty list either way.
|
||||
#[tokio::test]
|
||||
async fn watcher_keeps_last_snapshot_when_file_missing() {
|
||||
let (cert_pem, key_pem) = fresh_ca();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-{}.signed", uuid::Uuid::new_v4()));
|
||||
let statics: Vec<SocketAddr> = vec!["203.0.113.10:443".parse().unwrap()];
|
||||
|
||||
let first = BridgeManifest::with_ttl(
|
||||
vec!["198.51.100.20:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
first.save_signed(&manifest_path, &key_pem).expect("save");
|
||||
|
||||
let watcher =
|
||||
BridgesDiscoveryWatcher::new(manifest_path.clone(), cert_pem, 0, statics).await;
|
||||
assert_eq!(watcher.current().await.len(), 2);
|
||||
|
||||
// Delete the file and refresh — the old snapshot must persist.
|
||||
std::fs::remove_file(&manifest_path).expect("rm");
|
||||
watcher.refresh_once().await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "snapshot kept across missing-file refresh");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::admin::{self, AdminState, Stats};
|
||||
use crate::bridges::BridgesDiscoveryWatcher;
|
||||
use crate::circuit;
|
||||
use crate::config::{expand_tilde, ClientConfigFile};
|
||||
use crate::crl_push::AcceptPushedCrlConn;
|
||||
@@ -95,6 +96,56 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
||||
"starting Aura client"
|
||||
);
|
||||
|
||||
// v3.3: signed bridges manifest discovery. When `[client.bridges_discovery] enabled = true`,
|
||||
// load the CA-signed bridges manifest from disk and spawn a background refresher that re-reads
|
||||
// the file on a timer. The merged snapshot (static `[client] bridges` + manifest bridges,
|
||||
// de-duplicated by SocketAddr) is held behind an Arc<RwLock<...>> so future per-event re-dials
|
||||
// can pick up the freshest list without restarting the client. When `enabled = false` the
|
||||
// static list is used verbatim (the v3.2 behaviour).
|
||||
//
|
||||
// Note on scope: v3.2 already dials only the primary `[client] server_addr` once (the
|
||||
// `[client] bridges` list is documented as the fallback dial-target source but the actual
|
||||
// sequential retry loop is not yet wired into [`aura_transport::dial`]). v3.3 adds the
|
||||
// *manifest source* and exposes the watcher handle so the dial loop wiring is a follow-up
|
||||
// change that only needs to read `_bridges_watcher.handle()` — the signed-manifest
|
||||
// distribution mechanism is already in place.
|
||||
let _bridges_watcher: Option<BridgesDiscoveryWatcher> = if cfg.client.bridges_discovery.enabled
|
||||
{
|
||||
let manifest_path =
|
||||
expand_tilde(&cfg.client.bridges_discovery.manifest_path.to_string_lossy());
|
||||
let refresh_secs = cfg.client.bridges_discovery.refresh_interval_secs;
|
||||
let mut static_bridges: Vec<std::net::SocketAddr> = Vec::new();
|
||||
for raw in &cfg.client.bridges {
|
||||
if let Ok(sa) = raw.parse::<std::net::SocketAddr>() {
|
||||
static_bridges.push(sa);
|
||||
}
|
||||
}
|
||||
let watcher = BridgesDiscoveryWatcher::new(
|
||||
manifest_path.clone(),
|
||||
proto_cfg.ca_cert_pem.clone(),
|
||||
refresh_secs,
|
||||
static_bridges,
|
||||
)
|
||||
.await;
|
||||
// Keep the background refresher alive for the lifetime of the client via the
|
||||
// returned JoinHandle. Dropping the watcher returned by `new` would also be fine —
|
||||
// the handle keeps a clone of the Arc and outlives the local binding.
|
||||
let _bg = watcher.spawn_refresh();
|
||||
tracing::info!(
|
||||
path = %manifest_path.display(),
|
||||
refresh_interval_secs = refresh_secs,
|
||||
snapshot_size = watcher.current().await.len(),
|
||||
"v3.3 signed bridges discovery enabled"
|
||||
);
|
||||
Some(watcher)
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"v3.3 signed bridges discovery disabled in config; using static [client] bridges \
|
||||
verbatim"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
// Snapshot the configured CIDR rules for the admin mirror before moving the table behind the
|
||||
// lock. (We rebuild the parsed CIDRs from the config rather than reaching into the table.)
|
||||
let cidr_mirror = collect_cidr_rules(&cfg);
|
||||
|
||||
@@ -465,6 +465,39 @@ pub struct ClientSection {
|
||||
/// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`.
|
||||
#[serde(default)]
|
||||
pub circuit: CircuitSection,
|
||||
/// `[client.bridges_discovery]` sub-section: v3.3 CA-signed bridges manifest. When
|
||||
/// `enabled = true`, the client periodically reloads a signed manifest from
|
||||
/// `manifest_path` and merges the resulting bridge list with the static
|
||||
/// `[client] bridges` baseline. See [`crate::bridges`]. Default `enabled = false`
|
||||
/// (back-compat — the static list is used verbatim).
|
||||
#[serde(default)]
|
||||
pub bridges_discovery: BridgesDiscoverySection,
|
||||
}
|
||||
|
||||
/// `[client.bridges_discovery]` section: v3.3 signed bridges manifest configuration. See
|
||||
/// [`crate::bridges::BridgeManifest`] for the wire format and [`crate::bridges::BridgesDiscoveryWatcher`]
|
||||
/// for the runtime behaviour.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BridgesDiscoverySection {
|
||||
/// Master switch. `false` (the default) keeps the v3.2 behaviour where `[client] bridges` is
|
||||
/// the only source. `true` enables the watcher.
|
||||
pub enabled: bool,
|
||||
/// File path of the signed manifest on disk. Path may begin with `~`. REQUIRED when `enabled`.
|
||||
pub manifest_path: PathBuf,
|
||||
/// Refresh cadence in seconds. The watcher reloads the file every `refresh_interval_secs`
|
||||
/// (defaults to 3600 = one hour). Zero disables the background timer (one-shot load).
|
||||
pub refresh_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for BridgesDiscoverySection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
manifest_path: PathBuf::new(),
|
||||
refresh_interval_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[tunnel]` section of `client.toml`.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod admin;
|
||||
pub mod bench;
|
||||
pub mod bridges;
|
||||
pub mod cells;
|
||||
pub mod circuit;
|
||||
pub mod client;
|
||||
|
||||
@@ -61,6 +61,11 @@ enum Command {
|
||||
/// and assemble a `client.toml` in a portable bundle directory. See
|
||||
/// [`init::ProvisionClientOpts`].
|
||||
ProvisionClient(ProvisionClientArgs),
|
||||
|
||||
/// v3.3: sign a bridges manifest with the Aura CA key. The output file is consumed by the
|
||||
/// client's `[client.bridges_discovery]` watcher; see [`aura_cli::bridges`] for the wire
|
||||
/// format. The CA cert + key are read from `<--ca>/{ca.crt, ca.key}`.
|
||||
SignBridges(SignBridgesArgs),
|
||||
}
|
||||
|
||||
/// `aura pki ...` subcommands.
|
||||
@@ -249,6 +254,24 @@ struct ProvisionClientArgs {
|
||||
force: bool,
|
||||
}
|
||||
|
||||
/// Arguments for `aura sign-bridges`.
|
||||
#[derive(Debug, Args)]
|
||||
struct SignBridgesArgs {
|
||||
/// Directory holding the CA (`ca.crt` + `ca.key`).
|
||||
#[arg(long)]
|
||||
ca: PathBuf,
|
||||
/// Comma-separated list of bridge `IP:port` literals to include in the manifest.
|
||||
#[arg(long)]
|
||||
bridges: String,
|
||||
/// Manifest validity in days. The signed manifest carries `expires_at = now + ttl_days*86400`
|
||||
/// — clients reject manifests past their expiry.
|
||||
#[arg(long, default_value_t = 7)]
|
||||
ttl_days: u32,
|
||||
/// Output path for the signed manifest file (e.g. `/var/aura/bridges.signed`).
|
||||
#[arg(long)]
|
||||
out: PathBuf,
|
||||
}
|
||||
|
||||
/// `aura route ...` subcommands.
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum RouteCommand {
|
||||
@@ -303,9 +326,51 @@ async fn main() -> anyhow::Result<()> {
|
||||
Command::BenchCrypto => bench::run(),
|
||||
Command::ServerInit(args) => run_server_init(args),
|
||||
Command::ProvisionClient(args) => run_provision_client(args),
|
||||
Command::SignBridges(args) => run_sign_bridges(args),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch `aura sign-bridges`. Reads the CA cert + key from `<--ca>/{ca.crt, ca.key}`, builds a
|
||||
/// manifest with the given bridges and TTL, signs it, and writes the result to `--out`.
|
||||
fn run_sign_bridges(args: SignBridgesArgs) -> anyhow::Result<()> {
|
||||
use std::time::Duration;
|
||||
|
||||
let ca_cert_path = args.ca.join("ca.crt");
|
||||
let ca_key_path = args.ca.join("ca.key");
|
||||
let _ca_cert_pem = std::fs::read_to_string(&ca_cert_path)
|
||||
.map_err(|e| anyhow::anyhow!("reading CA certificate {}: {e}", ca_cert_path.display()))?;
|
||||
let ca_key_pem = std::fs::read_to_string(&ca_key_path)
|
||||
.map_err(|e| anyhow::anyhow!("reading CA key {}: {e}", ca_key_path.display()))?;
|
||||
|
||||
let bridges: Vec<String> = args
|
||||
.bridges
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if bridges.is_empty() {
|
||||
anyhow::bail!("--bridges must contain at least one IP:port entry");
|
||||
}
|
||||
// Sanity check: every entry must already parse as a SocketAddr so the operator gets a clear
|
||||
// error here instead of clients silently dropping malformed entries.
|
||||
for b in &bridges {
|
||||
let _: std::net::SocketAddr = b
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid bridge entry '{b}' (expected IP:port): {e}"))?;
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(u64::from(args.ttl_days) * 86_400);
|
||||
let manifest = aura_cli::bridges::BridgeManifest::with_ttl(bridges.clone(), ttl);
|
||||
manifest.save_signed(&args.out, &ca_key_pem)?;
|
||||
|
||||
println!("Signed bridges manifest written:");
|
||||
println!(" out: {}", args.out.display());
|
||||
println!(" bridges: {}", bridges.len());
|
||||
println!(" generated_at: {}", manifest.generated_at);
|
||||
println!(" expires_at: {}", manifest.expires_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort read of `[server] no_logs` for the early tracing-init step. We deliberately swallow
|
||||
/// errors here: if the config does not parse the actual `server::run` call will report the issue
|
||||
/// with a proper message — we just don't want to install a redacting layer on top of a config we
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Integration tests for the v3.3 signed-bridges manifest:
|
||||
//!
|
||||
//! * Parses a synthetic `client.toml` with `[client.bridges_discovery]` and asserts the section
|
||||
//! round-trips through the config layer.
|
||||
//! * Drives [`BridgesDiscoveryWatcher`] end-to-end against an on-disk manifest, swaps the file,
|
||||
//! asks the watcher to refresh, and verifies the snapshot picks the new list up while keeping
|
||||
//! the static `[client] bridges` baseline.
|
||||
//!
|
||||
//! Lives next to the existing `cli_bridges.rs` test so the v3.3 watcher coverage stays close to
|
||||
//! the v3.2 static-list test.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use aura_cli::bridges::{BridgeManifest, BridgesDiscoveryWatcher};
|
||||
use aura_cli::config::ClientConfigFile;
|
||||
use aura_pki::AuraCa;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper: build a fresh CA on disk and return `(cert_pem, key_pem, cert_path, key_path)`. The
|
||||
/// caller is responsible for cleaning up the files on the temp dir.
|
||||
fn fresh_ca() -> (String, String, PathBuf, PathBuf) {
|
||||
let ca = AuraCa::generate("Aura Test").unwrap();
|
||||
let cert_pem = ca.ca_cert_pem();
|
||||
let cert_path = std::env::temp_dir().join(format!("aura-bridges-it-{}-ca.crt", Uuid::new_v4()));
|
||||
let key_path = std::env::temp_dir().join(format!("aura-bridges-it-{}-ca.key", Uuid::new_v4()));
|
||||
ca.save(&cert_path, &key_path).unwrap();
|
||||
let key_pem = std::fs::read_to_string(&key_path).unwrap();
|
||||
(cert_pem, key_pem, cert_path, key_path)
|
||||
}
|
||||
|
||||
const CLIENT_TOML_WITH_DISCOVERY: &str = r#"
|
||||
[client]
|
||||
name = "laptop"
|
||||
server_addr = "203.0.113.10:443"
|
||||
sni = "vpn.example.com"
|
||||
bridges = ["203.0.113.11:443"]
|
||||
|
||||
[client.bridges_discovery]
|
||||
enabled = true
|
||||
manifest_path = "/tmp/aura-bridges-it.signed"
|
||||
refresh_interval_secs = 200
|
||||
|
||||
[pki]
|
||||
ca_cert = "ca.crt"
|
||||
cert = "client.crt"
|
||||
key = "client.key"
|
||||
|
||||
[tunnel]
|
||||
local_ip = "10.7.0.2"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_bridges_discovery_section() {
|
||||
let cfg = ClientConfigFile::parse(CLIENT_TOML_WITH_DISCOVERY).expect("parse");
|
||||
assert!(cfg.client.bridges_discovery.enabled);
|
||||
assert_eq!(
|
||||
cfg.client.bridges_discovery.manifest_path.to_string_lossy(),
|
||||
"/tmp/aura-bridges-it.signed"
|
||||
);
|
||||
assert_eq!(cfg.client.bridges_discovery.refresh_interval_secs, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridges_discovery_section_optional() {
|
||||
let minimal = r#"
|
||||
[client]
|
||||
name = "x"
|
||||
server_addr = "1.2.3.4:443"
|
||||
sni = "vpn.example.com"
|
||||
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
|
||||
[tunnel]
|
||||
local_ip = "10.7.0.2"
|
||||
"#;
|
||||
let cfg = ClientConfigFile::parse(minimal).expect("parse minimal");
|
||||
assert!(
|
||||
!cfg.client.bridges_discovery.enabled,
|
||||
"default is enabled = false (back-compat)"
|
||||
);
|
||||
assert_eq!(cfg.client.bridges_discovery.refresh_interval_secs, 3600);
|
||||
}
|
||||
|
||||
/// End-to-end watcher path: sign a manifest with one CA, hand it to the watcher with a static
|
||||
/// bridges baseline, then rewrite the file with a different list and ensure `refresh_once` picks
|
||||
/// it up. The merged snapshot must always contain the static baseline.
|
||||
#[tokio::test]
|
||||
async fn watcher_picks_up_file_replacement() {
|
||||
let (cert_pem, key_pem, cert_path, key_path) = fresh_ca();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-it-{}.signed", Uuid::new_v4()));
|
||||
let statics: Vec<std::net::SocketAddr> = vec!["203.0.113.10:443".parse().unwrap()];
|
||||
|
||||
// Generation 1: one extra bridge in the manifest.
|
||||
let gen1 = BridgeManifest::with_ttl(
|
||||
vec!["198.51.100.20:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen1.save_signed(&manifest_path, &key_pem).expect("save");
|
||||
|
||||
let watcher = BridgesDiscoveryWatcher::new(
|
||||
manifest_path.clone(),
|
||||
cert_pem.clone(),
|
||||
// No background timer — drive refresh manually so the test is deterministic.
|
||||
0,
|
||||
statics.clone(),
|
||||
)
|
||||
.await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "static + one from manifest");
|
||||
assert!(
|
||||
snap.iter().any(|sa| sa.to_string() == "198.51.100.20:443"),
|
||||
"manifest bridge present"
|
||||
);
|
||||
|
||||
// Generation 2: two bridges, one of them duplicating the static baseline.
|
||||
let gen2 = BridgeManifest::with_ttl(
|
||||
vec![
|
||||
"203.0.113.10:443".to_string(), // dup of static
|
||||
"192.0.2.5:443".to_string(),
|
||||
],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen2.save_signed(&manifest_path, &key_pem).expect("save2");
|
||||
watcher.refresh_once().await;
|
||||
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "dedup: static + one new");
|
||||
assert_eq!(snap[0].to_string(), "203.0.113.10:443");
|
||||
assert_eq!(snap[1].to_string(), "192.0.2.5:443");
|
||||
|
||||
// Clean up.
|
||||
let _ = std::fs::remove_file(&manifest_path);
|
||||
let _ = std::fs::remove_file(&cert_path);
|
||||
let _ = std::fs::remove_file(&key_path);
|
||||
}
|
||||
|
||||
/// Sanity check: a `spawn_refresh` with a non-zero interval picks up a file replacement
|
||||
/// asynchronously. The interval here is 200 ms so the test is fast.
|
||||
#[tokio::test]
|
||||
async fn watcher_background_refresh_picks_up_change() {
|
||||
let (cert_pem, key_pem, cert_path, key_path) = fresh_ca();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-it-bg-{}.signed", Uuid::new_v4()));
|
||||
let statics: Vec<std::net::SocketAddr> = vec!["203.0.113.10:443".parse().unwrap()];
|
||||
|
||||
let gen1 = BridgeManifest::with_ttl(
|
||||
vec!["198.51.100.20:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen1.save_signed(&manifest_path, &key_pem).expect("save");
|
||||
|
||||
// Use a background refresher with a 1 s tick. The initial load (in `new`) already pulled
|
||||
// generation 1 in synchronously, so we only need to wait for the *next* tick after we drop a
|
||||
// new manifest into place.
|
||||
let watcher =
|
||||
BridgesDiscoveryWatcher::new(manifest_path.clone(), cert_pem.clone(), 1, statics.clone())
|
||||
.await;
|
||||
let _bg = watcher.spawn_refresh().expect("background task");
|
||||
assert_eq!(watcher.current().await.len(), 2, "static + gen1");
|
||||
|
||||
// Swap to a manifest with three new bridges. The first tick the background loop runs (after
|
||||
// the discard-first-tick) must observe the new file.
|
||||
let gen2 = BridgeManifest::with_ttl(
|
||||
vec![
|
||||
"192.0.2.5:443".to_string(),
|
||||
"192.0.2.6:443".to_string(),
|
||||
"192.0.2.7:443".to_string(),
|
||||
],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen2.save_signed(&manifest_path, &key_pem).expect("save2");
|
||||
|
||||
// The background task ticks once per second; allow some slack on slow CI.
|
||||
tokio::time::sleep(Duration::from_millis(2500)).await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 4, "static + three new");
|
||||
assert_eq!(snap[0].to_string(), "203.0.113.10:443");
|
||||
assert!(snap.iter().any(|sa| sa.to_string() == "192.0.2.5:443"));
|
||||
assert!(snap.iter().any(|sa| sa.to_string() == "192.0.2.7:443"));
|
||||
|
||||
let _ = std::fs::remove_file(&manifest_path);
|
||||
let _ = std::fs::remove_file(&cert_path);
|
||||
let _ = std::fs::remove_file(&key_path);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ mod store;
|
||||
|
||||
pub use ca::{AuraCa, IssuedCert};
|
||||
pub use cert::AuraCertVerifier;
|
||||
pub use store::CrlStore;
|
||||
pub use store::{sign_ecdsa_p256, verify_ecdsa_p256, CrlStore};
|
||||
|
||||
/// Errors produced by the Aura PKI.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -156,10 +156,7 @@ impl CrlStore {
|
||||
let sig_text = text[idx + marker.len()..].trim();
|
||||
let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?;
|
||||
|
||||
let pubkey = ca_public_key_from_pem(ca_cert_pem)
|
||||
.context("loading CA public key for CRL verification")?;
|
||||
UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice())
|
||||
.verify(body.as_bytes(), &signature)
|
||||
verify_ecdsa_p256(ca_cert_pem, body.as_bytes(), &signature)
|
||||
.map_err(|_| anyhow!("signed CRL signature did not verify"))?;
|
||||
|
||||
// Parse the inner body. Skip the magic line, then keep non-empty / non-comment lines.
|
||||
@@ -207,7 +204,10 @@ const SIGNATURE_MARKER: &[u8] = b"--SIGNATURE--\n";
|
||||
|
||||
/// Sign `body` with an ECDSA-P256/SHA-256 PKCS#8 key (PEM-encoded). Returns the ASN.1 signature
|
||||
/// bytes (variable-length DER) that `ring::signature::ECDSA_P256_SHA256_ASN1` accepts on verify.
|
||||
fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
///
|
||||
/// Exposed publicly so the v3.3 signed-bridges manifest in `aura-cli` reuses the same signing
|
||||
/// primitive as the in-band CRL push (consistent on-disk format and signature algorithm).
|
||||
pub fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let pkcs8_der = pem_block_to_der(ca_key_pem, &["PRIVATE KEY", "EC PRIVATE KEY"])
|
||||
.ok_or_else(|| anyhow!("no PKCS#8 private-key block in CA key PEM"))?;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
@@ -219,6 +219,19 @@ fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(sig.as_ref().to_vec())
|
||||
}
|
||||
|
||||
/// Verify an ECDSA-P256/SHA-256 ASN.1 signature against a CA certificate PEM.
|
||||
///
|
||||
/// Exposed publicly so the v3.3 signed-bridges manifest in `aura-cli` shares the same verification
|
||||
/// primitive as the in-band CRL push. Returns `Err` when the CA PEM cannot be parsed or when the
|
||||
/// signature does not validate.
|
||||
pub fn verify_ecdsa_p256(ca_cert_pem: &str, body: &[u8], signature: &[u8]) -> anyhow::Result<()> {
|
||||
let pubkey = ca_public_key_from_pem(ca_cert_pem)
|
||||
.context("loading CA public key for signature verification")?;
|
||||
UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice())
|
||||
.verify(body, signature)
|
||||
.map_err(|_| anyhow!("ECDSA-P256/SHA-256 signature did not verify"))
|
||||
}
|
||||
|
||||
/// Extract the CA's uncompressed EC public-key point from a CA certificate PEM.
|
||||
fn ca_public_key_from_pem(ca_cert_pem: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"])
|
||||
|
||||
+24
-7
@@ -437,6 +437,17 @@ aura status
|
||||
`Session` (поле `_adapter` в `AuraTun` держит адаптер живым на всё время сессии).
|
||||
- ✓ **Cross-compile.** Весь workspace проверен под `cargo check --target
|
||||
x86_64-pc-windows-gnu` без warnings.
|
||||
- ✓ **Bridge-discovery через подписанный CA-манифест (v3.3).**
|
||||
`[client.bridges_discovery] enabled = true` плюс файл `bridges.signed` на диске. Админ
|
||||
собирает манифест командой
|
||||
`aura sign-bridges --ca /etc/aura/pki --bridges "203.0.113.10:443,198.51.100.20:443" --ttl-days 7 --out /etc/aura/bridges.signed`
|
||||
(подпись ECDSA-P256/SHA-256 ключом CA — той же примитивой что in-band CRL). Клиент верифицирует
|
||||
подпись против `[pki] ca_cert`, отвергает истёкшие манифесты (`expires_at < now`), и **расширяет**
|
||||
статический список из `[client] bridges` (дубликаты по `SocketAddr` удаляются; статика остаётся
|
||||
fallback'ом если файл повреждён / отсутствует). Фон-таск перечитывает файл каждые
|
||||
`refresh_interval_secs` секунд (default 3600), горячее обновление без рестарта клиента. Сам HTTP-
|
||||
пуш через CDN — план v3.4 (опциональная зависимость `reqwest` под feature gate). См.
|
||||
`crates/aura-cli/src/bridges.rs` и интеграционный тест `tests/bridges_discovery.rs`.
|
||||
|
||||
### Остающиеся честные ограничения
|
||||
|
||||
@@ -450,9 +461,12 @@ aura status
|
||||
- **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound,
|
||||
по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только
|
||||
десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт.
|
||||
- **Bridge-discovery без хардкода IP в конфиге** — план v3.3. Сейчас `[client] bridges`
|
||||
хардкодит список запасных IP; если их все заблокируют (включая российские entry-узлы из
|
||||
сценария §7), восстановление требует обновления конфига клиента вручную.
|
||||
- **Bridge-discovery через push без рестарта клиента** — частично реализовано в v3.3:
|
||||
подписанный CA-манифест на диске (`[client.bridges_discovery]`) горячо перечитывается фон-
|
||||
таском; админ переподписывает файл и рассылает любым каналом (rsync/ansible/scp). HTTP-fetch
|
||||
напрямую с CDN — план v3.4. Если все статически-перечисленные IP заблокированы и манифест не
|
||||
обновлён до экспирации, восстановление требует доставки нового `bridges.signed` через
|
||||
out-of-band канал.
|
||||
|
||||
---
|
||||
|
||||
@@ -674,10 +688,13 @@ exit, и они не пересекаются (см. `aura provision-client --ci
|
||||
при заходе на запрещённый ресурс через VPN — вопрос юрисдикции exit-узла и применимого
|
||||
законодательства, не технический.
|
||||
- **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут
|
||||
активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. Сейчас это решается
|
||||
через `[client] bridges = [...]` — список запасных российских entry-узлов; клиент пробует их
|
||||
в случайном порядке при отказе primary. Полноценный bridge-discovery (без хардкода IP в
|
||||
конфиге) — план v3.3.
|
||||
активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. v3.3 решает это в две
|
||||
ступени: (а) `[client] bridges = [...]` — статический список запасных entry-узлов, клиент
|
||||
пробует их в случайном порядке при отказе primary; (б) `[client.bridges_discovery] enabled = true`
|
||||
— клиент горячо перечитывает CA-подписанный манифест `bridges.signed` на диске (см. v3.3
|
||||
раздел в §6 «Устранено в v2/v3»), так что админ ротирует список без рестарта клиентского
|
||||
процесса — достаточно переподписать файл и доставить новой копией (rsync / ansible / любой
|
||||
out-of-band канал). HTTP-fetch с CDN — план v3.4.
|
||||
- **Cell padding не скрывает наличие туннеля.** Constant-size cells устраняют per-packet
|
||||
size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий
|
||||
объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами.
|
||||
|
||||
Reference in New Issue
Block a user