Skip to main content

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}