cindy 0.1.0

Managing infrastructure at breakneck speed.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Vaulted secrets. A `Secret<T>` is a wrapper that:
//!
//!   * Round-trips through serde — JSON, postcard, anything — as a
//!     compact tagged blob (`{ vault, ciphertext }`).
//!   * Reveals the inner `T` only on an explicit `.reveal()` call,
//!     using a per-vault data-encryption key looked up at runtime.
//!   * Refuses to leak the plaintext via `Debug`/`Display`. Drops with
//!     `zeroize` so plaintext doesn't linger in freed memory.
//!
//! There are two states the type lives in over its lifecycle. **Plain**
//! is only meant to exist transiently in source code that hasn't been
//! sealed yet (and at runtime in dev/tests); the `cindy secret seal`
//! workflow rewrites these into the **Sealed** form. The sealed form
//! is what the CLI actually serialises, deserialises, ships through
//! `CINDY_HOST_CONTEXT`, and so on.

use std::fmt;
use std::marker::PhantomData;

use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde::{Deserializer, Serializer};

pub mod crypto;
pub mod keychain;

/// Vaulted wrapper over `T`.
///
/// Two states:
///   * `Plain(T)` — pre-seal. The CLI's `cindy secret seal` step rewrites
///     this in source to `Sealed { .. }`. At runtime, `.reveal()` just
///     hands the inner value back.
///   * `Sealed { vault, ciphertext }` — committed form. `.reveal()`
///     looks up the named vault's DEK via the keychain and decrypts.
pub enum Secret<T> {
    Plain(T),
    Sealed { vault: String, ciphertext: Vec<u8> },
}

/// What actually gets encrypted by `cindy secret seal`. The wrapper
/// carries both the postcard-encoded `T` (so `Secret::<T>::reveal`
/// can hand a typed value back to user code) *and* the literal
/// source-tokens of the original `secret!` value expression (so
/// `cindy secret unseal` can splice the same syntax back into the file
/// and recover the editable shape).
///
/// Cost: roughly 2× the ciphertext size compared with a postcard-only
/// payload. Worth it for the round-trip UX — and `cargo fmt` cleans up
/// the whitespace `stringify!()` injects.
#[doc(hidden)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SealedPayload {
    /// `postcard::to_allocvec(&value)` — the bytes `Secret<T>::reveal`
    /// hands to `postcard::from_bytes::<T>(...)`.
    pub value: Vec<u8>,
    /// `stringify!($value)` captured at macro time. Used only by
    /// `cindy secret unseal` to put the original `secret!(...)` call
    /// back. Encrypted alongside `value` so leaking nothing about
    /// shape/field-names.
    pub source: String,
}

impl<T> Secret<T> {
    /// Construct a not-yet-sealed secret. Used in source code before
    /// `cindy secret seal` rewrites it. Never produced at runtime on the
    /// orchestrator.
    pub fn new(value: T) -> Self {
        Self::Plain(value)
    }

    /// Construct an already-sealed secret. Used by the seal step (and
    /// by serde deserialisation).
    pub fn sealed(vault: impl Into<String>, ciphertext: Vec<u8>) -> Self {
        Self::Sealed {
            vault: vault.into(),
            ciphertext,
        }
    }

    /// Construct a sealed secret from a base64-encoded ciphertext.
    /// This is the constructor the `cindy secret seal` source-rewriter
    /// emits in your code, because pasting raw bytes into a `.rs` file
    /// is awful and base64 is unambiguous + greppable.
    ///
    /// Panics at runtime if `ciphertext_b64` isn't valid base64; we'd
    /// rather fail loudly than silently produce a `Sealed` with bogus
    /// bytes that only blows up much later in `.reveal()`.
    pub fn sealed_b64(vault: impl Into<String>, ciphertext_b64: &str) -> Self {
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(ciphertext_b64.as_bytes())
            .expect("`Secret::sealed_b64` got non-base64 ciphertext");
        Self::Sealed {
            vault: vault.into(),
            ciphertext: bytes,
        }
    }

