Skip to main content

cyphr_cli/commands/
common.rs

1//! Shared helper functions for CLI commands.
2//!
3//! These were previously duplicated across multiple command modules.
4//! Consolidated per C.2 audit finding.
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use base64ct::{Base64UrlUnpadded, Encoding};
9use coz::Thumbprint;
10use cyphr::Key;
11use cyphr_storage::{CommitEntry, FileStore, Genesis};
12
13use crate::Error;
14use crate::keystore::{JsonKeyStore, KeyStore, StoredKey};
15
16/// Get Unix timestamp as i64 seconds.
17pub fn current_timestamp() -> i64 {
18    SystemTime::now()
19        .duration_since(UNIX_EPOCH)
20        .map(|d| d.as_secs() as i64)
21        .unwrap_or(0)
22}
23
24/// Load a cyphr Key from keystore by thumbprint.
25pub fn load_key_from_keystore(keystore: &JsonKeyStore, tmb: &str) -> crate::Result<Key> {
26    let stored = keystore.get(tmb)?;
27    let now = current_timestamp();
28
29    Ok(Key {
30        alg: stored.alg.clone(),
31        tmb: Thumbprint::from_bytes(decode_b64(tmb)?),
32        pub_key: stored.pub_key.clone(),
33        first_seen: now,
34        last_used: None,
35        revocation: None,
36        tag: stored.tag.clone(),
37    })
38}
39
40/// Extract genesis from stored commits.
41///
42/// Strategy depends on whether a keystore is available:
43///
44/// **With keystore**: Look up the signer of the first coz by
45/// thumbprint (`pay.tmb`). This is the correct approach for import,
46/// where the genesis key (signer) may not be embedded in the commit
47/// but the *new* key being added might be.
48///
49/// **Without keystore**: Scan the first commit's cozies for
50/// embedded `key` objects. This works for inspect/verify/list where
51/// all key material is in the commits themselves.
52pub fn extract_genesis_from_commits(
53    commits: &[CommitEntry],
54    keystore: Option<&JsonKeyStore>,
55) -> crate::Result<Genesis> {
56    let first_commit = commits.first().ok_or(Error::MissingField("commits"))?;
57
58    // When keystore is available, prefer signer-based lookup.
59    // The signer of the first coz IS the genesis key.
60    if let Some(ks) = keystore {
61        if let Some(first_tx) = first_commit.cozies.first() {
62            if let Some(signer_tmb) = first_tx
63                .get("pay")
64                .and_then(|p| p.get("tmb"))
65                .and_then(|v| v.as_str())
66            {
67                // Try embedded key first (self-signed genesis)
68                if let Some(key_obj) = first_tx.get("key") {
69                    if key_obj.get("tmb").and_then(|v| v.as_str()) == Some(signer_tmb) {
70                        return extract_key_from_obj(key_obj).map(Genesis::Implicit);
71                    }
72                }
73
74                // Fallback to keystore
75                if let Ok(key) = load_key_from_keystore(ks, signer_tmb) {
76                    return Ok(Genesis::Implicit(key));
77                }
78            }
79        }
80    }
81
82    // No keystore or signer lookup failed — scan for embedded keys.
83    let mut genesis_keys = Vec::new();
84
85    for tx_value in &first_commit.cozies {
86        if let Some(key_obj) = tx_value.get("key") {
87            genesis_keys.push(extract_key_from_obj(key_obj)?);
88        }
89    }
90
91    if genesis_keys.is_empty() {
92        return Err(Error::Storage(
93            "cannot determine genesis keys from storage".into(),
94        ));
95    }
96
97    if genesis_keys.len() == 1 {
98        Ok(Genesis::Implicit(genesis_keys.remove(0)))
99    } else {
100        Ok(Genesis::Explicit(genesis_keys))
101    }
102}
103
104/// Extract a Key from a JSON key object.
105fn extract_key_from_obj(key_obj: &serde_json::Value) -> crate::Result<Key> {
106    let alg = key_obj
107        .get("alg")
108        .and_then(|v| v.as_str())
109        .ok_or(Error::MissingField("key.alg"))?;
110    let pub_b64 = key_obj
111        .get("pub")
112        .and_then(|v| v.as_str())
113        .ok_or(Error::MissingField("key.pub"))?;
114    let tmb_b64 = key_obj
115        .get("tmb")
116        .and_then(|v| v.as_str())
117        .ok_or(Error::MissingField("key.tmb"))?;
118
119    let pub_key = Base64UrlUnpadded::decode_vec(pub_b64)?;
120    let tmb_bytes = Base64UrlUnpadded::decode_vec(tmb_b64)?;
121
122    Ok(Key {
123        alg: alg.to_string(),
124        tmb: Thumbprint::from_bytes(tmb_bytes),
125        pub_key,
126        first_seen: 0,
127        last_used: None,
128        revocation: None,
129        tag: None,
130    })
131}
132
133/// Parse the --store argument into a FileStore.
134pub fn parse_store(store_uri: &str) -> crate::Result<FileStore> {
135    if let Some(path) = store_uri.strip_prefix("file:") {
136        Ok(FileStore::new(path))
137    } else {
138        Err(Error::InvalidArgument(format!(
139            "unsupported store URI: {store_uri} (expected file:<path>)"
140        )))
141    }
142}
143
144/// Parse a base64url principal root string into a PrincipalGenesis.
145pub fn parse_principal_genesis(s: &str) -> crate::Result<cyphr::PrincipalGenesis> {
146    let bytes = Base64UrlUnpadded::decode_vec(s)?;
147    Ok(cyphr::PrincipalGenesis::from_bytes(bytes))
148}
149
150/// Decode base64url string to bytes.
151pub fn decode_b64(s: &str) -> crate::Result<Vec<u8>> {
152    Ok(Base64UrlUnpadded::decode_vec(s)?)
153}
154
155/// Generate a new keypair using `Alg` dispatch.
156///
157/// Returns the thumbprint string, a `StoredKey` for keystore, and a
158/// `cyphr::Key` for protocol operations.
159pub fn generate_key(algo: &str, tag: Option<&str>) -> crate::Result<(String, StoredKey, Key)> {
160    let alg_enum = coz::Alg::from_str(algo)
161        .ok_or_else(|| Error::InvalidArgument(format!("unknown algorithm: {algo}")))?;
162
163    let keypair = alg_enum.generate_keypair();
164    let tmb_b64 = Base64UrlUnpadded::encode_string(keypair.thumbprint.as_bytes());
165
166    let now = current_timestamp();
167
168    let stored = StoredKey {
169        alg: algo.to_string(),
170        pub_key: keypair.pub_bytes.clone(),
171        prv_key: keypair.prv_bytes,
172        tag: tag.map(String::from),
173    };
174
175    let key = Key {
176        alg: algo.to_string(),
177        tmb: Thumbprint::from_bytes(keypair.thumbprint.as_bytes().to_vec()),
178        pub_key: keypair.pub_bytes,
179        first_seen: now,
180        last_used: None,
181        revocation: None,
182        tag: tag.map(String::from),
183    };
184
185    Ok((tmb_b64, stored, key))
186}