typelock 0.3.1

Enforce security boundaries at the Type level
Documentation

Typelock

Enforce security boundaries at the Type level

typelock is a Rust framework for creating type-safe, encrypted and hashed data models. It uses a procedural macro to generate locked and unlocked versions of your data models to ensure sensitive data is only accessible through explicit, pre-defined, model conversions steps and to avoid type confusion.

Features

Given a single input definition, typelock generates the following:

  • An unlocked representation
  • A locked representation
  • Explicit lock() and unlock() transitions, based on the current model

Example

#[derive(LockSchema, Clone)]
#[typelock(unlocked(name = UserUnlocked, derives(Debug)), locked(name = UserLocked))]
pub struct User {
    pub id: i64,

    #[secure(policy(encrypt))]
    pub email: String,

    #[secure(policy(hash), rename = "password_hash")]
    pub password: String,

    #[secure(policy(encrypt))]
    pub user_data: UserData,
}

Custom types

Secured fields must define how they are convered to and from byte. This is intentional, so that typelock doesn't force you into the usage of specific libraries.

#[derive(Debug, Clone)]
pub struct UserData {
    first_name: String,
}

impl ToBytes for UserData {
    fn to_bytes(&self) -> Vec<u8> {
        self.first_name.as_bytes().to_vec()
    }
}

impl FromBytes for UserData {
    fn from_bytes(bytes: &[u8]) -> Result<Self, typelock::Error> {
        Ok(Self {
            first_name: String::from_utf8(bytes.to_vec())?,
        })
    }
}

You also have the choice to enable the postcard-codec feature, which lets you gain access to the ToBytes and FromBytes derive macros. As long as your struct also derives from serde's serde::Serialize and serde::Deserialize, these macros automatically generate the necessary to_bytes and from_bytes implementations for you, removing the need to write them manually. This features leverages postcard under the hood.

Security providers

To stay flexible, typelock is intentionally backend agnostic, meaning you will have to provide your own implementation to encrypt/decrypt/hash your data. For that typelock provides the following traits:

pub struct MySecurityProvider;

// CryptProvider for encrypted fields
impl CryptProvider for MySecurityProvider {
    fn encrypt(&self, data: &[u8]) -> std::result::Result<Vec<u8>, crate::Error>;
        todo!("implement your own encryption logic here.")
    }

    fn decrypt(&self, data: &[u8]) -> std::result::Result<Vec<u8>, crate::Error>;
        todo!("implement your own decryption logic here.")
    }
}

// HashingProvider for hashed fields
impl HashingProvider for MySecurityProvider {
    fn hash(&self, data: &[u8]) -> std::result::Result<Vec<u8>, crate::Error>;
        todo!("implement your own hashing logic here.")
    }
}

typelock does not implement encryption or hashing itself, you will always have to provide your own cryptography.

Converting between models

typelock automatically provides the following model conversions:

  1. OriginalModel -> LockedModel // call .lock()
  2. LockedModel -> UnlockedModel // call .unlock()
  3. UnlockedModel -> LockedModel // call .lock()

You are encouraged to always instantiate your own models starting from OriginalModel and never from LockedModel or UnlockedModel. This is because otherwise typelock is not able to guarantee that you actually secured your data correctly.

use typelock::{Lockable, Unlockable};

let provider = MySecurityProvider;
let user = User {
    id: 1,
    email: "user@email".to_string(),
    password: "user-password".to_string(),
    user_data: UserData {
        first_name: "user".to_string(),
    },
};
let locked_user = user.lock(&provider);
let unlocked_user = locked_user.unlock(&provider).expect("implement error handling");

Calling .lock() and .unlock() move the previous model into the function call. This means that trying to access user after calling .lock() is not allowed. typelock also provides default implementations to call .lock() and .unlock() on collections of Vec.

Warning: you are discouraged from deriving Clone on your original model or any of the generated model's. This is because doing the following could lead to ghost copies that stay in your programm's memory, which could compromise data.

let user = User { ... }
let locked_user = user.clone().lock();

In cases you need to do so, please take a look at the zeroize crate for example.

Why use Typelock?

Without typelock, you typically end up writing code that looks like the following:

DecryptedUser {
    id: self.id,
    email: provider.decrypt(...),
    ...
}

This is verbose, easy to get wrong and typically repeated among multiple models. typelock generates that bondary once, correctly.

Use cases

typelock is useful when you need:

  • Type-safe boundaries between encrypted/decrypted or hashed data states
  • Compile-time guarantees that sensitive data can't be accidentally used in the wrong state
  • Automatic transitions between domain models and their storage representations
  • To eliminate boilerplate for encrypt/decrypt/hash operations across multiple models
  • To prevent accidentally serializing or logging sensitive data in plaintext form
  • A strict separation between domain logic and persistence layer with security enforced at compile time

typelock complements your cryptography library by modeling security state transitions at the type level. It does not implement encryption or hashing itself.

Status

typelock is still in an early-stage. Therefore some features are still missing and the API might change.

License

This project is licensed under the MIT License.

This crate provides optional integration with serde and postcard via the postcard-codec feature. Therefore the respective licenses (MIT/Apache-2.0) of these dependencies apply.