    /// Name of the vault this secret is encrypted under. `Plain`
    /// secrets have no vault yet; this returns `None` for them.
    pub fn vault(&self) -> Option<&str> {
        match self {
            Self::Plain(_) => None,
            Self::Sealed { vault, .. } => Some(vault.as_str()),
        }
    }
}

/// Inventory-crate slot for a `secret!(...)` macro invocation waiting
/// to be sealed. The `#[cindy::main]`-generated entry walks all
/// `PendingSecret`s when launched with `CINDY_SEAL_SECRETS=1`, invokes
/// each `serialize` thunk to get the plaintext bytes, encrypts them
/// under the named vault's DEK, and emits one JSON object per pending
/// secret on stdout so the CLI can patch the source files.
#[doc(hidden)]
pub struct PendingSecret {
    /// Source-file path captured by `file!()` at the macro call site.
    pub file: &'static str,
    /// 1-based line number from `line!()`.
    pub line: u32,
    /// 1-based column number from `column!()`.
    pub column: u32,
    /// Vault name (the macro's first argument).
    pub vault: &'static str,
    /// Thunk that re-evaluates the macro's value expression and
    /// returns its `postcard`-serialised bytes. Required to be a
    /// `fn` pointer (no captures) so it's registerable at link time;
    /// this is what forces the "literals-only" constraint on
    /// `secret!`.
    pub serialize: fn() -> Vec<u8>,
}

crate::__reexports::inventory::collect!(PendingSecret);

/// Wrap a literal value in a [`Secret<T>`] and register it for the
/// `cindy secret seal` step to encrypt in-place.
///
/// At runtime this expands to `cindy::Secret::Plain(value)` so your code
/// works unchanged during development and tests. At seal time the CLI
/// invokes the orchestrator with `CINDY_SEAL_SECRETS=1`, which walks
/// every `PendingSecret` registered by this macro, encrypts each
/// thunk's serialised plaintext under the named vault, and emits a
/// list of (file, line, column, vault, ciphertext) tuples. The CLI
/// then rewrites every `secret!(...)` call site in source to
/// `Secret::sealed_b64(...)`, removing the plaintext from the file.
///
/// **Constraint**: the value expression must be self-contained — no
/// references to local variables in the surrounding scope. The macro
/// expands `expr` into a `fn() -> Vec<u8>` thunk so it can be
/// registered at link time, and `fn` pointers can't capture. Literal
/// struct values (`VyosCreds { user: "admin".into(), pw: "h2".into() }`)
/// work; values built with `format!`, locals, or other captures don't.
///
/// `include_str!` / `include_bytes!` are evaluated *before* this macro
/// sees them so they're fine — the literal `&'static str` / `&'static
/// [u8]` they expand to is itself a no-capture literal.
///
/// ```ignore
/// let pw = cindy::secret!("prod", VyosCreds {
///     user: "admin".into(),
///     pw:   "hunter2".into(),
/// });
/// // After `cindy secret seal`:
/// // let pw = ::cindy::Secret::sealed_b64("prod", "AAAA...");
/// ```
#[macro_export]
macro_rules! secret {
    ($vault:literal, $value:expr $(,)?) => {{
        fn __cindy_secret_serialize() -> ::std::vec::Vec<u8> {
            let value_bytes = $crate::__reexports::postcard::to_allocvec(&($value))
                .expect("`cindy::secret!`: postcard couldn't serialise the value");
            // `stringify!()` is evaluated at macro-expansion time and
            // captures the *literal token text* of the value
            // expression. This is what `cindy secret unseal` needs to
            // splice back into the source after a round-trip through
            // ciphertext. It's encrypted along with `value_bytes`, so
            // nothing about the shape/field-names leaks at rest.
            let payload = $crate::secret::SealedPayload {
                value: value_bytes,
                source: ::core::stringify!($value).to_owned(),
            };
            $crate::__reexports::postcard::to_allocvec(&payload)
                .expect("`cindy::secret!`: postcard couldn't serialise the SealedPayload")
        }
        $crate::__reexports::inventory::submit! {
            $crate::PendingSecret {
                file:      ::core::file!(),
                line:      ::core::line!(),
                column:    ::core::column!(),
                vault:     $vault,
                serialize: __cindy_secret_serialize,
            }
        }
        $crate::Secret::new($value)
    }};
}

