///|
/// Secret storage.
///
/// Passwords are never stored in clear text in the configuration file. They
/// are ciphertext with AES-256-GCM under a key that lives in a `.env` file next to
/// the configuration. The ciphertext is a self-describing envelope:
///
/// ```text
/// enc:v1:::
/// ```
///
/// The key itself may be a random 32-byte key (`b64:...` or `hex:...`) or a
/// passphrase (any other value), in which case PBKDF2-HMAC-SHA256 with the
/// envelope salt derives the AES key.
let envelope_prefix : String = "enc:v1:"

///|
let envelope_aad : Bytes = b"mails/v1"

///|
let pbkdf2_iterations : Int32 = 100_000

///|
pub let default_key_env : String = "MAILS_KEY"

///|
async fn random_bytes(count : Int32) -> Bytes raise MailsError {
  let file = @fs.open("/dev/urandom", mode=ReadOnly) catch {
    _ => raise MailsError("cannot open /dev/urandom")
  }
  defer file.close()
  file.read_exactly(count) catch {
    _ => raise MailsError("cannot read random bytes from /dev/urandom")
  }
}

///|
priv enum KeyMaterial {
  Raw(Bytes)
  Passphrase(Bytes)
}

///|
fn decode_hex(text : String) -> Bytes raise MailsError {
  @hex.decode(text) catch {
    _ => raise MailsError("the encryption key is not valid hexadecimal")
  }
}

///|
fn parse_key(spec : String) -> KeyMaterial raise MailsError {
  let text = trim(spec)
  if text == "" {
    raise MailsError("the encryption key is empty")
  }
  if text.has_prefix("b64:") {
    let key = base64_decode(text[4:].to_owned())
    if key.length() != 32 {
      raise MailsError("a b64: encryption key must decode to exactly 32 bytes")
    }
    Raw(key)
  } else if text.has_prefix("hex:") {
    let key = decode_hex(text[4:].to_owned())
    if key.length() != 32 {
      raise MailsError("a hex: encryption key must decode to exactly 32 bytes")
    }
    Raw(key)
  } else if text.has_prefix("raw:") {
    let key = string_to_bytes(text[4:].to_owned())
    if key.length() != 32 {
      raise MailsError("a raw: encryption key must be exactly 32 bytes")
    }
    Raw(key)
  } else if text.has_prefix("pass:") {
    Passphrase(string_to_bytes(text[5:].to_owned()))
  } else {
    Passphrase(string_to_bytes(text))
  }
}

///|
/// Generate a fresh 32-byte key in the `b64:` form used by `.env`.
pub async fn generate_key_spec() -> String raise MailsError {
  "b64:" + base64_encode(random_bytes(32))
}

///|
/// Derive the AES key for `spec`, using `salt` for passphrases.
fn derive_key(spec : String, salt : Bytes) -> Bytes raise MailsError {
  match parse_key(spec) {
    Raw(key) => key
    Passphrase(password) => {
      if password.length() == 0 {
        raise MailsError("the encryption passphrase is empty")
      }
      @lib.pbkdf2_hmac_sha256(password, salt, pbkdf2_iterations, 32)
    }
  }
}

///|
fn key_is_passphrase(spec : String) -> Bool raise MailsError {
  match parse_key(spec) {
    Raw(_) => false
    Passphrase(_) => true
  }
}

///|
/// Seal `plaintext` with the key from `key_spec`.
pub async fn seal_secret(
  key_spec : String,
  plaintext : String,
) -> String raise MailsError {
  let salt = random_bytes(16)
  let nonce = random_bytes(12)
  let key = derive_key(key_spec, salt)
  let encrypted = @lib.aes_gcm_encrypt(
    string_to_bytes(plaintext),
    key,
    nonce,
    envelope_aad,
  )
  let payload = bytes_concat([encrypted.ct, encrypted.tag])
  envelope_prefix +
  base64_encode(salt) +
  ":" +
  base64_encode(nonce) +
  ":" +
  base64_encode(payload)
}

///|
/// True when `text` looks like an `enc:v1:` envelope.
pub fn is_sealed(text : String) -> Bool {
  trim(text).has_prefix(envelope_prefix)
}

///|
/// Open an `enc:v1:` envelope with the key from `key_spec`.
pub fn open_secret(
  key_spec : String,
  envelope : String,
) -> String raise MailsError {
  let text = trim(envelope)
  if !text.has_prefix(envelope_prefix) {
    raise MailsError("not an encrypted value (expected the enc:v1: prefix)")
  }
  let rest = text[envelope_prefix.length():].to_owned()
  let parts : Array[String] = []
  for part in rest.split(":") {
    parts.push(part.to_owned())
  }
  if parts.length() != 3 {
    raise MailsError("malformed encrypted value")
  }
  let salt = base64_decode(parts[0])
  let nonce = base64_decode(parts[1])
  let payload = base64_decode(parts[2])
  if nonce.length() != 12 || payload.length() < 16 {
    raise MailsError("malformed encrypted value")
  }
  let ciphertext_len = payload.length() - 16
  let ciphertext = payload[:ciphertext_len].to_owned()
  let tag = payload[ciphertext_len:].to_owned()
  let key = derive_key(key_spec, salt)
  let plain = @lib.aes_gcm_decrypt(ciphertext, key, nonce, envelope_aad, tag)
  match plain {
    Ok(bytes) => bytes_to_string_lossy(bytes)
    Err(_) =>
      raise MailsError(
        "wrong encryption key, or the encrypted value is corrupt",
      )
  }
}

///|
/// Resolve a configured password: an `enc:` envelope is opened with `key`,
/// anything else is returned unchanged (for accounts that use another auth
/// mechanism or a password helper).
pub fn resolve_secret(
  key : String,
  configured : String,
) -> String raise MailsError {
  if is_sealed(configured) {
    open_secret(key, configured)
  } else {
    configured
  }
}

///|
/// Whether a configured secret needs a key to be resolved.
pub fn needs_key(configured : String) -> Bool {
  is_sealed(configured)
}

///|
/// Unused by the runtime, but kept honest by a test: whether `spec` is a
/// passphrase-derived key.
pub fn key_uses_passphrase(spec : String) -> Bool raise MailsError {
  key_is_passphrase(spec)
}