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}