impl<T: Serialize + serde::de::DeserializeOwned> Secret<T> {
    /// Reveal the inner value. For `Plain`, returns it as-is. For
    /// `Sealed`, fetches the vault DEK from the keychain, decrypts
    /// the ciphertext into a [`SealedPayload`], and
    /// `postcard`-deserialises the inner `T` out of its `value`
    /// field. The `source` field of the payload is ignored here —
    /// it's there for `cindy secret unseal`.
    pub fn reveal(self) -> crate::Result<T> {
        match self {
            Self::Plain(v) => Ok(v),
            Self::Sealed { vault, ciphertext } => {
                let dek = keychain::get_dek(&vault)?;
                let plaintext = crypto::unseal(&dek, &ciphertext)?;
                let payload: SealedPayload =
                    postcard::from_bytes(&plaintext).map_err(|e| {
                        anyhow_serde::Error::msg(format!(
                            "couldn't deserialise SealedPayload for vault `{vault}`: {e}. \
                             If this secret was sealed by an older cindy version, \
                             rewrite it as `cindy::secret!(\"{vault}\", ..)` and re-run `cindy secret seal`."
                        ))
                    })?;
                let value: T = postcard::from_bytes(&payload.value).map_err(|e| {
                    anyhow_serde::Error::msg(format!(
                        "couldn't deserialise inner T for vault `{vault}`: {e}"
                    ))
                })?;
                Ok(value)
            }
        }
    }
}

// `Debug` never leaks plaintext. The vault name (if known) is included
// because it's not sensitive and helps when grepping logs for "which
// secrets did we touch in this run".
impl<T> fmt::Debug for Secret<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Plain(_) => write!(f, "<secret plain>"),
            Self::Sealed { vault, .. } => write!(f, "<secret vault={vault}>"),
        }
    }
}

// Equality on the encrypted blob is well-defined; equality on the
// plaintext is risky (could leak via comparison side channels in
// `T::eq`) so we only compare structurally on `Sealed`. Two `Plain`s
// compare via the inner `T`.
impl<T: PartialEq> PartialEq for Secret<T> {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (Self::Plain(a), Self::Plain(b)) => a == b,
            (
                Self::Sealed {
                    vault: va,
                    ciphertext: ca,
                },
                Self::Sealed {
                    vault: vb,
                    ciphertext: cb,
                },
            ) => va == vb && ca == cb,
            _ => false,
        }
    }
}

impl<T: Eq> Eq for Secret<T> {}

impl<T: Clone> Clone for Secret<T> {
    fn clone(&self) -> Self {
        match self {
            Self::Plain(v) => Self::Plain(v.clone()),
            Self::Sealed { vault, ciphertext } => Self::Sealed {
                vault: vault.clone(),
                ciphertext: ciphertext.clone(),
            },
        }
    }
}

// Note: deliberately no `Drop` impl. We'd want one for zeroizing the
// ciphertext buffer, but auto-`Drop` makes `reveal(self)` impossible
// (you can't move out of a type that implements `Drop`). Ciphertext is
// non-secret material once it's left our process anyway; the
// load-bearing zeroize is on the plaintext that lives inside the
// `Zeroizing<Vec<u8>>` returned by [`crypto::unseal`] during a
// `.reveal()` call. For `Plain(T)` containing sensitive data, callers
// should make sure `T` itself uses zeroize-aware types.

// ---- serde ---------------------------------------------------------
//
// The on-the-wire shape is **always** the sealed form. `Plain` values
// serialise by encrypting on the fly (so JSON dumps from a dev `main`
// don't leak plaintext to disk). The `vault` lookup pulls a DEK from
// the keychain at serialisation time — note this means *Plain* secrets
// can't be serialised by code that doesn't have the vault key loaded;
// that's a deliberate guardrail, not a bug.
//
// Deserialisation always produces `Sealed`; revealing requires an
// explicit `.reveal()` call.

