Skip to main content

aitp_core/
aid.rs

1//! AITP Agent Identifier (AID).
2//!
3//! v0.2 accepts both the legacy v0.1 grammar
4//! (`aid:pubkey:<43-char-b64url>`, Ed25519 implicit) and the
5//! algorithm-tagged grammar
6//! (`aid:pubkey:<alg>:<identifier>` where `<alg>` is `ed25519` or
7//! `p256`). Per RFC-AITP-0001 §5.3 the legacy and the tagged-ed25519
8//! forms are *trust-equivalent* but not byte-equal in canonical
9//! signing bytes, so each AID lifecycle picks one form and stays
10//! with it.
11
12use crate::AID_PUBKEY_IDENTIFIER_LEN;
13use base64ct::{Base64UrlUnpadded, Encoding};
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17/// `aid:pubkey:` prefix — common to both legacy and tagged forms.
18const AID_PUBKEY_PREFIX: &str = "aid:pubkey:";
19
20/// Algorithm-tagged AID prefix for Ed25519.
21const AID_PUBKEY_ED25519_PREFIX: &str = "aid:pubkey:ed25519:";
22
23/// Algorithm-tagged AID prefix for ECDSA P-256.
24const AID_PUBKEY_P256_PREFIX: &str = "aid:pubkey:p256:";
25
26/// Identifier length for a SEC1-compressed P-256 public key
27/// (33 raw bytes → 44 unpadded base64url chars).
28const AID_P256_IDENTIFIER_LEN: usize = 44;
29
30/// Algorithm of the public key bound to an AID.
31///
32/// Marked `#[non_exhaustive]` so future suites (e.g. Ed448, post-quantum
33/// candidates) can be added in a minor release without forcing
34/// downstream code to upgrade.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36#[non_exhaustive]
37pub enum AidAlgorithm {
38    /// Ed25519 (RFC 8032). Identifier is the 32-byte raw public key.
39    Ed25519,
40    /// ECDSA on P-256 with SHA-256. Identifier is the 33-byte SEC1
41    /// compressed point.
42    P256,
43}
44
45impl AidAlgorithm {
46    /// Wire tag (`ed25519`, `p256`).
47    pub fn as_str(&self) -> &'static str {
48        match self {
49            AidAlgorithm::Ed25519 => "ed25519",
50            AidAlgorithm::P256 => "p256",
51        }
52    }
53}
54
55/// A validated AITP Agent Identifier.
56///
57/// Construct via [`Aid::parse`] or [`Aid::from_ed25519`]. Holding an `Aid` is
58/// proof that the value passed format validation: the string starts with
59/// `aid:pubkey:` and the identifier component is the expected length and
60/// alphabet for its algorithm.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(try_from = "String", into = "String")]
63pub struct Aid(String);
64
65impl Aid {
66    /// Parse and validate an AID string. Accepts:
67    ///
68    /// - **Legacy v0.1 (untagged)**: `aid:pubkey:<43-char-b64url>` —
69    ///   interpreted as Ed25519.
70    /// - **v0.2 tagged Ed25519**: `aid:pubkey:ed25519:<43-char-b64url>`.
71    /// - **v0.2 tagged P-256**: `aid:pubkey:p256:<44-char-b64url>` (SEC1
72    ///   compressed point).
73    pub fn parse(s: &str) -> Result<Self, AidParseError> {
74        if let Some(identifier) = s.strip_prefix(AID_PUBKEY_ED25519_PREFIX) {
75            validate_ed25519_identifier(identifier)?;
76            return Ok(Self(s.to_string()));
77        }
78        if let Some(identifier) = s.strip_prefix(AID_PUBKEY_P256_PREFIX) {
79            validate_p256_identifier(identifier)?;
80            return Ok(Self(s.to_string()));
81        }
82        if let Some(identifier) = s.strip_prefix(AID_PUBKEY_PREFIX) {
83            // Legacy v0.1 form (untagged → Ed25519 implicit).
84            // identifier must NOT contain `:` (which would indicate a
85            // tag we didn't recognize above).
86            if identifier.contains(':') {
87                let (method, _) = identifier.split_once(':').unwrap();
88                return Err(AidParseError::UnsupportedMethod(format!("pubkey:{method}")));
89            }
90            validate_ed25519_identifier(identifier)?;
91            return Ok(Self(s.to_string()));
92        }
93        // Doesn't start with `aid:pubkey:` — surface the right error.
94        if let Some(rest) = s.strip_prefix("aid:") {
95            let method = rest.split(':').next().unwrap_or("");
96            return Err(AidParseError::UnsupportedMethod(method.to_string()));
97        }
98        Err(AidParseError::MissingScheme)
99    }
100
101    /// Construct a legacy (untagged) Ed25519 AID from a raw 32-byte public key.
102    /// Kept for backward-compat; new code should prefer
103    /// [`Aid::from_ed25519_tagged`] which emits the v0.2 algorithm-tagged
104    /// form.
105    pub fn from_ed25519(pubkey: &[u8; 32]) -> Self {
106        let identifier = Base64UrlUnpadded::encode_string(pubkey);
107        debug_assert_eq!(identifier.len(), AID_PUBKEY_IDENTIFIER_LEN);
108        Self(format!("{AID_PUBKEY_PREFIX}{identifier}"))
109    }
110
111    /// Construct an algorithm-tagged Ed25519 AID (v0.2 form).
112    pub fn from_ed25519_tagged(pubkey: &[u8; 32]) -> Self {
113        let identifier = Base64UrlUnpadded::encode_string(pubkey);
114        debug_assert_eq!(identifier.len(), AID_PUBKEY_IDENTIFIER_LEN);
115        Self(format!("{AID_PUBKEY_ED25519_PREFIX}{identifier}"))
116    }
117
118    /// Construct a P-256 AID from a 33-byte SEC1 compressed point.
119    pub fn from_p256(compressed_point: &[u8; 33]) -> Self {
120        let identifier = Base64UrlUnpadded::encode_string(compressed_point);
121        debug_assert_eq!(identifier.len(), AID_P256_IDENTIFIER_LEN);
122        Self(format!("{AID_PUBKEY_P256_PREFIX}{identifier}"))
123    }
124
125    /// Algorithm bound to this AID, derived from the prefix.
126    pub fn algorithm(&self) -> AidAlgorithm {
127        if self.0.starts_with(AID_PUBKEY_P256_PREFIX) {
128            AidAlgorithm::P256
129        } else {
130            AidAlgorithm::Ed25519
131        }
132    }
133
134    /// Return the identifier component (everything after the
135    /// algorithm prefix). For legacy AIDs this is everything after
136    /// `aid:pubkey:` (43 chars); for tagged AIDs it's everything
137    /// after `aid:pubkey:<alg>:`.
138    pub fn identifier(&self) -> &str {
139        if let Some(id) = self.0.strip_prefix(AID_PUBKEY_ED25519_PREFIX) {
140            id
141        } else if let Some(id) = self.0.strip_prefix(AID_PUBKEY_P256_PREFIX) {
142            id
143        } else {
144            &self.0[AID_PUBKEY_PREFIX.len()..]
145        }
146    }
147
148    /// Decode the identifier back to the raw 32-byte Ed25519 public key,
149    /// or `None` if this AID is not an Ed25519 AID. Prefer this over the
150    /// panicking [`Aid::to_ed25519_bytes`] on any path that can receive an
151    /// AID of attacker-controlled algorithm (e.g. handshake verification).
152    pub fn try_to_ed25519_bytes(&self) -> Option<[u8; 32]> {
153        if !matches!(self.algorithm(), AidAlgorithm::Ed25519) {
154            return None;
155        }
156        let mut out = [0u8; 32];
157        Base64UrlUnpadded::decode(self.identifier(), &mut out)
158            .expect("Aid is validated on construction; identifier MUST decode to 32 bytes");
159        Some(out)
160    }
161
162    /// Decode the identifier back to the raw 32-byte Ed25519 public key.
163    /// Panics if the AID is not an Ed25519 AID. Use
164    /// [`Aid::algorithm`] to discriminate first, or
165    /// [`Aid::try_to_ed25519_bytes`] for a non-panicking variant.
166    pub fn to_ed25519_bytes(&self) -> [u8; 32] {
167        self.try_to_ed25519_bytes()
168            .expect("Aid::to_ed25519_bytes called on non-Ed25519 AID")
169    }
170
171    /// Decode the identifier back to the 33-byte SEC1 compressed P-256
172    /// public key, or `None` if this AID is not a P-256 AID.
173    pub fn try_to_p256_bytes(&self) -> Option<[u8; 33]> {
174        if !matches!(self.algorithm(), AidAlgorithm::P256) {
175            return None;
176        }
177        let mut out = [0u8; 33];
178        Base64UrlUnpadded::decode(self.identifier(), &mut out)
179            .expect("Aid is validated on construction; identifier MUST decode to 33 bytes");
180        Some(out)
181    }
182
183    /// Decode the identifier back to the 33-byte SEC1 compressed
184    /// P-256 public key. Panics if the AID is not a P-256 AID. Use
185    /// [`Aid::try_to_p256_bytes`] for a non-panicking variant.
186    pub fn to_p256_bytes(&self) -> [u8; 33] {
187        self.try_to_p256_bytes()
188            .expect("Aid::to_p256_bytes called on non-P-256 AID")
189    }
190
191    /// Decode the identifier back to the AID's algorithm-agile
192    /// compressed public-key bytes — 32 bytes for Ed25519 (raw
193    /// pubkey) or 33 bytes for P-256 (SEC1-compressed). This is the
194    /// canonical encoding embedded in `TctBinding.cnf` /
195    /// `DelegationBinding.cnf` for algorithm-agile signing-key
196    /// bindings; callers verifying a `cnf` against an AID should
197    /// byte-compare against this value rather than the legacy
198    /// Ed25519-only [`Self::to_ed25519_bytes`].
199    pub fn pubkey_compressed_bytes(&self) -> Vec<u8> {
200        match self.algorithm() {
201            AidAlgorithm::Ed25519 => self.to_ed25519_bytes().to_vec(),
202            AidAlgorithm::P256 => self.to_p256_bytes().to_vec(),
203        }
204    }
205
206    /// Return the full AID string verbatim.
207    pub fn as_str(&self) -> &str {
208        &self.0
209    }
210}
211
212fn validate_ed25519_identifier(identifier: &str) -> Result<(), AidParseError> {
213    if identifier.len() != AID_PUBKEY_IDENTIFIER_LEN {
214        return Err(AidParseError::WrongLength(identifier.len()));
215    }
216    if !identifier.bytes().all(is_base64url_byte) {
217        return Err(AidParseError::InvalidChars);
218    }
219    let mut buf = [0u8; 32];
220    Base64UrlUnpadded::decode(identifier, &mut buf).map_err(|_| AidParseError::InvalidChars)?;
221    Ok(())
222}
223
224fn validate_p256_identifier(identifier: &str) -> Result<(), AidParseError> {
225    if identifier.len() != AID_P256_IDENTIFIER_LEN {
226        return Err(AidParseError::WrongLength(identifier.len()));
227    }
228    if !identifier.bytes().all(is_base64url_byte) {
229        return Err(AidParseError::InvalidChars);
230    }
231    let mut buf = [0u8; 33];
232    Base64UrlUnpadded::decode(identifier, &mut buf).map_err(|_| AidParseError::InvalidChars)?;
233    // SEC1 compressed-point byte MUST be 0x02 or 0x03 (sign bit on
234    // the y-coordinate). Anything else is malformed.
235    if buf[0] != 0x02 && buf[0] != 0x03 {
236        return Err(AidParseError::InvalidChars);
237    }
238    Ok(())
239}
240
241impl fmt::Display for Aid {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        f.write_str(&self.0)
244    }
245}
246
247impl TryFrom<String> for Aid {
248    type Error = AidParseError;
249    fn try_from(s: String) -> Result<Self, Self::Error> {
250        Aid::parse(&s)
251    }
252}
253
254impl From<Aid> for String {
255    fn from(a: Aid) -> String {
256        a.0
257    }
258}
259
260/// Reasons an AID string can be rejected.
261///
262/// Marked `#[non_exhaustive]`: new parse-failure modes added by future
263/// AID grammar revisions land as new variants without a major bump.
264#[derive(Debug, thiserror::Error, PartialEq, Eq)]
265#[non_exhaustive]
266pub enum AidParseError {
267    /// Missing the `aid:` URI scheme prefix.
268    #[error("AID does not start with 'aid:'")]
269    MissingScheme,
270
271    /// Method other than `pubkey` (e.g. `did`, `x509`) — not supported in v0.1.
272    #[error("AID method '{0}' is not supported in v0.1; expected 'pubkey'")]
273    UnsupportedMethod(String),
274
275    /// Identifier length is not 43 (raw Ed25519 base64url-unpadded).
276    #[error(
277        "AID identifier must be exactly {} characters; got {0}",
278        AID_PUBKEY_IDENTIFIER_LEN
279    )]
280    WrongLength(usize),
281
282    /// Identifier contains characters outside the base64url alphabet.
283    #[error("AID identifier contains non-base64url characters")]
284    InvalidChars,
285}
286
287fn is_base64url_byte(b: u8) -> bool {
288    matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn sample_pubkey() -> [u8; 32] {
296        let mut k = [0u8; 32];
297        for (i, b) in k.iter_mut().enumerate() {
298            *b = i as u8;
299        }
300        k
301    }
302
303    #[test]
304    fn rejects_missing_scheme() {
305        assert!(matches!(
306            Aid::parse("pubkey:abc"),
307            Err(AidParseError::MissingScheme)
308        ));
309    }
310
311    #[test]
312    fn rejects_unsupported_method() {
313        let s = format!("aid:did:{}", "A".repeat(AID_PUBKEY_IDENTIFIER_LEN));
314        assert!(matches!(
315            Aid::parse(&s),
316            Err(AidParseError::UnsupportedMethod(m)) if m == "did"
317        ));
318    }
319
320    #[test]
321    fn rejects_wrong_length() {
322        assert!(matches!(
323            Aid::parse(&format!("aid:pubkey:{}", "A".repeat(42))),
324            Err(AidParseError::WrongLength(42))
325        ));
326        assert!(matches!(
327            Aid::parse(&format!("aid:pubkey:{}", "A".repeat(44))),
328            Err(AidParseError::WrongLength(44))
329        ));
330    }
331
332    #[test]
333    fn rejects_padding() {
334        // 43 chars including a `=` is not valid base64url-unpadded.
335        let mut s = "A".repeat(42);
336        s.push('=');
337        assert!(matches!(
338            Aid::parse(&format!("aid:pubkey:{}", s)),
339            Err(AidParseError::InvalidChars)
340        ));
341    }
342
343    #[test]
344    fn rejects_invalid_chars() {
345        let mut id = "A".repeat(42);
346        id.push('!');
347        assert!(matches!(
348            Aid::parse(&format!("aid:pubkey:{}", id)),
349            Err(AidParseError::InvalidChars)
350        ));
351    }
352
353    #[test]
354    fn round_trips_pubkey_bytes() {
355        let pk = sample_pubkey();
356        let aid = Aid::from_ed25519(&pk);
357        assert!(aid.as_str().starts_with("aid:pubkey:"));
358        assert_eq!(aid.identifier().len(), AID_PUBKEY_IDENTIFIER_LEN);
359        assert_eq!(aid.to_ed25519_bytes(), pk);
360    }
361
362    #[test]
363    fn parse_accepts_valid_aid() {
364        let aid = Aid::from_ed25519(&sample_pubkey());
365        let parsed = Aid::parse(aid.as_str()).unwrap();
366        assert_eq!(parsed, aid);
367    }
368
369    #[test]
370    fn serde_round_trip() {
371        let aid = Aid::from_ed25519(&sample_pubkey());
372        let json = serde_json::to_string(&aid).unwrap();
373        let back: Aid = serde_json::from_str(&json).unwrap();
374        assert_eq!(back, aid);
375    }
376
377    #[test]
378    fn parse_accepts_tagged_ed25519() {
379        let pk = sample_pubkey();
380        let tagged = Aid::from_ed25519_tagged(&pk);
381        assert!(tagged.as_str().starts_with("aid:pubkey:ed25519:"));
382        assert_eq!(tagged.algorithm(), AidAlgorithm::Ed25519);
383        let parsed = Aid::parse(tagged.as_str()).unwrap();
384        assert_eq!(parsed, tagged);
385        // The legacy untagged AID over the same pubkey is a
386        // different string — they're trust-equivalent but not
387        // byte-equal in canonical signing bytes (RFC-AITP-0001 §5.3).
388        let legacy = Aid::from_ed25519(&pk);
389        assert_ne!(tagged.as_str(), legacy.as_str());
390    }
391
392    #[test]
393    fn parse_accepts_p256_kat() {
394        // kat-keypair-005-p256: private_scalar = 0x05*32, pubkey =
395        // 0x0307810ea974cea5773e63b897f37e3be9a09e7a5fe9b971a44d1065ac2a3a9311
396        // (SEC1 compressed point).
397        let aid_str = "aid:pubkey:p256:AweBDql0zqV3PmO4l_N-O-mgnnpf6blxpE0QZawqOpMR";
398        let aid = Aid::parse(aid_str).unwrap();
399        assert_eq!(aid.algorithm(), AidAlgorithm::P256);
400        let pubkey = aid.to_p256_bytes();
401        // First byte is the sign bit (0x02 or 0x03 for SEC1 compressed).
402        assert_eq!(pubkey[0], 0x03);
403    }
404
405    #[test]
406    fn p256_round_trip() {
407        let mut pubkey = [0u8; 33];
408        pubkey[0] = 0x02;
409        pubkey[1] = 0xAB;
410        let aid = Aid::from_p256(&pubkey);
411        let parsed = Aid::parse(aid.as_str()).unwrap();
412        assert_eq!(parsed.algorithm(), AidAlgorithm::P256);
413        assert_eq!(parsed.to_p256_bytes(), pubkey);
414    }
415
416    #[test]
417    fn p256_rejects_wrong_sec1_tag() {
418        // 0x04 = uncompressed point — we only accept 0x02/0x03
419        // (compressed forms). 33 raw bytes encode to 44 b64url chars.
420        let mut pubkey = [0u8; 33];
421        pubkey[0] = 0x04;
422        let identifier = Base64UrlUnpadded::encode_string(&pubkey);
423        let aid_str = format!("aid:pubkey:p256:{identifier}");
424        assert!(Aid::parse(&aid_str).is_err());
425    }
426
427    #[test]
428    fn rejects_unknown_algorithm_tag() {
429        // `aid:pubkey:rsa4096:...` is not in the registry.
430        let identifier = Base64UrlUnpadded::encode_string(&[0xFFu8; 33]);
431        let aid_str = format!("aid:pubkey:rsa4096:{identifier}");
432        assert!(Aid::parse(&aid_str).is_err());
433    }
434}