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
146 changes: 146 additions & 0 deletions keep-enclave/enclave/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub enum PolicyRule {
AllowedEventKinds(Vec<u32>),
And(Vec<PolicyRule>),
Or(Vec<PolicyRule>),
MaxFeeSats(u64),
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
Expand All @@ -49,6 +50,7 @@ pub enum PolicyDecision {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyDenyReason {
AmountExceeded,
FeeExceeded,
RateLimitExceeded,
AddressNotAllowed,
AddressBlocked,
Expand All @@ -62,6 +64,7 @@ impl std::fmt::Display for PolicyDenyReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AmountExceeded => write!(f, "amount exceeds limit"),
Self::FeeExceeded => write!(f, "fee exceeds limit"),
Self::RateLimitExceeded => write!(f, "rate limit exceeded"),
Self::AddressNotAllowed => write!(f, "address not in allowlist"),
Self::AddressBlocked => write!(f, "address is blocked"),
Expand All @@ -76,6 +79,7 @@ impl std::fmt::Display for PolicyDenyReason {
pub struct SigningContext<'a> {
pub key_id: &'a str,
pub amount_sats: Option<u64>,
pub fee_sats: Option<u64>,
pub destination: Option<&'a str>,
pub event_kind: Option<u32>,
pub timestamp: u64,
Expand All @@ -101,6 +105,13 @@ impl PolicyEngine {
self.rate_limiter.clear();
}

/// A fee is bounded when no rule sequence can allow it. An amount cap counts
/// only under Deny or RequireApproval. Under Allow, a failed rule is skipped
/// and a later policy can still sign.
pub fn bounds_fee(&self) -> bool {
self.policies.iter().any(|policy| !policy_allows_unbounded_fee(policy))
}

pub fn evaluate(&mut self, ctx: &SigningContext<'_>) -> PolicyDecision {
if self.policies.is_empty() {
return PolicyDecision::Deny(PolicyDenyReason::ExplicitDeny);
Expand Down Expand Up @@ -147,6 +158,11 @@ impl PolicyEngine {
ctx.amount_sats.map(|a| a <= *max).unwrap_or(true)
}

PolicyRule::MaxFeeSats(max) => match ctx.fee_sats {
Some(fee) if fee > *max => return Err(PolicyDenyReason::FeeExceeded),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_ => true,
},

PolicyRule::MaxPerMinute(max) => {
self.rate_limiter.check_minute(ctx.key_id, *max)
}
Expand Down Expand Up @@ -220,6 +236,79 @@ impl PolicyEngine {
}
}

struct FeeEffect {
pass: bool,
fail: bool,
}

fn policy_allows_unbounded_fee(policy: &Policy) -> bool {
for rule in &policy.rules {
let effect = rule_fee_effect(rule);
if effect.fail && matches!(policy.action, PolicyAction::Allow) {
return true;
}
if !effect.pass {
return false;
}
}
true
}

fn rule_fee_effect(rule: &PolicyRule) -> FeeEffect {
match rule {
PolicyRule::MaxFeeSats(_) => FeeEffect {
pass: false,
fail: false,
},
PolicyRule::MaxAmountSats(_) => FeeEffect {
pass: false,
fail: true,
},
PolicyRule::And(rules) => and_fee_effect(rules),
PolicyRule::Or(rules) => or_fee_effect(rules),
_ => FeeEffect {
pass: true,
fail: true,
},
}
}

fn and_fee_effect(rules: &[PolicyRule]) -> FeeEffect {
let mut effect = FeeEffect {
pass: true,
fail: false,
};
for rule in rules {
if !effect.pass {
break;
}
let child = rule_fee_effect(rule);
effect.fail |= child.fail;
effect.pass = child.pass;
}
effect
}

fn or_fee_effect(rules: &[PolicyRule]) -> FeeEffect {
let mut effect = FeeEffect {
pass: false,
fail: false,
};
let mut open = true;
for rule in rules {
if !open {
break;
}
let child = rule_fee_effect(rule);
effect.pass |= child.pass;
open = child.fail;
}
if open {
effect.fail = true;
}
effect
}

impl Default for PolicyEngine {
fn default() -> Self {
Self::new()
Expand All @@ -240,6 +329,7 @@ mod tests {
let ctx = SigningContext {
key_id: "test",
amount_sats: Some(1),
fee_sats: None,
destination: None,
event_kind: None,
timestamp: now(),
Expand All @@ -264,6 +354,7 @@ mod tests {
let ctx_ok = SigningContext {
key_id: "test",
amount_sats: Some(500),
fee_sats: None,
destination: None,
event_kind: None,
timestamp: now(),
Expand All @@ -273,6 +364,7 @@ mod tests {
let ctx_fail = SigningContext {
key_id: "test",
amount_sats: Some(2000),
fee_sats: None,
destination: None,
event_kind: None,
timestamp: now(),
Expand All @@ -299,6 +391,7 @@ mod tests {
let ctx_ok = SigningContext {
key_id: "test",
amount_sats: None,
fee_sats: None,
destination: Some("bc1allowed"),
event_kind: None,
timestamp: now(),
Expand All @@ -308,6 +401,7 @@ mod tests {
let ctx_fail = SigningContext {
key_id: "test",
amount_sats: None,
fee_sats: None,
destination: Some("bc1blocked"),
event_kind: None,
timestamp: now(),
Expand All @@ -317,4 +411,56 @@ mod tests {
PolicyDecision::Deny(_)
));
}

fn policy(action: PolicyAction, rules: Vec<PolicyRule>) -> Policy {
Policy {
name: "p".into(),
rules,
action,
}
}

fn engine(policies: Vec<Policy>) -> PolicyEngine {
let mut engine = PolicyEngine::new();
engine.set_policies(PolicyConfig { policies });
engine
}

#[test]
fn an_amount_cap_bounds_the_fee_only_when_breaking_it_stops_the_spend() {
let cap = PolicyRule::MaxAmountSats(1_000);
assert!(!engine(vec![policy(PolicyAction::Allow, vec![cap.clone()])]).bounds_fee());
assert!(engine(vec![policy(PolicyAction::Deny, vec![cap.clone()])]).bounds_fee());
assert!(engine(vec![policy(PolicyAction::RequireApproval, vec![cap])]).bounds_fee());
}

#[test]
fn a_fee_cap_bounds_the_fee_only_when_evaluation_reaches_it() {
let fee = PolicyRule::MaxFeeSats(1_000);
let listed = PolicyRule::AddressAllowlist(vec!["bc1".into()]);
assert!(engine(vec![policy(PolicyAction::Allow, vec![fee.clone()])]).bounds_fee());
assert!(!engine(vec![policy(
PolicyAction::Allow,
vec![listed.clone(), fee.clone()],
)])
.bounds_fee());
assert!(engine(vec![policy(PolicyAction::Allow, vec![fee, listed.clone()])]).bounds_fee());
assert!(!engine(vec![
policy(PolicyAction::Allow, vec![PolicyRule::MaxAmountSats(1_000)]),
policy(PolicyAction::Deny, vec![listed]),
])
.bounds_fee());
}

#[test]
fn max_fee_keeps_the_previous_rule_indexes() {
let allow = postcard::to_allocvec(&PolicyRule::AddressAllowlist(vec!["a".into()])).unwrap();
assert_eq!(allow[0], 4);
match postcard::from_bytes::<PolicyRule>(&[4, 1, 1, b'a']).unwrap() {
PolicyRule::AddressAllowlist(list) => assert_eq!(list, ["a"]),
other => panic!("expected an allowlist, got {other:?}"),
}
let fee = postcard::to_allocvec(&PolicyRule::MaxFeeSats(9)).unwrap();
assert_eq!(fee[0], 11);
}
}
93 changes: 93 additions & 0 deletions keep-enclave/enclave/src/vsock_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ impl VsockServer {
let ctx = SigningContext {
key_id: &req.key_id,
amount_sats: req.amount_sats,
fee_sats: None,
destination: req.destination.as_deref(),
event_kind: req.event_kind,
timestamp,
Expand Down Expand Up @@ -529,6 +530,12 @@ impl VsockServer {
total_spend: u64,
timestamp: u64,
) -> Option<EnclaveResponse> {
if analysis.fee_sats > 0 && !self.policy_engine.bounds_fee() {
return Some(EnclaveResponse::Error {
code: ErrorCode::PolicyDenied,
message: "fee is not bounded".into(),
});
}
let named: Vec<String> = analysis
.destinations
.iter()
Expand All @@ -547,6 +554,7 @@ impl VsockServer {
let ctx = SigningContext {
key_id,
amount_sats: Some(total_spend),
fee_sats: Some(analysis.fee_sats),
destination: addr,
event_kind: None,
timestamp,
Expand Down Expand Up @@ -786,6 +794,91 @@ mod tests {
assert!(denied(&sign(&mut server, &psbt)));
}

#[test]
fn an_allowlist_alone_does_not_authorize_a_fee() {
let to = p2tr(other_key());
let mut server = server(vec![PolicyRule::AddressAllowlist(vec![address(&to)])]);
let psbt = psbt(&[(p2tr(x_only(&SECRET)), 50_000)], &[(to, 40_000)]);
assert!(denied(&sign(&mut server, &psbt)));
}

#[test]
fn a_fee_cap_allows_a_small_fee_and_refuses_a_large_one() {
let to = p2tr(other_key());
let mut server = server(vec![
PolicyRule::AddressAllowlist(vec![address(&to)]),
PolicyRule::MaxFeeSats(1_000),
]);
let small = psbt(&[(p2tr(x_only(&SECRET)), 50_000)], &[(to.clone(), 49_000)]);
match sign(&mut server, &small) {
EnclaveResponse::SignedPsbt { signed_inputs, .. } => assert_eq!(signed_inputs, 1),
other => panic!("expected a signed PSBT, got {other:?}"),
}
let large = psbt(&[(p2tr(x_only(&SECRET)), 50_000)], &[(to, 40_000)]);
assert!(denied(&sign(&mut server, &large)));
}

#[test]
fn an_allow_amount_cap_does_not_bound_the_fee() {
let to = p2tr(other_key());
let mut server = bare();
server.handle_set_policy(PolicyConfig {
policies: vec![
Policy {
name: "cap".into(),
rules: vec![PolicyRule::MaxAmountSats(1_000)],
action: PolicyAction::Allow,
},
Policy {
name: "dest".into(),
rules: vec![PolicyRule::AddressAllowlist(vec![address(&to)])],
action: PolicyAction::Deny,
},
],
});
server.signer.import_key("k", &SECRET).unwrap();
let psbt = psbt(&[(p2tr(x_only(&SECRET)), 50_000)], &[(to, 1)]);
assert!(denied(&sign(&mut server, &psbt)));
}

#[test]
fn a_fee_cap_after_a_skippable_rule_does_not_bound_the_fee() {
let to = p2tr(other_key());
let other = p2tr(x_only(&[9u8; 32]));
let mut server = bare();
server.handle_set_policy(PolicyConfig {
policies: vec![
Policy {
name: "cap".into(),
rules: vec![
PolicyRule::AddressAllowlist(vec![address(&other)]),
PolicyRule::MaxFeeSats(1_000),
],
action: PolicyAction::Allow,
},
Policy {
name: "dest".into(),
rules: vec![PolicyRule::AddressAllowlist(vec![address(&to)])],
action: PolicyAction::Deny,
},
],
});
server.signer.import_key("k", &SECRET).unwrap();
let psbt = psbt(&[(p2tr(x_only(&SECRET)), 50_000)], &[(to, 1)]);
assert!(denied(&sign(&mut server, &psbt)));
}

#[test]
fn an_or_with_an_allowlist_does_not_bound_the_fee() {
let to = p2tr(other_key());
let mut server = server(vec![PolicyRule::Or(vec![
PolicyRule::AddressAllowlist(vec![address(&to)]),
PolicyRule::MaxAmountSats(1_000_000),
])]);
let psbt = psbt(&[(p2tr(x_only(&SECRET)), 50_000)], &[(to, 40_000)]);
assert!(denied(&sign(&mut server, &psbt)));
}

#[test]
fn the_policy_applies_when_only_the_fee_leaves() {
let (change, fp, path) = child(&SECRET, Network::Bitcoin, "m/86'/0'/0'/1/0");
Expand Down
14 changes: 14 additions & 0 deletions keep-enclave/host/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ pub enum PolicyRule {
AllowedEventKinds(Vec<u32>),
And(Vec<PolicyRule>),
Or(Vec<PolicyRule>),
MaxFeeSats(u64),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -159,3 +160,16 @@ pub enum PolicyDecision {

pub const VSOCK_PORT: u32 = 5000;
pub const ENCLAVE_CID: u32 = 16;

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

#[test]
fn max_fee_keeps_the_previous_rule_indexes() {
let allow = postcard::to_allocvec(&PolicyRule::AddressAllowlist(vec!["a".into()])).unwrap();
assert_eq!(allow[0], 4);
let fee = postcard::to_allocvec(&PolicyRule::MaxFeeSats(9)).unwrap();
assert_eq!(fee[0], 11);
}
}
Loading