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}