Skip to main content

oboron_cli_core/
key.rs

1//! Key string normalization.
2//!
3//! During the base64 → hex migration period (until oboron 1.0), keys
4//! may arrive as either 128-character hex (canonical) or 86-character
5//! base64 (legacy, deprecated). [`normalize_key_classify`] reports
6//! which form was used so callers can warn / migrate; the simpler
7//! [`normalize_key_to_hex`] just returns the canonical hex.
8
9use anyhow::{anyhow, bail, Result};
10use data_encoding::BASE64URL_NOPAD;
11
12/// Format the key arrived in.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum KeyFormat {
15    /// 128-character hex (canonical).
16    Hex,
17    /// 86-character URL-safe base64 (legacy; will be removed before
18    /// oboron 1.0).
19    LegacyBase64,
20}
21
22/// Convert a key string to canonical 128-character hex, accepting
23/// either form, and report which form was actually given.
24///
25/// - 128 hex chars (canonical) → `KeyFormat::Hex`, lowercased
26/// - 86 base64 chars (legacy)  → `KeyFormat::LegacyBase64`, re-encoded to hex
27///
28/// Any other length / invalid encoding is an error.
29pub fn normalize_key_classify(key: &str) -> Result<(String, KeyFormat)> {
30    let trimmed = key.trim();
31    match trimmed.len() {
32        128 => {
33            hex::decode(trimmed).map_err(|e| anyhow!("not a valid hex key: {e}"))?;
34            Ok((trimmed.to_lowercase(), KeyFormat::Hex))
35        }
36        86 => {
37            let bytes = BASE64URL_NOPAD
38                .decode(trimmed.as_bytes())
39                .map_err(|e| anyhow!("not a valid base64 key: {e}"))?;
40            if bytes.len() != 64 {
41                bail!("decoded base64 key is {} bytes, expected 64", bytes.len());
42            }
43            Ok((hex::encode(bytes), KeyFormat::LegacyBase64))
44        }
45        n => bail!("key has length {n}; expected 128 (hex) or 86 (legacy base64)"),
46    }
47}
48
49/// Like [`normalize_key_classify`] but discards the format tag.
50pub fn normalize_key_to_hex(key: &str) -> Result<String> {
51    Ok(normalize_key_classify(key)?.0)
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn hex_passes_through() {
60        let h = "0".repeat(128);
61        let (out, fmt) = normalize_key_classify(&h).unwrap();
62        assert_eq!(out, h);
63        assert_eq!(fmt, KeyFormat::Hex);
64    }
65
66    #[test]
67    fn base64_classifies_as_legacy() {
68        let b64 = "A".repeat(86);
69        let (_, fmt) = normalize_key_classify(&b64).unwrap();
70        assert_eq!(fmt, KeyFormat::LegacyBase64);
71    }
72
73    #[test]
74    fn hex_lowercased() {
75        let mixed = "AaBbCcDd".to_string() + &"0".repeat(120);
76        let n = normalize_key_to_hex(&mixed).unwrap();
77        assert_eq!(n.chars().next().unwrap(), 'a');
78    }
79
80    #[test]
81    fn base64_round_trips_to_hex() {
82        // 86 'A's = base64 of 64 zero bytes
83        let b64 = "A".repeat(86);
84        let h = normalize_key_to_hex(&b64).unwrap();
85        assert_eq!(h, "0".repeat(128));
86    }
87
88    #[test]
89    fn wrong_length_rejected() {
90        assert!(normalize_key_to_hex(&"a".repeat(50)).is_err());
91        assert!(normalize_key_to_hex(&"a".repeat(127)).is_err());
92        assert!(normalize_key_to_hex("").is_err());
93    }
94
95    #[test]
96    fn trims_whitespace() {
97        let h = "0".repeat(128);
98        let padded = format!("  {h}\n");
99        assert_eq!(normalize_key_to_hex(&padded).unwrap(), h);
100    }
101}