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}