1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! Sandbox capabilities: what the manager mints and the agent verifies.
//!
//! Lives here because both sides need identical rules, and a mismatch between minting and
//! verification is a security bug that only shows up as "it works" until it does not.
//!
//! A capability is scoped to **one sandbox and one operation class**. Provider ids and hostnames
//! are guessable, so neither is authorisation.
use serde::{Deserialize, Serialize};
use crate::error::{ErrorData, Result};
use alien_error::AlienError;
/// What a capability permits. Deliberately coarse: a class, not a method list, so adding a
/// method cannot silently widen an already-minted capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum SandboxOperationClass {
/// Running commands and moving files inside an existing sandbox
Execute,
/// Creating and terminating sandboxes
Manage,
}
/// The claims an agent checks before doing anything.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SandboxCapabilityClaims {
/// Sandbox this capability addresses
pub session_id: String,
/// Operation class permitted
pub operation: SandboxOperationClass,
/// Lifecycle generation the sandbox started under
pub generation: u64,
/// Unix seconds after which the capability is void
pub expires_at: i64,
/// Key that signed it, so rotation can retain an overlapping ring
pub key_id: String,
}
/// What the agent knows about itself, established at sandbox start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxSessionIdentity {
/// The sandbox this agent serves
pub session_id: String,
/// The generation it started under
pub generation: u64,
}
impl SandboxCapabilityClaims {
/// Verifies claims against the agent's own identity and the current time.
///
/// Signature checking happens before this — an unsigned claim never reaches here. What this
/// enforces is everything a valid signature does *not* prove: that the capability is for
/// this sandbox, this generation, this operation, and still in date.
pub fn verify(
&self,
identity: &SandboxSessionIdentity,
required: SandboxOperationClass,
now_unix: i64,
) -> Result<()> {
// Sandbox first: a capability for another sandbox is the case that matters most, and
// reporting expiry for it would tell an attacker the wrong thing.
if self.session_id != identity.session_id {
return Err(refused("this capability addresses a different sandbox"));
}
// A running agent cannot observe a generation changed outside it, so terminate fences
// ingress and this check catches anything that slipped through before the fence closed.
if self.generation != identity.generation {
return Err(refused(
"this capability was issued for a previous lifecycle generation",
));
}
if self.expires_at <= now_unix {
return Err(refused("this capability has expired"));
}
// Execute does not imply Manage. Manage does not imply Execute either: the whole point
// of the split is that an app which only runs code cannot terminate sandboxes.
if self.operation != required {
return Err(refused(
"this capability does not permit this operation class",
));
}
Ok(())
}
}
fn refused(reason: &str) -> AlienError<ErrorData> {
AlienError::new(ErrorData::SandboxCapabilityRefused {
reason: reason.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_000_000;
fn identity() -> SandboxSessionIdentity {
SandboxSessionIdentity {
session_id: "s1".to_string(),
generation: 2,
}
}
fn claims() -> SandboxCapabilityClaims {
SandboxCapabilityClaims {
session_id: "s1".to_string(),
operation: SandboxOperationClass::Execute,
generation: 2,
expires_at: NOW + 300,
key_id: "k1".to_string(),
}
}
#[test]
fn a_matching_capability_is_accepted() {
claims()
.verify(&identity(), SandboxOperationClass::Execute, NOW)
.expect("a capability for this sandbox, generation and class is valid");
}
/// The case that matters most: provider ids are guessable, so a capability
/// minted for one sandbox must be useless against another.
#[test]
fn a_capability_for_another_session_is_refused() {
let mut other = claims();
other.session_id = "s2".to_string();
let error = other
.verify(&identity(), SandboxOperationClass::Execute, NOW)
.expect_err("sandbox B must not accept sandbox A's capability");
assert!(error.to_string().contains("different sandbox"));
}
/// Terminate bumps the generation; anything minted before is void even if
/// its signature and expiry are still good.
#[test]
fn a_capability_from_a_previous_generation_is_refused() {
let mut stale = claims();
stale.generation = 1;
let error = stale
.verify(&identity(), SandboxOperationClass::Execute, NOW)
.expect_err("a previous generation must be refused");
assert!(error.to_string().contains("generation"));
}
#[test]
fn an_expired_capability_is_refused() {
let mut expired = claims();
expired.expires_at = NOW;
expired
.verify(&identity(), SandboxOperationClass::Execute, NOW)
.expect_err("expiry is inclusive: a capability expiring now is already void");
}
/// The split only means something if it holds in both directions. An execute-only app must
/// not terminate sandboxes, and a manage-only component must not read sandbox contents.
#[test]
fn operation_classes_do_not_imply_each_other() {
claims()
.verify(&identity(), SandboxOperationClass::Manage, NOW)
.expect_err("execute must not permit manage");
let mut manage = claims();
manage.operation = SandboxOperationClass::Manage;
manage
.verify(&identity(), SandboxOperationClass::Execute, NOW)
.expect_err("manage must not permit execute");
}
/// Checked before expiry on purpose: telling a caller "expired" for a capability that was
/// never theirs leaks which sandboxes exist.
#[test]
fn a_wrong_session_is_reported_as_wrong_session_even_when_also_expired() {
let mut wrong = claims();
wrong.session_id = "s2".to_string();
wrong.expires_at = NOW - 1;
let error = wrong
.verify(&identity(), SandboxOperationClass::Execute, NOW)
.expect_err("refused");
assert!(
error.to_string().contains("different sandbox"),
"the reason must not reveal that some other sandbox's capability had expired"
);
}
#[test]
fn claims_round_trip_so_minting_and_verification_cannot_drift() {
let json = serde_json::to_string(&claims()).expect("serializes");
let restored: SandboxCapabilityClaims = serde_json::from_str(&json).expect("deserializes");
assert_eq!(claims(), restored);
}
}