Skip to main content

void_crypto/
machine_token.rs

1//! Machine tokens for scoped, ephemeral access.
2//!
3//! A machine token packages the minimum credentials needed for a CI job,
4//! coding agent, or automation to fork a repo, work on it, and PR back.
5//!
6//! Contains:
7//! - A fresh Ed25519 signing identity (for the machine's own commits)
8//! - A content key (scoped read access to one commit in the source repo)
9//! - The source commit CID (what to fork from)
10//! - An expiry timestamp (TTL)
11//!
12//! The machine CANNOT commit to the source repo — it forks, works in its
13//! own repo, and submits changes via `void pull-request`. No root key
14//! exposure at any level.
15
16use serde::{Deserialize, Serialize};
17
18use crate::kdf::{RecipientSecretKey, SigningSecretKey};
19use crate::{decrypt, encrypt, CryptoResult};
20
21/// AAD for machine token encryption.
22const AAD_MACHINE_TOKEN: &[u8] = b"void:machine-token:v1";
23
24/// A machine token — packaged credentials for scoped repo access.
25#[derive(Serialize, Deserialize)]
26pub struct MachineToken {
27    /// Version tag for forward compatibility.
28    pub version: u32,
29    /// Ed25519 signing secret key (32 bytes) — for the machine's own commits.
30    pub signing_secret: Vec<u8>,
31    /// X25519 recipient secret key (32 bytes) — for ECIES operations.
32    pub recipient_secret: Vec<u8>,
33    /// Content key (32 bytes) — scoped read access to `source_commit`.
34    pub content_key: Vec<u8>,
35    /// CID of the source commit this token grants access to.
36    pub source_commit: Vec<u8>,
37    /// Repo name (for display/context).
38    pub repo_name: Option<String>,
39    /// Unix timestamp after which this token is invalid.
40    pub expires_at: u64,
41}
42
43impl MachineToken {
44    pub const VERSION: u32 = 1;
45
46    /// Create a new machine token.
47    pub fn new(
48        signing_secret: &SigningSecretKey,
49        recipient_secret: &RecipientSecretKey,
50        content_key: &[u8; 32],
51        source_commit: Vec<u8>,
52        repo_name: Option<String>,
53        expires_at: u64,
54    ) -> Self {
55        Self {
56            version: Self::VERSION,
57            signing_secret: signing_secret.as_bytes().to_vec(),
58            recipient_secret: recipient_secret.as_bytes().to_vec(),
59            content_key: content_key.to_vec(),
60            source_commit,
61            repo_name,
62            expires_at,
63        }
64    }
65
66    /// Check if the token has expired.
67    pub fn is_expired(&self, now: u64) -> bool {
68        now > self.expires_at
69    }
70
71    /// Serialize to CBOR bytes.
72    pub fn to_bytes(&self) -> CryptoResult<Vec<u8>> {
73        let mut buf = Vec::new();
74        ciborium::into_writer(self, &mut buf)
75            .map_err(|e| crate::CryptoError::Serialization(e.to_string()))?;
76        Ok(buf)
77    }
78
79    /// Deserialize from CBOR bytes.
80    pub fn from_bytes(data: &[u8]) -> CryptoResult<Self> {
81        ciborium::from_reader(data)
82            .map_err(|e| crate::CryptoError::Serialization(e.to_string()))
83    }
84
85    /// Encrypt the token with a passphrase-derived key.
86    ///
87    /// The token is CBOR-serialized then AES-256-GCM encrypted.
88    /// Use a key derived from a passphrase, PIN, or random secret.
89    pub fn seal(&self, key: &[u8; 32]) -> CryptoResult<Vec<u8>> {
90        let plaintext = self.to_bytes()?;
91        encrypt(key, &plaintext, AAD_MACHINE_TOKEN)
92    }
93
94    /// Decrypt a sealed token.
95    pub fn unseal(ciphertext: &[u8], key: &[u8; 32]) -> CryptoResult<Self> {
96        let plaintext = decrypt(key, ciphertext, AAD_MACHINE_TOKEN)?;
97        Self::from_bytes(&plaintext)
98    }
99
100    /// Encode as a base64 string (for env vars, CLI output).
101    pub fn to_base64(&self) -> CryptoResult<String> {
102        use base64::Engine;
103        let bytes = self.to_bytes()?;
104        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes))
105    }
106
107    /// Create a content-key KeyVault from this token's content key.
108    ///
109    /// The vault is read-only — it can decrypt the source commit's objects
110    /// but cannot seal new commits.
111    pub fn to_vault(&self) -> CryptoResult<crate::vault::KeyVault> {
112        let key_bytes: [u8; 32] = self.content_key.clone().try_into()
113            .map_err(|_| crate::CryptoError::InvalidKey("content key must be 32 bytes".into()))?;
114        let ck = crate::kdf::ContentKey::new(key_bytes);
115        Ok(crate::vault::KeyVault::from_content_key(ck))
116    }
117
118    /// Extract the signing key from this token.
119    pub fn to_signing_key(&self) -> CryptoResult<ed25519_dalek::SigningKey> {
120        let key_bytes: [u8; 32] = self.signing_secret.clone().try_into()
121            .map_err(|_| crate::CryptoError::InvalidKey("signing key must be 32 bytes".into()))?;
122        Ok(ed25519_dalek::SigningKey::from_bytes(&key_bytes))
123    }
124
125    /// Decode from a base64 string.
126    pub fn from_base64(s: &str) -> CryptoResult<Self> {
127        use base64::Engine;
128        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
129            .decode(s)
130            .map_err(|e| crate::CryptoError::Serialization(e.to_string()))?;
131        Self::from_bytes(&bytes)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::kdf::{RecipientSecretKey, SigningSecretKey};
139
140    fn make_test_token() -> MachineToken {
141        let signing = SigningSecretKey::from_bytes([0x42u8; 32]);
142        let recipient = RecipientSecretKey::from_bytes([0x43u8; 32]);
143        let content_key = [0x44u8; 32];
144        let commit_cid = vec![1, 2, 3, 4, 5];
145
146        MachineToken::new(
147            &signing,
148            &recipient,
149            &content_key,
150            commit_cid,
151            Some("test-repo".to_string()),
152            1700000000,
153        )
154    }
155
156    #[test]
157    fn roundtrip_cbor() {
158        let token = make_test_token();
159        let bytes = token.to_bytes().unwrap();
160        let decoded = MachineToken::from_bytes(&bytes).unwrap();
161
162        assert_eq!(decoded.version, MachineToken::VERSION);
163        assert_eq!(decoded.signing_secret, token.signing_secret);
164        assert_eq!(decoded.recipient_secret, token.recipient_secret);
165        assert_eq!(decoded.content_key, token.content_key);
166        assert_eq!(decoded.source_commit, token.source_commit);
167        assert_eq!(decoded.repo_name, Some("test-repo".to_string()));
168        assert_eq!(decoded.expires_at, 1700000000);
169    }
170
171    #[test]
172    fn roundtrip_base64() {
173        let token = make_test_token();
174        let encoded = token.to_base64().unwrap();
175        let decoded = MachineToken::from_base64(&encoded).unwrap();
176
177        assert_eq!(decoded.signing_secret, token.signing_secret);
178        assert_eq!(decoded.content_key, token.content_key);
179    }
180
181    #[test]
182    fn roundtrip_sealed() {
183        let token = make_test_token();
184        let seal_key = [0xAA; 32];
185
186        let sealed = token.seal(&seal_key).unwrap();
187        let unsealed = MachineToken::unseal(&sealed, &seal_key).unwrap();
188
189        assert_eq!(unsealed.signing_secret, token.signing_secret);
190        assert_eq!(unsealed.content_key, token.content_key);
191    }
192
193    #[test]
194    fn sealed_fails_with_wrong_key() {
195        let token = make_test_token();
196        let seal_key = [0xAA; 32];
197        let wrong_key = [0xBB; 32];
198
199        let sealed = token.seal(&seal_key).unwrap();
200        let result = MachineToken::unseal(&sealed, &wrong_key);
201
202        assert!(result.is_err());
203    }
204
205    #[test]
206    fn expiry_check() {
207        let token = make_test_token();
208        assert!(!token.is_expired(1699999999)); // before
209        assert!(token.is_expired(1700000001));  // after
210    }
211}