zuka
zuka/src/account/token.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/account/token.rs
RStoken.rs8.7 KBDownload
1// Tokens are scoped and expiring (SPEC §2.2). A single unscoped forever-token is
2// total account compromise, and the same secret is pasted into `git` — where it
3// lands in ~/.git-credentials in plaintext — and into an MCP client config.
4//
5// Only the SHA-256 of a token is ever stored. The plaintext exists once, at
6// creation, and is never recoverable.
7
8use crate::error::{Error, Result};
9use anyhow::Context;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::path::Path;
13
14/// Token prefix. Present so a leaked secret is greppable in logs and repos.
15pub const TOKEN_PREFIX: &str = "sk_";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Scope {
20 #[serde(rename = "repo:read")]
21 RepoRead,
22 #[serde(rename = "repo:write")]
23 RepoWrite,
24 Admin,
25 Ci,
26}
27
28impl Scope {
29 /// Whether holding `self` satisfies a requirement for `required`.
30 pub fn satisfies(self, required: Scope) -> bool {
31 match (self, required) {
32 (a, b) if a == b => true,
33 (Scope::Admin, _) => true,
34 (Scope::RepoWrite, Scope::RepoRead) => true,
35 _ => false,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct TokenRecord {
42 pub id: String,
43 pub account: String,
44 pub name: String,
45 /// Lowercase hex SHA-256 of the plaintext token.
46 pub hash: String,
47 pub scopes: Vec<Scope>,
48 /// Unix seconds. `None` means non-expiring, which the API refuses to mint but
49 /// a hand-written development token file may still contain.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub expires_at: Option<u64>,
52 /// Restricts the token to these repositories. Empty means every repo the
53 /// account owns.
54 #[serde(default)]
55 pub repos: Vec<String>,
56 /// Unix seconds of the last successful resolution, for revocation triage.
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub last_used_at: Option<u64>,
59}
60
61impl TokenRecord {
62 pub fn is_expired(&self, now: u64) -> bool {
63 matches!(self.expires_at, Some(at) if at <= now)
64 }
65}
66
67/// The standalone token file: `$DATA_DIR/tokens.json`.
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct TokenFile {
70 #[serde(default)]
71 pub tokens: Vec<TokenRecord>,
72}
73
74impl TokenFile {
75 pub fn load(path: &Path) -> Result<Self> {
76 match std::fs::read(path) {
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TokenFile::default()),
78 Err(e) => Err(Error::Internal(
79 anyhow::Error::from(e).context(format!("read {}", path.display())),
80 )),
81 Ok(bytes) => {
82 let parsed = serde_json::from_slice(&bytes)
83 .with_context(|| format!("parse {}", path.display()))?;
84 Ok(parsed)
85 }
86 }
87 }
88
89 pub fn save(&self, path: &Path) -> Result<()> {
90 if let Some(dir) = path.parent() {
91 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
92 }
93 let body = serde_json::to_vec_pretty(self).context("serialize token file")?;
94 let temp = path.with_extension("json.tmp");
95 // 0600: this file is the credential store. `std::fs::write` would create it
96 // 0644 and the rename would carry that mode onto the real path.
97 write_private(&temp, &body)?;
98 std::fs::rename(&temp, path).with_context(|| format!("rename into {}", path.display()))?;
99 Ok(())
100 }
101
102 /// Resolve a plaintext token to its record.
103 ///
104 /// Comparison is on the hash, so the plaintext is never held alongside stored
105 /// material, and an expired record resolves to `None` rather than to a valid
106 /// identity.
107 pub fn resolve(&self, plaintext: &str, now: u64) -> Option<&TokenRecord> {
108 let hash = hash_token(plaintext);
109 self.tokens
110 .iter()
111 .find(|t| constant_time_eq(&t.hash, &hash) && !t.is_expired(now))
112 }
113}
114
115/// Write a file readable only by its owner. It holds credentials.
116pub fn write_private(path: &Path, body: &[u8]) -> Result<()> {
117 use std::io::Write;
118
119 let mut options = std::fs::OpenOptions::new();
120 options.write(true).create(true).truncate(true);
121 #[cfg(unix)]
122 {
123 use std::os::unix::fs::OpenOptionsExt;
124 options.mode(0o600);
125 }
126 let mut file = options
127 .open(path)
128 .with_context(|| format!("create {}", path.display()))?;
129 file.write_all(body)
130 .with_context(|| format!("write {}", path.display()))?;
131 file.sync_all()
132 .with_context(|| format!("fsync {}", path.display()))?;
133 Ok(())
134}
135
136/// Lowercase hex SHA-256 of a token's plaintext.
137pub fn hash_token(plaintext: &str) -> String {
138 let mut hasher = Sha256::new();
139 hasher.update(plaintext.as_bytes());
140 hex::encode(hasher.finalize())
141}
142
143/// Compare two hex digests without an early return on the first differing byte.
144fn constant_time_eq(a: &str, b: &str) -> bool {
145 if a.len() != b.len() {
146 return false;
147 }
148 let mut diff = 0u8;
149 for (x, y) in a.bytes().zip(b.bytes()) {
150 diff |= x ^ y;
151 }
152 diff == 0
153}
154
155/// Mint a new token, returning the plaintext and the record to store.
156///
157/// The plaintext is returned exactly once — there is no path that recovers it from
158/// the record.
159pub fn mint(
160 account: &str,
161 name: &str,
162 scopes: Vec<Scope>,
163 repos: Vec<String>,
164 expires_at: Option<u64>,
165) -> (String, TokenRecord) {
166 assert!(!scopes.is_empty(), "a token without scopes can do nothing");
167 let secret = format!("{TOKEN_PREFIX}{}", uuid::Uuid::new_v4().simple());
168 let record = TokenRecord {
169 id: uuid::Uuid::new_v4().simple().to_string(),
170 account: account.to_string(),
171 name: name.to_string(),
172 hash: hash_token(&secret),
173 scopes,
174 expires_at,
175 repos,
176 last_used_at: None,
177 };
178 (secret, record)
179}
180
181pub fn now_secs() -> u64 {
182 std::time::SystemTime::now()
183 .duration_since(std::time::UNIX_EPOCH)
184 .map(|d| d.as_secs())
185 .unwrap_or(0)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn file_with(record: TokenRecord) -> TokenFile {
193 TokenFile {
194 tokens: vec![record],
195 }
196 }
197
198 #[test]
199 fn a_minted_token_resolves_to_its_own_record() {
200 let (secret, record) = mint("alice", "laptop", vec![Scope::RepoWrite], vec![], None);
201 let file = file_with(record);
202 let resolved = file.resolve(&secret, 0).expect("token should resolve");
203 assert_eq!(resolved.account, "alice");
204 }
205
206 #[test]
207 fn the_plaintext_is_never_stored_in_the_record() {
208 let (secret, record) = mint("alice", "laptop", vec![Scope::RepoRead], vec![], None);
209 let serialized = serde_json::to_string(&record).unwrap();
210 assert!(
211 !serialized.contains(&secret),
212 "plaintext token leaked into the stored record"
213 );
214 }
215
216 #[test]
217 fn an_expired_token_does_not_resolve() {
218 let (secret, record) = mint("alice", "old", vec![Scope::RepoRead], vec![], Some(1_000));
219 let file = file_with(record);
220 assert!(file.resolve(&secret, 999).is_some(), "valid before expiry");
221 assert!(
222 file.resolve(&secret, 1_000).is_none(),
223 "expired at the boundary"
224 );
225 assert!(file.resolve(&secret, 1_001).is_none(), "expired after");
226 }
227
228 #[test]
229 fn an_unknown_token_does_not_resolve() {
230 let (_, record) = mint("alice", "laptop", vec![Scope::RepoRead], vec![], None);
231 let file = file_with(record);
232 assert!(file.resolve("sk_not-a-real-token", 0).is_none());
233 }
234
235 #[test]
236 fn scopes_widen_from_admin_down_and_never_upward() {
237 assert!(Scope::Admin.satisfies(Scope::RepoWrite));
238 assert!(Scope::Admin.satisfies(Scope::RepoRead));
239 assert!(Scope::RepoWrite.satisfies(Scope::RepoRead));
240
241 assert!(!Scope::RepoRead.satisfies(Scope::RepoWrite));
242 assert!(!Scope::RepoWrite.satisfies(Scope::Admin));
243 assert!(!Scope::Ci.satisfies(Scope::RepoWrite));
244 }
245
246 #[test]
247 fn token_file_round_trips_through_disk() {
248 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join("tokens.json");
250
251 let (secret, record) = mint("alice", "laptop", vec![Scope::RepoWrite], vec![], None);
252 file_with(record).save(&path).unwrap();
253
254 let loaded = TokenFile::load(&path).unwrap();
255 assert!(loaded.resolve(&secret, 0).is_some());
256 }
257
258 #[test]
259 fn a_missing_token_file_loads_as_empty_rather_than_failing_boot() {
260 let dir = tempfile::tempdir().unwrap();
261 let loaded = TokenFile::load(&dir.path().join("absent.json")).unwrap();
262 assert!(loaded.tokens.is_empty());
263 }
264}