Skip to main content

hive_btle/security/
membership_token.rs

1// Copyright (c) 2025-2026 (r)evolve - Revolve Team LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Membership tokens for tactical trust in HIVE meshes
17//!
18//! A `MembershipToken` is an authority-signed credential that binds:
19//! - A device's public key to a human-readable callsign
20//! - The mesh this membership is valid for
21//! - Expiration time for the credential
22//!
23//! Tokens are issued by the mesh authority (creator) and can be verified
24//! by any node that knows the authority's public key.
25//!
26//! # Wire Format
27//!
28//! ```text
29//! ┌──────────────┬─────────┬──────────┬────────────┬─────────────┬───────────────────┐
30//! │ public_key   │ mesh_id │ callsign │ issued_at  │ expires_at  │ authority_sig     │
31//! │ 32 bytes     │ 4 bytes │ 12 bytes │ 8 bytes    │ 8 bytes     │ 64 bytes          │
32//! └──────────────┴─────────┴──────────┴────────────┴─────────────┴───────────────────┘
33//! Total: 128 bytes
34//! ```
35//!
36//! # Example
37//!
38//! ```
39//! use hive_btle::security::{DeviceIdentity, MembershipToken, MeshGenesis, MembershipPolicy};
40//!
41//! // Authority creates the mesh
42//! let authority = DeviceIdentity::generate();
43//! let genesis = MeshGenesis::create("ALPHA", &authority, MembershipPolicy::Controlled);
44//!
45//! // New member generates identity
46//! let member = DeviceIdentity::generate();
47//!
48//! // Authority issues token
49//! let token = MembershipToken::issue(
50//!     &authority,
51//!     &genesis,
52//!     member.public_key(),
53//!     "BRAVO-07",
54//!     3600 * 24 * 30 * 1000, // 30 days in ms
55//! );
56//!
57//! // Anyone can verify with authority's public key
58//! assert!(token.verify(&authority.public_key()));
59//!
60//! // Get the callsign
61//! assert_eq!(token.callsign_str(), "BRAVO-07");
62//! ```
63
64#[cfg(not(feature = "std"))]
65use alloc::string::String;
66#[cfg(not(feature = "std"))]
67use alloc::vec::Vec;
68
69use super::genesis::MeshGenesis;
70use super::identity::{verify_signature, DeviceIdentity};
71
72/// Maximum callsign length (null-padded in wire format)
73pub const MAX_CALLSIGN_LEN: usize = 12;
74
75/// Size of mesh_id in bytes (matches MeshGenesis 8-char hex = 4 bytes)
76pub const MESH_ID_SIZE: usize = 4;
77
78/// Total wire size of a MembershipToken
79pub const TOKEN_WIRE_SIZE: usize = 32 + 4 + 12 + 8 + 8 + 64; // 128 bytes
80
81/// A membership token binding a device to a callsign within a mesh
82///
83/// Issued by the mesh authority and verifiable by any node.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct MembershipToken {
86    /// Member's Ed25519 public key
87    pub public_key: [u8; 32],
88
89    /// Mesh ID this token is valid for (4 bytes from MeshGenesis)
90    pub mesh_id: [u8; MESH_ID_SIZE],
91
92    /// Assigned callsign (up to 12 chars, null-padded)
93    pub callsign: [u8; MAX_CALLSIGN_LEN],
94
95    /// When this token was issued (milliseconds since Unix epoch)
96    pub issued_at_ms: u64,
97
98    /// When this token expires (milliseconds since Unix epoch)
99    /// 0 means no expiration
100    pub expires_at_ms: u64,
101
102    /// Authority's Ed25519 signature over the above fields
103    pub authority_signature: [u8; 64],
104}
105
106impl MembershipToken {
107    /// Issue a new membership token
108    ///
109    /// # Arguments
110    /// * `authority` - The mesh authority's identity (must be mesh creator)
111    /// * `genesis` - The mesh genesis containing mesh_id
112    /// * `member_public_key` - The new member's public key
113    /// * `callsign` - Human-readable callsign (max 12 chars)
114    /// * `validity_ms` - How long the token is valid (0 = forever)
115    ///
116    /// # Panics
117    /// Panics if callsign is longer than 12 characters.
118    pub fn issue(
119        authority: &DeviceIdentity,
120        genesis: &MeshGenesis,
121        member_public_key: [u8; 32],
122        callsign: &str,
123        validity_ms: u64,
124    ) -> Self {
125        assert!(
126            callsign.len() <= MAX_CALLSIGN_LEN,
127            "callsign must be <= {} chars",
128            MAX_CALLSIGN_LEN
129        );
130
131        let mesh_id = Self::mesh_id_bytes(&genesis.mesh_id());
132        let mut callsign_bytes = [0u8; MAX_CALLSIGN_LEN];
133        callsign_bytes[..callsign.len()].copy_from_slice(callsign.as_bytes());
134
135        let now_ms = Self::now_ms();
136        let expires_at_ms = if validity_ms == 0 {
137            0
138        } else {
139            now_ms.saturating_add(validity_ms)
140        };
141
142        let mut token = Self {
143            public_key: member_public_key,
144            mesh_id,
145            callsign: callsign_bytes,
146            issued_at_ms: now_ms,
147            expires_at_ms,
148            authority_signature: [0u8; 64],
149        };
150
151        // Sign the token
152        let signable = token.signable_bytes();
153        token.authority_signature = authority.sign(&signable);
154
155        token
156    }
157
158    /// Issue a token with explicit timestamps (for testing)
159    pub fn issue_at(
160        authority: &DeviceIdentity,
161        mesh_id: [u8; MESH_ID_SIZE],
162        member_public_key: [u8; 32],
163        callsign: &str,
164        issued_at_ms: u64,
165        expires_at_ms: u64,
166    ) -> Self {
167        assert!(
168            callsign.len() <= MAX_CALLSIGN_LEN,
169            "callsign must be <= {} chars",
170            MAX_CALLSIGN_LEN
171        );
172
173        let mut callsign_bytes = [0u8; MAX_CALLSIGN_LEN];
174        callsign_bytes[..callsign.len()].copy_from_slice(callsign.as_bytes());
175
176        let mut token = Self {
177            public_key: member_public_key,
178            mesh_id,
179            callsign: callsign_bytes,
180            issued_at_ms,
181            expires_at_ms,
182            authority_signature: [0u8; 64],
183        };
184
185        let signable = token.signable_bytes();
186        token.authority_signature = authority.sign(&signable);
187
188        token
189    }
190
191    /// Verify the token's authority signature
192    ///
193    /// # Arguments
194    /// * `authority_public_key` - The mesh authority's public key
195    ///
196    /// # Returns
197    /// `true` if the signature is valid
198    pub fn verify(&self, authority_public_key: &[u8; 32]) -> bool {
199        let signable = self.signable_bytes();
200        verify_signature(authority_public_key, &signable, &self.authority_signature)
201    }
202
203    /// Check if the token has expired
204    ///
205    /// # Arguments
206    /// * `now_ms` - Current time in milliseconds since epoch
207    ///
208    /// # Returns
209    /// `true` if the token has expired (expires_at_ms != 0 and now_ms > expires_at_ms)
210    pub fn is_expired(&self, now_ms: u64) -> bool {
211        self.expires_at_ms != 0 && now_ms > self.expires_at_ms
212    }
213
214    /// Check if the token is valid (signature OK and not expired)
215    ///
216    /// # Arguments
217    /// * `authority_public_key` - The mesh authority's public key
218    /// * `now_ms` - Current time in milliseconds since epoch
219    pub fn is_valid(&self, authority_public_key: &[u8; 32], now_ms: u64) -> bool {
220        self.verify(authority_public_key) && !self.is_expired(now_ms)
221    }
222
223    /// Get the callsign as a string (trimmed of null padding)
224    pub fn callsign_str(&self) -> &str {
225        let len = self
226            .callsign
227            .iter()
228            .position(|&b| b == 0)
229            .unwrap_or(MAX_CALLSIGN_LEN);
230        // Safety: We control callsign creation and only allow valid UTF-8
231        core::str::from_utf8(&self.callsign[..len]).unwrap_or("")
232    }
233
234    /// Get the mesh_id as a hex string (e.g., "A1B2C3D4")
235    pub fn mesh_id_hex(&self) -> String {
236        format!(
237            "{:02X}{:02X}{:02X}{:02X}",
238            self.mesh_id[0], self.mesh_id[1], self.mesh_id[2], self.mesh_id[3]
239        )
240    }
241
242    /// Encode token to wire format (128 bytes)
243    pub fn encode(&self) -> [u8; TOKEN_WIRE_SIZE] {
244        let mut buf = [0u8; TOKEN_WIRE_SIZE];
245        let mut offset = 0;
246
247        buf[offset..offset + 32].copy_from_slice(&self.public_key);
248        offset += 32;
249
250        buf[offset..offset + MESH_ID_SIZE].copy_from_slice(&self.mesh_id);
251        offset += MESH_ID_SIZE;
252
253        buf[offset..offset + MAX_CALLSIGN_LEN].copy_from_slice(&self.callsign);
254        offset += MAX_CALLSIGN_LEN;
255
256        buf[offset..offset + 8].copy_from_slice(&self.issued_at_ms.to_le_bytes());
257        offset += 8;
258
259        buf[offset..offset + 8].copy_from_slice(&self.expires_at_ms.to_le_bytes());
260        offset += 8;
261
262        buf[offset..offset + 64].copy_from_slice(&self.authority_signature);
263
264        buf
265    }
266
267    /// Decode token from wire format
268    ///
269    /// Returns `None` if data is not exactly 128 bytes.
270    pub fn decode(data: &[u8]) -> Option<Self> {
271        if data.len() != TOKEN_WIRE_SIZE {
272            return None;
273        }
274
275        let mut offset = 0;
276
277        let mut public_key = [0u8; 32];
278        public_key.copy_from_slice(&data[offset..offset + 32]);
279        offset += 32;
280
281        let mut mesh_id = [0u8; MESH_ID_SIZE];
282        mesh_id.copy_from_slice(&data[offset..offset + MESH_ID_SIZE]);
283        offset += MESH_ID_SIZE;
284
285        let mut callsign = [0u8; MAX_CALLSIGN_LEN];
286        callsign.copy_from_slice(&data[offset..offset + MAX_CALLSIGN_LEN]);
287        offset += MAX_CALLSIGN_LEN;
288
289        let issued_at_ms = u64::from_le_bytes([
290            data[offset],
291            data[offset + 1],
292            data[offset + 2],
293            data[offset + 3],
294            data[offset + 4],
295            data[offset + 5],
296            data[offset + 6],
297            data[offset + 7],
298        ]);
299        offset += 8;
300
301        let expires_at_ms = u64::from_le_bytes([
302            data[offset],
303            data[offset + 1],
304            data[offset + 2],
305            data[offset + 3],
306            data[offset + 4],
307            data[offset + 5],
308            data[offset + 6],
309            data[offset + 7],
310        ]);
311        offset += 8;
312
313        let mut authority_signature = [0u8; 64];
314        authority_signature.copy_from_slice(&data[offset..offset + 64]);
315
316        Some(Self {
317            public_key,
318            mesh_id,
319            callsign,
320            issued_at_ms,
321            expires_at_ms,
322            authority_signature,
323        })
324    }
325
326    /// Get the bytes that are signed (everything except the signature)
327    fn signable_bytes(&self) -> Vec<u8> {
328        let mut buf = Vec::with_capacity(TOKEN_WIRE_SIZE - 64);
329        buf.extend_from_slice(&self.public_key);
330        buf.extend_from_slice(&self.mesh_id);
331        buf.extend_from_slice(&self.callsign);
332        buf.extend_from_slice(&self.issued_at_ms.to_le_bytes());
333        buf.extend_from_slice(&self.expires_at_ms.to_le_bytes());
334        buf
335    }
336
337    /// Convert mesh_id hex string to bytes
338    fn mesh_id_bytes(hex: &str) -> [u8; MESH_ID_SIZE] {
339        let mut bytes = [0u8; MESH_ID_SIZE];
340        // MeshGenesis returns 8 hex chars = 4 bytes
341        if hex.len() == 8 {
342            for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
343                if i < MESH_ID_SIZE {
344                    let s = core::str::from_utf8(chunk).unwrap_or("00");
345                    bytes[i] = u8::from_str_radix(s, 16).unwrap_or(0);
346                }
347            }
348        }
349        bytes
350    }
351
352    #[cfg(feature = "std")]
353    fn now_ms() -> u64 {
354        std::time::SystemTime::now()
355            .duration_since(std::time::UNIX_EPOCH)
356            .map(|d| d.as_millis() as u64)
357            .unwrap_or(0)
358    }
359
360    #[cfg(not(feature = "std"))]
361    fn now_ms() -> u64 {
362        0 // Platform should provide timestamp
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::security::MembershipPolicy;
370
371    #[test]
372    fn test_issue_and_verify() {
373        let authority = DeviceIdentity::generate();
374        let genesis = MeshGenesis::create("ALPHA", &authority, MembershipPolicy::Controlled);
375        let member = DeviceIdentity::generate();
376
377        let token = MembershipToken::issue(
378            &authority,
379            &genesis,
380            member.public_key(),
381            "BRAVO-07",
382            3600_000, // 1 hour
383        );
384
385        assert!(token.verify(&authority.public_key()));
386        assert_eq!(token.callsign_str(), "BRAVO-07");
387        assert_eq!(token.public_key, member.public_key());
388    }
389
390    #[test]
391    fn test_wrong_authority_fails() {
392        let authority = DeviceIdentity::generate();
393        let other = DeviceIdentity::generate();
394        let genesis = MeshGenesis::create("ALPHA", &authority, MembershipPolicy::Controlled);
395        let member = DeviceIdentity::generate();
396
397        let token = MembershipToken::issue(
398            &authority,
399            &genesis,
400            member.public_key(),
401            "BRAVO-07",
402            3600_000,
403        );
404
405        // Verification with wrong authority should fail
406        assert!(!token.verify(&other.public_key()));
407    }
408
409    #[test]
410    fn test_tampered_token_fails() {
411        let authority = DeviceIdentity::generate();
412        let genesis = MeshGenesis::create("ALPHA", &authority, MembershipPolicy::Controlled);
413        let member = DeviceIdentity::generate();
414
415        let mut token = MembershipToken::issue(
416            &authority,
417            &genesis,
418            member.public_key(),
419            "BRAVO-07",
420            3600_000,
421        );
422
423        // Tamper with callsign
424        token.callsign[0] = b'X';
425
426        assert!(!token.verify(&authority.public_key()));
427    }
428
429    #[test]
430    fn test_expiration() {
431        let authority = DeviceIdentity::generate();
432        let mesh_id = [0x12, 0x34, 0x56, 0x78];
433        let member = DeviceIdentity::generate();
434
435        let token = MembershipToken::issue_at(
436            &authority,
437            mesh_id,
438            member.public_key(),
439            "ALPHA-01",
440            1000, // issued at
441            2000, // expires at
442        );
443
444        // Before expiration
445        assert!(!token.is_expired(1500));
446        assert!(token.is_valid(&authority.public_key(), 1500));
447
448        // After expiration
449        assert!(token.is_expired(2500));
450        assert!(!token.is_valid(&authority.public_key(), 2500));
451    }
452
453    #[test]
454    fn test_no_expiration() {
455        let authority = DeviceIdentity::generate();
456        let mesh_id = [0x12, 0x34, 0x56, 0x78];
457        let member = DeviceIdentity::generate();
458
459        let token = MembershipToken::issue_at(
460            &authority,
461            mesh_id,
462            member.public_key(),
463            "ALPHA-01",
464            1000,
465            0, // Never expires
466        );
467
468        // Should never expire
469        assert!(!token.is_expired(u64::MAX));
470    }
471
472    #[test]
473    fn test_encode_decode_roundtrip() {
474        let authority = DeviceIdentity::generate();
475        let genesis = MeshGenesis::create("ALPHA", &authority, MembershipPolicy::Controlled);
476        let member = DeviceIdentity::generate();
477
478        let token = MembershipToken::issue(
479            &authority,
480            &genesis,
481            member.public_key(),
482            "CHARLIE-12",
483            86400_000, // 24 hours
484        );
485
486        let encoded = token.encode();
487        assert_eq!(encoded.len(), TOKEN_WIRE_SIZE);
488
489        let decoded = MembershipToken::decode(&encoded).unwrap();
490        assert_eq!(decoded, token);
491        assert!(decoded.verify(&authority.public_key()));
492    }
493
494    #[test]
495    fn test_callsign_str_trimmed() {
496        let authority = DeviceIdentity::generate();
497        let mesh_id = [0x12, 0x34, 0x56, 0x78];
498        let member = DeviceIdentity::generate();
499
500        // Short callsign
501        let token =
502            MembershipToken::issue_at(&authority, mesh_id, member.public_key(), "A-1", 0, 0);
503        assert_eq!(token.callsign_str(), "A-1");
504
505        // Max length callsign
506        let token = MembershipToken::issue_at(
507            &authority,
508            mesh_id,
509            member.public_key(),
510            "ALPHA-BRAVO1",
511            0,
512            0,
513        );
514        assert_eq!(token.callsign_str(), "ALPHA-BRAVO1");
515    }
516
517    #[test]
518    fn test_mesh_id_hex() {
519        let authority = DeviceIdentity::generate();
520        let mesh_id = [0xAB, 0xCD, 0xEF, 0x12];
521        let member = DeviceIdentity::generate();
522
523        let token =
524            MembershipToken::issue_at(&authority, mesh_id, member.public_key(), "TEST", 0, 0);
525
526        assert_eq!(token.mesh_id_hex(), "ABCDEF12");
527    }
528
529    #[test]
530    fn test_wire_size() {
531        // Verify our constant matches actual struct encoding
532        assert_eq!(TOKEN_WIRE_SIZE, 128);
533        assert_eq!(
534            TOKEN_WIRE_SIZE,
535            32 + MESH_ID_SIZE + MAX_CALLSIGN_LEN + 8 + 8 + 64
536        );
537    }
538
539    #[test]
540    #[should_panic(expected = "callsign must be <= 12 chars")]
541    fn test_callsign_too_long_panics() {
542        let authority = DeviceIdentity::generate();
543        let mesh_id = [0x12, 0x34, 0x56, 0x78];
544        let member = DeviceIdentity::generate();
545
546        MembershipToken::issue_at(
547            &authority,
548            mesh_id,
549            member.public_key(),
550            "THIS-IS-TOO-LONG",
551            0,
552            0,
553        );
554    }
555}