Skip to main content

clawdentity_core/identity/
identity.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use ed25519_dalek::{SigningKey, VerifyingKey};
7use getrandom::fill as getrandom_fill;
8use serde::{Deserialize, Serialize};
9
10use crate::config::{CliConfig, ConfigPathOptions, get_config_dir, resolve_config, write_config};
11use crate::did::{did_authority_from_url, new_human_did};
12use crate::error::{CoreError, Result};
13
14const IDENTITY_FILE: &str = "identity.json";
15const FILE_MODE: u32 = 0o600;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct LocalIdentity {
20    pub did: String,
21    pub public_key: String,
22    pub secret_key: String,
23    pub registry_url: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct PublicIdentityView {
29    pub did: String,
30    pub public_key: String,
31    pub registry_url: String,
32}
33
34impl LocalIdentity {
35    /// TODO(clawdentity): document `public_view`.
36    pub fn public_view(&self) -> PublicIdentityView {
37        PublicIdentityView {
38            did: self.did.clone(),
39            public_key: self.public_key.clone(),
40            registry_url: self.registry_url.clone(),
41        }
42    }
43}
44
45fn identity_path(options: &ConfigPathOptions) -> Result<PathBuf> {
46    Ok(get_config_dir(options)?.join(IDENTITY_FILE))
47}
48
49fn set_secure_permissions(path: &Path) -> Result<()> {
50    #[cfg(unix)]
51    {
52        use std::os::unix::fs::PermissionsExt;
53        let perms = fs::Permissions::from_mode(FILE_MODE);
54        fs::set_permissions(path, perms).map_err(|source| CoreError::Io {
55            path: path.to_path_buf(),
56            source,
57        })?;
58    }
59    Ok(())
60}
61
62fn write_secure_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
63    if let Some(parent) = path.parent() {
64        fs::create_dir_all(parent).map_err(|source| CoreError::Io {
65            path: parent.to_path_buf(),
66            source,
67        })?;
68    }
69    let raw = serde_json::to_string_pretty(value)?;
70    fs::write(path, format!("{raw}\n")).map_err(|source| CoreError::Io {
71        path: path.to_path_buf(),
72        source,
73    })?;
74    set_secure_permissions(path)?;
75    Ok(())
76}
77
78/// TODO(clawdentity): document `decode_secret_key`.
79pub fn decode_secret_key(value: &str) -> Result<SigningKey> {
80    let raw = URL_SAFE_NO_PAD
81        .decode(value)
82        .map_err(|error| CoreError::Base64Decode(error.to_string()))?;
83    let bytes: [u8; 32] = raw
84        .try_into()
85        .map_err(|_| CoreError::InvalidInput("secret key must decode to 32 bytes".to_string()))?;
86    Ok(SigningKey::from_bytes(&bytes))
87}
88
89/// TODO(clawdentity): document `init_identity`.
90pub fn init_identity(
91    options: &ConfigPathOptions,
92    registry_url_override: Option<String>,
93) -> Result<LocalIdentity> {
94    let mut config = resolve_config(options)?;
95    if let Some(override_url) = registry_url_override {
96        let trimmed = override_url.trim().to_string();
97        if trimmed.is_empty() {
98            return Err(CoreError::InvalidInput(
99                "registryUrl cannot be empty".to_string(),
100            ));
101        }
102        config.registry_url = trimmed;
103    }
104
105    let state_options = options.with_registry_hint(config.registry_url.clone());
106    let path = identity_path(&state_options)?;
107    if path.exists() {
108        return Err(CoreError::IdentityAlreadyExists(path));
109    }
110
111    let mut secret_bytes = [0_u8; 32];
112    getrandom_fill(&mut secret_bytes)
113        .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
114    let signing_key = SigningKey::from_bytes(&secret_bytes);
115    let verifying_key: VerifyingKey = signing_key.verifying_key();
116
117    let did_authority = did_authority_from_url(&config.registry_url, "registryUrl")?;
118    let did = new_human_did(&did_authority)?;
119    let identity = LocalIdentity {
120        did,
121        public_key: URL_SAFE_NO_PAD.encode(verifying_key.as_bytes()),
122        secret_key: URL_SAFE_NO_PAD.encode(signing_key.to_bytes()),
123        registry_url: config.registry_url.clone(),
124    };
125
126    write_secure_json(&path, &identity)?;
127    let _ = write_config(
128        &CliConfig {
129            registry_url: config.registry_url,
130            proxy_url: config.proxy_url,
131            api_key: config.api_key,
132            human_name: config.human_name,
133        },
134        options,
135    )?;
136    Ok(identity)
137}
138
139/// TODO(clawdentity): document `read_identity`.
140pub fn read_identity(options: &ConfigPathOptions) -> Result<LocalIdentity> {
141    let path = identity_path(options)?;
142    let raw = fs::read_to_string(&path).map_err(|source| {
143        if source.kind() == std::io::ErrorKind::NotFound {
144            return CoreError::IdentityNotFound(path.clone());
145        }
146        CoreError::Io {
147            path: path.clone(),
148            source,
149        }
150    })?;
151
152    serde_json::from_str::<LocalIdentity>(&raw)
153        .map_err(|source| CoreError::JsonParse { path, source })
154}
155
156#[cfg(test)]
157mod tests {
158    use tempfile::TempDir;
159
160    use crate::config::ConfigPathOptions;
161
162    use super::{decode_secret_key, init_identity, read_identity};
163
164    fn options(home: &std::path::Path) -> ConfigPathOptions {
165        ConfigPathOptions {
166            home_dir: Some(home.to_path_buf()),
167            registry_url_hint: Some("https://registry.clawdentity.com".to_string()),
168        }
169    }
170
171    #[test]
172    fn init_identity_creates_identity_and_can_read_it() {
173        let tmp = TempDir::new().expect("temp dir");
174        let opts = options(tmp.path());
175
176        let created = init_identity(&opts, None).expect("identity should initialize");
177        let loaded = read_identity(&opts).expect("identity should load");
178        assert_eq!(created.did, loaded.did);
179        assert_eq!(created.public_key, loaded.public_key);
180    }
181
182    #[test]
183    fn decode_secret_key_accepts_generated_material() {
184        let tmp = TempDir::new().expect("temp dir");
185        let opts = options(tmp.path());
186        let created = init_identity(&opts, None).expect("identity should initialize");
187        let key = decode_secret_key(&created.secret_key).expect("secret key should decode");
188        assert_eq!(key.to_bytes().len(), 32);
189    }
190}