Skip to main content

pamoja_ffi/
security.rs

1//! The C ABI for device identity and signed telemetry.
2//!
3//! These functions wrap [`pamoja_security`] for callers that reach the SDK
4//! through the flat C boundary. Signing and verifying are deterministic and need
5//! no runtime, so unlike the transport capabilities nothing here blocks on an
6//! executor.
7//!
8//! Every value this capability exchanges has a fixed width - a 32-byte seed, a
9//! 32-byte public key, a 64-byte signature, a 16-character fingerprint - so the
10//! caller supplies the output array and no allocation crosses the boundary. Only
11//! the private identity is a handle, because it holds a secret that should not be
12//! copied around by value.
13
14use std::panic::{catch_unwind, AssertUnwindSafe};
15use std::ptr;
16
17use pamoja_security::{DeviceIdentity, PublicIdentity, Signature};
18
19use crate::{read_bytes, set_last_error, PamojaBuffer, PamojaStatus};
20
21/// The length in bytes of an identity seed and of a public key.
22pub const PAMOJA_KEY_LEN: usize = 32;
23
24/// The length in bytes of a signature.
25pub const PAMOJA_SIGNATURE_LEN: usize = 64;
26
27/// The length in characters of a hex fingerprint.
28pub const PAMOJA_FINGERPRINT_LEN: usize = 16;
29
30/// An opaque handle to a device's private signing identity.
31pub struct PamojaDeviceIdentity {
32    pub(crate) inner: DeviceIdentity,
33}
34
35/// Creates a device identity from a provisioned 32-byte secret seed.
36///
37/// # Returns
38///
39/// A heap-allocated identity handle the caller owns and must release with
40/// [`pamoja_device_identity_free`], or null on failure with the reason available
41/// from [`pamoja_last_error_message`](crate::pamoja_last_error_message).
42///
43/// # Safety
44///
45/// `seed` must point to at least `seed_len` readable bytes, and `seed_len` must
46/// be [`PAMOJA_KEY_LEN`].
47#[no_mangle]
48pub unsafe extern "C" fn pamoja_device_identity_new(
49    seed: *const u8,
50    seed_len: usize,
51) -> *mut PamojaDeviceIdentity {
52    let bytes = match read_bytes(seed, seed_len) {
53        Ok(bytes) => bytes,
54        Err(_) => return ptr::null_mut(),
55    };
56    let Ok(seed) = <[u8; PAMOJA_KEY_LEN]>::try_from(bytes.as_slice()) else {
57        set_last_error(format!("seed must be exactly {PAMOJA_KEY_LEN} bytes"));
58        return ptr::null_mut();
59    };
60    Box::into_raw(Box::new(PamojaDeviceIdentity {
61        inner: DeviceIdentity::from_seed(&seed),
62    }))
63}
64
65/// Writes the public key matching a device identity.
66///
67/// # Returns
68///
69/// [`PamojaStatus::Ok`] on success, having written [`PAMOJA_KEY_LEN`] bytes to
70/// `out_public_key`.
71///
72/// # Safety
73///
74/// `identity` must be a live handle from [`pamoja_device_identity_new`], and
75/// `out_public_key` must point to at least [`PAMOJA_KEY_LEN`] writable bytes.
76#[no_mangle]
77pub unsafe extern "C" fn pamoja_device_identity_public_key(
78    identity: *const PamojaDeviceIdentity,
79    out_public_key: *mut u8,
80) -> PamojaStatus {
81    let Some(identity) = identity_handle(identity) else {
82        return PamojaStatus::InvalidArgument;
83    };
84    if out_public_key.is_null() {
85        set_last_error("out_public_key must not be null".to_owned());
86        return PamojaStatus::InvalidArgument;
87    }
88    let key = identity.inner.public().to_bytes();
89    ptr::copy_nonoverlapping(key.as_ptr(), out_public_key, PAMOJA_KEY_LEN);
90    PamojaStatus::Ok
91}
92
93/// Signs a payload with a device identity.
94///
95/// # Returns
96///
97/// [`PamojaStatus::Ok`] on success, having written [`PAMOJA_SIGNATURE_LEN`] bytes
98/// to `out_signature`.
99///
100/// # Safety
101///
102/// `identity` must be a live handle from [`pamoja_device_identity_new`];
103/// `payload` must point to at least `payload_len` readable bytes, or be null when
104/// `payload_len` is 0; and `out_signature` must point to at least
105/// [`PAMOJA_SIGNATURE_LEN`] writable bytes.
106#[no_mangle]
107pub unsafe extern "C" fn pamoja_device_identity_sign(
108    identity: *const PamojaDeviceIdentity,
109    payload: *const u8,
110    payload_len: usize,
111    out_signature: *mut u8,
112) -> PamojaStatus {
113    let Some(identity) = identity_handle(identity) else {
114        return PamojaStatus::InvalidArgument;
115    };
116    if out_signature.is_null() {
117        set_last_error("out_signature must not be null".to_owned());
118        return PamojaStatus::InvalidArgument;
119    }
120    let payload = match read_bytes(payload, payload_len) {
121        Ok(payload) => payload,
122        Err(status) => return status,
123    };
124    match catch_unwind(AssertUnwindSafe(|| {
125        identity.inner.sign(&payload).to_bytes()
126    })) {
127        Ok(signature) => {
128            ptr::copy_nonoverlapping(signature.as_ptr(), out_signature, PAMOJA_SIGNATURE_LEN);
129            PamojaStatus::Ok
130        }
131        Err(_) => {
132            set_last_error("panic at the FFI boundary".to_owned());
133            PamojaStatus::Panic
134        }
135    }
136}
137
138/// Signs a payload and returns one buffer holding the signature and the payload.
139///
140/// This is the answering half of [`pamoja_public_identity_verify_message`]: the
141/// caller sends one blob instead of keeping a payload and a detached signature
142/// together and splitting them correctly at the far end.
143///
144/// # Returns
145///
146/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
147/// the caller must release with `pamoja_buffer_free`.
148///
149/// # Safety
150///
151/// `identity` must be a live handle from [`pamoja_device_identity_new`];
152/// `payload` must point to at least `payload_len` readable bytes, or be null when
153/// `payload_len` is 0; and `out_buffer` must point to a writable
154/// `*mut PamojaBuffer`.
155#[no_mangle]
156pub unsafe extern "C" fn pamoja_device_identity_sign_message(
157    identity: *const PamojaDeviceIdentity,
158    payload: *const u8,
159    payload_len: usize,
160    out_buffer: *mut *mut PamojaBuffer,
161) -> PamojaStatus {
162    let Some(identity) = identity_handle(identity) else {
163        return PamojaStatus::InvalidArgument;
164    };
165    if out_buffer.is_null() {
166        set_last_error("out_buffer must not be null".to_owned());
167        return PamojaStatus::InvalidArgument;
168    }
169    let payload = match read_bytes(payload, payload_len) {
170        Ok(payload) => payload,
171        Err(status) => return status,
172    };
173    match catch_unwind(AssertUnwindSafe(|| identity.inner.sign_message(&payload))) {
174        Ok(message) => {
175            *out_buffer = PamojaBuffer::into_raw(message);
176            PamojaStatus::Ok
177        }
178        Err(_) => {
179            set_last_error("panic at the FFI boundary".to_owned());
180            PamojaStatus::Panic
181        }
182    }
183}
184
185/// Verifies a signed message and returns the payload it carries.
186///
187/// # Returns
188///
189/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
190/// holding the payload, which the caller must release with `pamoja_buffer_free`.
191/// Returns [`PamojaStatus::Auth`] if the message is shorter than a signature, was
192/// altered, or was signed by a different device.
193///
194/// # Safety
195///
196/// `public_key` must point to at least [`PAMOJA_KEY_LEN`] readable bytes;
197/// `message` must point to at least `message_len` readable bytes, or be null when
198/// `message_len` is 0; and `out_buffer` must point to a writable
199/// `*mut PamojaBuffer`.
200#[no_mangle]
201pub unsafe extern "C" fn pamoja_public_identity_verify_message(
202    public_key: *const u8,
203    message: *const u8,
204    message_len: usize,
205    out_buffer: *mut *mut PamojaBuffer,
206) -> PamojaStatus {
207    if out_buffer.is_null() {
208        set_last_error("out_buffer must not be null".to_owned());
209        return PamojaStatus::InvalidArgument;
210    }
211    let public = match read_public(public_key) {
212        Ok(public) => public,
213        Err(status) => return status,
214    };
215    let message = match read_bytes(message, message_len) {
216        Ok(message) => message,
217        Err(status) => return status,
218    };
219    match public.verify_message(&message) {
220        Ok(payload) => {
221            *out_buffer = PamojaBuffer::into_raw(payload.to_vec());
222            PamojaStatus::Ok
223        }
224        Err(error) => {
225            set_last_error(error.to_string());
226            PamojaStatus::from_error(&error)
227        }
228    }
229}
230
231/// Releases a device identity handle.
232///
233/// Passing null is a no-op.
234///
235/// # Safety
236///
237/// `identity` must be a handle from [`pamoja_device_identity_new`] that has not
238/// already been freed, or null. After this call it must not be used again.
239#[no_mangle]
240pub unsafe extern "C" fn pamoja_device_identity_free(identity: *mut PamojaDeviceIdentity) {
241    if !identity.is_null() {
242        drop(Box::from_raw(identity));
243    }
244}
245
246/// Writes the short hex fingerprint of a public key.
247///
248/// The fingerprint is a convenient label for logs and displays, not a substitute
249/// for the full key when checking trust.
250///
251/// # Returns
252///
253/// [`PamojaStatus::Ok`] on success, having written [`PAMOJA_FINGERPRINT_LEN`]
254/// lowercase hex characters to `out_fingerprint`. No null terminator is written.
255///
256/// # Safety
257///
258/// `public_key` must point to at least [`PAMOJA_KEY_LEN`] readable bytes, and
259/// `out_fingerprint` must point to at least [`PAMOJA_FINGERPRINT_LEN`] writable
260/// bytes.
261#[no_mangle]
262pub unsafe extern "C" fn pamoja_public_identity_fingerprint(
263    public_key: *const u8,
264    out_fingerprint: *mut u8,
265) -> PamojaStatus {
266    let public = match read_public(public_key) {
267        Ok(public) => public,
268        Err(status) => return status,
269    };
270    if out_fingerprint.is_null() {
271        set_last_error("out_fingerprint must not be null".to_owned());
272        return PamojaStatus::InvalidArgument;
273    }
274    let fingerprint = public.fingerprint();
275    ptr::copy_nonoverlapping(
276        fingerprint.as_ptr(),
277        out_fingerprint,
278        PAMOJA_FINGERPRINT_LEN,
279    );
280    PamojaStatus::Ok
281}
282
283/// Verifies that a signature covers a payload and was made by a public key.
284///
285/// # Returns
286///
287/// [`PamojaStatus::Ok`] if the signature is authentic, or [`PamojaStatus::Auth`]
288/// if it is not, which means the payload was altered or was signed by a different
289/// device.
290///
291/// # Safety
292///
293/// `public_key` must point to at least [`PAMOJA_KEY_LEN`] readable bytes;
294/// `payload` must point to at least `payload_len` readable bytes, or be null when
295/// `payload_len` is 0; and `signature` must point to at least
296/// [`PAMOJA_SIGNATURE_LEN`] readable bytes.
297#[no_mangle]
298pub unsafe extern "C" fn pamoja_public_identity_verify(
299    public_key: *const u8,
300    payload: *const u8,
301    payload_len: usize,
302    signature: *const u8,
303) -> PamojaStatus {
304    let public = match read_public(public_key) {
305        Ok(public) => public,
306        Err(status) => return status,
307    };
308    let payload = match read_bytes(payload, payload_len) {
309        Ok(payload) => payload,
310        Err(status) => return status,
311    };
312    let signature_bytes = match read_bytes(signature, PAMOJA_SIGNATURE_LEN) {
313        Ok(bytes) => bytes,
314        Err(status) => return status,
315    };
316    let Ok(signature_bytes) = <[u8; PAMOJA_SIGNATURE_LEN]>::try_from(signature_bytes.as_slice())
317    else {
318        set_last_error(format!(
319            "signature must be exactly {PAMOJA_SIGNATURE_LEN} bytes"
320        ));
321        return PamojaStatus::InvalidArgument;
322    };
323    let signature = Signature::from_bytes(&signature_bytes);
324
325    match public.verify(&payload, &signature) {
326        Ok(()) => PamojaStatus::Ok,
327        Err(error) => {
328            set_last_error(error.to_string());
329            PamojaStatus::from_error(&error)
330        }
331    }
332}
333
334/// Borrows an identity handle, recording an error when it is null.
335///
336/// # Safety
337///
338/// `identity` must be a live handle from [`pamoja_device_identity_new`], or null.
339pub(crate) unsafe fn identity_handle<'a>(
340    identity: *const PamojaDeviceIdentity,
341) -> Option<&'a PamojaDeviceIdentity> {
342    if identity.is_null() {
343        set_last_error("identity must not be null".to_owned());
344        return None;
345    }
346    Some(&*identity)
347}
348
349/// Reads a 32-byte public key, rejecting a null pointer or an invalid key.
350///
351/// # Safety
352///
353/// `public_key` must point to at least [`PAMOJA_KEY_LEN`] readable bytes, or be
354/// null.
355pub(crate) unsafe fn read_public(public_key: *const u8) -> Result<PublicIdentity, PamojaStatus> {
356    let bytes = read_bytes(public_key, PAMOJA_KEY_LEN)?;
357    let Ok(bytes) = <[u8; PAMOJA_KEY_LEN]>::try_from(bytes.as_slice()) else {
358        set_last_error(format!("public key must be exactly {PAMOJA_KEY_LEN} bytes"));
359        return Err(PamojaStatus::InvalidArgument);
360    };
361    PublicIdentity::from_bytes(&bytes).map_err(|error| {
362        let status = PamojaStatus::from_error(&error);
363        set_last_error(error.to_string());
364        status
365    })
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    /// Builds an identity from a repeated-byte seed for the tests below.
373    fn identity(seed: u8) -> *mut PamojaDeviceIdentity {
374        let seed = [seed; PAMOJA_KEY_LEN];
375        // Safety: the seed is a valid 32-byte buffer for the call.
376        let handle = unsafe { pamoja_device_identity_new(seed.as_ptr(), seed.len()) };
377        assert!(!handle.is_null());
378        handle
379    }
380
381    #[test]
382    fn a_signature_verifies_against_its_signer() {
383        let device = identity(1);
384        let mut public = [0u8; PAMOJA_KEY_LEN];
385        let mut signature = [0u8; PAMOJA_SIGNATURE_LEN];
386        let payload = b"reading";
387
388        // Safety: every buffer below is correctly sized and the handle is live.
389        unsafe {
390            assert_eq!(
391                pamoja_device_identity_public_key(device, public.as_mut_ptr()),
392                PamojaStatus::Ok
393            );
394            assert_eq!(
395                pamoja_device_identity_sign(
396                    device,
397                    payload.as_ptr(),
398                    payload.len(),
399                    signature.as_mut_ptr()
400                ),
401                PamojaStatus::Ok
402            );
403            assert_eq!(
404                pamoja_public_identity_verify(
405                    public.as_ptr(),
406                    payload.as_ptr(),
407                    payload.len(),
408                    signature.as_ptr()
409                ),
410                PamojaStatus::Ok
411            );
412            pamoja_device_identity_free(device);
413        }
414    }
415
416    #[test]
417    fn a_tampered_payload_fails_with_an_auth_status() {
418        let device = identity(2);
419        let mut public = [0u8; PAMOJA_KEY_LEN];
420        let mut signature = [0u8; PAMOJA_SIGNATURE_LEN];
421        let payload = b"reading";
422
423        // Safety: every buffer below is correctly sized and the handle is live.
424        unsafe {
425            pamoja_device_identity_public_key(device, public.as_mut_ptr());
426            pamoja_device_identity_sign(
427                device,
428                payload.as_ptr(),
429                payload.len(),
430                signature.as_mut_ptr(),
431            );
432            let tampered = b"reading!";
433            assert_eq!(
434                pamoja_public_identity_verify(
435                    public.as_ptr(),
436                    tampered.as_ptr(),
437                    tampered.len(),
438                    signature.as_ptr()
439                ),
440                PamojaStatus::Auth
441            );
442            pamoja_device_identity_free(device);
443        }
444    }
445
446    #[test]
447    fn a_seed_of_the_wrong_length_is_rejected() {
448        let seed = [7u8; 16];
449        // Safety: the pointer and length agree; only the length is wrong for a seed.
450        let handle = unsafe { pamoja_device_identity_new(seed.as_ptr(), seed.len()) };
451        assert!(handle.is_null());
452    }
453
454    #[test]
455    fn the_fingerprint_is_lowercase_hex() {
456        let device = identity(3);
457        let mut public = [0u8; PAMOJA_KEY_LEN];
458        let mut fingerprint = [0u8; PAMOJA_FINGERPRINT_LEN];
459
460        // Safety: every buffer below is correctly sized and the handle is live.
461        unsafe {
462            pamoja_device_identity_public_key(device, public.as_mut_ptr());
463            assert_eq!(
464                pamoja_public_identity_fingerprint(public.as_ptr(), fingerprint.as_mut_ptr()),
465                PamojaStatus::Ok
466            );
467            pamoja_device_identity_free(device);
468        }
469        assert!(fingerprint
470            .iter()
471            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)));
472    }
473
474    #[test]
475    fn calls_on_a_null_identity_are_rejected() {
476        let mut public = [0u8; PAMOJA_KEY_LEN];
477        // Safety: every entry point tolerates a null handle without dereferencing it.
478        unsafe {
479            assert_eq!(
480                pamoja_device_identity_public_key(ptr::null(), public.as_mut_ptr()),
481                PamojaStatus::InvalidArgument
482            );
483            // Freeing null is a documented no-op.
484            pamoja_device_identity_free(ptr::null_mut());
485        }
486    }
487}