1use 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 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}