Skip to main content

astrid_crypto/
identifier.rs

1//! Domain-separated BLAKE3 identifiers.
2//!
3//! [`ContentHash`](crate::ContentHash) identifies bytes as content. This
4//! module covers identifiers derived from values for a particular purpose,
5//! such as a token verifier or public-key fingerprint. Keeping the two types
6//! distinct prevents equal-width digests from being used across protocols.
7
8use std::fmt;
9
10use crate::{CryptoResult, PublicKey};
11
12/// A domain-separated BLAKE3 identifier.
13///
14/// The default representation is the full 32-byte identifier. The generic
15/// parameter supports typed wrappers without changing the derivation API.
16#[derive(Clone, Copy, PartialEq, Eq, Hash)]
17pub struct IdentifierHash<T = [u8; 32]>(T);
18
19impl<T> IdentifierHash<T> {
20    /// Borrow the wrapped representation.
21    #[must_use]
22    pub const fn as_inner(&self) -> &T {
23        &self.0
24    }
25
26    /// Consume the identifier and return its wrapped representation.
27    #[must_use]
28    pub fn into_inner(self) -> T {
29        self.0
30    }
31
32    /// Transform the representation while preserving the identifier's domain.
33    ///
34    /// This is the safe construction path for non-default generic
35    /// specializations: callers first derive or validate an identifier, then
36    /// map its representation without reinterpreting unrelated bytes.
37    #[must_use]
38    pub fn map<U>(self, map: impl FnOnce(T) -> U) -> IdentifierHash<U> {
39        IdentifierHash(map(self.0))
40    }
41}
42
43impl IdentifierHash<[u8; 32]> {
44    /// Derive an identifier within a fixed application context.
45    ///
46    /// Callers must use a stable, purpose-specific context constant. BLAKE3's
47    /// derive-key mode provides the domain separation; callers do not need to
48    /// construct ad-hoc byte prefixes.
49    #[must_use]
50    pub fn derive(context: &'static str, value: &[u8]) -> Self {
51        Self(blake3::derive_key(context, value))
52    }
53
54    /// Create an identifier from its full byte representation.
55    #[must_use]
56    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
57        Self(bytes)
58    }
59
60    /// Borrow the full byte representation.
61    #[must_use]
62    pub const fn as_bytes(&self) -> &[u8; 32] {
63        &self.0
64    }
65
66    /// Encode the identifier as lowercase hexadecimal.
67    #[must_use]
68    pub fn to_hex(&self) -> String {
69        hex::encode(self.0)
70    }
71
72    /// Encode as `blake3:<lowercase hex>`.
73    #[must_use]
74    pub fn to_prefixed_hex(&self) -> String {
75        format!("blake3:{}", self.to_hex())
76    }
77}
78
79impl fmt::Debug for IdentifierHash<[u8; 32]> {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "IdentifierHash({})", self.to_prefixed_hex())
82    }
83}
84
85impl fmt::Display for IdentifierHash<[u8; 32]> {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str(&self.to_prefixed_hex())
88    }
89}
90
91impl AsRef<[u8]> for IdentifierHash<[u8; 32]> {
92    fn as_ref(&self) -> &[u8] {
93        &self.0
94    }
95}
96
97/// A full BLAKE3 fingerprint for an Ed25519 public key.
98///
99/// This is deliberately distinct from [`IdentifierHash`] at API boundaries:
100/// a public-key fingerprint must not be accepted where a token verifier or
101/// another identifier happens to have the same representation.
102#[derive(Clone, PartialEq, Eq, Hash)]
103pub struct PublicKeyFingerprint<T = String>(T);
104
105impl<T> PublicKeyFingerprint<T> {
106    /// Borrow the wrapped representation.
107    #[must_use]
108    pub const fn as_inner(&self) -> &T {
109        &self.0
110    }
111
112    /// Consume the fingerprint and return its wrapped representation.
113    #[must_use]
114    pub fn into_inner(self) -> T {
115        self.0
116    }
117
118    /// Transform the representation of a validated fingerprint.
119    ///
120    /// This constructs non-default generic specializations without exposing an
121    /// unchecked constructor for the semantic fingerprint type.
122    #[must_use]
123    pub fn map<U>(self, map: impl FnOnce(T) -> U) -> PublicKeyFingerprint<U> {
124        PublicKeyFingerprint(map(self.0))
125    }
126}
127
128impl PublicKeyFingerprint<String> {
129    /// Derive a fingerprint from a validated Ed25519 public key.
130    #[must_use]
131    pub fn from_public_key(public_key: &PublicKey) -> Self {
132        const CONTEXT: &str = "astrid.runtime.ed25519-public-key.fingerprint.v1";
133        Self(IdentifierHash::derive(CONTEXT, public_key.as_bytes()).to_prefixed_hex())
134    }
135
136    /// Parse a bare or `ed25519:`-prefixed hexadecimal key and fingerprint it.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error when the input is not a 32-byte Ed25519 public key.
141    pub fn from_ed25519_hex(value: &str) -> CryptoResult<Self> {
142        let hex = value.strip_prefix("ed25519:").unwrap_or(value);
143        let public_key = PublicKey::from_hex(hex)?;
144        Ok(Self::from_public_key(&public_key))
145    }
146
147    /// Borrow the `blake3:<lowercase hex>` representation.
148    #[must_use]
149    pub fn as_str(&self) -> &str {
150        &self.0
151    }
152}
153
154impl fmt::Debug for PublicKeyFingerprint<String> {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "PublicKeyFingerprint({})", self.0)
157    }
158}
159
160impl fmt::Display for PublicKeyFingerprint<String> {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.write_str(&self.0)
163    }
164}
165
166impl AsRef<str> for PublicKeyFingerprint<String> {
167    fn as_ref(&self) -> &str {
168        self.as_str()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn domains_produce_distinct_identifiers() {
178        let value = b"same input";
179        let invite = IdentifierHash::derive("Astrid invite token 2026-07-15 v1", value);
180        let pair = IdentifierHash::derive("Astrid pair token 2026-07-15 v1", value);
181        assert_ne!(invite, pair);
182        assert_eq!(invite.to_hex().len(), 64);
183        let encoded = invite.map(hex::encode);
184        assert_eq!(encoded.as_inner().len(), 64);
185    }
186
187    #[test]
188    fn public_key_fingerprint_normalizes_supported_hex_forms() {
189        let key = "ab".repeat(32);
190        let bare = PublicKeyFingerprint::from_ed25519_hex(&key).unwrap();
191        let prefixed = PublicKeyFingerprint::from_ed25519_hex(&format!("ed25519:{key}")).unwrap();
192        assert_eq!(bare, prefixed);
193        assert_eq!(
194            bare.as_str(),
195            "blake3:bfae897f0bf656d68f50c4fde6fc273a7d6c8a28feb8c127f7d2cf9bb54d18b8"
196        );
197        assert_eq!(bare.as_str().len(), 71);
198        let bytes = bare.map(String::into_bytes);
199        assert_eq!(bytes.as_inner().len(), 71);
200    }
201
202    #[test]
203    fn public_key_fingerprint_rejects_invalid_keys() {
204        assert!(PublicKeyFingerprint::from_ed25519_hex("abcd").is_err());
205        assert!(PublicKeyFingerprint::from_ed25519_hex(&"zz".repeat(32)).is_err());
206    }
207}