aa_security/sdk_identity.rs
1//! SDK identity classification (AAASM-3621).
2//!
3//! A pure, no-I/O classifier that compares the SDK identity an agent *claimed*
4//! on the wire (the **observed** signal, attacker-controlled) against the
5//! identity an authenticated channel *established* (the **verified** signal,
6//! from the AAASM-3569 IPC handshake) and produces an [`SdkIdentityVerdict`].
7//!
8//! ## Why this is its own pure module
9//!
10//! The trusted enforcement layers must **never trust an SDK-supplied identity**
11//! at face value — they recompute the verdict from inputs they control
12//! (extends the AAASM-2569 no-trust-marker principle). Centralising that
13//! decision here, with zero I/O and no `tokio` / `aa-proto` dependency, keeps
14//! the forged / downgraded logic exhaustively unit-testable without a running
15//! runtime. The module mirrors the leaf placement of [`scanner`](crate::scanner)
16//! and [`redaction`](crate::redaction).
17//!
18//! ## What a "version" is here
19//!
20//! Versions are compared as dot-separated numeric components (a simple
21//! `semver`-ish ordering) without pulling in a `semver` crate — the leaf crate
22//! stays dependency-light. Non-numeric / malformed components fail closed:
23//! an unparseable observed version that has a minimum to clear is treated as a
24//! downgrade rather than silently passing.
25
26/// The SDK identity an agent **claimed** on the wire.
27///
28/// This is the *observed* (untrusted) channel: it is read out of the
29/// attacker-controlled `AuditEvent.labels` map by the runtime ingest stage
30/// (AAASM-3625). Nothing downstream may grant trust based on these values
31/// alone — they exist only to be recomputed against a [`VerifiedSdkIdentity`].
32#[derive(Debug, Clone, PartialEq, Eq, Default)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub struct ObservedSdkIdentity {
35 /// Whether the SDK presented an identity signal at all (the reserved
36 /// `aa.sdk_version` label was present). `false` means the agent connected
37 /// without claiming any SDK identity — a stripped / bypassed SDK.
38 pub present: bool,
39 /// The SDK version string the agent claimed, when present.
40 pub version: Option<String>,
41}
42
43impl ObservedSdkIdentity {
44 /// An observed identity that presented a version claim.
45 pub fn present(version: impl Into<String>) -> Self {
46 Self {
47 present: true,
48 version: Some(version.into()),
49 }
50 }
51
52 /// An observed identity with no SDK signal at all (stripped / bypassed SDK).
53 pub fn missing() -> Self {
54 Self {
55 present: false,
56 version: None,
57 }
58 }
59}
60
61/// The SDK identity an authenticated channel **established**.
62///
63/// This is the *verified* counterpart, populated from the AAASM-3569 IPC
64/// handshake (which authenticates the agent's Ed25519 identity bound to its
65/// configured agent id). When no authenticated version reference is available
66/// — no handshake completed, or the handshake carries no version — the fields
67/// are `None` and the classifier returns [`SdkIdentityVerdict::Unverifiable`]
68/// rather than guessing.
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub struct VerifiedSdkIdentity {
72 /// The SDK version established over the authenticated channel, when the
73 /// channel carries one. `None` when only presence (not a version) was
74 /// authenticated, or when no handshake completed.
75 pub version: Option<String>,
76}
77
78impl VerifiedSdkIdentity {
79 /// No verified signal is available (no handshake / unsupported). Classifies
80 /// version comparisons as [`SdkIdentityVerdict::Unverifiable`].
81 pub fn none() -> Self {
82 Self { version: None }
83 }
84
85 /// A verified identity carrying an authenticated version reference.
86 pub fn with_version(version: impl Into<String>) -> Self {
87 Self {
88 version: Some(version.into()),
89 }
90 }
91
92 /// Whether any verified reference is available to compare against.
93 pub fn is_available(&self) -> bool {
94 self.version.is_some()
95 }
96}
97
98/// The server-recomputed verdict on an agent's presented SDK identity.
99///
100/// Ordered so the most security-relevant tampering signals are distinct enum
101/// variants the audit / metric layer (AAASM-3637) can label by.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
104pub enum SdkIdentityVerdict {
105 /// Identity present, matches the verified reference (or no minimum to
106 /// clear), and is at or above the minimum supported version.
107 Ok,
108 /// No SDK identity was presented — a stripped / bypassed SDK.
109 Missing,
110 /// A version below the minimum supported version was presented (an old /
111 /// downgraded SDK build).
112 VersionDowngraded,
113 /// The observed version contradicts the version established over the
114 /// authenticated channel — an impersonation / forgery attempt.
115 Forged,
116 /// An identity was presented but there is no verified reference to compare
117 /// it against (handshake absent / unsupported). Not itself a tamper signal.
118 Unverifiable,
119}
120
121impl SdkIdentityVerdict {
122 /// A stable lowercase label for metric / audit dimensions. Never carries
123 /// any agent-supplied free text.
124 pub fn as_str(&self) -> &'static str {
125 match self {
126 SdkIdentityVerdict::Ok => "ok",
127 SdkIdentityVerdict::Missing => "missing",
128 SdkIdentityVerdict::VersionDowngraded => "downgraded",
129 SdkIdentityVerdict::Forged => "forged",
130 SdkIdentityVerdict::Unverifiable => "unverifiable",
131 }
132 }
133
134 /// Whether this verdict is a tamper signal worth a distinct audit record
135 /// and metric. `Ok` and `Unverifiable` are not — they are the steady-state
136 /// outcomes for a well-behaved or not-yet-attested SDK.
137 pub fn is_suspected_tamper(&self) -> bool {
138 matches!(
139 self,
140 SdkIdentityVerdict::Missing | SdkIdentityVerdict::VersionDowngraded | SdkIdentityVerdict::Forged
141 )
142 }
143}
144
145/// Recompute the SDK-identity verdict from inputs the server controls.
146///
147/// Precedence (most severe first):
148/// 1. **`Missing`** — the agent presented no SDK identity at all.
149/// 2. **`Forged`** — a verified version reference exists and the observed
150/// version contradicts it.
151/// 3. **`VersionDowngraded`** — the observed version is below
152/// `min_supported_version` (or is unparseable while a minimum is required).
153/// 4. **`Unverifiable`** — an identity was presented but there is no verified
154/// reference and no minimum to clear.
155/// 5. **`Ok`** — otherwise.
156///
157/// `min_supported_version` is `None` when the operator imposes no floor.
158pub fn classify(
159 observed: &ObservedSdkIdentity,
160 verified: &VerifiedSdkIdentity,
161 min_supported_version: Option<&str>,
162) -> SdkIdentityVerdict {
163 if !observed.present {
164 return SdkIdentityVerdict::Missing;
165 }
166
167 // Forgery: the claim contradicts what the authenticated channel established.
168 if let (Some(claimed), Some(attested)) = (observed.version.as_deref(), verified.version.as_deref()) {
169 if !versions_equal(claimed, attested) {
170 return SdkIdentityVerdict::Forged;
171 }
172 }
173
174 // Downgrade: below the operator-configured minimum.
175 if let Some(min) = min_supported_version {
176 match observed.version.as_deref() {
177 Some(claimed) if version_at_least(claimed, min) => {}
178 // A claimed-but-unparseable or below-minimum version is a downgrade
179 // (fail closed — never pass an unparseable version against a floor).
180 _ => return SdkIdentityVerdict::VersionDowngraded,
181 }
182 }
183
184 // Present, consistent with any verified reference, but no verified reference
185 // and no floor cleared by comparison: nothing left to attest against.
186 if !verified.is_available() && min_supported_version.is_none() {
187 return SdkIdentityVerdict::Unverifiable;
188 }
189
190 SdkIdentityVerdict::Ok
191}
192
193/// Compare two dot-separated numeric version strings for equality, padding the
194/// shorter with implicit zero components (`"1.2" == "1.2.0"`).
195fn versions_equal(a: &str, b: &str) -> bool {
196 matches!(compare_versions(a, b), Some(std::cmp::Ordering::Equal))
197}
198
199/// `true` when `version >= min`. An unparseable `version` or `min` yields
200/// `false` (fail closed against the floor).
201fn version_at_least(version: &str, min: &str) -> bool {
202 matches!(
203 compare_versions(version, min),
204 Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
205 )
206}
207
208/// Compare two dot-separated numeric versions component-by-component.
209///
210/// Returns `None` when either side has a non-numeric component, so callers can
211/// fail closed on malformed input rather than treating it as comparable.
212fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
213 let pa = parse_components(a)?;
214 let pb = parse_components(b)?;
215 let len = pa.len().max(pb.len());
216 for i in 0..len {
217 let ca = pa.get(i).copied().unwrap_or(0);
218 let cb = pb.get(i).copied().unwrap_or(0);
219 match ca.cmp(&cb) {
220 std::cmp::Ordering::Equal => continue,
221 other => return Some(other),
222 }
223 }
224 Some(std::cmp::Ordering::Equal)
225}
226
227/// Parse `"1.2.3"` into `[1, 2, 3]`. Returns `None` on any non-numeric or empty
228/// component so malformed versions are not silently treated as comparable.
229fn parse_components(v: &str) -> Option<Vec<u64>> {
230 if v.is_empty() {
231 return None;
232 }
233 v.split('.').map(|c| c.parse::<u64>().ok()).collect()
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn missing_when_not_present() {
242 let observed = ObservedSdkIdentity::missing();
243 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), Some("1.0.0"));
244 assert_eq!(verdict, SdkIdentityVerdict::Missing);
245 }
246
247 #[test]
248 fn missing_takes_precedence_over_a_verified_reference() {
249 // No identity presented at all, even if a verified version exists.
250 let observed = ObservedSdkIdentity::missing();
251 let verified = VerifiedSdkIdentity::with_version("2.0.0");
252 assert_eq!(classify(&observed, &verified, None), SdkIdentityVerdict::Missing);
253 }
254
255 #[test]
256 fn forged_when_observed_version_mismatches_verified() {
257 let observed = ObservedSdkIdentity::present("9.9.9");
258 let verified = VerifiedSdkIdentity::with_version("1.2.3");
259 assert_eq!(classify(&observed, &verified, None), SdkIdentityVerdict::Forged);
260 }
261
262 #[test]
263 fn forged_beats_downgrade_when_both_apply() {
264 // Observed contradicts the verified reference AND is below the floor:
265 // the impersonation signal (Forged) is the more severe verdict.
266 let observed = ObservedSdkIdentity::present("0.1.0");
267 let verified = VerifiedSdkIdentity::with_version("2.0.0");
268 assert_eq!(
269 classify(&observed, &verified, Some("1.0.0")),
270 SdkIdentityVerdict::Forged
271 );
272 }
273
274 #[test]
275 fn ok_when_observed_matches_verified() {
276 let observed = ObservedSdkIdentity::present("1.2.3");
277 let verified = VerifiedSdkIdentity::with_version("1.2.3");
278 assert_eq!(classify(&observed, &verified, None), SdkIdentityVerdict::Ok);
279 }
280
281 #[test]
282 fn ok_when_observed_matches_verified_with_zero_padding() {
283 // "1.2" and "1.2.0" are equal under implicit-zero padding, so a match
284 // is not flagged as forged.
285 let observed = ObservedSdkIdentity::present("1.2");
286 let verified = VerifiedSdkIdentity::with_version("1.2.0");
287 assert_eq!(classify(&observed, &verified, None), SdkIdentityVerdict::Ok);
288 }
289
290 #[test]
291 fn version_downgraded_below_minimum() {
292 let observed = ObservedSdkIdentity::present("0.9.0");
293 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), Some("1.0.0"));
294 assert_eq!(verdict, SdkIdentityVerdict::VersionDowngraded);
295 }
296
297 #[test]
298 fn ok_at_exactly_the_minimum() {
299 let observed = ObservedSdkIdentity::present("1.0.0");
300 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), Some("1.0.0"));
301 assert_eq!(verdict, SdkIdentityVerdict::Ok);
302 }
303
304 #[test]
305 fn ok_above_the_minimum() {
306 let observed = ObservedSdkIdentity::present("1.5.2");
307 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), Some("1.0.0"));
308 assert_eq!(verdict, SdkIdentityVerdict::Ok);
309 }
310
311 #[test]
312 fn unparseable_version_against_a_floor_fails_closed_as_downgrade() {
313 // A non-numeric claimed version must never silently pass a floor.
314 let observed = ObservedSdkIdentity::present("not-a-version");
315 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), Some("1.0.0"));
316 assert_eq!(verdict, SdkIdentityVerdict::VersionDowngraded);
317 }
318
319 #[test]
320 fn unverifiable_when_present_but_no_verified_reference_and_no_floor() {
321 let observed = ObservedSdkIdentity::present("1.2.3");
322 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), None);
323 assert_eq!(verdict, SdkIdentityVerdict::Unverifiable);
324 }
325
326 #[test]
327 fn present_without_a_claimed_version_and_no_floor_is_unverifiable() {
328 // present == true but no version string and nothing to compare against.
329 let observed = ObservedSdkIdentity {
330 present: true,
331 version: None,
332 };
333 let verdict = classify(&observed, &VerifiedSdkIdentity::none(), None);
334 assert_eq!(verdict, SdkIdentityVerdict::Unverifiable);
335 }
336
337 #[test]
338 fn as_str_labels_are_stable() {
339 assert_eq!(SdkIdentityVerdict::Ok.as_str(), "ok");
340 assert_eq!(SdkIdentityVerdict::Missing.as_str(), "missing");
341 assert_eq!(SdkIdentityVerdict::VersionDowngraded.as_str(), "downgraded");
342 assert_eq!(SdkIdentityVerdict::Forged.as_str(), "forged");
343 assert_eq!(SdkIdentityVerdict::Unverifiable.as_str(), "unverifiable");
344 }
345
346 // ── AAASM-3666: with a handshake-verified version, downgrade is now
347 // classifiable instead of resolving to `Unverifiable` ──────────────────
348
349 #[test]
350 fn downgrade_flagged_against_a_verified_version_and_floor() {
351 // The authenticated channel established 2.0.0, the operator floor is
352 // 1.5.0, and the agent claims an old 1.0.0 build. Before AAASM-3666 the
353 // handshake carried no version, so the verified reference was absent and
354 // this resolved to `Unverifiable`; now it is a clean `VersionDowngraded`.
355 let observed = ObservedSdkIdentity::present("1.0.0");
356 let verified = VerifiedSdkIdentity::with_version("1.0.0");
357 let verdict = classify(&observed, &verified, Some("1.5.0"));
358 assert_eq!(verdict, SdkIdentityVerdict::VersionDowngraded);
359 }
360
361 #[test]
362 fn ok_when_verified_current_version_clears_the_floor() {
363 // A current, handshake-verified version at/above the floor is OK — the
364 // happy path AAASM-3666 enables.
365 let observed = ObservedSdkIdentity::present("2.0.0");
366 let verified = VerifiedSdkIdentity::with_version("2.0.0");
367 assert_eq!(classify(&observed, &verified, Some("1.5.0")), SdkIdentityVerdict::Ok);
368 }
369
370 #[test]
371 fn forged_when_observed_contradicts_the_verified_version() {
372 // The authenticated channel proved 2.0.0 but the agent's audit labels
373 // claim a different version — an impersonation/forgery, now detectable
374 // because the verified reference is no longer absent.
375 let observed = ObservedSdkIdentity::present("1.0.0");
376 let verified = VerifiedSdkIdentity::with_version("2.0.0");
377 assert_eq!(classify(&observed, &verified, None), SdkIdentityVerdict::Forged);
378 }
379
380 #[test]
381 fn only_missing_downgraded_forged_are_suspected_tamper() {
382 assert!(SdkIdentityVerdict::Missing.is_suspected_tamper());
383 assert!(SdkIdentityVerdict::VersionDowngraded.is_suspected_tamper());
384 assert!(SdkIdentityVerdict::Forged.is_suspected_tamper());
385 assert!(!SdkIdentityVerdict::Ok.is_suspected_tamper());
386 assert!(!SdkIdentityVerdict::Unverifiable.is_suspected_tamper());
387 }
388}