Skip to main content

pamoja_security/
identity.rs

1//! Device identities: the private key that signs and the public key that verifies.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
7
8use pamoja_core::{Error, Result};
9
10use crate::Signature;
11
12/// A device's private signing identity.
13///
14/// This is the secret half of a device's identity: the key it uses to sign its own
15/// telemetry so a gateway or auditor can later prove the data came from this device
16/// and was not tampered with. It is built from a 32-byte seed, which a device is
17/// provisioned with and keeps in secure storage, so the same identity is recreated
18/// deterministically across reboots without generating a new key each time.
19///
20/// Signing is deterministic and needs no randomness, so this works unchanged on a
21/// microcontroller.
22///
23/// # Examples
24///
25/// ```
26/// use pamoja_security::DeviceIdentity;
27///
28/// let device = DeviceIdentity::from_seed(&[7u8; 32]);
29/// let signature = device.sign(b"fridge-1: 4.8C");
30/// assert!(device.public().verify(b"fridge-1: 4.8C", &signature).is_ok());
31/// ```
32#[derive(Clone)]
33pub struct DeviceIdentity {
34    signing: SigningKey,
35}
36
37impl DeviceIdentity {
38    /// Builds an identity from a 32-byte secret seed.
39    ///
40    /// # Arguments
41    ///
42    /// * `seed` - the 32 secret bytes the identity is derived from.
43    ///
44    /// # Returns
45    ///
46    /// The device identity.
47    pub fn from_seed(seed: &[u8; 32]) -> Self {
48        Self {
49            signing: SigningKey::from_bytes(seed),
50        }
51    }
52
53    /// Returns the public identity others use to verify this device's signatures.
54    ///
55    /// # Returns
56    ///
57    /// The matching [`PublicIdentity`].
58    pub fn public(&self) -> PublicIdentity {
59        PublicIdentity {
60            verifying: self.signing.verifying_key(),
61        }
62    }
63
64    /// Signs a payload with this device's key.
65    ///
66    /// # Arguments
67    ///
68    /// * `payload` - the bytes to sign, such as an encoded reading.
69    ///
70    /// # Returns
71    ///
72    /// A [`Signature`] over `payload`.
73    pub fn sign(&self, payload: &[u8]) -> Signature {
74        Signature(self.signing.sign(payload))
75    }
76
77    /// Signs a payload and returns one message carrying both.
78    ///
79    /// The message is the 64-byte signature followed by the payload, which is what a
80    /// caller usually wants to put on a link: one blob to send, rather than a payload
81    /// and a detached signature to keep together and split correctly at the far end.
82    /// [`PublicIdentity::verify_message`] reverses it.
83    ///
84    /// # Arguments
85    ///
86    /// * `payload` - the bytes to sign, such as an encoded reading.
87    ///
88    /// # Returns
89    ///
90    /// The signature followed by `payload`.
91    pub fn sign_message(&self, payload: &[u8]) -> Vec<u8> {
92        let mut message = Vec::with_capacity(Signature::LEN + payload.len());
93        message.extend_from_slice(&self.sign(payload).to_bytes());
94        message.extend_from_slice(payload);
95        message
96    }
97}
98
99/// A device's public identity: it names the device and verifies its signatures.
100///
101/// This is the public half of a device's identity, safe to share and distribute. A
102/// gateway holds the public identities of the devices it trusts and uses them to
103/// check that each signed payload is authentic and unaltered.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct PublicIdentity {
106    verifying: VerifyingKey,
107}
108
109impl PublicIdentity {
110    /// Reconstructs a public identity from its 32-byte form.
111    ///
112    /// # Arguments
113    ///
114    /// * `bytes` - the 32-byte encoded public key.
115    ///
116    /// # Returns
117    ///
118    /// The public identity.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`Error::Auth`](pamoja_core::Error::Auth) if `bytes` is not a valid
123    /// public key.
124    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
125        VerifyingKey::from_bytes(bytes)
126            .map(|verifying| Self { verifying })
127            .map_err(|_| Error::Auth("invalid public identity".into()))
128    }
129
130    /// Returns the 32-byte wire form of this identity.
131    ///
132    /// # Returns
133    ///
134    /// The public key encoded as 32 bytes.
135    pub fn to_bytes(&self) -> [u8; 32] {
136        self.verifying.to_bytes()
137    }
138
139    /// Returns a short hex fingerprint of this identity for logs and displays.
140    ///
141    /// The fingerprint is the first eight bytes of the public key in hex. It is a
142    /// convenient label, not a substitute for the full key when checking trust.
143    ///
144    /// # Returns
145    ///
146    /// A 16-character lowercase hex string.
147    pub fn fingerprint(&self) -> String {
148        let bytes = self.verifying.to_bytes();
149        let mut hex = String::with_capacity(16);
150        for &byte in &bytes[..8] {
151            hex.push(nibble(byte >> 4));
152            hex.push(nibble(byte & 0x0f));
153        }
154        hex
155    }
156
157    /// Verifies that `signature` covers `payload` and was made by this identity.
158    ///
159    /// # Arguments
160    ///
161    /// * `payload` - the bytes the signature is expected to cover.
162    /// * `signature` - the signature to check.
163    ///
164    /// # Returns
165    ///
166    /// `Ok(())` if the signature is authentic for `payload`.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`Error::Auth`](pamoja_core::Error::Auth) if the signature does not
171    /// match, which means the payload was altered or was not signed by this device.
172    pub fn verify(&self, payload: &[u8], signature: &Signature) -> Result<()> {
173        self.verifying
174            .verify(payload, &signature.0)
175            .map_err(|_| Error::Auth("signature verification failed".into()))
176    }
177
178    /// Verifies a message built by [`sign_message`] and returns the payload it carries.
179    ///
180    /// The signature travels with the payload, so a caller sends one message and gets
181    /// one payload back instead of tracking two byte strings and splitting them by hand.
182    /// The payload is borrowed from `message`, and is only returned once the signature
183    /// over it has been checked.
184    ///
185    /// [`sign_message`]: DeviceIdentity::sign_message
186    ///
187    /// # Arguments
188    ///
189    /// * `message` - the signature followed by the payload, as [`sign_message`] wrote it.
190    ///
191    /// # Returns
192    ///
193    /// The payload, authentic and unaltered.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`Error::Auth`](pamoja_core::Error::Auth) if `message` is shorter than a
198    /// signature or the signature does not match the payload, which means the message was
199    /// altered or was not signed by this device.
200    pub fn verify_message<'a>(&self, message: &'a [u8]) -> Result<&'a [u8]> {
201        let (signature, payload) = message
202            .split_at_checked(Signature::LEN)
203            .ok_or_else(|| Error::Auth("message is shorter than a signature".into()))?;
204        let signature: [u8; Signature::LEN] = signature
205            .try_into()
206            .map_err(|_| Error::Auth("message is shorter than a signature".into()))?;
207        self.verify(payload, &Signature::from_bytes(&signature))?;
208        Ok(payload)
209    }
210}
211
212// Maps a 0-15 value to its lowercase hex digit.
213fn nibble(value: u8) -> char {
214    char::from_digit(u32::from(value), 16).unwrap_or('0')
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn key_derivation_and_signing_match_the_rfc_8032_test_vector() {
223        // RFC 8032 section 7.1, TEST 2: the 32-byte secret, the one-byte message 0x72,
224        // and the signature the specification publishes for them. Anchoring to the
225        // document rather than to a round trip is what catches an implementation that
226        // is wrong but self-consistent.
227        let device = DeviceIdentity::from_seed(&[
228            0x4c, 0xcd, 0x08, 0x9b, 0x28, 0xff, 0x96, 0xda, 0x9d, 0xb6, 0xc3, 0x46, 0xec, 0x11,
229            0x4e, 0x0f, 0x5b, 0x8a, 0x31, 0x9f, 0x35, 0xab, 0xa6, 0x24, 0xda, 0x8c, 0xf6, 0xed,
230            0x4f, 0xb8, 0xa6, 0xfb,
231        ]);
232        assert_eq!(device.public().fingerprint(), "3d4017c3e843895a");
233        assert_eq!(
234            device.sign(&[0x72]).to_bytes(),
235            [
236                0x92, 0xa0, 0x09, 0xa9, 0xf0, 0xd4, 0xca, 0xb8, 0x72, 0x0e, 0x82, 0x0b, 0x5f, 0x64,
237                0x25, 0x40, 0xa2, 0xb2, 0x7b, 0x54, 0x16, 0x50, 0x3f, 0x8f, 0xb3, 0x76, 0x22, 0x23,
238                0xeb, 0xdb, 0x69, 0xda, 0x08, 0x5a, 0xc1, 0xe4, 0x3e, 0x15, 0x99, 0x6e, 0x45, 0x8f,
239                0x36, 0x13, 0xd0, 0xf1, 0x1d, 0x8c, 0x38, 0x7b, 0x2e, 0xae, 0xb4, 0x30, 0x2a, 0xee,
240                0xb0, 0x0d, 0x29, 0x16, 0x12, 0xbb, 0x0c, 0x00,
241            ]
242        );
243    }
244
245    #[test]
246    fn a_signed_message_carries_its_payload_and_is_checked_before_it_is_returned() {
247        let device = DeviceIdentity::from_seed(&[3u8; 32]);
248        let message = device.sign_message(b"meter-4 1182.750 kWh");
249        assert_eq!(message.len(), Signature::LEN + 20);
250
251        let public = device.public();
252        assert_eq!(
253            public
254                .verify_message(&message)
255                .expect("an authentic message"),
256            b"meter-4 1182.750 kWh"
257        );
258
259        // A payload edited in transit no longer matches the signature travelling with it.
260        let mut edited = message.clone();
261        *edited.last_mut().expect("a payload byte") ^= 0xFF;
262        assert!(public.verify_message(&edited).is_err());
263
264        // So does a message too short to hold a signature at all.
265        assert!(public.verify_message(&[0u8; 8]).is_err());
266        assert!(DeviceIdentity::from_seed(&[4u8; 32])
267            .public()
268            .verify_message(&message)
269            .is_err());
270    }
271
272    #[test]
273    fn a_signature_verifies_against_its_signer() {
274        let device = DeviceIdentity::from_seed(&[1u8; 32]);
275        let signature = device.sign(b"reading");
276        assert!(device.public().verify(b"reading", &signature).is_ok());
277    }
278
279    #[test]
280    fn a_tampered_payload_fails_verification() {
281        let device = DeviceIdentity::from_seed(&[2u8; 32]);
282        let signature = device.sign(b"4.8C");
283        let result = device.public().verify(b"9.9C", &signature);
284        assert!(matches!(result, Err(Error::Auth(_))));
285    }
286
287    #[test]
288    fn another_device_cannot_verify_the_signature() {
289        let device = DeviceIdentity::from_seed(&[3u8; 32]);
290        let other = DeviceIdentity::from_seed(&[4u8; 32]);
291        let signature = device.sign(b"reading");
292        assert!(other.public().verify(b"reading", &signature).is_err());
293    }
294
295    #[test]
296    fn a_public_identity_round_trips_through_bytes() {
297        let public = DeviceIdentity::from_seed(&[5u8; 32]).public();
298        let restored = PublicIdentity::from_bytes(&public.to_bytes()).expect("valid key");
299        assert_eq!(public, restored);
300    }
301
302    #[test]
303    fn a_signature_round_trips_through_bytes() {
304        let device = DeviceIdentity::from_seed(&[6u8; 32]);
305        let signature = device.sign(b"reading");
306        let restored = Signature::from_bytes(&signature.to_bytes());
307        assert!(device.public().verify(b"reading", &restored).is_ok());
308    }
309
310    #[test]
311    fn the_fingerprint_is_sixteen_hex_characters() {
312        let public = DeviceIdentity::from_seed(&[7u8; 32]).public();
313        let fingerprint = public.fingerprint();
314        assert_eq!(fingerprint.len(), 16);
315        assert!(fingerprint.chars().all(|c| c.is_ascii_hexdigit()));
316    }
317}