Skip to main content

net_sdk/
identity.rs

1//! Identity handle — keypair + token cache.
2//!
3//! Built once at node start, handed to [`crate::NetBuilder::identity`]
4//! or [`crate::MeshBuilder::identity`]. Owns the ed25519 signing key;
5//! the transport borrows it for `OriginStamp` derivation, event
6//! signing, and token-gated subscribe checks.
7//!
8//! `Identity` is cheap to clone (both the keypair and the token cache
9//! are held behind `Arc`). Clone and share between threads freely.
10//!
11//! # Example
12//!
13//! ```
14//! use std::time::Duration;
15//! use net_sdk::{Identity, TokenScope};
16//! use net_sdk::ChannelName;
17//!
18//! // Two entities — a publisher issuing a subscribe grant to a
19//! // subscriber it trusts.
20//! let publisher = Identity::generate();
21//! let subscriber = Identity::generate();
22//!
23//! let channel = ChannelName::new("sensors/temp").unwrap();
24//! let token = publisher.issue_token(
25//!     subscriber.entity_id().clone(),
26//!     TokenScope::SUBSCRIBE,
27//!     &channel,
28//!     Duration::from_secs(300),
29//!     0, // delegation depth — 0 disallows re-delegation
30//! );
31//!
32//! // Full round-trip: signature verifies against the issuer's key,
33//! // install stores it in the subscriber's cache, lookup returns it.
34//! assert!(token.verify().is_ok());
35//! subscriber.install_token(token.clone()).unwrap();
36//! let cached = subscriber.lookup_token(subscriber.entity_id(), &channel);
37//! assert!(cached.is_some());
38//! ```
39//!
40//! # Persistence
41//!
42//! Treat the bytes from [`Identity::to_bytes`] as secret material —
43//! they're the 32-byte ed25519 seed. Typical flow: generate once on
44//! first run, write-encrypted to disk (or a vault / enclave / k8s
45//! secret), reload with [`Identity::from_bytes`] on every subsequent
46//! start. The SDK never touches a hardcoded path — where the bytes
47//! live is the caller's call.
48
49use std::sync::Arc;
50use std::time::Duration;
51
52use net::adapter::net::channel::ChannelName;
53
54// Re-export of core identity primitives so users can import directly
55// from `net_sdk::identity::*` instead of reaching into the core crate.
56pub use net::adapter::net::identity::{
57    EntityError, EntityId, EntityKeypair, OriginStamp, PermissionToken, TokenCache, TokenError,
58    TokenScope, MAX_TOKEN_TTL_SECS,
59};
60
61/// Caller-owned identity bundle: one ed25519 keypair + one token
62/// cache.
63///
64/// See the [module docs](self) for generation / persistence / issuance
65/// semantics.
66#[derive(Clone, Debug)]
67pub struct Identity {
68    keypair: Arc<EntityKeypair>,
69    cache: Arc<TokenCache>,
70}
71
72impl Identity {
73    /// Generate a fresh ed25519 identity.
74    ///
75    /// Use once at first-run; persist the returned bytes via
76    /// [`Self::to_bytes`] and reload with [`Self::from_bytes`] on
77    /// subsequent runs. Every call to `generate()` produces a *new*
78    /// entity id — don't call it on every startup unless you actually
79    /// want a fresh identity (you almost never do).
80    pub fn generate() -> Self {
81        Self::from_keypair(EntityKeypair::generate())
82    }
83
84    /// Load from a caller-owned 32-byte ed25519 seed.
85    pub fn from_seed(seed: [u8; 32]) -> Self {
86        Self::from_keypair(EntityKeypair::from_bytes(seed))
87    }
88
89    /// Serialize the identity as its 32-byte seed. Token cache entries
90    /// are runtime-only and not serialized — reinstall any long-lived
91    /// grants via [`Self::install_token`] after reloading.
92    pub fn to_bytes(&self) -> [u8; 32] {
93        *self.keypair.secret_bytes()
94    }
95
96    /// Load a previously-serialized identity. Expects exactly 32
97    /// bytes — the ed25519 seed — otherwise returns
98    /// [`TokenError::InvalidFormat`].
99    pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenError> {
100        if bytes.len() != 32 {
101            return Err(TokenError::InvalidFormat);
102        }
103        let mut seed = [0u8; 32];
104        seed.copy_from_slice(bytes);
105        Ok(Self::from_seed(seed))
106    }
107
108    /// Ed25519 public key. 32 bytes.
109    pub fn entity_id(&self) -> &EntityId {
110        self.keypair.entity_id()
111    }
112
113    /// Derived 64-bit hash used in packet headers (`OriginStamp`).
114    pub fn origin_hash(&self) -> u64 {
115        self.keypair.origin_hash()
116    }
117
118    /// Derived 64-bit node id used for routing / addressing.
119    pub fn node_id(&self) -> u64 {
120        self.keypair.node_id()
121    }
122
123    /// Sign arbitrary bytes. Typically used by the transport to sign
124    /// `CapabilityAnnouncement`s; exposed here so callers can sign
125    /// their own out-of-band messages with the same identity.
126    pub fn sign(&self, message: &[u8]) -> [u8; 64] {
127        self.keypair.sign(message).to_bytes()
128    }
129
130    /// Issue a scoped permission token to `subject`.
131    ///
132    /// Short TTLs + periodic re-issuance is the designed v1 answer to
133    /// revocation — a [`PermissionToken`] has no CRL lookup. Pick
134    /// TTLs that match how long you'd tolerate a compromised token
135    /// being valid.
136    ///
137    /// `delegation_depth = 0` disallows re-delegation (subject cannot
138    /// mint further tokens from this one).
139    ///
140    /// `ttl == Duration::ZERO` is soft-clamped to 1 second (the
141    /// minimum non-born-expired TTL), and a `ttl` longer than
142    /// [`MAX_TOKEN_TTL_SECS`] is soft-clamped down to that ceiling.
143    /// Both keep this infallible surface non-panicking: `try_issue`
144    /// rejects an over-long TTL with `TokenError::TtlTooLong`, which
145    /// the `.expect()` below would otherwise turn into a process
146    /// abort. In debug builds a `debug_assert!` fires so either misuse
147    /// surfaces in tests; in release the SDK keeps a non-panicking
148    /// surface for callers that may receive an out-of-range value from
149    /// upstream configuration. Callers that need to *reject* these at
150    /// the boundary should use [`Self::try_issue_token`], which returns
151    /// `TokenError::ZeroTtl` / `TokenError::TtlTooLong`.
152    pub fn issue_token(
153        &self,
154        subject: EntityId,
155        scope: TokenScope,
156        channel: &ChannelName,
157        ttl: Duration,
158        delegation_depth: u8,
159    ) -> PermissionToken {
160        debug_assert!(
161            !ttl.is_zero(),
162            "Identity::issue_token called with Duration::ZERO; \
163             release builds soft-clamp to 1s, but the call site is likely a bug"
164        );
165        debug_assert!(
166            ttl.as_secs() <= MAX_TOKEN_TTL_SECS,
167            "Identity::issue_token called with ttl > MAX_TOKEN_TTL_SECS ({MAX_TOKEN_TTL_SECS}s); \
168             release builds soft-clamp to the ceiling, but the call site is likely a bug"
169        );
170        let effective_ttl = if ttl.is_zero() {
171            Duration::from_secs(1)
172        } else {
173            // Clamp to the issuance ceiling so the infallible wrapper
174            // can't panic on the `TtlTooLong` that `try_issue` returns
175            // past `MAX_TOKEN_TTL_SECS`.
176            Duration::from_secs(ttl.as_secs().min(MAX_TOKEN_TTL_SECS))
177        };
178        self.try_issue_token(subject, scope, channel, effective_ttl, delegation_depth)
179            .expect("Identity::issue_token: invalid input (use try_issue_token for fallible)")
180    }
181
182    /// Fallible variant of [`Self::issue_token`].
183    ///
184    /// Returns [`TokenError::ZeroTtl`] when `ttl ==
185    /// Duration::ZERO`. Pre-fix this minted a born-expired token
186    /// — every receiver rejected it as `Expired` and the issuer
187    /// learned about the misuse only by reading log lines on the
188    /// receiver side.
189    pub fn try_issue_token(
190        &self,
191        subject: EntityId,
192        scope: TokenScope,
193        channel: &ChannelName,
194        ttl: Duration,
195        delegation_depth: u8,
196    ) -> Result<PermissionToken, TokenError> {
197        PermissionToken::try_issue(
198            &self.keypair,
199            subject,
200            scope,
201            channel.hash(),
202            ttl.as_secs(),
203            delegation_depth,
204        )
205    }
206
207    /// Install a token received from another issuer — typically a
208    /// delegated subscribe / publish grant. The signature is verified
209    /// on insert; an invalid token returns
210    /// [`TokenError::InvalidSignature`].
211    pub fn install_token(&self, token: PermissionToken) -> Result<(), TokenError> {
212        self.cache.insert(token)
213    }
214
215    /// Look up a cached token by `(subject, channel)`. Sub-microsecond
216    /// (DashMap-backed). Returns `None` if no exact-channel token is
217    /// cached; the transport's wildcard fallback is handled separately
218    /// by [`TokenCache::check`].
219    pub fn lookup_token(
220        &self,
221        subject: &EntityId,
222        channel: &ChannelName,
223    ) -> Option<PermissionToken> {
224        self.cache.get(subject, channel.hash())
225    }
226
227    /// Shared reference to the underlying keypair. Used by the mesh
228    /// builder to hand the keypair to `MeshNode::new`; most callers
229    /// don't need this directly.
230    pub fn keypair(&self) -> &Arc<EntityKeypair> {
231        &self.keypair
232    }
233
234    /// Shared reference to the underlying token cache. Used by the
235    /// transport to check subscribe authorizations; most callers
236    /// don't need this directly.
237    pub fn token_cache(&self) -> &Arc<TokenCache> {
238        &self.cache
239    }
240
241    fn from_keypair(kp: EntityKeypair) -> Self {
242        Self {
243            keypair: Arc::new(kp),
244            cache: Arc::new(TokenCache::new()),
245        }
246    }
247}
248
249// NOTE: `Identity` deliberately does NOT implement `Default`.
250// Returning a fresh random keypair from `default()` would be a
251// footgun — any `unwrap_or_default()` or `#[derive(Default)]` on a
252// struct containing `Identity` would silently spin up a throwaway
253// identity, bypassing the explicit `generate()` / `from_seed()`
254// constructors where the docs warn about secret-material handling.
255// Callers who want a random identity must call
256// [`Identity::generate`] directly; callers restoring from a seed
257// call [`Identity::from_seed`].
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    /// `Identity::issue_token` previously routed through
264    /// `try_issue_token(...).expect(...)`, which blew up the
265    /// process on `Duration::ZERO` (because `try_issue` returns
266    /// `TokenError::ZeroTtl`). The current behaviour soft-clamps
267    /// to a 1-second TTL (with a `debug_assert!` to surface the
268    /// misuse in tests). Release builds therefore mint a
269    /// short-but-valid token instead of process-aborting.
270    ///
271    /// The `debug_assert!` fires under `cargo test`, so we
272    /// exercise the soft-clamp via `release` semantics by
273    /// `#[cfg]`-gating off of `debug_assertions`. The assertion
274    /// itself is covered by a separate `#[should_panic]` test
275    /// below.
276    #[cfg(not(debug_assertions))]
277    #[test]
278    fn issue_token_zero_duration_soft_clamps_in_release() {
279        let id = Identity::generate();
280        let subject = Identity::generate();
281        let channel = ChannelName::new("zero-ttl-soft-clamp").unwrap();
282        let token = id.issue_token(
283            subject.entity_id().clone(),
284            crate::TokenScope::PUBLISH,
285            &channel,
286            Duration::ZERO,
287            0,
288        );
289        assert!(
290            token.verify().is_ok(),
291            "soft-clamped 1s TTL must produce a verify-ok token"
292        );
293        assert!(
294            token.is_valid().is_ok(),
295            "soft-clamped 1s TTL must be live at issue time"
296        );
297    }
298
299    /// Companion to the above: in debug builds the soft-clamp
300    /// fires `debug_assert!` so the misuse surfaces in tests.
301    #[cfg(debug_assertions)]
302    #[test]
303    #[should_panic(expected = "Duration::ZERO")]
304    fn issue_token_zero_duration_debug_asserts() {
305        let id = Identity::generate();
306        let subject = Identity::generate();
307        let channel = ChannelName::new("zero-ttl-debug").unwrap();
308        let _ = id.issue_token(
309            subject.entity_id().clone(),
310            crate::TokenScope::PUBLISH,
311            &channel,
312            Duration::ZERO,
313            0,
314        );
315    }
316
317    /// `try_issue_token` is the explicit fallible surface — must
318    /// reject `Duration::ZERO` with `TokenError::ZeroTtl` rather
319    /// than soft-clamping. This is the path FFI bindings route
320    /// through; an attempt to mint a zero-TTL token there should
321    /// surface as an error to the caller, not be silently
322    /// remediated.
323    #[test]
324    fn try_issue_token_zero_duration_returns_zero_ttl() {
325        let id = Identity::generate();
326        let subject = Identity::generate();
327        let channel = ChannelName::new("zero-ttl-fallible").unwrap();
328        let err = id
329            .try_issue_token(
330                subject.entity_id().clone(),
331                crate::TokenScope::PUBLISH,
332                &channel,
333                Duration::ZERO,
334                0,
335            )
336            .unwrap_err();
337        assert!(
338            matches!(err, TokenError::ZeroTtl),
339            "expected ZeroTtl, got {err:?}"
340        );
341    }
342
343    /// Security-review follow-up: a `ttl` past `MAX_TOKEN_TTL_SECS`
344    /// used to reach `try_issue`, which (after audit H3) returns
345    /// `TokenError::TtlTooLong` — and the infallible wrapper's
346    /// `.expect()` would have turned that into a process abort. The
347    /// wrapper now soft-clamps down to the ceiling, mirroring the
348    /// zero-TTL soft-clamp. Release-gated like its zero-TTL sibling
349    /// because the `debug_assert!` fires under `cargo test`.
350    #[cfg(not(debug_assertions))]
351    #[test]
352    fn issue_token_over_long_ttl_soft_clamps_in_release() {
353        let id = Identity::generate();
354        let subject = Identity::generate();
355        let channel = ChannelName::new("long-ttl-soft-clamp").unwrap();
356        let token = id.issue_token(
357            subject.entity_id().clone(),
358            crate::TokenScope::PUBLISH,
359            &channel,
360            // 10x the ceiling — the old saturating path would have
361            // produced a near-immortal token; clamp caps it.
362            Duration::from_secs(MAX_TOKEN_TTL_SECS * 10),
363            0,
364        );
365        assert!(
366            token.not_after < u64::MAX,
367            "clamped TTL must not saturate not_after"
368        );
369        assert!(
370            token.verify().is_ok(),
371            "clamped TTL must produce a verify-ok token"
372        );
373        assert!(
374            token.is_valid().is_ok(),
375            "clamped TTL must be live at issue time"
376        );
377    }
378
379    /// Companion to the above: in debug builds the over-long soft-clamp
380    /// fires `debug_assert!` so the misuse surfaces in tests.
381    #[cfg(debug_assertions)]
382    #[test]
383    #[should_panic(expected = "MAX_TOKEN_TTL_SECS")]
384    fn issue_token_over_long_ttl_debug_asserts() {
385        let id = Identity::generate();
386        let subject = Identity::generate();
387        let channel = ChannelName::new("long-ttl-debug").unwrap();
388        let _ = id.issue_token(
389            subject.entity_id().clone(),
390            crate::TokenScope::PUBLISH,
391            &channel,
392            Duration::from_secs(MAX_TOKEN_TTL_SECS * 10),
393            0,
394        );
395    }
396
397    /// The fallible surface rejects an over-long TTL with
398    /// `TokenError::TtlTooLong` rather than clamping — the boundary
399    /// path FFI bindings route through.
400    #[test]
401    fn try_issue_token_over_long_ttl_returns_ttl_too_long() {
402        let id = Identity::generate();
403        let subject = Identity::generate();
404        let channel = ChannelName::new("long-ttl-fallible").unwrap();
405        let err = id
406            .try_issue_token(
407                subject.entity_id().clone(),
408                crate::TokenScope::PUBLISH,
409                &channel,
410                Duration::from_secs(MAX_TOKEN_TTL_SECS + 1),
411                0,
412            )
413            .unwrap_err();
414        assert!(
415            matches!(err, TokenError::TtlTooLong),
416            "expected TtlTooLong, got {err:?}"
417        );
418    }
419}