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:
xah30
2026-05-27 21:39:23 +03:00
parent 5e553b79df
commit a173ced9b2
9 changed files with 1023 additions and 13 deletions
+641
View File
@@ -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");
}
}
+51
View File
@@ -29,6 +29,7 @@ use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::admin::{self, AdminState, Stats}; use crate::admin::{self, AdminState, Stats};
use crate::bridges::BridgesDiscoveryWatcher;
use crate::circuit; use crate::circuit;
use crate::config::{expand_tilde, ClientConfigFile}; use crate::config::{expand_tilde, ClientConfigFile};
use crate::crl_push::AcceptPushedCrlConn; use crate::crl_push::AcceptPushedCrlConn;
@@ -95,6 +96,56 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
"starting Aura client" "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 // 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.) // lock. (We rebuild the parsed CIDRs from the config rather than reaching into the table.)
let cidr_mirror = collect_cidr_rules(&cfg); let cidr_mirror = collect_cidr_rules(&cfg);
+33
View File
@@ -465,6 +465,39 @@ pub struct ClientSection {
/// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`. /// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`.
#[serde(default)] #[serde(default)]
pub circuit: CircuitSection, 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`. /// `[tunnel]` section of `client.toml`.
+1
View File
@@ -14,6 +14,7 @@
pub mod admin; pub mod admin;
pub mod bench; pub mod bench;
pub mod bridges;
pub mod cells; pub mod cells;
pub mod circuit; pub mod circuit;
pub mod client; pub mod client;
+65
View File
@@ -61,6 +61,11 @@ enum Command {
/// and assemble a `client.toml` in a portable bundle directory. See /// and assemble a `client.toml` in a portable bundle directory. See
/// [`init::ProvisionClientOpts`]. /// [`init::ProvisionClientOpts`].
ProvisionClient(ProvisionClientArgs), 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. /// `aura pki ...` subcommands.
@@ -249,6 +254,24 @@ struct ProvisionClientArgs {
force: bool, 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. /// `aura route ...` subcommands.
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum RouteCommand { enum RouteCommand {
@@ -303,9 +326,51 @@ async fn main() -> anyhow::Result<()> {
Command::BenchCrypto => bench::run(), Command::BenchCrypto => bench::run(),
Command::ServerInit(args) => run_server_init(args), Command::ServerInit(args) => run_server_init(args),
Command::ProvisionClient(args) => run_provision_client(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 /// 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 /// 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 /// with a proper message — we just don't want to install a redacting layer on top of a config we
+189
View File
@@ -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);
}
+1 -1
View File
@@ -16,7 +16,7 @@ mod store;
pub use ca::{AuraCa, IssuedCert}; pub use ca::{AuraCa, IssuedCert};
pub use cert::AuraCertVerifier; pub use cert::AuraCertVerifier;
pub use store::CrlStore; pub use store::{sign_ecdsa_p256, verify_ecdsa_p256, CrlStore};
/// Errors produced by the Aura PKI. /// Errors produced by the Aura PKI.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+18 -5
View File
@@ -156,10 +156,7 @@ impl CrlStore {
let sig_text = text[idx + marker.len()..].trim(); let sig_text = text[idx + marker.len()..].trim();
let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?; let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?;
let pubkey = ca_public_key_from_pem(ca_cert_pem) verify_ecdsa_p256(ca_cert_pem, body.as_bytes(), &signature)
.context("loading CA public key for CRL verification")?;
UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice())
.verify(body.as_bytes(), &signature)
.map_err(|_| anyhow!("signed CRL signature did not verify"))?; .map_err(|_| anyhow!("signed CRL signature did not verify"))?;
// Parse the inner body. Skip the magic line, then keep non-empty / non-comment lines. // 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 /// 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. /// 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"]) 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"))?; .ok_or_else(|| anyhow!("no PKCS#8 private-key block in CA key PEM"))?;
let rng = ring::rand::SystemRandom::new(); 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()) 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. /// 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>> { fn ca_public_key_from_pem(ca_cert_pem: &str) -> anyhow::Result<Vec<u8>> {
let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"]) let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"])
+24 -7
View File
@@ -437,6 +437,17 @@ aura status
`Session` (поле `_adapter` в `AuraTun` держит адаптер живым на всё время сессии). `Session` (поле `_adapter` в `AuraTun` держит адаптер живым на всё время сессии).
-**Cross-compile.** Весь workspace проверен под `cargo check --target -**Cross-compile.** Весь workspace проверен под `cargo check --target
x86_64-pc-windows-gnu` без warnings. 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, - **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound,
по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только
десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт. десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт.
- **Bridge-discovery без хардкода IP в конфиге** — план v3.3. Сейчас `[client] bridges` - **Bridge-discovery через push без рестарта клиента** — частично реализовано в v3.3:
хардкодит список запасных IP; если их все заблокируют (включая российские entry-узлы из подписанный CA-манифест на диске (`[client.bridges_discovery]`) горячо перечитывается фон-
сценария §7), восстановление требует обновления конфига клиента вручную. таском; админ переподписывает файл и рассылает любым каналом (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-узла и применимого при заходе на запрещённый ресурс через VPN — вопрос юрисдикции exit-узла и применимого
законодательства, не технический. законодательства, не технический.
- **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут - **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут
активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. Сейчас это решается активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. v3.3 решает это в две
через `[client] bridges = [...]` — список запасных российских entry-узлов; клиент пробует их ступени: (а) `[client] bridges = [...]` — статический список запасных entry-узлов, клиент
в случайном порядке при отказе primary. Полноценный bridge-discovery (без хардкода IP в пробует их в случайном порядке при отказе primary; (б) `[client.bridges_discovery] enabled = true`
конфиге) — план v3.3. — клиент горячо перечитывает 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 - **Cell padding не скрывает наличие туннеля.** Constant-size cells устраняют per-packet
size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий
объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами. объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами.