Skip to main content

pamoja_ffi/
audit.rs

1//! The C ABI for tamper-evident audit logs.
2//!
3//! These functions wrap [`pamoja_audit`] for callers that reach the SDK through
4//! the flat C boundary: a log that signs each record and chains it to the one
5//! before, and the two ways to check such a chain, one entry at a time as it
6//! streams in or all at once over a batch that has already arrived.
7//!
8//! A log carries the position and hash it will chain the next entry onto, and an
9//! entry owns its payload, so both cross as opaque handles. The identity that
10//! signs and the key that checks come from the [`security`](crate::security)
11//! capability, which this one builds on.
12
13use std::panic::{catch_unwind, AssertUnwindSafe};
14use std::ptr;
15
16use pamoja_audit::{verify_chain, AuditLog, Entry, Verifier};
17
18use crate::security::{identity_handle, read_public, PamojaDeviceIdentity};
19use crate::{read_bytes, set_last_error, PamojaBuffer, PamojaStatus};
20
21/// The length in bytes of an entry hash.
22pub const PAMOJA_AUDIT_DIGEST_LEN: usize = 32;
23
24/// The length in bytes of an entry signature.
25pub const PAMOJA_AUDIT_SIGNATURE_LEN: usize = 64;
26
27/// An opaque handle to one signed, chained record.
28///
29/// Obtain one from [`pamoja_audit_log_append`] or [`pamoja_audit_entry_from_bytes`],
30/// and release it with [`pamoja_audit_entry_free`].
31pub struct PamojaAuditEntry {
32    entry: Entry,
33}
34
35/// An opaque handle to a log that signs and chains what it is given.
36///
37/// Create it with [`pamoja_audit_log_new`], or with
38/// [`pamoja_audit_log_resume`] to carry on from a log that already has entries,
39/// and release it with [`pamoja_audit_log_free`].
40pub struct PamojaAuditLog {
41    log: AuditLog,
42}
43
44/// An opaque handle that checks a chain one entry at a time as it arrives.
45///
46/// Create it with [`pamoja_audit_verifier_new`] and release it with
47/// [`pamoja_audit_verifier_free`].
48pub struct PamojaAuditVerifier {
49    verifier: Verifier,
50}
51
52/// Creates a log that signs with a device identity and starts from nothing.
53///
54/// # Arguments
55///
56/// * `identity` - the identity whose signature each entry will carry.
57///
58/// # Returns
59///
60/// A handle the caller must release with [`pamoja_audit_log_free`], or null on
61/// failure with the reason available from
62/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
63///
64/// # Safety
65///
66/// `identity` must be a live handle from
67/// [`pamoja_device_identity_new`](crate::security::pamoja_device_identity_new),
68/// or null.
69#[no_mangle]
70pub unsafe extern "C" fn pamoja_audit_log_new(
71    identity: *const PamojaDeviceIdentity,
72) -> *mut PamojaAuditLog {
73    let Some(handle) = identity_handle(identity) else {
74        return ptr::null_mut();
75    };
76    Box::into_raw(Box::new(PamojaAuditLog {
77        log: AuditLog::new(handle.inner.clone()),
78    }))
79}
80
81/// Creates a log that carries on from the last entry an earlier one wrote.
82///
83/// This is what a device does after a restart: the chain continues at the next
84/// index and hashes onto the entry it left off at, so a reboot leaves no gap for
85/// a record to be removed through.
86///
87/// # Arguments
88///
89/// * `identity` - the identity whose signature each entry will carry.
90/// * `last` - the final entry of the existing log.
91///
92/// # Returns
93///
94/// A handle the caller must release with [`pamoja_audit_log_free`], or null on
95/// failure.
96///
97/// # Safety
98///
99/// `identity` must be a live identity handle, and `last` a live handle from a
100/// call that produced one, or either may be null.
101#[no_mangle]
102pub unsafe extern "C" fn pamoja_audit_log_resume(
103    identity: *const PamojaDeviceIdentity,
104    last: *const PamojaAuditEntry,
105) -> *mut PamojaAuditLog {
106    let Some(handle) = identity_handle(identity) else {
107        return ptr::null_mut();
108    };
109    let Some(last) = entry_handle(last) else {
110        return ptr::null_mut();
111    };
112    Box::into_raw(Box::new(PamojaAuditLog {
113        log: AuditLog::resume(handle.inner.clone(), &last.entry),
114    }))
115}
116
117/// Appends a payload to a log, signing it and chaining it onto the last entry.
118///
119/// # Arguments
120///
121/// * `log` - the log to append to.
122/// * `payload` - the record to store.
123/// * `payload_len` - the length of `payload` in bytes.
124///
125/// # Returns
126///
127/// A handle to the new entry, which the caller must release with
128/// [`pamoja_audit_entry_free`], or null on failure.
129///
130/// # Safety
131///
132/// `log` must be a live handle from [`pamoja_audit_log_new`] or
133/// [`pamoja_audit_log_resume`], and `payload` must point to at least
134/// `payload_len` readable bytes, or be null when `payload_len` is 0.
135#[no_mangle]
136pub unsafe extern "C" fn pamoja_audit_log_append(
137    log: *mut PamojaAuditLog,
138    payload: *const u8,
139    payload_len: usize,
140) -> *mut PamojaAuditEntry {
141    if log.is_null() {
142        set_last_error("log must not be null".to_owned());
143        return ptr::null_mut();
144    }
145    let Ok(payload) = read_bytes(payload, payload_len) else {
146        return ptr::null_mut();
147    };
148    let result = catch_unwind(AssertUnwindSafe(|| (*log).log.append(&payload)));
149    match result {
150        Ok(entry) => Box::into_raw(Box::new(PamojaAuditEntry { entry })),
151        Err(_) => {
152            set_last_error("append panicked".to_owned());
153            ptr::null_mut()
154        }
155    }
156}
157
158/// Releases a log handle.
159///
160/// Passing null is a no-op.
161///
162/// # Safety
163///
164/// `log` must be a handle from a call that produced one and that has not already
165/// been freed, or null. After this call it must not be used again.
166#[no_mangle]
167pub unsafe extern "C" fn pamoja_audit_log_free(log: *mut PamojaAuditLog) {
168    if !log.is_null() {
169        drop(Box::from_raw(log));
170    }
171}
172
173/// Reads an entry back from the bytes it was written as.
174///
175/// # Arguments
176///
177/// * `bytes` - the encoded entry.
178/// * `len` - the length of `bytes`.
179///
180/// # Returns
181///
182/// A handle the caller must release with [`pamoja_audit_entry_free`], or null if
183/// the bytes are not a well-formed entry.
184///
185/// # Safety
186///
187/// `bytes` must point to at least `len` readable bytes, or be null when `len` is
188/// 0.
189#[no_mangle]
190pub unsafe extern "C" fn pamoja_audit_entry_from_bytes(
191    bytes: *const u8,
192    len: usize,
193) -> *mut PamojaAuditEntry {
194    let Ok(bytes) = read_bytes(bytes, len) else {
195        return ptr::null_mut();
196    };
197    match Entry::from_bytes(&bytes) {
198        Ok(entry) => Box::into_raw(Box::new(PamojaAuditEntry { entry })),
199        Err(error) => {
200            set_last_error(error.to_string());
201            ptr::null_mut()
202        }
203    }
204}
205
206/// Encodes an entry for storage or transmission.
207///
208/// # Arguments
209///
210/// * `entry` - the entry to encode.
211///
212/// # Returns
213///
214/// A buffer the caller must release with
215/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or null if `entry` is null.
216///
217/// # Safety
218///
219/// `entry` must be a live handle from a call that produced one, or null.
220#[no_mangle]
221pub unsafe extern "C" fn pamoja_audit_entry_to_bytes(
222    entry: *const PamojaAuditEntry,
223) -> *mut PamojaBuffer {
224    let Some(entry) = entry_handle(entry) else {
225        return ptr::null_mut();
226    };
227    PamojaBuffer::into_raw(entry.entry.to_bytes())
228}
229
230/// Returns the position of an entry in its chain.
231///
232/// # Arguments
233///
234/// * `entry` - the entry.
235///
236/// # Returns
237///
238/// The zero-based index, or 0 if `entry` is null.
239///
240/// # Safety
241///
242/// `entry` must be a live handle from a call that produced one, or null.
243#[no_mangle]
244pub unsafe extern "C" fn pamoja_audit_entry_index(entry: *const PamojaAuditEntry) -> u64 {
245    match entry_handle(entry) {
246        Some(entry) => entry.entry.index(),
247        None => 0,
248    }
249}
250
251/// Copies out the hash of the entry before this one.
252///
253/// The first entry of a chain carries all zeroes here, since nothing precedes it.
254///
255/// # Arguments
256///
257/// * `entry` - the entry.
258/// * `out_previous` - receives [`PAMOJA_AUDIT_DIGEST_LEN`] bytes.
259///
260/// # Returns
261///
262/// [`PamojaStatus::Ok`] on success.
263///
264/// # Safety
265///
266/// `entry` must be a live handle, and `out_previous` must point to at least
267/// [`PAMOJA_AUDIT_DIGEST_LEN`] writable bytes.
268#[no_mangle]
269pub unsafe extern "C" fn pamoja_audit_entry_previous(
270    entry: *const PamojaAuditEntry,
271    out_previous: *mut u8,
272) -> PamojaStatus {
273    let Some(entry) = entry_handle(entry) else {
274        return PamojaStatus::InvalidArgument;
275    };
276    write_digest(entry.entry.previous(), out_previous, "out_previous")
277}
278
279/// Copies out the hash of this entry, which the next one chains onto.
280///
281/// # Arguments
282///
283/// * `entry` - the entry.
284/// * `out_digest` - receives [`PAMOJA_AUDIT_DIGEST_LEN`] bytes.
285///
286/// # Returns
287///
288/// [`PamojaStatus::Ok`] on success.
289///
290/// # Safety
291///
292/// `entry` must be a live handle, and `out_digest` must point to at least
293/// [`PAMOJA_AUDIT_DIGEST_LEN`] writable bytes.
294#[no_mangle]
295pub unsafe extern "C" fn pamoja_audit_entry_digest(
296    entry: *const PamojaAuditEntry,
297    out_digest: *mut u8,
298) -> PamojaStatus {
299    let Some(entry) = entry_handle(entry) else {
300        return PamojaStatus::InvalidArgument;
301    };
302    write_digest(entry.entry.digest(), out_digest, "out_digest")
303}
304
305/// Copies out the signature over an entry.
306///
307/// # Arguments
308///
309/// * `entry` - the entry.
310/// * `out_signature` - receives [`PAMOJA_AUDIT_SIGNATURE_LEN`] bytes.
311///
312/// # Returns
313///
314/// [`PamojaStatus::Ok`] on success.
315///
316/// # Safety
317///
318/// `entry` must be a live handle, and `out_signature` must point to at least
319/// [`PAMOJA_AUDIT_SIGNATURE_LEN`] writable bytes.
320#[no_mangle]
321pub unsafe extern "C" fn pamoja_audit_entry_signature(
322    entry: *const PamojaAuditEntry,
323    out_signature: *mut u8,
324) -> PamojaStatus {
325    let Some(entry) = entry_handle(entry) else {
326        return PamojaStatus::InvalidArgument;
327    };
328    if out_signature.is_null() {
329        set_last_error("out_signature must not be null".to_owned());
330        return PamojaStatus::InvalidArgument;
331    }
332    let bytes = entry.entry.signature().to_bytes();
333    ptr::copy_nonoverlapping(bytes.as_ptr(), out_signature, PAMOJA_AUDIT_SIGNATURE_LEN);
334    PamojaStatus::Ok
335}
336
337/// Copies out the record an entry carries.
338///
339/// # Arguments
340///
341/// * `entry` - the entry.
342///
343/// # Returns
344///
345/// A buffer the caller must release with
346/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or null if `entry` is null.
347///
348/// # Safety
349///
350/// `entry` must be a live handle from a call that produced one, or null.
351#[no_mangle]
352pub unsafe extern "C" fn pamoja_audit_entry_payload(
353    entry: *const PamojaAuditEntry,
354) -> *mut PamojaBuffer {
355    let Some(entry) = entry_handle(entry) else {
356        return ptr::null_mut();
357    };
358    PamojaBuffer::into_raw(entry.entry.payload().to_vec())
359}
360
361/// Releases an entry handle.
362///
363/// Passing null is a no-op.
364///
365/// # Safety
366///
367/// `entry` must be a handle from a call that produced one and that has not
368/// already been freed, or null. After this call it must not be used again.
369#[no_mangle]
370pub unsafe extern "C" fn pamoja_audit_entry_free(entry: *mut PamojaAuditEntry) {
371    if !entry.is_null() {
372        drop(Box::from_raw(entry));
373    }
374}
375
376/// Creates a verifier that checks a chain signed by one public key.
377///
378/// # Arguments
379///
380/// * `public_key` - the `PAMOJA_KEY_LEN`-byte key the entries were signed with.
381///
382/// # Returns
383///
384/// A handle the caller must release with [`pamoja_audit_verifier_free`], or null
385/// if the key is not a valid public key.
386///
387/// # Safety
388///
389/// `public_key` must point to at least `PAMOJA_KEY_LEN` readable bytes.
390#[no_mangle]
391pub unsafe extern "C" fn pamoja_audit_verifier_new(
392    public_key: *const u8,
393) -> *mut PamojaAuditVerifier {
394    let Ok(public) = read_public(public_key) else {
395        return ptr::null_mut();
396    };
397    Box::into_raw(Box::new(PamojaAuditVerifier {
398        verifier: Verifier::new(public),
399    }))
400}
401
402/// Checks the next entry of a chain, in the order the entries were written.
403///
404/// A verifier only accepts an entry that follows the one before it, so feeding
405/// entries out of order, skipping one, or repeating one is refused just as an
406/// altered payload is.
407///
408/// # Arguments
409///
410/// * `verifier` - the verifier.
411/// * `entry` - the next entry to check.
412///
413/// # Returns
414///
415/// [`PamojaStatus::Ok`] if the entry belongs where it was offered, or
416/// [`PamojaStatus::Auth`] if the chain, the index, or the signature does not hold.
417///
418/// # Safety
419///
420/// `verifier` must be a live handle from [`pamoja_audit_verifier_new`], and
421/// `entry` a live entry handle, or either may be null.
422#[no_mangle]
423pub unsafe extern "C" fn pamoja_audit_verifier_check(
424    verifier: *mut PamojaAuditVerifier,
425    entry: *const PamojaAuditEntry,
426) -> PamojaStatus {
427    if verifier.is_null() {
428        set_last_error("verifier must not be null".to_owned());
429        return PamojaStatus::InvalidArgument;
430    }
431    let Some(entry) = entry_handle(entry) else {
432        return PamojaStatus::InvalidArgument;
433    };
434    match (*verifier).verifier.check(&entry.entry) {
435        Ok(()) => PamojaStatus::Ok,
436        Err(error) => {
437            let status = PamojaStatus::from_error(&error);
438            set_last_error(error.to_string());
439            status
440        }
441    }
442}
443
444/// Releases a verifier handle.
445///
446/// Passing null is a no-op.
447///
448/// # Safety
449///
450/// `verifier` must be a handle from [`pamoja_audit_verifier_new`] that has not
451/// already been freed, or null. After this call it must not be used again.
452#[no_mangle]
453pub unsafe extern "C" fn pamoja_audit_verifier_free(verifier: *mut PamojaAuditVerifier) {
454    if !verifier.is_null() {
455        drop(Box::from_raw(verifier));
456    }
457}
458
459/// Checks a whole chain that has already arrived.
460///
461/// # Arguments
462///
463/// * `public_key` - the `PAMOJA_KEY_LEN`-byte key the entries were signed with.
464/// * `entries` - an array of entry handles, in the order they were written.
465/// * `count` - how many handles `entries` holds.
466///
467/// # Returns
468///
469/// [`PamojaStatus::Ok`] if every entry follows the one before it and carries a
470/// signature that holds, or [`PamojaStatus::Auth`] if any does not.
471///
472/// # Safety
473///
474/// `public_key` must point to at least `PAMOJA_KEY_LEN` readable bytes, and
475/// `entries` must point to at least `count` live entry handles, none of them
476/// null, or be null when `count` is 0.
477#[no_mangle]
478pub unsafe extern "C" fn pamoja_audit_verify_chain(
479    public_key: *const u8,
480    entries: *const *const PamojaAuditEntry,
481    count: usize,
482) -> PamojaStatus {
483    let public = match read_public(public_key) {
484        Ok(public) => public,
485        Err(status) => return status,
486    };
487    if count != 0 && entries.is_null() {
488        set_last_error("entries must not be null when count is non-zero".to_owned());
489        return PamojaStatus::InvalidArgument;
490    }
491
492    let mut owned = Vec::with_capacity(count);
493    for offset in 0..count {
494        let Some(entry) = entry_handle(*entries.add(offset)) else {
495            return PamojaStatus::InvalidArgument;
496        };
497        owned.push(entry.entry.clone());
498    }
499
500    match verify_chain(&public, &owned) {
501        Ok(()) => PamojaStatus::Ok,
502        Err(error) => {
503            let status = PamojaStatus::from_error(&error);
504            set_last_error(error.to_string());
505            status
506        }
507    }
508}
509
510/// Borrows an entry handle, rejecting a null pointer.
511///
512/// # Safety
513///
514/// `entry` must be a live handle from a call that produced one, or null.
515unsafe fn entry_handle<'a>(entry: *const PamojaAuditEntry) -> Option<&'a PamojaAuditEntry> {
516    if entry.is_null() {
517        set_last_error("entry must not be null".to_owned());
518        return None;
519    }
520    Some(&*entry)
521}
522
523/// Copies a 32-byte hash into a caller buffer, rejecting a null destination.
524///
525/// # Safety
526///
527/// `out` must point to at least [`PAMOJA_AUDIT_DIGEST_LEN`] writable bytes, or
528/// be null.
529unsafe fn write_digest(
530    digest: [u8; PAMOJA_AUDIT_DIGEST_LEN],
531    out: *mut u8,
532    name: &str,
533) -> PamojaStatus {
534    if out.is_null() {
535        set_last_error(format!("{name} must not be null"));
536        return PamojaStatus::InvalidArgument;
537    }
538    ptr::copy_nonoverlapping(digest.as_ptr(), out, PAMOJA_AUDIT_DIGEST_LEN);
539    PamojaStatus::Ok
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::security::{
546        pamoja_device_identity_free, pamoja_device_identity_new, PAMOJA_KEY_LEN,
547    };
548    use crate::{pamoja_buffer_data, pamoja_buffer_free, pamoja_buffer_len};
549
550    /// Builds an identity and its public key from a repeated-byte seed.
551    unsafe fn signer(seed: u8) -> (*mut PamojaDeviceIdentity, [u8; PAMOJA_KEY_LEN]) {
552        let seed = [seed; PAMOJA_KEY_LEN];
553        let identity = pamoja_device_identity_new(seed.as_ptr(), seed.len());
554        assert!(!identity.is_null());
555        let mut public = [0u8; PAMOJA_KEY_LEN];
556        assert_eq!(
557            crate::security::pamoja_device_identity_public_key(identity, public.as_mut_ptr()),
558            PamojaStatus::Ok
559        );
560        (identity, public)
561    }
562
563    /// Copies a buffer out and releases it.
564    unsafe fn take(buffer: *mut PamojaBuffer) -> Vec<u8> {
565        assert!(!buffer.is_null());
566        let bytes =
567            std::slice::from_raw_parts(pamoja_buffer_data(buffer), pamoja_buffer_len(buffer))
568                .to_vec();
569        pamoja_buffer_free(buffer);
570        bytes
571    }
572
573    #[test]
574    fn a_chain_verifies_entry_by_entry() {
575        unsafe {
576            let (identity, public) = signer(7);
577            let log = pamoja_audit_log_new(identity);
578            let verifier = pamoja_audit_verifier_new(public.as_ptr());
579
580            for index in 0..3u64 {
581                let payload = [index as u8; 4];
582                let entry = pamoja_audit_log_append(log, payload.as_ptr(), payload.len());
583                assert_eq!(pamoja_audit_entry_index(entry), index);
584                assert_eq!(
585                    pamoja_audit_verifier_check(verifier, entry),
586                    PamojaStatus::Ok
587                );
588                assert_eq!(take(pamoja_audit_entry_payload(entry)), payload);
589                pamoja_audit_entry_free(entry);
590            }
591
592            pamoja_audit_verifier_free(verifier);
593            pamoja_audit_log_free(log);
594            pamoja_device_identity_free(identity);
595        }
596    }
597
598    #[test]
599    fn an_altered_record_breaks_the_chain() {
600        unsafe {
601            let (identity, public) = signer(9);
602            let log = pamoja_audit_log_new(identity);
603
604            let first = pamoja_audit_log_append(log, b"open".as_ptr(), 4);
605            let second = pamoja_audit_log_append(log, b"shut".as_ptr(), 4);
606
607            let mut bytes = take(pamoja_audit_entry_to_bytes(second));
608            let last = bytes.len() - 1;
609            bytes[last] ^= 0xff;
610            let tampered = pamoja_audit_entry_from_bytes(bytes.as_ptr(), bytes.len());
611            assert!(!tampered.is_null());
612
613            let chain = [first.cast_const(), tampered.cast_const()];
614            assert_eq!(
615                pamoja_audit_verify_chain(public.as_ptr(), chain.as_ptr(), chain.len()),
616                PamojaStatus::Auth
617            );
618
619            let honest = [first.cast_const(), second.cast_const()];
620            assert_eq!(
621                pamoja_audit_verify_chain(public.as_ptr(), honest.as_ptr(), honest.len()),
622                PamojaStatus::Ok
623            );
624
625            pamoja_audit_entry_free(tampered);
626            pamoja_audit_entry_free(second);
627            pamoja_audit_entry_free(first);
628            pamoja_audit_log_free(log);
629            pamoja_device_identity_free(identity);
630        }
631    }
632
633    #[test]
634    fn a_resumed_log_continues_the_chain() {
635        unsafe {
636            let (identity, public) = signer(11);
637            let first_log = pamoja_audit_log_new(identity);
638            let first = pamoja_audit_log_append(first_log, b"boot".as_ptr(), 4);
639
640            let resumed = pamoja_audit_log_resume(identity, first);
641            let second = pamoja_audit_log_append(resumed, b"read".as_ptr(), 4);
642            assert_eq!(pamoja_audit_entry_index(second), 1);
643
644            let mut previous = [0u8; PAMOJA_AUDIT_DIGEST_LEN];
645            let mut digest = [0u8; PAMOJA_AUDIT_DIGEST_LEN];
646            assert_eq!(
647                pamoja_audit_entry_previous(second, previous.as_mut_ptr()),
648                PamojaStatus::Ok
649            );
650            assert_eq!(
651                pamoja_audit_entry_digest(first, digest.as_mut_ptr()),
652                PamojaStatus::Ok
653            );
654            assert_eq!(previous, digest);
655
656            let chain = [first.cast_const(), second.cast_const()];
657            assert_eq!(
658                pamoja_audit_verify_chain(public.as_ptr(), chain.as_ptr(), chain.len()),
659                PamojaStatus::Ok
660            );
661
662            pamoja_audit_entry_free(second);
663            pamoja_audit_entry_free(first);
664            pamoja_audit_log_free(resumed);
665            pamoja_audit_log_free(first_log);
666            pamoja_device_identity_free(identity);
667        }
668    }
669
670    #[test]
671    fn a_null_handle_is_refused_rather_than_dereferenced() {
672        unsafe {
673            assert!(pamoja_audit_log_new(ptr::null()).is_null());
674            assert!(pamoja_audit_entry_to_bytes(ptr::null()).is_null());
675            assert_eq!(pamoja_audit_entry_index(ptr::null()), 0);
676            assert_eq!(
677                pamoja_audit_verifier_check(ptr::null_mut(), ptr::null()),
678                PamojaStatus::InvalidArgument
679            );
680            pamoja_audit_entry_free(ptr::null_mut());
681            pamoja_audit_log_free(ptr::null_mut());
682            pamoja_audit_verifier_free(ptr::null_mut());
683        }
684    }
685}