Skip to main content

rustferry_remote/
secret.rs

1use std::{error::Error, fmt, hint::black_box, str};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Deserializer, Serialize};
5
6const MAX_REFERENCE_NAME_BYTES: usize = 128;
7
8/// UTF-8 secret material whose backing bytes are overwritten when dropped.
9///
10/// This type intentionally does not implement `Clone`, `Debug`, `Display`, or
11/// serde traits. Callers must opt in to access through [`Secret::expose_secret`].
12/// The standard-library-only overwrite is a defense-in-depth measure, not a
13/// compiler-guaranteed secure erasure primitive.
14///
15/// ```compile_fail
16/// use rustferry_remote::Secret;
17/// let secret = Secret::new("private-key-value");
18/// println!("{secret:?}");
19/// ```
20///
21/// ```compile_fail
22/// use rustferry_remote::Secret;
23/// let secret = Secret::new("password-value");
24/// let _copy = secret.clone();
25/// ```
26///
27/// ```compile_fail
28/// use rustferry_remote::Secret;
29/// let secret = Secret::new("password-value");
30/// let _json = serde_json::to_string(&secret).unwrap();
31/// ```
32pub struct Secret {
33    bytes: SecretBytes,
34}
35
36impl Secret {
37    /// Store one UTF-8 secret, taking ownership when the input is an owned string.
38    pub fn new(value: impl Into<String>) -> Self {
39        Self {
40            bytes: SecretBytes::new(value.into().into_bytes()),
41        }
42    }
43
44    /// Expose the secret to the narrow operation that needs it.
45    ///
46    /// # Panics
47    ///
48    /// This cannot panic through the public API because construction accepts
49    /// only UTF-8 strings.
50    pub fn expose_secret(&self) -> &str {
51        str::from_utf8(self.bytes.expose_secret_bytes())
52            .expect("Secret is constructed from valid UTF-8")
53    }
54
55    /// Return the secret length without exposing its contents.
56    pub fn len(&self) -> usize {
57        self.bytes.len()
58    }
59
60    /// Return whether the secret is empty.
61    pub fn is_empty(&self) -> bool {
62        self.bytes.is_empty()
63    }
64
65    /// Overwrite the currently held bytes before the value is dropped.
66    pub fn clear(&mut self) {
67        self.bytes.clear();
68    }
69}
70
71impl From<String> for Secret {
72    fn from(value: String) -> Self {
73        Self::new(value)
74    }
75}
76
77/// Arbitrary secret bytes whose backing bytes are overwritten when dropped.
78///
79/// This type intentionally does not implement `Clone`, `Debug`, `Display`, or
80/// serde traits. The standard-library-only overwrite is best-effort: Rust does
81/// not promise that an optimizer, allocator, operating system, crash dump, or
82/// earlier copy cannot retain the bytes.
83///
84/// ```compile_fail
85/// use rustferry_remote::SecretBytes;
86/// let private_key = SecretBytes::new(b"private-key-value".to_vec());
87/// let _copy = private_key.clone();
88/// ```
89pub struct SecretBytes {
90    bytes: Vec<u8>,
91}
92
93impl SecretBytes {
94    /// Store secret bytes, taking ownership when the input is an owned vector.
95    pub fn new(value: impl Into<Vec<u8>>) -> Self {
96        Self {
97            bytes: value.into(),
98        }
99    }
100
101    /// Expose the bytes to the narrow operation that needs them.
102    pub fn expose_secret_bytes(&self) -> &[u8] {
103        &self.bytes
104    }
105
106    /// Return the secret length without exposing its contents.
107    pub fn len(&self) -> usize {
108        self.bytes.len()
109    }
110
111    /// Return whether the secret is empty.
112    pub fn is_empty(&self) -> bool {
113        self.bytes.is_empty()
114    }
115
116    /// Overwrite the currently held bytes.
117    pub fn clear(&mut self) {
118        wipe(&mut self.bytes);
119    }
120}
121
122impl Drop for SecretBytes {
123    fn drop(&mut self) {
124        self.clear();
125    }
126}
127
128fn wipe(bytes: &mut [u8]) {
129    bytes.fill(0);
130    // Keep the write observable to the optimizer without claiming the stronger
131    // guarantees provided by a dedicated zeroization primitive.
132    let _ = black_box(bytes);
133}
134
135/// Storage namespace for an opaque secret reference.
136#[derive(
137    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
138)]
139#[serde(rename_all = "snake_case")]
140pub enum SecretReferenceKind {
141    /// Name of a process environment variable populated outside the project.
142    Environment,
143    /// Opaque entry in the operating-system credential store.
144    CredentialStore,
145    /// Name of a protected GitHub Actions secret.
146    GithubActions,
147    /// Opaque secret handle understood only by the trusted remote worker.
148    Worker,
149}
150
151/// Serializable reference to secret material, never the material itself.
152///
153/// Construction and deserialization validate the identifier. The restricted
154/// alphabet prevents paths, URLs, shell fragments, control characters, and
155/// accidental inline `NAME=value` secret material from entering a protocol.
156#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
157pub struct SecretReference {
158    kind: SecretReferenceKind,
159    name: String,
160}
161
162impl SecretReference {
163    /// Construct and validate a secret reference.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`SecretReferenceError`] for an empty, oversized, or unsafe
168    /// identifier.
169    pub fn new(
170        kind: SecretReferenceKind,
171        name: impl Into<String>,
172    ) -> Result<Self, SecretReferenceError> {
173        let name = name.into();
174        validate_reference_name(kind, &name)?;
175        Ok(Self { kind, name })
176    }
177
178    /// Return the storage namespace.
179    pub fn kind(&self) -> SecretReferenceKind {
180        self.kind
181    }
182
183    /// Return the validated opaque identifier.
184    pub fn name(&self) -> &str {
185        &self.name
186    }
187}
188
189#[derive(Deserialize)]
190#[serde(deny_unknown_fields)]
191struct UncheckedSecretReference {
192    kind: SecretReferenceKind,
193    name: String,
194}
195
196impl<'de> Deserialize<'de> for SecretReference {
197    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
198    where
199        D: Deserializer<'de>,
200    {
201        let unchecked = UncheckedSecretReference::deserialize(deserializer)?;
202        Self::new(unchecked.kind, unchecked.name).map_err(serde::de::Error::custom)
203    }
204}
205
206/// Reason an opaque secret reference was rejected.
207#[derive(Clone, Copy, Debug, Eq, PartialEq)]
208pub enum SecretReferenceError {
209    /// The reference identifier was empty.
210    Empty,
211    /// The reference exceeded the protocol limit.
212    TooLong {
213        /// Maximum allowed byte length.
214        maximum: usize,
215    },
216    /// The first byte cannot start an identifier in this namespace.
217    InvalidStart,
218    /// A byte outside the namespace's allowlist was found.
219    InvalidCharacter {
220        /// Byte offset of the rejected character; the character is not echoed.
221        index: usize,
222    },
223    /// A path-like dot sequence was found.
224    DotSequence,
225}
226
227impl fmt::Display for SecretReferenceError {
228    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self {
230            Self::Empty => formatter.write_str("secret reference is empty"),
231            Self::TooLong { maximum } => {
232                write!(formatter, "secret reference exceeds {maximum} bytes")
233            }
234            Self::InvalidStart => formatter.write_str("secret reference has an invalid first byte"),
235            Self::InvalidCharacter { index } => {
236                write!(
237                    formatter,
238                    "secret reference contains an unsafe byte at offset {index}"
239                )
240            }
241            Self::DotSequence => {
242                formatter.write_str("secret reference contains a path-like dot sequence")
243            }
244        }
245    }
246}
247
248impl Error for SecretReferenceError {}
249
250fn validate_reference_name(
251    kind: SecretReferenceKind,
252    name: &str,
253) -> Result<(), SecretReferenceError> {
254    if name.is_empty() {
255        return Err(SecretReferenceError::Empty);
256    }
257    if name.len() > MAX_REFERENCE_NAME_BYTES {
258        return Err(SecretReferenceError::TooLong {
259            maximum: MAX_REFERENCE_NAME_BYTES,
260        });
261    }
262    if name.contains("..") {
263        return Err(SecretReferenceError::DotSequence);
264    }
265
266    let first = name.as_bytes()[0];
267    let valid_start = first.is_ascii_alphabetic() || first == b'_';
268    if !valid_start {
269        return Err(SecretReferenceError::InvalidStart);
270    }
271
272    for (index, byte) in name.bytes().enumerate() {
273        let valid = match kind {
274            SecretReferenceKind::Environment | SecretReferenceKind::GithubActions => {
275                byte.is_ascii_alphanumeric() || byte == b'_'
276            }
277            SecretReferenceKind::CredentialStore | SecretReferenceKind::Worker => {
278                byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
279            }
280        };
281        if !valid {
282            return Err(SecretReferenceError::InvalidCharacter { index });
283        }
284    }
285
286    Ok(())
287}