typelock 0.5.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 secured data models. It uses a procedural macro to generate locked and unlocked versions of your data models so sensitive data is only accessible through explicit model conversion steps.

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
  • Field policies for encrypt, secret, digest, sign, and mac
  • Optional ToBytes/FromBytes derive macros via the wincode-codec feature
  • Optional Diesel integration for direct database storage of locked fields

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(secret), rename = "password_hash")]
    pub password: String,

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

Custom types

Secured fields must define how they are converted to and from bytes. This is intentional, so that typelock does not force you into using a specific serialization library.

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

impl ToBytes for UserData {
    fn to_bytes(&self) -> Result<Vec<u8>, typelock::Error> {
        Ok(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())?,
        })
    }
}

typelock includes ToBytes/FromBytes implementations for String and Vec<u8> out of the box.

You can also enable the wincode-codec feature to use the #[derive(ToBytes, FromBytes)] macros. Those derives generate byte conversion implementations using wincode, so you can avoid writing manual conversion code for custom data types.

Security providers

To stay flexible, typelock is intentionally backend agnostic. You provide your own implementation for the policy traits your model uses:

pub struct MySecurityProvider;

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

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

// SecretProvider for one-way secret hashing
impl typelock::SecretProvider for MySecurityProvider {
    fn hash_secret(&self, data: &[u8]) -> std::result::Result<Vec<u8>, typelock::Error> {
        todo!("implement your own secret hashing logic here.")
    }
}

Additional traits are available for other policies:

  • DigestProvider for digest
  • SignProvider for sign / verify_signature
  • MacProvider for mac / verify_mac

typelock does not implement cryptography itself; you always provide your own backend.

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()

For one-way policies (secret and digest), the unlocked model still carries wrapped bytes (Secret<T> / Digested<T>) because the original plaintext cannot be reconstructed.

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 models. This is because doing the following could lead to ghost copies that stay in your program'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.

Diesel Integration

When the diesel feature is enabled, typelock provides FromSql and ToSql implementations for Encrypted<T> and Secret<T>. This allows those locked fields to be stored directly as Diesel Binary columns.

#[derive(LockSchema)]
#[typelock(
    // unlocked...
    locked(
        name = UserLocked, 
        derives(Queryable, Selectable, Insertable),
        attributes(diesel(table_name = crate::schema::users))
    )
)]
pub struct User {
    pub id: i64,
    #[secure(policy(encrypt))]
    pub email: String,
}

Locked fields are stored as Binary in the database.

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 boundary once, correctly.

Use cases

typelock is useful when you need:

  • Type-safe boundaries between plaintext and secured states (encrypted, secret-hashed, digested, signed, or MAC-tagged)
  • 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 policy-based security transformations 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 cryptography 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 wincode via the wincode-codec feature, and with Diesel via the diesel feature. Therefore the respective licenses of these dependencies apply.