alkvault 0.1.0

Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption for securing provider keys, credentials, and identity material
Documentation
//! SLIP-0010 Ed25519 HD key derivation and path constants.
//!
//! This module provides hierarchical deterministic (HD) key derivation following
//! SLIP-0010 for Ed25519 keys and BIP-0032 for secp256k1 keys. The `74'`
//! coin type is unallocated per SLIP-0044 and reserved for alknet.
//!
//! # Derivation Paths
//!
//! | Path | Purpose | Curve/Algorithm |
//! |------|---------|----------------|
//! | `m/74'/0'/0'/0'` | Primary identity keypair | Ed25519 (alknet auth) |
//! | `m/74'/0'/0'/{n}'` | Worker/device identity | Ed25519 |
//! | `m/74'/0'/1'/0'` | SSH host key | Ed25519 |
//! | `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM |
//! | `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 |

use ed25519_bip32::XPrv;
use hmac::{Hmac, Mac};
use sha2::Sha512;
use zeroize::Zeroize;

type HmacSha512 = Hmac<Sha512>;

/// Well-known derivation path constants for alknet key material.
///
/// These paths are defined once and referenced by both the vault service and
/// external consumers that need to request specific key types.
#[allow(non_snake_case)]
pub mod PATHS {
    /// Primary identity keypair for alknet authentication.
    pub const IDENTITY: &str = "m/74'/0'/0'/0'";

    /// Worker/device identity keypair (parameterized by device index).
    /// Use `device_path(n)` to construct the full path.
    pub const DEVICE_PREFIX: &str = "m/74'/0'/0'";

    /// SSH host key.
    pub const SSH_HOST: &str = "m/74'/0'/1'/0'";

    /// Encryption key for external credentials (AES-256-GCM).
    pub const ENCRYPTION: &str = "m/74'/2'/0'/0'";

    /// Ethereum signing key.
    pub const ETHEREUM: &str = "m/44'/60'/0'/0/0";
}

/// Construct a device identity derivation path with the given index.
///
/// Path: `m/74'/0'/0'/{n}'`
pub fn device_path(index: u32) -> String {
    format!("m/74'/0'/0'/{}'", index)
}

/// Construct the version-indexed encryption key derivation path (ADR-021).
///
/// Maps a key version to its derivation path: v2 → `m/74'/2'/0'/0'`
/// (which is `PATHS::ENCRYPTION`), v3 → `m/74'/2'/0'/1'`, etc. Returns
/// `DerivationError::InvalidPath` for `version < 2` — v1 is reserved for
/// the TypeScript PBKDF2 legacy (ADR-020), which the vault cannot derive,
/// and v0 is meaningless.
pub fn encryption_path_for_version(version: u32) -> Result<String, DerivationError> {
    if version < 2 {
        return Err(DerivationError::InvalidPath(format!(
            "key version {version} has no derivable path (v1 is TS PBKDF2 legacy)"
        )));
    }
    Ok(format!("m/74'/2'/0'/{}'", version - 2))
}
/// A derived extended private key with its public key.
///
/// Contains the private key bytes and public key bytes from
/// SLIP-0010 Ed25519 derivation.
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ExtendedPrivKey {
    /// The private key bytes (first 32 bytes of the extended key).
    private_key: Vec<u8>,
    /// The public key bytes (32 bytes).
    public_key: Vec<u8>,
    /// The chain code for child derivation (32 bytes).
    chain_code: Vec<u8>,
    /// The derivation path that produced this key.
    path: String,
}

impl ExtendedPrivKey {
    /// Returns the private key bytes (32 bytes for Ed25519).
    pub fn private_key(&self) -> &[u8] {
        &self.private_key
    }

    /// Returns the public key bytes (32 bytes for Ed25519).
    pub fn public_key(&self) -> &[u8] {
        &self.public_key
    }

