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
15 changes: 13 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
hex = "0.4"
tower-http = { version = "0.6", features = ["trace", "cors"] }
base64 = "0.22"

tempfile = "3"

Expand Down
1 change: 1 addition & 0 deletions crates/bin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ forge-auth = { workspace = true }
forge-query = { workspace = true }
clap = { workspace = true }
tokio = { workspace = true }
aws-lc-rs = { workspace = true }
pasetors = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
Expand Down
8 changes: 8 additions & 0 deletions crates/bin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,19 @@ fn cmd_serve(config_path: PathBuf) -> forge_types::Result<()> {
config.bind_address
);

// Derive a 32-byte cursor signing key from the master password.
let cursor_key_hash =
aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, password.as_bytes());
let mut cursor_key = [0u8; 32];
cursor_key.copy_from_slice(cursor_key_hash.as_ref());
let cursor_signer = std::sync::Arc::new(forge_security::CursorSigner::new(&cursor_key));

let app_state = forge_server::AppState {
engine: engine.clone(),
writer,
public_key: public_key.clone(),
policy_engine: policy_engine.clone(),
cursor_signer,
};
let app = forge_server::app(app_state);

Expand Down
32 changes: 29 additions & 3 deletions crates/query/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ pub struct AuthContext {
pub resource: String,
}

/// Escapes a string for use as a Cedar entity ID within double quotes.
///
/// Prevents "Cedar injection" by ensuring that characters like double quotes
/// or backslashes can't be used to break out of the string literal and
/// inject additional policy logic.
fn cedar_escape(raw: &str) -> String {
// Cedar uses standard C-style escaping for strings.
raw.replace('\\', "\\\\").replace('"', "\\\"")
}

