Skip to main content

perspt_agent/
grant.rs

1//! Persistent grant signing-key resolution.
2
3use std::fs::OpenOptions;
4use std::io::{Read, Write};
5use std::path::PathBuf;
6
7use anyhow::{Context, Result};
8use rand_core::{OsRng, RngCore};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum GrantKeySource {
12    OperatorEnvironment,
13    OsCredentialStore,
14    InstallationFile,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct GrantSigningKey {
19    pub bytes: [u8; 32],
20    pub source: GrantKeySource,
21}
22
23impl GrantSigningKey {
24    /// The Ed25519 public key persisted grants must verify against.
25    pub fn public_key(&self) -> [u8; 32] {
26        perspt_sdk::grant_public_key(&self.bytes)
27    }
28
29    pub fn resolve() -> Result<Self> {
30        if let Ok(value) = std::env::var("PERSPT_GRANT_SIGNING_KEY") {
31            return Ok(Self {
32                bytes: decode_key(value.trim())?,
33                source: GrantKeySource::OperatorEnvironment,
34            });
35        }
36        if let Some(bytes) = credential_store_key() {
37            return Ok(Self {
38                bytes,
39                source: GrantKeySource::OsCredentialStore,
40            });
41        }
42        let path = key_file_path()?;
43        if path.is_file() {
44            let mut value = String::new();
45            std::fs::File::open(&path)?.read_to_string(&mut value)?;
46            return Ok(Self {
47                bytes: decode_key(value.trim())?,
48                source: GrantKeySource::InstallationFile,
49            });
50        }
51        let parent = path.parent().context("grant key path has no parent")?;
52        std::fs::create_dir_all(parent)?;
53        let mut bytes = [0u8; 32];
54        OsRng.fill_bytes(&mut bytes);
55        write_private_key(&path, &encode_key(&bytes))?;
56        log::warn!(
57            "OS credential store unavailable; persistent grant key stored in mode-0600 file {}",
58            path.display()
59        );
60        Ok(Self {
61            bytes,
62            source: GrantKeySource::InstallationFile,
63        })
64    }
65}
66
67fn key_file_path() -> Result<PathBuf> {
68    perspt_core::paths::config_dir()
69        .map(|directory| directory.join("grant-signing.key"))
70        .context("platform configuration directory is unavailable")
71}
72
73#[cfg(unix)]
74fn write_private_key(path: &std::path::Path, value: &str) -> Result<()> {
75    use std::os::unix::fs::OpenOptionsExt;
76    let mut file = OpenOptions::new()
77        .create_new(true)
78        .write(true)
79        .mode(0o600)
80        .open(path)?;
81    file.write_all(value.as_bytes())?;
82    Ok(())
83}
84
85#[cfg(not(unix))]
86fn write_private_key(path: &std::path::Path, value: &str) -> Result<()> {
87    let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
88    file.write_all(value.as_bytes())?;
89    Ok(())
90}
91
92#[cfg(target_os = "macos")]
93fn credential_store_key() -> Option<[u8; 32]> {
94    let output = std::process::Command::new("/usr/bin/security")
95        .args([
96            "find-generic-password",
97            "-s",
98            "perspt-grant-signing-key",
99            "-w",
100        ])
101        .output()
102        .ok()?;
103    if output.status.success() {
104        decode_key(String::from_utf8_lossy(&output.stdout).trim()).ok()
105    } else {
106        None
107    }
108}
109
110#[cfg(target_os = "linux")]
111fn credential_store_key() -> Option<[u8; 32]> {
112    let output = std::process::Command::new("secret-tool")
113        .args([
114            "lookup",
115            "application",
116            "perspt",
117            "purpose",
118            "grant-signing",
119        ])
120        .output()
121        .ok()?;
122    if output.status.success() {
123        decode_key(String::from_utf8_lossy(&output.stdout).trim()).ok()
124    } else {
125        None
126    }
127}
128
129#[cfg(not(any(target_os = "macos", target_os = "linux")))]
130fn credential_store_key() -> Option<[u8; 32]> {
131    None
132}
133
134fn decode_key(value: &str) -> Result<[u8; 32]> {
135    anyhow::ensure!(
136        value.len() == 64,
137        "grant signing key must be 64 hex characters"
138    );
139    perspt_sdk::hex_decode(value)
140        .map_err(|error| anyhow::anyhow!("grant signing key contains invalid hex: {error}"))?
141        .try_into()
142        .map_err(|_| anyhow::anyhow!("grant signing key must decode to 32 bytes"))
143}
144
145fn encode_key(bytes: &[u8; 32]) -> String {
146    perspt_sdk::hex_encode(bytes)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn key_encoding_round_trips() {
155        let bytes = [19u8; 32];
156        assert_eq!(decode_key(&encode_key(&bytes)).unwrap(), bytes);
157    }
158}