    /// Returns the derivation path string.
    pub fn path(&self) -> &str {
        &self.path
    }
}

/// Derive an extended private key from a seed and derivation path.
///
/// This is the primary entry point for HD key derivation. Create a master key
/// from the seed, then derive the specified path.
///
/// # Example
///
/// ```
/// use alkvault::derivation::{derive_path_from_seed, PATHS};
/// use alkvault::mnemonic::Mnemonic;
///
/// let mnemonic = Mnemonic::generate(24).unwrap();
/// let seed = mnemonic.to_seed(None);
/// let identity_key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
/// assert!(!identity_key.private_key().is_empty());
/// ```
pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result<ExtendedPrivKey, DerivationError> {
    let indices = parse_derivation_path(path)?;
    let xprv = derive_master_key(seed)?;

    let mut current = xprv;
    for index in indices {
        current = current.derive(ed25519_bip32::DerivationScheme::V2, index);
    }

    let public_key = current.public();

    Ok(ExtendedPrivKey {
        private_key: current.extended_secret_key_bytes()[..32].to_vec(),
        public_key: public_key.as_ref()[..32].to_vec(),
        chain_code: current.chain_code().to_vec(),
        path: path.to_string(),
    })
}

/// Derive the SLIP-0010 Ed25519 master key from a seed.
///
/// Uses HMAC-SHA512 with key "ed25519 seed" over the seed bytes,
/// following SLIP-0010 specification.
fn derive_master_key(seed: &[u8]) -> Result<XPrv, DerivationError> {
    let mut mac = HmacSha512::new_from_slice(b"ed25519 seed")
        .map_err(|e| DerivationError::Hmac(e.to_string()))?;
    mac.update(seed);
    let result = mac.finalize().into_bytes();

    // First 32 bytes: private key (kL in SLIP-0010)
    // Next 32 bytes: chain code
    let private_key_bytes = &result[..32];
    let chain_code_bytes = &result[32..];

    // Construct XPrv from the HMAC result
    // ed25519-bip32 expects a 96-byte extended key:
    // [32 bytes: kL || 32 bytes: kR (extended secret key) || 32 bytes: chain code]
    // SLIP-0010 uses the first 32 bytes as kL and hashes through SHA-512
    // to get the full extended key. We use from_nonextended_force to handle this.
    let mut priv_bytes = [0u8; 32];
    priv_bytes.copy_from_slice(private_key_bytes);
    let mut cc_bytes = [0u8; 32];
    cc_bytes.copy_from_slice(chain_code_bytes);

    Ok(XPrv::from_nonextended_force(&priv_bytes, &cc_bytes))
}

/// Parse a derivation path string into child indices.
///
/// Path format: `m/74'/0'/0'/0'`
/// Hardened indices have `'` or `h` suffix. Unhardened indices are allowed
/// for BIP-0032 paths (e.g., Ethereum `m/44'/60'/0'/0/0`).
pub fn parse_derivation_path(path: &str) -> Result<Vec<u32>, DerivationError> {
    if !path.starts_with('m') {
        return Err(DerivationError::InvalidPath(
            "path must start with 'm'".to_string(),
        ));
    }

    let mut indices = Vec::new();
    let parts: Vec<&str> = path.split('/').skip(1).collect(); // skip "m"

    for part in parts {
        let hardened = part.ends_with('\'') || part.ends_with('h');
        let index_str = part.trim_end_matches('\'').trim_end_matches('h');
        let index: u32 = index_str
            .parse()
            .map_err(|_| DerivationError::InvalidPath(format!("invalid index: {part}")))?;

        if hardened {
            indices.push(index + 0x80000000);
        } else {
            indices.push(index);
        }
    }

    Ok(indices)
}