impl AuthContext {
/// Creates a new AuthContext.
pub fn new(
Expand All @@ -48,9 +58,9 @@ impl AuthContext {
/// strings can't be wrangled into valid Cedar `EntityUid`s. For instance,
/// if some client sends over malicious characters that Cedar outright rejects in entity IDs.
pub fn to_cedar_request(&self) -> Result<Request> {
let principal_eid = format!(r#"ForgeDB::User::"{}""#, self.principal);
let action_eid = format!(r#"ForgeDB::Action::"{}""#, self.action);
let resource_eid = format!(r#"ForgeDB::Document::"{}""#, self.resource);
let principal_eid = format!(r#"ForgeDB::User::"{}""#, cedar_escape(&self.principal));
let action_eid = format!(r#"ForgeDB::Action::"{}""#, cedar_escape(&self.action));
let resource_eid = format!(r#"ForgeDB::Document::"{}""#, cedar_escape(&self.resource));

let p_uid: EntityUid = principal_eid
.parse()
Expand Down Expand Up @@ -104,4 +114,20 @@ mod tests {
let req = ctx.to_cedar_request().unwrap();
assert!(req.principal().is_some());
}

#[test]
fn cedar_injection_attempt_is_escaped() {
// Attempting to inject a bypass: "principal,action,resource); //"
let malicious = r#"alice", action, resource); //"#;
let ctx = AuthContext::new(malicious, "Read", "docs/1");
let req = ctx
.to_cedar_request()
.expect("Escaping should make this a valid, if weird, ID");

// The key check: Is the principal still exactly what we passed, but quoted?
// Cedar .to_string() will show the escaped version.
let p_str = req.principal().unwrap().to_string();
assert!(p_str.contains(r#"alice\", action, resource); //"#));
assert!(p_str.starts_with(r#"ForgeDB::User::"#));
}
}
34 changes: 30 additions & 4 deletions crates/query/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ pub struct PolicyEngine {
schema: Schema,
}

impl std::fmt::Debug for PolicyEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PolicyEngine")
.field("policies", &self.policies)
.finish_non_exhaustive()
}
}

impl PolicyEngine {
/// Builds a new, validated engine from raw Cedar source code.
///
Expand All @@ -49,10 +57,16 @@ impl PolicyEngine {

let schema = forge_schema()?;

// Normally in a larger production system we'd use the Validator to heavily enforce
// `policies` against `schema` right here. But for v0.2.0? Cedar's standard
// flow actually evaluates them together safely enough. The parser above catches the
// syntax bugs, and the schema dictates shape at runtime.
// Mandatory Validation: catch bugs like typos and type mismatches early.
let validator = cedar_policy::Validator::new(schema.clone());
let output = validator.validate(&policies, cedar_policy::ValidationMode::Strict);
if !output.validation_passed() {
let errors: Vec<String> = output.validation_errors().map(|e| e.to_string()).collect();
return Err(ForgeError::Policy(format!(
"policy validation failed: {}",
errors.join("; ")
)));
}

Ok(Self {
authorizer: Authorizer::new(),
Expand Down Expand Up @@ -169,4 +183,16 @@ mod tests {
let src = r#"permit( principal = "whoops" )"#; // = instead of ==
assert!(PolicyEngine::new(src).is_err());
}

#[test]
fn schema_validation_catches_typos() {
// "owner" is valid, "ownerrr" is not in our schema
let src = r#"
permit(principal, action, resource)
when { resource.ownerrr == "alice" };
"#;
let err = PolicyEngine::new(src).unwrap_err();
assert!(err.to_string().contains("validation failed"));
assert!(err.to_string().contains("ownerrr"));
}
}
2 changes: 2 additions & 0 deletions crates/security/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ rustls = { workspace = true }
rustls-pemfile = { workspace = true }
rcgen = { workspace = true }
tracing = { workspace = true }
aws-lc-rs = { workspace = true }
base64 = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
111 changes: 111 additions & 0 deletions crates/security/src/cursor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! HMAC-signed opaque cursors for secure pagination.
//!
//! Provides the [`CursorSigner`] which wraps internal database IDs (like B-Tree keys)
//! in a signed, base64-encoded envelope. This prevents clients from guessing
//! or tampering with pagination offsets, as any modification to the cursor
//! results in an invalid signature.

use aws_lc_rs::hmac;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use forge_types::{ForgeError, Result};

/// Signs and verifies pagination cursors using HMAC-SHA256.
pub struct CursorSigner {
key: hmac::Key,
}

impl CursorSigner {
/// Create a new signer from a raw 32-byte key.
pub fn new(key_bytes: &[u8; 32]) -> Self {
Self {
key: hmac::Key::new(hmac::HMAC_SHA256, key_bytes),
}
}

/// Wraps a raw document ID into an opaque, signed string.
///
/// The output format is: `URL_SAFE_BASE64(HMAC_SIG || RAW_ID)`
pub fn encode(&self, id: &str) -> String {
let sig = hmac::sign(&self.key, id.as_bytes());
let sig_bytes = sig.as_ref();

let mut combined = Vec::with_capacity(sig_bytes.len() + id.len());
combined.extend_from_slice(sig_bytes);
combined.extend_from_slice(id.as_bytes());

URL_SAFE_NO_PAD.encode(combined)
}

/// Verifies the signature and extracts the raw document ID.
///
/// # Errors
///
/// Returns [`ForgeError::Security`] if the signature is invalid or
/// the base64 decoding fails.
pub fn decode(&self, opaque: &str) -> Result<String> {
let decoded = URL_SAFE_NO_PAD
.decode(opaque)
.map_err(|_| ForgeError::Security("invalid cursor encoding".into()))?;

// HMAC-SHA256 signature is exactly 32 bytes
if decoded.len() < 32 {
return Err(ForgeError::Security("malformed cursor payload".into()));
}

let (sig_bytes, id_bytes) = decoded.split_at(32);

// Verify the signature. constant-time comparison is handled by aws-lc-rs.
if hmac::verify(&self.key, id_bytes, sig_bytes).is_err() {
return Err(ForgeError::Security("cursor signature mismatch".into()));
}

String::from_utf8(id_bytes.to_vec())
.map_err(|_| ForgeError::Security("invalid cursor utf8".into()))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn roundtrip_valid_id() {
let key = [0u8; 32];
let signer = CursorSigner::new(&key);
let original = "item_123";

let opaque = signer.encode(original);
let decoded = signer.decode(&opaque).unwrap();

assert_eq!(original, decoded);
}

#[test]
fn rejects_tampered_cursor() {
let key = [1u8; 32];
let signer = CursorSigner::new(&key);
let opaque = signer.encode("valid_id");

// Slightly modify the base64 string
let mut tampered = opaque.into_bytes();
if tampered[0] == b'a' {
tampered[0] = b'b';
} else {
tampered[0] = b'a';
}
let tampered_str = String::from_utf8(tampered).unwrap();

assert!(signer.decode(&tampered_str).is_err());
}

#[test]
fn rejects_malicious_id_swap() {
let key = [2u8; 32];
let signer = CursorSigner::new(&key);
let opaque = signer.encode("id_1");

// Try to decode it with a different key (simulating key rotation or attacker guess)
let evil_signer = CursorSigner::new(&[3u8; 32]);
assert!(evil_signer.decode(&opaque).is_err());
}
}
3 changes: 3 additions & 0 deletions crates/security/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ pub mod tls;

pub use certgen::generate_self_signed_cert;
pub use tls::build_server_tls_config;

pub mod cursor;
pub use cursor::CursorSigner;
1 change: 1 addition & 0 deletions crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ hyper-util = { version = "0.1", features = ["server-auto", "tokio", "http1", "ht
pasetors = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }
forge-security = { workspace = true }
rmp-serde = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
Expand Down
Loading