API design: Enforcing mutually exclusive access


#1

Background:

I’m working on a crate that uses C FFI bindings to provide an interface to a USB device – nitrokey. I try to enforce correct usage of the device through the API design. For example, some operations require authentication using an admin or user PIN. It should not be possible to execute an operation that requires the admin PIN without authentication (i. e. such code should not compile). Also, there can be only one admin or user session at the same time on the device. So I’d like to ensure that the user cannot create multiple sessions at the same time (which would silently invalidate the first session).

The crate has two main structs, Pro and Storage, that represent the two supported device models. The functionality common to both models is provided by the Device trait. The operations that require authentication are provided by the User<T: Device> and Admin<T: Device> structs that can be obtained from the authenticate_user and authenticate_admin methods from the Device trait.


My question is: What is the best way to enforce that a user cannot obtain two Admin instances from one Device at the same time (by calling the authenticate_admin method)?

  • My initial implementation was to consume the Device in the authenticate_admin method, store it in the Admin struct and provide a method to convert the Admin back into a Device. While this enforced the requirements, it led to a very clumsy API. If the authentication fails, I have to return the Device as part of the error. And I have to explicitly destroy the Admin instance if I want to go back to Device. Usage example:
let device = nitrokey::connect()?;
let device = match device.authenticate_admin("secret") {
    Ok(admin) => {
        do_something(&admin);
        admin.into_device()
    },
    Err((device, err)) => {
        eprintln("{}", err);
        device
    },
};
do_something_else(&device);
  • Then I had the idea to use a mutable reference instead. I would not have to return the device value if an error occurred, and the borrow checker could often automatically infer when the borrow ends. But the problem with this approach is that it is hard to use with generic methods that work for both Admin and User, see this thread. Usage example:
let device = nitrokey::connect()?;
match device.authenticate_admin("secret") {
    Ok(admin) => do_something(&admin),
    Err(err) => println("{}", err),
};
do_something_else(&admin);

Are there other options to implement this? Is there a standard for implementing such restrictions in Rust? Can I do something to avoid the lifetimes problems with the second solution?


#2

You can use Arc and Weak to track the existence of an object:

use std::sync::{Arc, Weak};

struct Admin {
    _lock: Arc<()>,
}

#[derive(Default)]
struct Device {
    admin: Option<Weak<()>>,
}

impl Device {
    fn admin(&mut self) -> Result<Admin, String> {
        if self.admin.as_ref().map_or(false, |w| w.upgrade().is_some()) {
            return Err("admin already exists".into());
        }
        let lock = Arc::new(());
        self.admin = Some(Arc::downgrade(&lock));
        Ok(Admin { _lock: lock })
    }
}

fn main() {
    let mut device = Device::default();
    let admin = device.admin().unwrap();
    assert!(device.admin().is_err());
    drop(admin);
    let _admin2 = device.admin().unwrap();
}

Playground

You can use Rc instead of Arc if your library is not thread-safe.


#3

Thanks for the suggestion! I’d rather enforce this at compile time, if possible. Is there any way to do that?


#4

Would something like this work?

https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=57fada9e5c74aafcf1ed46266f81a23e

here is a second version, that uses lifetimes to restrict how long a admin is valid or user is valid since it seems like you want to use them temporarily, this should be fine.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=9a1b8ccafddd61253009fdb77a2da947


edit: These seems similar to the version you already thought up, and these two are the standard ways that you would do it.