#[derive(Serialize, Deserialize)]
struct WireForm<'a> {
    vault: std::borrow::Cow<'a, str>,
    /// Base64 of the AEAD blob (nonce || ciphertext || tag).
    ciphertext: std::borrow::Cow<'a, str>,
}

impl<T: Serialize> Serialize for Secret<T> {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::Error as _;
        match self {
            Self::Sealed { vault, ciphertext } => {
                let b64 = base64::engine::general_purpose::STANDARD.encode(ciphertext);
                WireForm {
                    vault: std::borrow::Cow::Borrowed(vault),
                    ciphertext: std::borrow::Cow::Owned(b64),
                }
                .serialize(serializer)
            }
            Self::Plain(_) => Err(S::Error::custom(
                "refusing to serialise a `Secret::Plain(_)`. \
                 Run `cindy secret seal` to turn it into `Secret::Sealed { .. }` \
                 before persisting it (or any inventory containing it).",
            )),
        }
    }
}

impl<'de, T> Deserialize<'de> for Secret<T> {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        use serde::de::Error as _;
        let wire = WireForm::deserialize(deserializer)?;
        let ciphertext = base64::engine::general_purpose::STANDARD
            .decode(wire.ciphertext.as_bytes())
            .map_err(D::Error::custom)?;
        let _ = PhantomData::<T>; // suppress unused-type-param warning
        Ok(Self::Sealed {
            vault: wire.vault.into_owned(),
            ciphertext,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::{Deserialize, Serialize};

    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
    struct Creds {
        user: String,
        pw: String,
    }

    #[test]
    fn debug_never_leaks_plain() {
        let s = Secret::new(Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        });
        let out = format!("{s:?}");
        assert!(!out.contains("alice"));
        assert!(!out.contains("hunter2"));
        assert_eq!(out, "<secret plain>");
    }

    #[test]
    fn debug_includes_vault_for_sealed() {
        let s: Secret<Creds> = Secret::sealed("prod", b"ignored".to_vec());
        let out = format!("{s:?}");
        assert!(out.contains("prod"));
    }

    #[test]
    fn refuses_to_serialise_plain() {
        let s = Secret::new(Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        });
        let err = serde_json::to_string(&s).expect_err("must refuse Plain");
        assert!(err.to_string().contains("Plain"));
    }

    #[test]
    fn sealed_round_trips_through_json() {
        let s: Secret<Creds> = Secret::sealed("prod", vec![1, 2, 3, 4]);
        let j = serde_json::to_string(&s).unwrap();
        let back: Secret<Creds> = serde_json::from_str(&j).unwrap();
        assert!(matches!(back, Secret::Sealed { ref vault, ref ciphertext }
                              if vault == "prod" && ciphertext == &[1, 2, 3, 4]));
    }

    #[test]
    fn reveal_plain_is_identity() {
        let s = Secret::new(Creds {
            user: "u".into(),
            pw: "p".into(),
        });
        let revealed = s.reveal().unwrap();
        assert_eq!(revealed.user, "u");
        assert_eq!(revealed.pw, "p");
    }

    #[test]
    fn reveal_sealed_roundtrip_with_installed_dek() {
        // Drive the keychain manually: build a `SealedPayload`,
        // encrypt with a known DEK, install it under a test vault
        // name, reveal, compare.
        let dek = crypto::generate_dek();
        let creds = Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        };
        let payload = SealedPayload {
            value: postcard::to_allocvec(&creds).unwrap(),
            source: "Creds { user: \"alice\".into(), pw: \"hunter2\".into() }".to_owned(),
        };
        let payload_bytes = postcard::to_allocvec(&payload).unwrap();
        let ciphertext = crypto::seal(&dek, &payload_bytes).unwrap();

        let mut chain = std::collections::HashMap::new();
        chain.insert("test-reveal".to_owned(), dek);
        keychain::install_keys(chain);

        let s: Secret<Creds> = Secret::sealed("test-reveal", ciphertext);
        let revealed = s.reveal().unwrap();
        assert_eq!(revealed, creds);
    }
}