rscale 0.1.0

Self-hosted Rust control plane for operating a single tailnet with Tailscale clients
Documentation
use std::fmt::Write as _;

use graviola::key_agreement::x25519::StaticPrivateKey;

use crate::error::{AppError, AppResult};

const MACHINE_PRIVATE_PREFIX: &str = "privkey:";
const MACHINE_PUBLIC_PREFIX: &str = "mkey:";
const NODE_PUBLIC_PREFIX: &str = "nodekey:";
const CHALLENGE_PUBLIC_PREFIX: &str = "chalpub:";

pub fn parse_machine_private_key(value: &str) -> AppResult<[u8; 32]> {
    parse_typed_hex_key(value, MACHINE_PRIVATE_PREFIX)
}

pub fn parse_node_private_key(value: &str) -> AppResult<[u8; 32]> {
    parse_typed_hex_key(value, MACHINE_PRIVATE_PREFIX)
}

pub fn machine_public_key_from_private(private_key: &[u8; 32]) -> String {
    let private_key = StaticPrivateKey::from_array(private_key);
    let public_key = private_key.public_key();
    encode_typed_hex_key(MACHINE_PUBLIC_PREFIX, &public_key.as_bytes())
}

pub fn machine_public_key_from_raw(raw: &[u8; 32]) -> String {
    encode_typed_hex_key(MACHINE_PUBLIC_PREFIX, raw)
}

pub fn node_public_key_from_private(private_key: &[u8; 32]) -> String {
    let private_key = StaticPrivateKey::from_array(private_key);
    let public_key = private_key.public_key();
    encode_typed_hex_key(NODE_PUBLIC_PREFIX, &public_key.as_bytes())
}

pub fn node_public_key_from_raw(raw: &[u8; 32]) -> String {
    encode_typed_hex_key(NODE_PUBLIC_PREFIX, raw)
}

pub fn parse_node_public_key(value: &str) -> AppResult<[u8; 32]> {
    parse_typed_hex_key(value, NODE_PUBLIC_PREFIX)
}

pub fn raw_key_hex(raw: &[u8]) -> String {
    encode_typed_hex_key("", raw)
}

pub fn generate_challenge_public_key() -> AppResult<String> {
    let private_key = StaticPrivateKey::new_random()
        .map_err(|err| AppError::Bootstrap(format!("failed to generate challenge key: {err}")))?;
    let public_key = private_key.public_key();
    Ok(encode_typed_hex_key(
        CHALLENGE_PUBLIC_PREFIX,
        &public_key.as_bytes(),
    ))
}

fn parse_typed_hex_key(value: &str, prefix: &str) -> AppResult<[u8; 32]> {
    let encoded = value
        .strip_prefix(prefix)
        .ok_or_else(|| AppError::InvalidConfig(format!("key must start with {prefix}")))?;

    if encoded.len() != 64 {
        return Err(AppError::InvalidConfig(format!(
            "key with prefix {prefix} must contain exactly 64 hex characters"
        )));
    }

    let mut bytes = [0_u8; 32];
    for (index, byte) in bytes.iter_mut().enumerate() {
        let offset = index * 2;
        let chunk = &encoded[offset..offset + 2];
        *byte = u8::from_str_radix(chunk, 16).map_err(|err| {
            AppError::InvalidConfig(format!("failed to decode key with prefix {prefix}: {err}"))
        })?;
    }

    Ok(bytes)
}

fn encode_typed_hex_key(prefix: &str, raw: &[u8]) -> String {
    let mut value = String::with_capacity(prefix.len() + raw.len() * 2);
    value.push_str(prefix);
    for byte in raw {
        let _ = write!(value, "{byte:02x}");
    }
    value
}

#[cfg(test)]
mod tests {
    use std::error::Error;

    use super::*;

    const PRIVATE_KEY: &str =
        "privkey:1111111111111111111111111111111111111111111111111111111111111111";

    #[test]
    fn parse_machine_private_key_accepts_valid_typed_hex() -> Result<(), Box<dyn Error>> {
        let parsed = parse_machine_private_key(PRIVATE_KEY)?;
        assert_eq!(parsed, [0x11; 32]);
        Ok(())
    }

    #[test]
    fn parse_node_public_key_rejects_wrong_prefix_and_length() -> Result<(), Box<dyn Error>> {
        let wrong_prefix = match parse_node_public_key(
            "mkey:1111111111111111111111111111111111111111111111111111111111111111",
        ) {
            Ok(_) => return Err(std::io::Error::other("wrong prefix should be rejected").into()),
            Err(err) => err,
        };
        assert!(
            matches!(wrong_prefix, AppError::InvalidConfig(message) if message.contains("nodekey:"))
        );

        let wrong_len = match parse_node_public_key("nodekey:abcd") {
            Ok(_) => return Err(std::io::Error::other("short key should be rejected").into()),
            Err(err) => err,
        };
        assert!(
            matches!(wrong_len, AppError::InvalidConfig(message) if message.contains("exactly 64 hex characters"))
        );
        Ok(())
    }

    #[test]
    fn public_key_helpers_encode_expected_prefixes() -> Result<(), Box<dyn Error>> {
        let private_key = parse_machine_private_key(PRIVATE_KEY)?;
        let machine_public = machine_public_key_from_private(&private_key);
        let node_public = node_public_key_from_private(&private_key);

        assert!(machine_public.starts_with(MACHINE_PUBLIC_PREFIX));
        assert!(node_public.starts_with(NODE_PUBLIC_PREFIX));
        assert_eq!(machine_public.len(), MACHINE_PUBLIC_PREFIX.len() + 64);
        assert_eq!(node_public.len(), NODE_PUBLIC_PREFIX.len() + 64);
        Ok(())
    }

    #[test]
    fn node_public_key_round_trips_from_private_to_raw() -> Result<(), Box<dyn Error>> {
        let private_key = parse_machine_private_key(PRIVATE_KEY)?;
        let encoded = node_public_key_from_private(&private_key);
        let parsed = parse_node_public_key(&encoded)?;
        assert_eq!(encoded, node_public_key_from_raw(&parsed));
        Ok(())
    }

    #[test]
    fn raw_key_hex_does_not_include_a_prefix() {
        assert_eq!(raw_key_hex(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
    }

    #[test]
    fn generate_challenge_public_key_uses_challenge_prefix() -> Result<(), Box<dyn Error>> {
        let challenge = generate_challenge_public_key()?;
        assert!(challenge.starts_with(CHALLENGE_PUBLIC_PREFIX));
        assert_eq!(challenge.len(), CHALLENGE_PUBLIC_PREFIX.len() + 64);
        Ok(())
    }
}