galata-vault 0.4.0

End-to-end-encrypted secrets and config documents with no accounts: the galata-vault Rust SDK. A token client and the owner API over a pluggable transport, with an optional in-process backend.
Documentation
//! Token keys: generated by the minting client, never seen
//! by the server.
//!
//! ```text
//! gvt1_ = token_id(16) ‖ vault_id(16) ‖ token_secret(32)
//! token_auth (Ed25519) = HKDF(token_secret, "gv/v1/token-auth" ‖ 0 ‖ token_id)   signs requests
//! token_box  (X25519)  = HKDF(token_secret, "gv/v1/token-box"  ‖ 0 ‖ token_id)   opens the bundle
//! ```
//!
//! The server registers only the two public keys. A request carries a
//! signature, never the token string, so nothing on the wire and nothing the
//! server stores derives the box key (review finding C1).

use crate::proto::FormatError;
use crate::proto::api::{Scope, SignedBundle, report_signing_input};
use crate::proto::codec::TokenString;
use crate::proto::frame::label;
use crate::proto::ids::{Key32, Sig64, TokenId, VaultId};
use crate::proto::integrity::IntegrityError;
use crate::proto::sig::{SigActor, SigParams, SignedRequest, signing_input};
use ed25519_dalek::{Signer, SigningKey};
use zeroize::Zeroizing;

use crate::keys::KeyError;
use crate::keys::bundle::{Binding, Bundle, expect_scope, open_bundle};
use crate::keys::kdf::{derive32, info};
use crate::keys::random::random_bytes;

pub struct TokenKeys {
    id: TokenId,
    vault_id: VaultId,
    secret: Zeroizing<[u8; 32]>,
    auth: SigningKey,
    auth_pub: Key32,
    boxing: crypto_box::SecretKey,
    box_pub: Key32,
}

impl std::fmt::Debug for TokenKeys {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TokenKeys")
            .field("id", &self.id)
            .field("vault_id", &self.vault_id)
            .finish_non_exhaustive()
    }
}

impl TokenKeys {
    /// A new token for `vault_id`: random id, random secret.
    pub fn generate(vault_id: VaultId) -> TokenKeys {
        TokenKeys::from_parts(
            TokenId(*random_bytes::<16>()),
            vault_id,
            random_bytes::<32>(),
        )
    }

    pub fn from_parts(id: TokenId, vault_id: VaultId, secret: Zeroizing<[u8; 32]>) -> TokenKeys {
        let auth_seed = derive32(secret.as_slice(), &info(label::TOKEN_AUTH, &id.0));
        let auth = SigningKey::from_bytes(&auth_seed);
        let box_secret = derive32(secret.as_slice(), &info(label::TOKEN_BOX, &id.0));
        let boxing = crypto_box::SecretKey::from_bytes(*box_secret);
        TokenKeys {
            auth_pub: Key32(auth.verifying_key().to_bytes()),
            box_pub: Key32(*boxing.public_key().as_bytes()),
            id,
            vault_id,
            secret,
            auth,
            boxing,
        }
    }

    /// Parse a `gvt1_…` string, checksum first.
    pub fn parse(s: &str) -> Result<TokenKeys, FormatError> {
        let t = TokenString::parse(s)?;
        Ok(TokenKeys::from_parts(t.id, t.vault_id, t.secret))
    }

    /// The `gvt1_…` string, shown exactly once at mint time.
    pub fn token_string(&self) -> Zeroizing<String> {
        TokenString::encode(&self.id, &self.vault_id, &self.secret)
    }

    pub fn id(&self) -> TokenId {
        self.id
    }

    /// The vault this token belongs to: the holder pins it.
    pub fn vault_id(&self) -> VaultId {
        self.vault_id
    }

    /// What the server registers to verify this token's requests.
    pub fn auth_pub(&self) -> Key32 {
        self.auth_pub
    }

    /// What the token's bundle is sealed to.
    pub fn box_pub(&self) -> Key32 {
        self.box_pub
    }

    /// Sign a request with a fresh random nonce.
    pub fn sign_request(&self, request: &SignedRequest<'_>, ts: i64) -> SigParams {
        let nonce = *random_bytes::<16>();
        let actor = SigActor::Token(self.id);
        let input = signing_input(&actor, &self.vault_id, ts, &nonce, request);
        SigParams {
            actor,
            vault_id: self.vault_id,
            ts,
            nonce,
            sig: self.auth.sign(&input).to_bytes(),
        }
    }

