Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/cli/src/commands/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ pub async fn run(action: AuditAction) -> Result<()> {
let owner = owner
.or_else(|| std::env::var("RELAIS_AUDIT_OWNER").ok())
.unwrap_or_else(|| "relais".into());
let key = AuditKey::load_or_init(&dir, &owner, None)
let passphrase = super::audit_passphrase()?;
let key = AuditKey::load_or_init(&dir, &owner, passphrase.as_deref())
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
println!("audit key ready under {}", dir.join("keys").display());
println!("owner: {owner}");
Expand All @@ -38,7 +39,8 @@ pub async fn run(action: AuditAction) -> Result<()> {
Ok(())
}
AuditAction::Pubkey => {
let key = AuditKey::load_or_init(&dir, "relais", None)
let passphrase = super::audit_passphrase()?;
let key = AuditKey::load_or_init(&dir, "relais", passphrase.as_deref())
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
println!("{}", key.pubkey);
Ok(())
Expand Down
28 changes: 28 additions & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ pub mod vault;

use relais_core::router::Router;

/// Resolve the audit signing-key passphrase (M6). Encrypted at rest by default via
/// `RELAIS_AUDIT_PASSPHRASE`; an unencrypted key is allowed only under
/// `RELAIS_AUDIT_ALLOW_UNENCRYPTED_KEY=1|true` (dev).
#[cfg(feature = "audit")]
pub fn audit_passphrase() -> anyhow::Result<Option<String>> {
if let Ok(p) = std::env::var("RELAIS_AUDIT_PASSPHRASE") {
if !p.is_empty() {
return Ok(Some(p));
}
}
let allow_unencrypted = matches!(
std::env::var("RELAIS_AUDIT_ALLOW_UNENCRYPTED_KEY").as_deref(),
Ok("1") | Ok("true")
);
if allow_unencrypted {
tracing::warn!(
"RELAIS_AUDIT_ALLOW_UNENCRYPTED_KEY is set — the audit signing key is stored UNENCRYPTED (DEV ONLY)"
);
return Ok(None);
}
anyhow::bail!(
"RELAIS_AUDIT_PASSPHRASE is not set; refusing to store the audit signing key unencrypted. \
Set a passphrase, or for local dev only: RELAIS_AUDIT_ALLOW_UNENCRYPTED_KEY=1"
)
}

/// The signet/audit directory: `RELAIS_SIGNET_DIR` or `~/.relais/signet`.
#[cfg(feature = "audit")]
pub fn audit_dir() -> anyhow::Result<std::path::PathBuf> {
Expand Down Expand Up @@ -47,12 +73,14 @@ fn attach_audit(router: Router) -> anyhow::Result<Router> {
};
let dir = audit_dir()?;
let owner = std::env::var("RELAIS_AUDIT_OWNER").unwrap_or_else(|_| "relais".into());
let passphrase = audit_passphrase()?;
match AuditSink::new(AuditConfig {
dir,
owner,
mode,
capacity: 1024,
ack_timeout: std::time::Duration::from_secs(5),
passphrase,
}) {
Ok(sink) => Ok(router.with_audit(sink)),
Err(e) => match mode {
Expand Down
37 changes: 36 additions & 1 deletion crates/core/src/audit/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ pub fn build_action(
params: request,
params_hash: String::new(),
target: base_url.to_string(),
transport: "https".to_string(),
transport: scheme_of(base_url).to_string(),
session,
call_id: Some(call_id),
response_hash: None,
Expand All @@ -93,6 +93,16 @@ pub fn build_action(
}
}

/// The transport scheme of `base_url` (`https`/`http`), so the receipt reflects the
/// real transport security rather than always claiming https (M4).
fn scheme_of(base_url: &str) -> &'static str {
if base_url.to_ascii_lowercase().starts_with("https://") {
"https"
} else {
"http"
}
}

/// Stable short identifier for an `AdapterError` variant (the `kind` in a failure
/// envelope).
fn error_kind(e: &AdapterError) -> &'static str {
Expand Down Expand Up @@ -173,6 +183,31 @@ mod tests {
assert!(a.params_hash.is_empty());
}

#[test]
fn transport_reflects_base_url_scheme() {
let r = Redactor::new();
let ctx = ctx_with_token("t");
let req = build_request(&ctx, &meta(), &r, &[]);
let http = build_action(
&ctx,
req.clone(),
"http://h:8501",
None,
"t".into(),
"c".into(),
);
assert_eq!(http.transport, "http");
let https = build_action(
&ctx,
req,
"https://api.example",
None,
"t".into(),
"c".into(),
);
assert_eq!(https.transport, "https");
}

#[test]
fn response_envelope_ok_and_err() {
let r = Redactor::new();
Expand Down
86 changes: 80 additions & 6 deletions crates/core/src/audit/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,25 @@ impl AuditKey {

/// What an opaque `credential_ref` resolves to, locally. This map is **never**
/// serialized into a receipt/sidecar and is omitted from exports.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredBinding {
pub site: String,
/// Non-reversible, salted fingerprint of the credential secrets, so a rotated
/// credential gets a *different* opaque ref (L2). Empty when no credential.
#[serde(default)]
pub cred_fp: String,
}

/// Persists `kref_… -> CredBinding` at `dir/credential_refs.json`. Minting is
/// idempotent per binding, so a credential keeps one stable opaque ref.
/// idempotent per binding, so a given credential keeps one stable opaque ref.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CredRefStore {
#[serde(skip)]
path: PathBuf,
/// Local-only salt for credential fingerprints (never exported), so a fingerprint
/// isn't brute-forceable across hosts.
#[serde(default)]
salt: Vec<u8>,
map: HashMap<String, CredBinding>,
}

Expand All @@ -104,9 +112,29 @@ impl CredRefStore {
CredRefStore::default()
};
store.path = path;
if store.salt.is_empty() {
let s: [u8; 16] = rand::random();
store.salt = s.to_vec();
}
Ok(store)
}

/// Non-reversible, salted fingerprint of the credential secrets (L2). A rotated
/// credential (different secrets) yields a different fingerprint, hence a new ref.
pub fn fingerprint(&self, secrets: &[String]) -> String {
if secrets.is_empty() {
return String::new(); // no credential → empty fingerprint
}
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&self.salt);
for s in secrets {
h.update(s.as_bytes());
h.update([0u8]); // length-independent separator
Comment on lines +131 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize secrets before hashing credential refs

When the credential is CredentialData::Cookie with multiple cookies, secret_values_of builds this slice from HashMap::values(), whose iteration order is arbitrary across maps/processes. Hashing the secrets in that order means the same unchanged cookie credential can produce a different cred_fp after reload or reconstruction, causing mint to allocate a new credential_ref even without rotation and breaking the intended stable per-credential binding. Sort or otherwise canonicalize the secrets (or include cookie names in a stable order) before feeding them into the fingerprint.

Useful? React with 👍 / 👎.

}
hex::encode(&h.finalize()[..8]) // 16 hex chars
}

/// Return the existing opaque ref for `binding`, or mint, persist and return a
/// new one. Idempotent per binding.
pub fn mint(&mut self, binding: CredBinding) -> Result<String, AuditError> {
Expand Down Expand Up @@ -182,11 +210,22 @@ mod tests {
fn mint_is_idempotent_per_binding_and_persists() {
let dir = tempfile::tempdir().unwrap();
let mut store = CredRefStore::load(dir.path()).unwrap();
let a1 = store.mint(CredBinding { site: "scs".into() }).unwrap();
let a2 = store.mint(CredBinding { site: "scs".into() }).unwrap();
let a1 = store
.mint(CredBinding {
site: "scs".into(),
..Default::default()
})
.unwrap();
let a2 = store
.mint(CredBinding {
site: "scs".into(),
..Default::default()
})
.unwrap();
let b = store
.mint(CredBinding {
site: "github".into(),
..Default::default()
})
.unwrap();
assert_eq!(a1, a2, "same binding → same ref");
Expand All @@ -197,8 +236,38 @@ mod tests {
let reloaded = CredRefStore::load(dir.path()).unwrap();
assert_eq!(
reloaded.map.get(&a1),
Some(&CredBinding { site: "scs".into() })
Some(&CredBinding {
site: "scs".into(),
..Default::default()
})
);
}

#[test]
fn fingerprint_rotates_the_ref() {
let dir = tempfile::tempdir().unwrap();
let mut store = CredRefStore::load(dir.path()).unwrap();
let fp1 = store.fingerprint(&["tok-v1".into()]);
let fp2 = store.fingerprint(&["tok-v2".into()]);
assert_ne!(fp1, fp2, "different secrets → different fingerprint");
assert_eq!(
fp1,
store.fingerprint(&["tok-v1".into()]),
"stable per secret"
);
let r1 = store
.mint(CredBinding {
site: "s".into(),
cred_fp: fp1,
})
.unwrap();
let r2 = store
.mint(CredBinding {
site: "s".into(),
cred_fp: fp2,
})
.unwrap();
assert_ne!(r1, r2, "a rotated credential gets a new opaque ref");
}

#[cfg(unix)]
Expand All @@ -207,7 +276,12 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let mut store = CredRefStore::load(dir.path()).unwrap();
store.mint(CredBinding { site: "scs".into() }).unwrap();
store
.mint(CredBinding {
site: "scs".into(),
..Default::default()
})
.unwrap();
let mode = std::fs::metadata(dir.path().join("credential_refs.json"))
.unwrap()
.permissions()
Expand Down
14 changes: 10 additions & 4 deletions crates/core/src/audit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ pub struct AuditConfig {
pub mode: AuditMode,
pub capacity: usize,
pub ack_timeout: Duration,
/// Passphrase to encrypt the signing key at rest (Argon2id + XChaCha20-Poly1305).
/// `None` stores it unencrypted (dev only) — see M6 / the CLI gate.
pub passphrase: Option<String>,
}

/// The audit sink: owns the single writer task, the opaque credential-ref store and
Expand All @@ -87,7 +90,7 @@ impl AuditSink {
/// Build a sink: load-or-init the gateway key, load the credential-ref store and
/// spawn the single writer task. Must be called within a tokio runtime.
pub fn new(cfg: AuditConfig) -> Result<Self, AuditError> {
let key = AuditKey::load_or_init(&cfg.dir, &cfg.owner, None)?;
let key = AuditKey::load_or_init(&cfg.dir, &cfg.owner, cfg.passphrase.as_deref())?;
let credrefs = CredRefStore::load(&cfg.dir)?;
let writer = spawn_writer(cfg.dir.clone(), key, cfg.capacity);
Ok(Self {
Expand Down Expand Up @@ -117,19 +120,22 @@ impl AuditSink {
t0: DateTime<Utc>,
t1: DateTime<Utc>,
) -> Result<Option<ReceiptHandle>, AuditError> {
let secrets = secret_values_of(&ctx.credentials);

// Mint the opaque credential ref in a tight sync scope; never hold the guard
// across an await.
// across an await. Bind it to a salted fingerprint of the credential so a
// rotation produces a new ref (L2).
let credential_ref = {
let mut store = self
.credrefs
.lock()
.map_err(|_| AuditError::Io("credential-ref store lock poisoned".into()))?;
let cred_fp = store.fingerprint(&secrets);
store.mint(CredBinding {
site: ctx.site.clone(),
cred_fp,
})?
};

let secrets = secret_values_of(&ctx.credentials);
let meta = AuditMeta {
auth_injection: describe_injection(&ctx.credentials),
credential_ref,
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ mod audit_tests {
mode,
capacity: 16,
ack_timeout: Duration::from_secs(5),
passphrase: None,
})
.unwrap()
}
Expand Down
Loading