/// Errors that can occur during key derivation.
#[derive(Debug, thiserror::Error)]
pub enum DerivationError {
    #[error("invalid derivation path: {0}")]
    InvalidPath(String),
    #[error("HMAC error: {0}")]
    Hmac(String),
    #[error("key derivation error: {0}")]
    KeyDerivation(String),
    #[error("seed is not unlocked")]
    Locked,
    #[error("secp256k1 error: {0}")]
    Secp256k1(String),
    #[error("unsupported key type")]
    UnsupportedKeyType,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_derivation_path_hardened() {
        let indices = parse_derivation_path("m/74'/0'/0'/0'").unwrap();
        assert_eq!(
            indices,
            vec![0x80000000 + 74, 0x80000000, 0x80000000, 0x80000000]
        );
    }

    #[test]
    fn test_parse_derivation_path_mixed() {
        // Ethereum path has unhardened indices
        let indices = parse_derivation_path("m/44'/60'/0'/0/0").unwrap();
        assert_eq!(
            indices,
            vec![0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, 0]
        );
    }

    #[test]
    fn test_parse_rejects_no_m_prefix() {
        let result = parse_derivation_path("74'/0'/0'/0'");
        assert!(result.is_err());
    }

    #[test]
    fn test_path_constants() {
        assert_eq!(PATHS::IDENTITY, "m/74'/0'/0'/0'");
        assert_eq!(PATHS::ENCRYPTION, "m/74'/2'/0'/0'");
        assert_eq!(PATHS::SSH_HOST, "m/74'/0'/1'/0'");
        assert_eq!(PATHS::ETHEREUM, "m/44'/60'/0'/0/0");
    }

    #[test]
    fn test_device_path() {
        assert_eq!(device_path(0), "m/74'/0'/0'/0'");
        assert_eq!(device_path(1), "m/74'/0'/0'/1'");
    }

    #[test]
    fn test_encryption_path_for_version_v2() {
        assert_eq!(encryption_path_for_version(2).unwrap(), PATHS::ENCRYPTION);
    }

    #[test]
    fn test_encryption_path_for_version_v3() {
        assert_eq!(encryption_path_for_version(3).unwrap(), "m/74'/2'/0'/1'");
    }

    #[test]
    fn test_encryption_path_for_version_v4() {
        assert_eq!(encryption_path_for_version(4).unwrap(), "m/74'/2'/0'/2'");
    }

    #[test]
    fn test_encryption_path_for_version_rejects_v1() {
        assert!(matches!(
            encryption_path_for_version(1),
            Err(DerivationError::InvalidPath(_))
        ));
    }

    #[test]
    fn test_encryption_path_for_version_rejects_v0() {
        assert!(matches!(
            encryption_path_for_version(0),
            Err(DerivationError::InvalidPath(_))
        ));
    }

    #[test]
    fn test_derive_master_key_from_seed() {
        // Use a known 64-byte seed
        let seed = [0xABu8; 64];
        let result = derive_master_key(&seed);
        assert!(result.is_ok());
    }

    #[test]
    fn test_derive_identity_key_from_random_seed() {
        let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
        let seed = mnemonic.to_seed(None);
        let key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY);
        assert!(key.is_ok());

        let key = key.unwrap();
        assert_eq!(key.private_key().len(), 32);
        assert_eq!(key.public_key().len(), 32);
        assert_eq!(key.path(), PATHS::IDENTITY);
    }

    #[test]
    fn test_deterministic_derivation() {
        let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
        let seed = mnemonic.to_seed(None);

        let key1 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
        let key2 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();

        assert_eq!(key1.private_key(), key2.private_key());
        assert_eq!(key1.public_key(), key2.public_key());
    }

    #[test]
    fn test_different_paths_different_keys() {
        let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
        let seed = mnemonic.to_seed(None);

        let identity = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
        let ssh = derive_path_from_seed(seed.as_bytes(), PATHS::SSH_HOST).unwrap();

        assert_ne!(identity.private_key(), ssh.private_key());
        assert_ne!(identity.public_key(), ssh.public_key());
    }
}