    /// Sign `message` with the token-auth key (the vector runner builds
    /// request signatures with a given nonce this way).
    #[cfg(feature = "vectors")]
    pub(crate) fn sign_raw(&self, message: &[u8]) -> Sig64 {
        Sig64(self.auth.sign(message).to_bytes())
    }

    /// Prove possession to report this token as leaked.
    pub fn sign_report(&self, ts: i64) -> Sig64 {
        Sig64(
            self.auth
                .sign(&report_signing_input(&self.id, ts))
                .to_bytes(),
        )
    }

    /// Open this token's bundle:
    /// - the owner key must hash to the pinned vault id;
    /// - the owner's signature must bind this vault, token, scope and generation;
    /// - the plaintext must name the same, and be the scope's kind.
    pub fn open_bundle(
        &self,
        signed: &SignedBundle,
        owner_sign_pub: &Key32,
        scope: Scope,
        generation: u32,
    ) -> Result<Bundle, KeyError> {
        if VaultId::from_owner_sign_pub(owner_sign_pub) != self.vault_id {
            return Err(IntegrityError::KeyMismatch("owner signing key").into());
        }
        signed.verify(
            owner_sign_pub,
            &self.vault_id,
            &self.id,
            scope.code(),
            generation,
        )?;
        let bundle = open_bundle(
            &self.boxing,
            &signed.sealed.0,
            &Binding {
                vault_id: self.vault_id,
                token_id: self.id,
                generation,
            },
        )?;
        expect_scope(&bundle, scope)?;
        Ok(bundle)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::proto::integrity::verify_ed25519;

    #[test]
    fn string_roundtrip_preserves_keys_and_vault() {
        let t = TokenKeys::generate(VaultId([3; 16]));
        let s = t.token_string();
        assert!(s.starts_with("gvt1_"));
        let back = TokenKeys::parse(&s).unwrap();
        assert_eq!(back.id(), t.id());
        assert_eq!(back.vault_id(), VaultId([3; 16]));
        assert_eq!(back.box_pub(), t.box_pub());
        assert_eq!(back.auth_pub(), t.auth_pub());
    }

    #[test]
    fn auth_and_box_keys_are_independent_and_id_bound() {
        let secret = Zeroizing::new([5u8; 32]);
        let a = TokenKeys::from_parts(TokenId([1; 16]), VaultId([0; 16]), secret.clone());
        let b = TokenKeys::from_parts(TokenId([2; 16]), VaultId([0; 16]), secret);
        assert_ne!(a.box_pub(), b.box_pub());
        assert_ne!(a.auth_pub(), b.auth_pub());
        assert_ne!(a.auth_pub(), a.box_pub());
        assert!(!format!("{a:?}").contains("secret"));
    }

    #[test]
    fn requests_and_reports_verify_under_the_auth_key() {
        let t = TokenKeys::generate(VaultId([4; 16]));
        let req = SignedRequest::new("GET", "/v1/vault", b"");
        let p = t.sign_request(&req, 100);
        assert_eq!(p.actor, SigActor::Token(t.id()));
        assert_eq!(p.vault_id, t.vault_id());
        assert!(
            verify_ed25519(
                &t.auth_pub(),
                &p.signing_input(&req),
                &Sig64(p.sig),
                "request"
            )
            .is_ok()
        );
        let report = t.sign_report(100);
        assert!(
            verify_ed25519(
                &t.auth_pub(),
                &report_signing_input(&t.id(), 100),
                &report,
                "report"
            )
            .is_ok()
        );
        assert!(
            verify_ed25519(
                &t.auth_pub(),
                &report_signing_input(&t.id(), 101),
                &report,
                "report"
            )
            .is_err()
        );
    }

    #[test]
    fn a_token_of_another_version_is_refused() {
        let other = crate::proto::codec::encode_checked("gvt2_", &[1u8; 48]);
        assert!(matches!(
            TokenKeys::parse(&other),
            Err(FormatError::UnknownVersion { found: '2' })
        ));
    }
}