Skip to main content

pamoja_ffi/
session.rs

1//! The C ABI for encrypted, authenticated sessions.
2//!
3//! These functions wrap [`pamoja_session`] for callers that reach the SDK through
4//! the flat C boundary: the key agreement two devices use to arrive at the same
5//! session key without sending it, and the sealed messages that key then protects.
6//!
7//! A session holds the counter it sends under and the window of what it has
8//! accepted, so it crosses as an opaque handle; so does an agreement key, because
9//! it holds a secret that should not be copied around by value. Messages are
10//! encrypted in place, so the caller supplies the buffer and nothing allocates.
11
12use std::panic::{catch_unwind, AssertUnwindSafe};
13use std::ptr;
14use std::slice;
15
16use pamoja_session::{
17    hkdf_sha256, hmac_sha256, AgreementKey, AgreementPublicKey, Role, Sealed, Session,
18};
19
20use crate::{read_bytes, set_last_error, PamojaStatus};
21
22/// The length in bytes of an agreement seed, a public key, and a digest.
23pub const PAMOJA_SESSION_KEY_LEN: usize = 32;
24
25/// The length in bytes of the tag that authenticates a sealed message.
26pub const PAMOJA_SESSION_TAG_LEN: usize = 16;
27
28/// Which side of a session a device is on.
29///
30/// The two devices must choose opposite roles. The role decides the order the
31/// public keys are mixed in and which direction each side tags its messages with,
32/// so a session where both sides claim the same role will not open anything.
33#[repr(C)]
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum PamojaSessionRole {
36    /// The device that opens the session.
37    Initiator = 0,
38    /// The device that answers.
39    Responder = 1,
40}
41
42/// The header that travels beside a sealed message.
43///
44/// The peer needs the counter to rebuild the nonce and to reject a replay, and
45/// the tag to tell whether the message arrived as it was sent.
46#[repr(C)]
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct PamojaSealed {
49    /// The counter naming this message within the session.
50    pub counter: u64,
51    /// The tag over the ciphertext and its associated data.
52    pub tag: [u8; PAMOJA_SESSION_TAG_LEN],
53}
54
55/// An opaque handle to a key-agreement secret.
56///
57/// Create it with [`pamoja_agreement_key_from_seed`] and release it with
58/// [`pamoja_agreement_key_free`].
59pub struct PamojaAgreementKey {
60    key: AgreementKey,
61}
62
63/// An opaque handle to a live session with one peer.
64///
65/// Create it with [`pamoja_session_establish`] and release it with
66/// [`pamoja_session_free`].
67pub struct PamojaSession {
68    session: Session,
69}
70
71/// Creates a key-agreement secret from a provisioned 32-byte seed.
72///
73/// # Arguments
74///
75/// * `seed` - the [`PAMOJA_SESSION_KEY_LEN`] secret bytes.
76/// * `seed_len` - the length of `seed`, which must be
77///   [`PAMOJA_SESSION_KEY_LEN`].
78///
79/// # Returns
80///
81/// A handle the caller must release with [`pamoja_agreement_key_free`], or null
82/// on failure with the reason available from
83/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
84///
85/// # Safety
86///
87/// `seed` must point to at least `seed_len` readable bytes.
88#[no_mangle]
89pub unsafe extern "C" fn pamoja_agreement_key_from_seed(
90    seed: *const u8,
91    seed_len: usize,
92) -> *mut PamojaAgreementKey {
93    let Ok(bytes) = read_bytes(seed, seed_len) else {
94        return ptr::null_mut();
95    };
96    let Ok(seed) = <[u8; PAMOJA_SESSION_KEY_LEN]>::try_from(bytes.as_slice()) else {
97        set_last_error(format!(
98            "seed must be exactly {PAMOJA_SESSION_KEY_LEN} bytes"
99        ));
100        return ptr::null_mut();
101    };
102    Box::into_raw(Box::new(PamojaAgreementKey {
103        key: AgreementKey::from_seed(&seed),
104    }))
105}
106
107/// Copies out the public key to hand to a peer.
108///
109/// # Arguments
110///
111/// * `key` - the agreement key.
112/// * `out_public_key` - receives [`PAMOJA_SESSION_KEY_LEN`] bytes.
113///
114/// # Returns
115///
116/// [`PamojaStatus::Ok`] on success.
117///
118/// # Safety
119///
120/// `key` must be a live handle from [`pamoja_agreement_key_from_seed`], and
121/// `out_public_key` must point to at least [`PAMOJA_SESSION_KEY_LEN`] writable
122/// bytes.
123#[no_mangle]
124pub unsafe extern "C" fn pamoja_agreement_key_public(
125    key: *const PamojaAgreementKey,
126    out_public_key: *mut u8,
127) -> PamojaStatus {
128    if key.is_null() {
129        set_last_error("key must not be null".to_owned());
130        return PamojaStatus::InvalidArgument;
131    }
132    if out_public_key.is_null() {
133        set_last_error("out_public_key must not be null".to_owned());
134        return PamojaStatus::InvalidArgument;
135    }
136    let public = (*key).key.public().to_bytes();
137    ptr::copy_nonoverlapping(public.as_ptr(), out_public_key, PAMOJA_SESSION_KEY_LEN);
138    PamojaStatus::Ok
139}
140
141/// Releases an agreement key handle.
142///
143/// Passing null is a no-op.
144///
145/// # Safety
146///
147/// `key` must be a handle from [`pamoja_agreement_key_from_seed`] that has not
148/// already been freed, or null. After this call it must not be used again.
149#[no_mangle]
150pub unsafe extern "C" fn pamoja_agreement_key_free(key: *mut PamojaAgreementKey) {
151    if !key.is_null() {
152        drop(Box::from_raw(key));
153    }
154}
155
156/// Establishes a session with a peer.
157///
158/// Both devices call this with the same salt and opposite roles, and arrive at
159/// the same key without either sending it. The salt is a fresh per-session value
160/// exchanged in the clear; reusing one with the same pair of keys reuses the
161/// session key, so it must change each session.
162///
163/// # Arguments
164///
165/// * `local` - this device key-agreement secret.
166/// * `peer_public_key` - the [`PAMOJA_SESSION_KEY_LEN`]-byte public key of the
167///   peer, already authenticated by pinning or by a signature.
168/// * `salt` - the fresh per-session salt both sides share.
169/// * `salt_len` - the length of `salt`.
170/// * `role` - whether this device opens the session or answers.
171///
172/// # Returns
173///
174/// A handle the caller must release with [`pamoja_session_free`], or null on
175/// failure.
176///
177/// # Safety
178///
179/// `local` must be a live agreement key handle, `peer_public_key` must point to
180/// at least [`PAMOJA_SESSION_KEY_LEN`] readable bytes, and `salt` must point to
181/// at least `salt_len` readable bytes, or be null when `salt_len` is 0.
182#[no_mangle]
183pub unsafe extern "C" fn pamoja_session_establish(
184    local: *const PamojaAgreementKey,
185    peer_public_key: *const u8,
186    salt: *const u8,
187    salt_len: usize,
188    role: PamojaSessionRole,
189) -> *mut PamojaSession {
190    if local.is_null() {
191        set_last_error("local must not be null".to_owned());
192        return ptr::null_mut();
193    }
194    let Ok(peer_bytes) = read_bytes(peer_public_key, PAMOJA_SESSION_KEY_LEN) else {
195        return ptr::null_mut();
196    };
197    let Ok(peer_bytes) = <[u8; PAMOJA_SESSION_KEY_LEN]>::try_from(peer_bytes.as_slice()) else {
198        set_last_error(format!(
199            "peer public key must be exactly {PAMOJA_SESSION_KEY_LEN} bytes"
200        ));
201        return ptr::null_mut();
202    };
203    let Ok(salt) = read_bytes(salt, salt_len) else {
204        return ptr::null_mut();
205    };
206
207    let peer = AgreementPublicKey::from_bytes(&peer_bytes);
208    let established = catch_unwind(AssertUnwindSafe(|| {
209        Session::establish(&(*local).key, &peer, &salt, rust_role(role))
210    }));
211    match established {
212        Ok(session) => Box::into_raw(Box::new(PamojaSession { session })),
213        Err(_) => {
214            set_last_error("establishing the session panicked".to_owned());
215            ptr::null_mut()
216        }
217    }
218}
219
220/// Seals a message for the peer, encrypting it in place.
221///
222/// The associated data is authenticated but not encrypted, so it stays readable
223/// on the wire yet cannot be altered: a device identifier or a routing header
224/// belongs there. On success `buf` holds the ciphertext and `out_sealed` holds
225/// the counter and tag to send with it.
226///
227/// # Arguments
228///
229/// * `session` - the session.
230/// * `buf` - the plaintext, replaced by the ciphertext of equal length.
231/// * `len` - the length of `buf`.
232/// * `aad` - associated data to authenticate alongside the message.
233/// * `aad_len` - the length of `aad`.
234/// * `out_sealed` - receives the counter and tag.
235///
236/// # Returns
237///
238/// [`PamojaStatus::Ok`] on success.
239///
240/// # Safety
241///
242/// `session` must be a live handle from [`pamoja_session_establish`], `buf` must
243/// point to at least `len` readable and writable bytes or be null when `len` is
244/// 0, `aad` must point to at least `aad_len` readable bytes or be null when
245/// `aad_len` is 0, and `out_sealed` must be writable.
246#[no_mangle]
247pub unsafe extern "C" fn pamoja_session_seal(
248    session: *mut PamojaSession,
249    buf: *mut u8,
250    len: usize,
251    aad: *const u8,
252    aad_len: usize,
253    out_sealed: *mut PamojaSealed,
254) -> PamojaStatus {
255    if session.is_null() {
256        set_last_error("session must not be null".to_owned());
257        return PamojaStatus::InvalidArgument;
258    }
259    if out_sealed.is_null() {
260        set_last_error("out_sealed must not be null".to_owned());
261        return PamojaStatus::InvalidArgument;
262    }
263    if len != 0 && buf.is_null() {
264        set_last_error("buf must not be null when its length is non-zero".to_owned());
265        return PamojaStatus::InvalidArgument;
266    }
267    let aad = match read_bytes(aad, aad_len) {
268        Ok(aad) => aad,
269        Err(status) => return status,
270    };
271
272    let message = if len == 0 {
273        &mut [][..]
274    } else {
275        slice::from_raw_parts_mut(buf, len)
276    };
277    let sealed = (*session).session.seal(message, &aad);
278    *out_sealed = PamojaSealed {
279        counter: sealed.counter,
280        tag: sealed.tag,
281    };
282    PamojaStatus::Ok
283}
284
285/// Opens a message from the peer, verifying it and decrypting it in place.
286///
287/// A message is rejected if its counter repeats or is older than the replay
288/// window still tracks, and if its tag does not authenticate. On any rejection
289/// `buf` is left zeroed, so a failed open never yields readable bytes.
290///
291/// # Arguments
292///
293/// * `session` - the session.
294/// * `sealed` - the counter and tag that arrived with the ciphertext.
295/// * `buf` - the ciphertext, replaced by the plaintext on success.
296/// * `len` - the length of `buf`.
297/// * `aad` - the same associated data the sender authenticated.
298/// * `aad_len` - the length of `aad`.
299///
300/// # Returns
301///
302/// [`PamojaStatus::Ok`] if the message is authentic and fresh, or
303/// [`PamojaStatus::Auth`] if it is not, with the message from
304/// [`pamoja_last_error_message`](crate::pamoja_last_error_message) saying whether
305/// it failed authentication or repeated a counter.
306///
307/// # Safety
308///
309/// `session` must be a live handle from [`pamoja_session_establish`], `buf` must
310/// point to at least `len` readable and writable bytes or be null when `len` is
311/// 0, and `aad` must point to at least `aad_len` readable bytes or be null when
312/// `aad_len` is 0.
313#[no_mangle]
314pub unsafe extern "C" fn pamoja_session_open(
315    session: *mut PamojaSession,
316    sealed: PamojaSealed,
317    buf: *mut u8,
318    len: usize,
319    aad: *const u8,
320    aad_len: usize,
321) -> PamojaStatus {
322    if session.is_null() {
323        set_last_error("session must not be null".to_owned());
324        return PamojaStatus::InvalidArgument;
325    }
326    if len != 0 && buf.is_null() {
327        set_last_error("buf must not be null when its length is non-zero".to_owned());
328        return PamojaStatus::InvalidArgument;
329    }
330    let aad = match read_bytes(aad, aad_len) {
331        Ok(aad) => aad,
332        Err(status) => return status,
333    };
334
335    let message = if len == 0 {
336        &mut [][..]
337    } else {
338        slice::from_raw_parts_mut(buf, len)
339    };
340    let header = Sealed {
341        counter: sealed.counter,
342        tag: sealed.tag,
343    };
344    match (*session).session.open(&header, message, &aad) {
345        Ok(()) => PamojaStatus::Ok,
346        Err(error) => {
347            set_last_error(error.to_string());
348            PamojaStatus::Auth
349        }
350    }
351}
352
353/// Releases a session handle.
354///
355/// Passing null is a no-op.
356///
357/// # Safety
358///
359/// `session` must be a handle from [`pamoja_session_establish`] that has not
360/// already been freed, or null. After this call it must not be used again.
361#[no_mangle]
362pub unsafe extern "C" fn pamoja_session_free(session: *mut PamojaSession) {
363    if !session.is_null() {
364        drop(Box::from_raw(session));
365    }
366}
367
368/// Computes a keyed hash over a message.
369///
370/// This is the primitive a host uses to authenticate a pairing exchange or a
371/// single command, where a whole session would be more than the job needs.
372///
373/// # Arguments
374///
375/// * `key` - the secret key.
376/// * `key_len` - the length of `key`.
377/// * `message` - the message to authenticate.
378/// * `message_len` - the length of `message`.
379/// * `out_digest` - receives [`PAMOJA_SESSION_KEY_LEN`] bytes.
380///
381/// # Returns
382///
383/// [`PamojaStatus::Ok`] on success.
384///
385/// # Safety
386///
387/// `key` and `message` must point to at least their stated lengths of readable
388/// bytes, or be null when those lengths are 0, and `out_digest` must point to at
389/// least [`PAMOJA_SESSION_KEY_LEN`] writable bytes.
390#[no_mangle]
391pub unsafe extern "C" fn pamoja_session_hmac_sha256(
392    key: *const u8,
393    key_len: usize,
394    message: *const u8,
395    message_len: usize,
396    out_digest: *mut u8,
397) -> PamojaStatus {
398    let key = match read_bytes(key, key_len) {
399        Ok(key) => key,
400        Err(status) => return status,
401    };
402    let message = match read_bytes(message, message_len) {
403        Ok(message) => message,
404        Err(status) => return status,
405    };
406    if out_digest.is_null() {
407        set_last_error("out_digest must not be null".to_owned());
408        return PamojaStatus::InvalidArgument;
409    }
410    let digest = hmac_sha256(&key, &message);
411    ptr::copy_nonoverlapping(digest.as_ptr(), out_digest, PAMOJA_SESSION_KEY_LEN);
412    PamojaStatus::Ok
413}
414
415/// Expands input keying material into as many bytes as are asked for.
416///
417/// # Arguments
418///
419/// * `salt` - the salt, which may be empty.
420/// * `salt_len` - the length of `salt`.
421/// * `ikm` - the input keying material.
422/// * `ikm_len` - the length of `ikm`.
423/// * `info` - context binding the output to its purpose, which may be empty.
424/// * `info_len` - the length of `info`.
425/// * `out` - receives `out_len` derived bytes.
426/// * `out_len` - how many bytes to derive.
427///
428/// # Returns
429///
430/// [`PamojaStatus::Ok`] on success.
431///
432/// # Safety
433///
434/// `salt`, `ikm`, and `info` must each point to at least their stated lengths of
435/// readable bytes, or be null when those lengths are 0, and `out` must point to
436/// at least `out_len` writable bytes, or be null when `out_len` is 0.
437#[allow(clippy::too_many_arguments)]
438#[no_mangle]
439pub unsafe extern "C" fn pamoja_session_hkdf_sha256(
440    salt: *const u8,
441    salt_len: usize,
442    ikm: *const u8,
443    ikm_len: usize,
444    info: *const u8,
445    info_len: usize,
446    out: *mut u8,
447    out_len: usize,
448) -> PamojaStatus {
449    let salt = match read_bytes(salt, salt_len) {
450        Ok(salt) => salt,
451        Err(status) => return status,
452    };
453    let ikm = match read_bytes(ikm, ikm_len) {
454        Ok(ikm) => ikm,
455        Err(status) => return status,
456    };
457    let info = match read_bytes(info, info_len) {
458        Ok(info) => info,
459        Err(status) => return status,
460    };
461    if out_len == 0 {
462        return PamojaStatus::Ok;
463    }
464    if out.is_null() {
465        set_last_error("out must not be null when its length is non-zero".to_owned());
466        return PamojaStatus::InvalidArgument;
467    }
468
469    let mut derived = vec![0u8; out_len];
470    hkdf_sha256(&salt, &ikm, &info, &mut derived);
471    ptr::copy_nonoverlapping(derived.as_ptr(), out, out_len);
472    PamojaStatus::Ok
473}
474
475/// Maps a boundary role back onto the Rust one.
476fn rust_role(role: PamojaSessionRole) -> Role {
477    match role {
478        PamojaSessionRole::Initiator => Role::Initiator,
479        PamojaSessionRole::Responder => Role::Responder,
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    /// Establishes the two ends of one session over a fixed salt.
488    unsafe fn pair() -> (
489        *mut PamojaAgreementKey,
490        *mut PamojaAgreementKey,
491        *mut PamojaSession,
492        *mut PamojaSession,
493    ) {
494        let device_seed = [1u8; PAMOJA_SESSION_KEY_LEN];
495        let gateway_seed = [2u8; PAMOJA_SESSION_KEY_LEN];
496        let device_key = pamoja_agreement_key_from_seed(device_seed.as_ptr(), device_seed.len());
497        let gateway_key = pamoja_agreement_key_from_seed(gateway_seed.as_ptr(), gateway_seed.len());
498
499        let mut device_public = [0u8; PAMOJA_SESSION_KEY_LEN];
500        let mut gateway_public = [0u8; PAMOJA_SESSION_KEY_LEN];
501        assert_eq!(
502            pamoja_agreement_key_public(device_key, device_public.as_mut_ptr()),
503            PamojaStatus::Ok
504        );
505        assert_eq!(
506            pamoja_agreement_key_public(gateway_key, gateway_public.as_mut_ptr()),
507            PamojaStatus::Ok
508        );
509
510        let salt = [9u8; 16];
511        let device = pamoja_session_establish(
512            device_key,
513            gateway_public.as_ptr(),
514            salt.as_ptr(),
515            salt.len(),
516            PamojaSessionRole::Initiator,
517        );
518        let gateway = pamoja_session_establish(
519            gateway_key,
520            device_public.as_ptr(),
521            salt.as_ptr(),
522            salt.len(),
523            PamojaSessionRole::Responder,
524        );
525        assert!(!device.is_null() && !gateway.is_null());
526        (device_key, gateway_key, device, gateway)
527    }
528
529    #[test]
530    fn a_sealed_message_opens_at_the_peer() {
531        unsafe {
532            let (device_key, gateway_key, device, gateway) = pair();
533
534            let mut message = *b"4.8C";
535            let mut sealed = PamojaSealed {
536                counter: 0,
537                tag: [0; PAMOJA_SESSION_TAG_LEN],
538            };
539            assert_eq!(
540                pamoja_session_seal(
541                    device,
542                    message.as_mut_ptr(),
543                    message.len(),
544                    b"fridge-1".as_ptr(),
545                    8,
546                    &mut sealed,
547                ),
548                PamojaStatus::Ok
549            );
550            assert_ne!(&message, b"4.8C");
551
552            assert_eq!(
553                pamoja_session_open(
554                    gateway,
555                    sealed,
556                    message.as_mut_ptr(),
557                    message.len(),
558                    b"fridge-1".as_ptr(),
559                    8,
560                ),
561                PamojaStatus::Ok
562            );
563            assert_eq!(&message, b"4.8C");
564
565            pamoja_session_free(gateway);
566            pamoja_session_free(device);
567            pamoja_agreement_key_free(gateway_key);
568            pamoja_agreement_key_free(device_key);
569        }
570    }
571
572    #[test]
573    fn a_repeated_counter_is_refused() {
574        unsafe {
575            let (device_key, gateway_key, device, gateway) = pair();
576
577            let mut message = *b"on";
578            let mut sealed = PamojaSealed {
579                counter: 0,
580                tag: [0; PAMOJA_SESSION_TAG_LEN],
581            };
582            pamoja_session_seal(
583                device,
584                message.as_mut_ptr(),
585                message.len(),
586                ptr::null(),
587                0,
588                &mut sealed,
589            );
590            let ciphertext = message;
591
592            assert_eq!(
593                pamoja_session_open(
594                    gateway,
595                    sealed,
596                    message.as_mut_ptr(),
597                    message.len(),
598                    ptr::null(),
599                    0
600                ),
601                PamojaStatus::Ok
602            );
603
604            message = ciphertext;
605            assert_eq!(
606                pamoja_session_open(
607                    gateway,
608                    sealed,
609                    message.as_mut_ptr(),
610                    message.len(),
611                    ptr::null(),
612                    0
613                ),
614                PamojaStatus::Auth
615            );
616
617            pamoja_session_free(gateway);
618            pamoja_session_free(device);
619            pamoja_agreement_key_free(gateway_key);
620            pamoja_agreement_key_free(device_key);
621        }
622    }
623
624    #[test]
625    fn altered_associated_data_fails_authentication() {
626        unsafe {
627            let (device_key, gateway_key, device, gateway) = pair();
628
629            let mut message = *b"open";
630            let mut sealed = PamojaSealed {
631                counter: 0,
632                tag: [0; PAMOJA_SESSION_TAG_LEN],
633            };
634            pamoja_session_seal(
635                device,
636                message.as_mut_ptr(),
637                message.len(),
638                b"door-1".as_ptr(),
639                6,
640                &mut sealed,
641            );
642
643            assert_eq!(
644                pamoja_session_open(
645                    gateway,
646                    sealed,
647                    message.as_mut_ptr(),
648                    message.len(),
649                    b"door-2".as_ptr(),
650                    6,
651                ),
652                PamojaStatus::Auth
653            );
654
655            pamoja_session_free(gateway);
656            pamoja_session_free(device);
657            pamoja_agreement_key_free(gateway_key);
658            pamoja_agreement_key_free(device_key);
659        }
660    }
661
662    #[test]
663    fn the_keyed_hash_matches_the_crate() {
664        unsafe {
665            let mut digest = [0u8; PAMOJA_SESSION_KEY_LEN];
666            assert_eq!(
667                pamoja_session_hmac_sha256(
668                    b"key".as_ptr(),
669                    3,
670                    b"message".as_ptr(),
671                    7,
672                    digest.as_mut_ptr()
673                ),
674                PamojaStatus::Ok
675            );
676            assert_eq!(digest, hmac_sha256(b"key", b"message"));
677        }
678    }
679
680    #[test]
681    fn expansion_matches_the_crate() {
682        unsafe {
683            let mut derived = [0u8; 40];
684            assert_eq!(
685                pamoja_session_hkdf_sha256(
686                    b"salt".as_ptr(),
687                    4,
688                    b"secret".as_ptr(),
689                    6,
690                    b"pairing".as_ptr(),
691                    7,
692                    derived.as_mut_ptr(),
693                    derived.len(),
694                ),
695                PamojaStatus::Ok
696            );
697
698            let mut want = [0u8; 40];
699            hkdf_sha256(b"salt", b"secret", b"pairing", &mut want);
700            assert_eq!(derived, want);
701        }
702    }
703
704    #[test]
705    fn a_null_handle_is_refused_rather_than_dereferenced() {
706        unsafe {
707            assert!(pamoja_agreement_key_from_seed(ptr::null(), 0).is_null());
708            assert_eq!(
709                pamoja_agreement_key_public(ptr::null(), ptr::null_mut()),
710                PamojaStatus::InvalidArgument
711            );
712            assert!(pamoja_session_establish(
713                ptr::null(),
714                ptr::null(),
715                ptr::null(),
716                0,
717                PamojaSessionRole::Initiator
718            )
719            .is_null());
720            pamoja_agreement_key_free(ptr::null_mut());
721            pamoja_session_free(ptr::null_mut());
722        }
723    }
724}