Skip to content

Commit a792f41

Browse files
authored
feat(trogon-identity-types): introduce shared mesh identity wire types (#386)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent eaca5e6 commit a792f41

6 files changed

Lines changed: 399 additions & 0 deletions

File tree

rsworkspace/Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rsworkspace/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mcp-nats-server = { path = "crates/mcp-nats-server" }
2626
mcp-nats-stdio = { path = "crates/mcp-nats-stdio" }
2727
trogon-nats = { path = "crates/trogon-nats" }
2828
trogon-decider-runtime = { path = "crates/trogon-decider-runtime" }
29+
trogon-identity-types = { path = "crates/trogon-identity-types" }
2930
trogon-service-config = { path = "crates/trogon-service-config" }
3031
trogon-std = { path = "crates/trogon-std" }
3132

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "trogon-identity-types"
3+
version = "0.1.0"
4+
edition = "2024"
5+
license = "Apache-2.0"
6+
description = "Shared mesh identity wire types (act_chain, depth limits, AAuth claims)"
7+
publish = false
8+
9+
[lints]
10+
workspace = true
11+
12+
[dependencies]
13+
serde = { workspace = true, features = ["derive"] }
14+
serde_json = { workspace = true }
15+
thiserror = { workspace = true }
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
//! AAuth (draft-hardt-aauth-protocol) wire types: token typ values, claim sets,
2+
//! HTTP/NATS PoP envelopes, requirement headers.
3+
//!
4+
//! This module is transport-agnostic. Verification and signing live in
5+
//! `trogon-aauth-verify`; key management in `trogon-aauth-person` /
6+
//! `trogon-jwks-publisher`.
7+
8+
use serde::{Deserialize, Serialize};
9+
use serde_json::Value;
10+
11+
/// `typ` header value identifying an agent identity token.
12+
pub const TYP_AGENT: &str = "aa-agent+jwt";
13+
/// `typ` header value identifying a resource challenge token.
14+
pub const TYP_RESOURCE: &str = "aa-resource+jwt";
15+
/// `typ` header value identifying an authorization token from a Person Server.
16+
pub const TYP_AUTH: &str = "aa-auth+jwt";
17+
18+
/// `dwk` (discoverable-well-known) values used by AAuth issuers.
19+
pub const DWK_AGENT: &str = "aauth-agent.json";
20+
pub const DWK_RESOURCE: &str = "aauth-resource.json";
21+
pub const DWK_PERSON: &str = "aauth-person.json";
22+
23+
/// HTTP / NATS header names used by the AAuth wire protocol.
24+
pub mod headers {
25+
pub const REQUIREMENT: &str = "AAuth-Requirement";
26+
pub const ACCESS: &str = "AAuth-Access";
27+
pub const MISSION: &str = "AAuth-Mission";
28+
pub const CAPABILITIES: &str = "AAuth-Capabilities";
29+
30+
// RFC 9421 HTTP path
31+
pub const SIGNATURE_KEY: &str = "Signature-Key";
32+
pub const SIGNATURE_INPUT: &str = "Signature-Input";
33+
pub const SIGNATURE: &str = "Signature";
34+
pub const CONTENT_DIGEST: &str = "Content-Digest";
35+
36+
// NATS path (Trogon-defined, mirrors RFC 9421 shape).
37+
pub const NATS_TOKEN: &str = "AAuth-Token";
38+
pub const NATS_SIG_INPUT: &str = "AAuth-Sig-Input";
39+
pub const NATS_SIG: &str = "AAuth-Sig";
40+
pub const NATS_SIG_CREATED: &str = "AAuth-Sig-Created";
41+
pub const NATS_SIG_NONCE: &str = "AAuth-Sig-Nonce";
42+
}
43+
44+
/// Public-key confirmation claim (`cnf`) as carried in `aa-agent+jwt`.
45+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46+
pub struct Cnf {
47+
/// Embedded JWK. Stored as serde_json::Value so this crate avoids depending on
48+
/// `jsonwebtoken`. Verifier-side parses into `jsonwebtoken::jwk::Jwk`.
49+
pub jwk: Value,
50+
}
51+
52+
/// Claims for an `aa-agent+jwt`. Issued by an Agent Provider at bootstrap.
53+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54+
pub struct AgentClaims {
55+
pub iss: String,
56+
pub sub: String,
57+
pub jti: String,
58+
pub iat: i64,
59+
pub exp: i64,
60+
pub dwk: String,
61+
pub cnf: Cnf,
62+
#[serde(default, skip_serializing_if = "Option::is_none")]
63+
pub ps: Option<String>,
64+
}
65+
66+
/// Claims for an `aa-resource+jwt`. Issued by a resource as a 401/NATS-401 challenge.
67+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68+
pub struct ResourceClaims {
69+
pub iss: String,
70+
pub aud: String,
71+
pub jti: String,
72+
pub iat: i64,
73+
pub exp: i64,
74+
pub dwk: String,
75+
pub agent: String,
76+
pub agent_jkt: String,
77+
pub scope: String,
78+
#[serde(default, skip_serializing_if = "Option::is_none")]
79+
pub mission: Option<MissionRef>,
80+
}
81+
82+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83+
pub struct MissionRef {
84+
pub approver: String,
85+
pub s256: String,
86+
}
87+
88+
/// Claims for an `aa-auth+jwt`. Issued by a Person Server (3-party) or AS (4-party).
89+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90+
pub struct AuthClaims {
91+
pub iss: String,
92+
pub sub: String,
93+
pub aud: String,
94+
pub jti: String,
95+
pub iat: i64,
96+
pub exp: i64,
97+
pub agent: String,
98+
pub agent_jkt: String,
99+
pub scope: String,
100+
#[serde(default, skip_serializing_if = "Option::is_none")]
101+
pub principal: Option<String>,
102+
#[serde(default, skip_serializing_if = "Option::is_none")]
103+
pub consent_id: Option<String>,
104+
#[serde(default, skip_serializing_if = "Option::is_none")]
105+
pub resource: Option<String>,
106+
}
107+
108+
/// Parsed `AAuth-Requirement` header value.
109+
#[derive(Clone, Debug, PartialEq, Eq)]
110+
pub enum Requirement {
111+
AuthToken { resource_token: String },
112+
Interaction { url: String, code: Option<String> },
113+
Clarification,
114+
ApprovalPending,
115+
Other { raw: String },
116+
}
117+
118+
impl Requirement {
119+
/// Render the canonical wire form for an HTTP response header value or NATS header.
120+
#[must_use]
121+
pub fn to_header_value(&self) -> String {
122+
match self {
123+
Requirement::AuthToken { resource_token } => {
124+
format!("requirement=auth-token; resource-token=\"{resource_token}\"")
125+
}
126+
Requirement::Interaction { url, code } => match code {
127+
Some(c) => format!("requirement=interaction; url=\"{url}\"; code=\"{c}\""),
128+
None => format!("requirement=interaction; url=\"{url}\""),
129+
},
130+
Requirement::Clarification => "requirement=clarification".into(),
131+
Requirement::ApprovalPending => "requirement=approval-pending".into(),
132+
Requirement::Other { raw } => raw.clone(),
133+
}
134+
}
135+
136+
/// Parse the value of an `AAuth-Requirement` header into a typed enum.
137+
#[must_use]
138+
pub fn parse(raw: &str) -> Self {
139+
let parts = split_header(raw);
140+
let mut requirement: Option<&str> = None;
141+
let mut resource_token: Option<String> = None;
142+
let mut url: Option<String> = None;
143+
let mut code: Option<String> = None;
144+
for (key, val) in &parts {
145+
match key.as_str() {
146+
"requirement" => requirement = Some(val.as_str()),
147+
"resource-token" => resource_token = Some(val.clone()),
148+
"url" => url = Some(val.clone()),
149+
"code" => code = Some(val.clone()),
150+
_ => {}
151+
}
152+
}
153+
match requirement {
154+
Some("auth-token") => Requirement::AuthToken {
155+
resource_token: resource_token.unwrap_or_default(),
156+
},
157+
Some("interaction") => Requirement::Interaction {
158+
url: url.unwrap_or_default(),
159+
code,
160+
},
161+
Some("clarification") => Requirement::Clarification,
162+
Some("approval-pending") => Requirement::ApprovalPending,
163+
_ => Requirement::Other { raw: raw.to_string() },
164+
}
165+
}
166+
}
167+
168+
fn split_header(raw: &str) -> Vec<(String, String)> {
169+
raw.split(';')
170+
.filter_map(|seg| {
171+
let seg = seg.trim();
172+
if seg.is_empty() {
173+
return None;
174+
}
175+
let (k, v) = seg.split_once('=')?;
176+
let key = k.trim().to_ascii_lowercase();
177+
let val = strip_quotes(v.trim());
178+
Some((key, val))
179+
})
180+
.collect()
181+
}
182+
183+
fn strip_quotes(s: &str) -> String {
184+
let bytes = s.as_bytes();
185+
if bytes.len() >= 2 && bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"') {
186+
s[1..s.len() - 1].to_string()
187+
} else {
188+
s.to_string()
189+
}
190+
}
191+
192+
/// NATS PoP signature envelope, mirrored to RFC 9421 but adapted for NATS.
193+
#[derive(Clone, Debug, PartialEq, Eq)]
194+
pub struct NatsSignatureEnvelope {
195+
pub token: String,
196+
pub sig_input: String,
197+
pub sig: String,
198+
pub created: i64,
199+
pub nonce: String,
200+
pub content_digest: String,
201+
}
202+
203+
impl NatsSignatureEnvelope {
204+
/// Compute the canonical signature base string the agent and verifier must agree on.
205+
#[must_use]
206+
pub fn canonical_base(&self, subject: &str, reply: Option<&str>, jkt: &str) -> String {
207+
let reply = reply.unwrap_or("");
208+
format!(
209+
concat!(
210+
"\"@subject\": {subject}\n",
211+
"\"@reply\": {reply}\n",
212+
"\"content-digest\": {digest}\n",
213+
"\"aauth-token\": {token}\n",
214+
"\"aauth-sig-created\": {created}\n",
215+
"\"aauth-sig-nonce\": {nonce}\n",
216+
"\"@signature-params\": {input};created={created};keyid=\"{kid}\""
217+
),
218+
subject = subject,
219+
reply = reply,
220+
digest = self.content_digest,
221+
token = self.token,
222+
created = self.created,
223+
nonce = self.nonce,
224+
input = self.sig_input,
225+
kid = jkt,
226+
)
227+
}
228+
}
229+
230+
/// Errors returned by AAuth parsing helpers.
231+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
232+
pub enum AAuthParseError {
233+
#[error("aauth: missing field {0}")]
234+
MissingField(&'static str),
235+
#[error("aauth: invalid number for {0}")]
236+
InvalidNumber(&'static str),
237+
}
238+
239+
#[cfg(test)]
240+
mod tests {
241+
use super::*;
242+
243+
#[test]
244+
fn parses_auth_token_requirement() {
245+
let raw = "requirement=auth-token; resource-token=\"eyJ.AAA\"";
246+
let req = Requirement::parse(raw);
247+
assert_eq!(
248+
req,
249+
Requirement::AuthToken {
250+
resource_token: "eyJ.AAA".into()
251+
}
252+
);
253+
}
254+
255+
#[test]
256+
fn parses_interaction_requirement() {
257+
let raw = "requirement=interaction; url=\"https://ps.example/i/123\"; code=\"AB12\"";
258+
let req = Requirement::parse(raw);
259+
assert_eq!(
260+
req,
261+
Requirement::Interaction {
262+
url: "https://ps.example/i/123".into(),
263+
code: Some("AB12".into()),
264+
}
265+
);
266+
}
267+
268+
#[test]
269+
fn renders_round_trip_auth_token() {
270+
let req = Requirement::AuthToken {
271+
resource_token: "eyJTOK".into(),
272+
};
273+
let v = req.to_header_value();
274+
let again = Requirement::parse(&v);
275+
assert_eq!(req, again);
276+
}
277+
278+
#[test]
279+
fn agent_claims_serde() {
280+
let c = AgentClaims {
281+
iss: "https://ap.example".into(),
282+
sub: "aauth:agent-1@example".into(),
283+
jti: "abc".into(),
284+
iat: 100,
285+
exp: 200,
286+
dwk: DWK_AGENT.into(),
287+
cnf: Cnf {
288+
jwk: serde_json::json!({"kty": "EC", "crv": "P-256", "x": "X", "y": "Y"}),
289+
},
290+
ps: Some("https://ps.example".into()),
291+
};
292+
let j = serde_json::to_value(&c).unwrap();
293+
assert_eq!(j["dwk"], DWK_AGENT);
294+
assert_eq!(j["cnf"]["jwk"]["kty"], "EC");
295+
}
296+
297+
#[test]
298+
fn nats_envelope_canonical_base_is_stable() {
299+
let env = NatsSignatureEnvelope {
300+
token: "TOK".into(),
301+
sig_input: "(\"@subject\")".into(),
302+
sig: "SIG".into(),
303+
created: 1000,
304+
nonce: "N".into(),
305+
content_digest: "sha-256=:abc:".into(),
306+
};
307+
let a = env.canonical_base("foo.bar", Some("_INBOX.1"), "JKT");
308+
let b = env.canonical_base("foo.bar", Some("_INBOX.1"), "JKT");
309+
assert_eq!(a, b);
310+
assert!(a.contains("\"@subject\": foo.bar"));
311+
assert!(a.contains("keyid=\"JKT\""));
312+
}
313+
}

0 commit comments

Comments
 (0)