Skip to main content

openagent_auth_protocol/
message.rs

1// Copyright (c) 2026 L1fe Labs, Inc. — Apache-2.0 OR MIT
2//! Wire message types for the OpenAgent Core Protocol identity flow.
3//!
4//! These types implement the abstract message model defined **normatively**
5//! by the OpenAgent Core Protocol Specification, Section 15
6//! (`openagents/openagent.id/OPENAGENT-CORE-SPEC.md`). This crate does not
7//! define a protocol; it implements one. If a field here disagrees with the
8//! specification, the specification is correct and this file is wrong.
9//!
10//! The identity flow is three messages:
11//!
12//! 1. [`IdentityChallenge`] — server issues a cryptographic challenge
13//! 2. [`IdentityProof`] — agent proves identity by signing the challenge
14//! 3. [`IdentityVerified`] — server confirms identity and issues a session
15
16use serde::{Deserialize, Serialize};
17
18/// The literal `type` value of an [`IdentityChallenge`], per Section 15.2.
19pub const CHALLENGE_TYPE: &str = "openagent-challenge-v1";
20
21/// Server → Agent: a cryptographic challenge (Section 15.2).
22///
23/// The signing payload for the proof is the JCS-canonicalized (RFC 8785)
24/// UTF-8 byte representation of this object — no framing, prefix, or
25/// envelope. Use [`crate::handshake::canonical_challenge_bytes`].
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct IdentityChallenge {
28    /// MUST be the literal string `openagent-challenge-v1`.
29    #[serde(rename = "type")]
30    pub message_type: String,
31    /// 64-character lowercase hexadecimal string (32 CSPRNG bytes).
32    pub nonce: String,
33    /// ISO 8601 UTC timestamp, `Z` suffix, seconds precision.
34    pub timestamp: String,
35    /// Server origin per RFC 6454: `scheme://host[:port]`.
36    pub origin: String,
37    /// Optional protection-space identifier. Omitted entirely when absent.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub realm: Option<String>,
40}
41
42impl IdentityChallenge {
43    /// Construct a challenge of the current format version.
44    pub fn new(nonce: String, timestamp: String, origin: String, realm: Option<String>) -> Self {
45        Self {
46            message_type: CHALLENGE_TYPE.to_string(),
47            nonce,
48            timestamp,
49            origin,
50            realm,
51        }
52    }
53}
54
55/// Signature scheme used for an [`IdentityProof`] (Section 15.3).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum KeyType {
59    /// Ed25519 (32-byte public key).
60    Ed25519,
61    /// secp256k1 (33-byte compressed SEC1 public key).
62    Secp256k1,
63}
64
65impl std::fmt::Display for KeyType {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::Ed25519 => write!(f, "ed25519"),
69            Self::Secp256k1 => write!(f, "secp256k1"),
70        }
71    }
72}
73
74/// Agent → Server: the cryptographic proof of identity (Section 15.3).
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct IdentityProof {
77    /// Base64url (no padding) signature over the JCS-canonicalized challenge
78    /// bytes. 64 bytes for both schemes.
79    pub signature: String,
80    /// Base64url (no padding) raw public key: 32 bytes (Ed25519) or 33 bytes
81    /// (secp256k1 compressed).
82    pub public_key: String,
83    /// The signature scheme.
84    pub key_type: KeyType,
85    /// The nonce from the challenge, echoed to assist server-side lookup.
86    pub nonce: String,
87}
88
89/// The resolution tier the server assigns after verification (Section 15.4).
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum TrustTier {
93    /// Identity verified against the presented key only.
94    Anonymous,
95    /// Identity resolved with additional registry context.
96    Identified,
97    /// Identity resolved with full lineage authority.
98    Sovereign,
99}
100
101impl std::fmt::Display for TrustTier {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            Self::Anonymous => write!(f, "anonymous"),
105            Self::Identified => write!(f, "identified"),
106            Self::Sovereign => write!(f, "sovereign"),
107        }
108    }
109}
110
111/// Server → Agent: identity confirmed; session issued (Section 15.4).
112///
113/// The `session_token` replaces further challenge-response cycles until
114/// expiry; the agent presents it as a bearer credential (Section 12).
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct IdentityVerified {
117    /// Most specific resolved DID for the agent.
118    pub did: String,
119    /// The resolution tier assigned by the server.
120    pub trust_tier: TrustTier,
121    /// JWT session token for subsequent requests.
122    pub session_token: String,
123    /// ISO 8601 UTC timestamp of session expiry.
124    pub session_expires: String,
125    /// Granted capability identifiers, populated by Arsenal when present.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub capabilities: Option<Vec<String>>,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn challenge_serializes_with_type_literal() {
136        let challenge = IdentityChallenge::new(
137            "a".repeat(64),
138            "2026-03-21T12:00:00Z".to_string(),
139            "https://api.example.com".to_string(),
140            None,
141        );
142        let json = serde_json::to_string(&challenge).expect("serialize");
143        assert!(json.contains(r#""type":"openagent-challenge-v1""#));
144        // realm omitted entirely when absent, never null.
145        assert!(!json.contains("realm"));
146    }
147
148    #[test]
149    fn challenge_includes_realm_only_when_present() {
150        let challenge = IdentityChallenge::new(
151            "a".repeat(64),
152            "2026-03-21T12:00:00Z".to_string(),
153            "https://api.example.com".to_string(),
154            Some("admin".to_string()),
155        );
156        let json = serde_json::to_string(&challenge).expect("serialize");
157        assert!(json.contains(r#""realm":"admin""#));
158    }
159
160    #[test]
161    fn key_type_uses_spec_spelling() {
162        let json = serde_json::to_string(&KeyType::Ed25519).expect("serialize");
163        assert_eq!(json, r#""ed25519""#);
164        let json = serde_json::to_string(&KeyType::Secp256k1).expect("serialize");
165        assert_eq!(json, r#""secp256k1""#);
166    }
167
168    #[test]
169    fn trust_tier_uses_spec_spelling() {
170        let json = serde_json::to_string(&TrustTier::Sovereign).expect("serialize");
171        assert_eq!(json, r#""sovereign""#);
172    }
173}