boatctl 0.1.2

CLI for Blueboat Cloud.
Documentation
use std::{
  path::PathBuf,
  time::{SystemTime, UNIX_EPOCH},
};

use data_encoding::{BASE32_NOPAD, BASE64};
use ed25519_dalek::{ed25519::signature::Signature, Keypair, PublicKey, SecretKey, Signer};
use regex::Regex;
use reqwest::{header::HeaderValue, Request};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct CredentialsJson {
  pub access_key: String,
  pub secret_key: String,
}

pub struct Credentials {
  ak: String,
  keypair: ed25519_dalek::Keypair,
}

impl Credentials {
  pub fn init(credentials_file: &Option<String>) -> anyhow::Result<Self> {
    let ak_regex = Regex::new(r#"^lha_([0-9a-z]{1,100})$"#).unwrap();
    let sk_regex = Regex::new(r#"^lhs_([0-9a-z]{1,100})$"#).unwrap();

    let (ak, sk) = if let (Ok(ak), Ok(sk)) = (
      std::env::var("BOAT_ACCESS_KEY"),
      std::env::var("BOAT_SECRET_KEY"),
    ) {
      (ak, sk)
    } else {
      let path = credentials_file
        .as_ref()
        .map(|x| PathBuf::from(x.as_str()))
        .unwrap_or_else(|| {
          dirs::home_dir()
            .unwrap_or_else(|| std::path::PathBuf::from("/"))
            .join(".boat/credentials.json")
        });

      let raw_creds = std::fs::read(&path)
        .map_err(|e| anyhow::Error::from(e).context("cannot read credentials file"))?;

      let raw_creds: CredentialsJson = serde_json::from_slice(&raw_creds)
        .map_err(|e| anyhow::Error::from(e).context("cannot decode credentials file"))?;
      (raw_creds.access_key, raw_creds.secret_key)
    };

    if !ak_regex.is_match(&ak) {
      anyhow::bail!("invalid access key format");
    }

    if !sk_regex.is_match(&sk) {
      anyhow::bail!("invalid secret key format");
    }

    let ak_bin = BASE32_NOPAD
      .decode(ak.strip_prefix("lha_").unwrap().to_uppercase().as_bytes())
      .unwrap();

    let sk_bin = BASE32_NOPAD
      .decode(sk.strip_prefix("lhs_").unwrap().to_uppercase().as_bytes())
      .unwrap();

    if ak_bin.len() != 32 {
      anyhow::bail!("invalid access key length");
    }

    if sk_bin.len() != 32 {
      anyhow::bail!("invalid secret key length");
    }

    let sk = SecretKey::from_bytes(&sk_bin).unwrap();
    let computed_pubkey = PublicKey::from(&sk);
    if computed_pubkey.as_bytes() != &ak_bin[..] {
      anyhow::bail!("secret key does not match access key");
    }

    let keypair = Keypair {
      secret: sk,
      public: computed_pubkey,
    };

    Ok(Self { ak, keypair })
  }

  pub fn annotate_request(&self, req: &mut Request) {
    let current_time = SystemTime::now()
      .duration_since(UNIX_EPOCH)
      .unwrap()
      .as_secs();
    let sig = self.sign(current_time);
    let headers = req.headers_mut();
    headers.insert(
      "x-lighthouse-access-key",
      HeaderValue::from_str(&self.ak).unwrap(),
    );
    headers.insert(
      "x-lighthouse-request-time",
      HeaderValue::from_str(&format!("{}", current_time)).unwrap(),
    );
    headers.insert(
      "x-lighthouse-request-signature",
      HeaderValue::from_str(&sig).unwrap(),
    );
  }

  fn sign(&self, time_sec: u64) -> String {
    let payload = format!("request:{}", time_sec);
    let sig = self.keypair.sign(payload.as_bytes());
    BASE64.encode(sig.as_bytes())
  }
}