Skip to content

Commit a3c5fb7

Browse files
committed
Require in-sync secrets for keys rotate; emoji status output
Add models/secret_file_status with secret_file_statuses(), compact emoji triplets for each sync state, and a legend printed after status. keys rotate now refuses to start unless every file matches FilesInSync (same logic as status), then re-encrypts each .age from ~/.a8c-secrets plaintext instead of decrypting existing ciphertext. apply_key_rotation takes rotating_owned instead of the old private key used only for decrypt. On preflight failure, rotate tells users to run status instead of echoing the full file list. README, manual, CLI help, and integration tests are updated accordingly. Made-with: Cursor
1 parent 7a9038f commit a3c5fb7

File tree

8 files changed

+207
-128
lines changed

8 files changed

+207
-128
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,16 @@ On employee offboarding (or when rotating CI’s key), treat **age keys** (`keys
121121

122122
### What `keys rotate` does
123123

124-
It updates `keys.pub`, then **re-encrypts each committed `.age` file** under `.a8c-secrets/` by reading that ciphertext from disk, decrypting with your current private key, and encrypting again to the updated recipient list. It **does not** read decrypted files under `~/.a8c-secrets/`. If you changed plaintext only there, run `a8c-secrets encrypt` **after** rotation when you want those values in git.
124+
It refuses to run until **`a8c-secrets status` shows every secret as “in sync”** (no encrypted-only, decrypted-only, or modified local copy). Then it updates `keys.pub` and **re-encrypts each `.age` file from the matching plaintext under `~/.a8c-secrets/<host>/<org>/<name>/`**, so new ciphertext matches your local decrypted files. If anything is out of sync, fix it with `decrypt` / `encrypt` (or remove stray files) and retry.
125125

126126
### Recommended order
127127

128128
1. **Revoke or disable old credentials** at each provider as soon as your runbook allows (so stolen keys stop working at the API).
129-
2. **Run `a8c-secrets keys rotate`** — interactive: pick the recipient, confirm; the tool prints the new private key and re-wraps the existing `.age` files in the repo.
129+
2. **Run `a8c-secrets keys rotate`** — interactive: pick the recipient, confirm; the tool prints the new private key and re-encrypts `.age` files from your in-sync plaintext under `~/.a8c-secrets/`.
130130
3. **Update Secret Store / CI** (or equivalent) with the new private key; notify the team to `keys import` when the dev key changed.
131131
4. **Issue new provider credentials if needed**, update the decrypted secret files, then **`a8c-secrets encrypt`** — usually **`--force`** right after a key rotation. Commit `keys.pub` and `.age` changes (and any follow-up commits for new secret content).
132132

133-
**Why age keys before new secrets in git:** if you `encrypt` and push **new** API material while `keys.pub` still includes a recipient who should no longer decrypt, anyone with that old dev key could decrypt that commit. Completing `keys rotate` first avoids encrypting new secrets to the old audience. Diffs right after rotation reflect **re-wrapping existing ciphertext**, not necessarily new provider values — those show up after you `encrypt` again.
133+
**Why age keys before new secrets in git:** if you `encrypt` and push **new** API material while `keys.pub` still includes a recipient who should no longer decrypt, anyone with that old dev key could decrypt that commit. Completing `keys rotate` first avoids encrypting new secrets to the old audience. Diffs right after rotation reflect **new ciphertext for the same plaintext** you had locally (age nonces differ each time), not necessarily new provider values — those show up after you change decrypted files and `encrypt` again.
134134

135135
5. Team runs `a8c-secrets keys import && a8c-secrets decrypt` where needed.
136136

src/cli.rs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,8 @@ EXAMPLES:
124124
Show the sync status of all secret files.
125125
126126
Displays the repo identifier, how many public keys were read from keys.pub (2 expected),
127-
private key status, and each file's sync state:
128-
\u{2713} in sync — decrypted plaintext matches encrypted .age content
129-
\u{26a0} modified decrypted copy — plaintext differs from .age (needs encrypt)
130-
\u{2739} decrypted only — no .age file in repo (new, needs encrypt)
131-
\u{25c7} encrypted only — no decrypted plaintext (needs decrypt)")]
127+
private key status, each file as a compact emoji triplet (📝 plaintext · 🔏 .age · ✅/❌/❓),
128+
and a legend explaining the rows. Example in-sync row: 📝✅🔏 config.json")]
132129
Status,
133130

134131
/// Key management (show, import, rotate)
@@ -210,9 +207,9 @@ key for this repo identifier.")]
210207
long_about = "\
211208
Rotate one recipient in keys.pub: pick which public key to replace from an
212209
interactive list, confirm with y/N, then generate a new key pair, update keys.pub in place
213-
(preserving comments), and re-encrypt each .age file under .a8c-secrets/
214-
(decrypt the existing file from disk with your current private key in memory, then encrypt to the
215-
updated recipient list). Does not read ~/.a8c-secrets plaintext.
210+
(preserving comments), and re-encrypt each .age file under .a8c-secrets/ using the matching
211+
plaintext under ~/.a8c-secrets/<repo-id>/ (every file must already show \"in sync\" in
212+
`a8c-secrets status`).
216213
217214
Requires a local private key that matches at least one line in keys.pub.
218215
After rotation, prints the new private key and next steps (Secret Store /
@@ -221,7 +218,8 @@ CI secrets depending on whether you rotated the key you hold locally).
221218
Recommended: run this before encrypting and pushing new provider/API secrets, so new material
222219
is not encrypted to recipients who should no longer have the old dev key. Still revoke or
223220
replace credentials at each provider as your process requires — this command does not expire
224-
API keys. After rotation, update secret file content and run encrypt (often --force).
221+
API keys. After rotation, update secret file content and run encrypt (often --force) when you
222+
change provider material.
225223
226224
Requires stdout connected to a terminal so the new private key is shown on screen
227225
(do not redirect stdout). Requires stdin connected to a terminal for interactive prompts.",

src/commands/keys/rotate.rs

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ use std::path::Path;
33
use anyhow::{Context, Result};
44
use inquire::{Confirm, Select};
55
use std::io::IsTerminal;
6+
use zeroize::Zeroizing;
67

78
use super::{PUBLIC_KEY_LIST_LEGEND, PublicKeyListRow};
8-
use crate::crypto::{CryptoEngine, PrivateKey, PublicKey};
9+
use crate::crypto::{CryptoEngine, PublicKey};
910
use crate::fs_helpers::{self, REPO_SECRETS_DIR, SecretFileName};
1011
use crate::keys;
12+
use crate::models::secret_file_statuses;
1113

1214
fn select_public_key_to_rotate(
1315
public_keys: &[PublicKey],
@@ -52,10 +54,9 @@ fn print_rotation_reminder() {
5254
);
5355
println!();
5456
println!(
55-
" • This command only re-wraps each committed `.age` file under `.a8c-secrets/` (read from \
56-
disk, decrypt, encrypt to the updated keys.pub). It does not read ~/.a8c-secrets/. If \
57-
local plaintext is ahead of `.age`, run `encrypt` after rotation when you want that \
58-
content in git — otherwise git diffs here are crypto-only, not your pending plaintext edits."
57+
" • This command requires every secret to show \"in sync\" in `a8c-secrets status` first. \
58+
It then re-encrypts each `.age` from the matching plaintext under ~/.a8c-secrets/, so \
59+
new ciphertext matches your local decrypted files (not stale `.age` blobs if they had drifted)."
5960
);
6061
println!();
6162
println!("{PUBLIC_KEY_LIST_LEGEND}");
@@ -84,21 +85,25 @@ fn print_confirmation_plan(
8485
" - Update `{}` to replace the chosen public key line (other lines and comments unchanged)",
8586
keys_pub_path.display()
8687
);
88+
let decrypted_hint = decrypted_dir_display
89+
.as_deref()
90+
.unwrap_or("~/.a8c-secrets/<repo-id>/");
8791
if age_files.is_empty() {
8892
println!(
8993
" - (No `.age` files under `{}` to re-encrypt)",
9094
secrets_dir.display()
9195
);
9296
} else {
9397
println!(
94-
" - For each `.age` file under `{}`, decrypt ciphertext in memory with your current private key, then re-encrypt to the updated recipient list and write the file back",
95-
secrets_dir.display()
98+
" - For each `.age` file under `{}`, read plaintext from `{}`, then encrypt to the updated recipient list and write the `.age` file back",
99+
secrets_dir.display(),
100+
decrypted_hint
96101
);
97102
for name in age_files {
98103
println!(" - {name}.age");
99104
}
100105
println!(
101-
" (Each step uses the existing `.age` file on disk, not plaintext under ~/.a8c-secrets/.)"
106+
" (Preflight already verified each pair is \"in sync\" in `a8c-secrets status`.)"
102107
);
103108
}
104109
println!(" - Print the new private key to stdout");
@@ -131,17 +136,17 @@ fn print_confirmation_plan(
131136
}
132137

133138
/// Applies key rotation after interactive confirmation: new keypair, `keys.pub` update,
134-
/// re-encryption of `.age` files, optional local private key file update.
139+
/// re-encryption of `.age` files from plaintext under `~/.a8c-secrets/`, optional local private key file update.
135140
///
136141
/// `old_public_key` must be exactly one of the recipient lines currently in `keys.pub` (as
137142
/// returned by [`keys::load_public_keys`]). Recipients used for re-encryption are read from
138143
/// disk again after updating `keys.pub`.
139144
///
140-
/// `private_key_for_decrypt` is the caller’s current age identity (typically from
141-
/// [`keys::get_private_key`]). It is used to decrypt **committed** `.age` files under
142-
/// `.a8c-secrets/` before re-encrypting them — not files under `~/.a8c-secrets/`.
143-
/// When `old_public_key` is the public key derived from this same identity, the local
144-
/// key file is updated with the newly generated private key.
145+
/// Callers must ensure every secret file is **in sync** (see [`crate::models::secret_file_statuses`]) before
146+
/// calling this function; re-encryption reads decrypted plaintext from disk, not ciphertext from `.age`.
147+
///
148+
/// When `rotating_owned` is true (the rotated recipient is the one derived from the user’s
149+
/// current local identity), the local key file is updated with the newly generated private key.
145150
///
146151
/// Used by [`run`] and by unit tests (inquire’s `Select`/`Confirm` prompts are not wired for
147152
/// non-interactive subprocess tests; see crate tests in this module).
@@ -150,28 +155,27 @@ pub(crate) fn apply_key_rotation(
150155
repo_root: &Path,
151156
repo_identifier: &fs_helpers::RepoIdentifier,
152157
old_public_key: &PublicKey,
153-
private_key_for_decrypt: &PrivateKey,
158+
rotating_owned: bool,
154159
) -> Result<()> {
155-
let public_key_from_decrypt_private_key = private_key_for_decrypt.to_public();
156-
let rotating_owned = old_public_key == &public_key_from_decrypt_private_key;
157-
158160
let (new_private_key, new_public_key) = crypto_engine.keygen()?;
159161

160162
keys::replace_recipient_public_key_in_keys_pub(repo_root, old_public_key, &new_public_key)?;
161163

162164
let recipient_public_keys_after_rotation = keys::load_public_keys(repo_root)?;
163165

164166
let secrets_dir = repo_root.join(REPO_SECRETS_DIR);
167+
let decrypted_dir = fs_helpers::decrypted_dir(repo_identifier)?;
165168
let age_files = fs_helpers::list_age_files(repo_root)?;
166169

167170
for name in &age_files {
168-
let age_path = secrets_dir.join(format!("{name}.age"));
169-
let ciphertext = std::fs::read(&age_path)?;
170-
let plaintext = crypto_engine
171-
.decrypt(&ciphertext, private_key_for_decrypt)
172-
.with_context(|| format!("Failed to decrypt {name} during re-encryption"))?;
171+
let decrypted_path = decrypted_dir.join(name.as_str());
172+
let plaintext = Zeroizing::new(
173+
std::fs::read(&decrypted_path)
174+
.with_context(|| format!("Failed to read decrypted file {name}"))?,
175+
);
173176
let new_ciphertext =
174177
crypto_engine.encrypt(plaintext.as_slice(), &recipient_public_keys_after_rotation)?;
178+
let age_path = secrets_dir.join(format!("{name}.age"));
175179
fs_helpers::atomic_write(&age_path, &new_ciphertext)?;
176180
println!(" {name} — re-encrypted");
177181
}
@@ -188,7 +192,7 @@ pub(crate) fn apply_key_rotation(
188192
keys::print_private_key_to_stdout("New private key", &new_private_key)?;
189193

190194
println!(
191-
"NOTE: Only ciphertext already in each `.age` file was re-wrapped; ~/.a8c-secrets was not read."
195+
"NOTE: Each `.age` was written from plaintext under ~/.a8c-secrets/ (after a full \"in sync\" preflight); local decrypted files were not modified."
192196
);
193197
println!(
194198
"NOTE: Rotate provider/API secrets separately as needed, then `a8c-secrets encrypt` (often `--force`) when committing new secret content."
@@ -230,6 +234,22 @@ pub fn run(crypto_engine: &dyn CryptoEngine) -> Result<()> {
230234
);
231235
}
232236

237+
let sync_rows = secret_file_statuses(
238+
crypto_engine,
239+
&repo_root,
240+
&repo_identifier,
241+
Some(&private_key_for_decrypt),
242+
)?;
243+
if sync_rows.iter().any(|(_, s)| !s.is_in_sync()) {
244+
println!(
245+
"All secret files must be in sync before rotating keys (same checks as `a8c-secrets status`)."
246+
);
247+
println!(
248+
"Run `a8c-secrets status` for the per-file view and legend, then use `decrypt` / `encrypt` (or remove stray files) until every line shows 📝✅🔏, and retry."
249+
);
250+
anyhow::bail!("secret files are not all in sync; see `a8c-secrets status` and retry");
251+
}
252+
233253
print_rotation_reminder();
234254

235255
let selection =
@@ -260,7 +280,7 @@ pub fn run(crypto_engine: &dyn CryptoEngine) -> Result<()> {
260280
&repo_root,
261281
&repo_identifier,
262282
&selection.key,
263-
&private_key_for_decrypt,
283+
selection.matches_local_private_key,
264284
)
265285
}
266286

@@ -343,6 +363,10 @@ mod tests {
343363
let age_path = repo_dir.path().join(".a8c-secrets/secret.txt.age");
344364
fs::write(&age_path, ciphertext).unwrap();
345365

366+
let decrypted_dir = secrets_home.join(repo_identifier.as_path());
367+
fs::create_dir_all(&decrypted_dir).unwrap();
368+
fs::write(decrypted_dir.join("secret.txt"), plaintext).unwrap();
369+
346370
let engine = AgeCrateEngine::new();
347371
let old_dev_public_key = old_dev_identity.to_public();
348372

@@ -351,7 +375,7 @@ mod tests {
351375
repo_dir.path(),
352376
&repo_identifier,
353377
&old_dev_public_key,
354-
&old_dev_identity,
378+
true,
355379
)
356380
.expect("apply_key_rotation");
357381

@@ -432,6 +456,10 @@ mod tests {
432456
)
433457
.unwrap();
434458

459+
let decrypted_dir = secrets_home.join(repo_identifier.as_path());
460+
fs::create_dir_all(&decrypted_dir).unwrap();
461+
fs::write(decrypted_dir.join("secret.txt"), plaintext).unwrap();
462+
435463
let engine = AgeCrateEngine::new();
436464
let old_dev_public_key = old_dev_identity.to_public();
437465

@@ -440,7 +468,7 @@ mod tests {
440468
repo_dir.path(),
441469
&repo_identifier,
442470
&old_dev_public_key,
443-
&old_dev_identity,
471+
true,
444472
)
445473
.unwrap();
446474

src/commands/status.rs

Lines changed: 12 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
1-
use std::collections::BTreeSet;
2-
31
use anyhow::Result;
42

53
use crate::crypto::CryptoEngine;
6-
use crate::fs_helpers::{self, REPO_SECRETS_DIR, SecretFileName};
4+
use crate::fs_helpers;
75
use crate::keys;
8-
use zeroize::Zeroizing;
9-
10-
fn collect_all_files(
11-
age_files: &BTreeSet<SecretFileName>,
12-
decrypted_files: &BTreeSet<SecretFileName>,
13-
) -> BTreeSet<SecretFileName> {
14-
age_files.union(decrypted_files).cloned().collect()
15-
}
6+
use crate::models::{SECRET_FILE_STATUS_LEGEND, secret_file_statuses};
167

178
/// Expected number of `age` recipient lines in `keys.pub` (dev + CI).
189
const EXPECTED_PUBLIC_KEYS: usize = 2;
@@ -69,80 +60,24 @@ pub fn run(crypto_engine: &dyn CryptoEngine) -> Result<()> {
6960

7061
println!();
7162

72-
// Collect all known file names from both sides
73-
let age_files: BTreeSet<SecretFileName> = fs_helpers::list_age_files(&repo_root)?
74-
.into_iter()
75-
.collect();
76-
let decrypted_files: BTreeSet<SecretFileName> =
77-
fs_helpers::list_decrypted_files(&repo_identifier)?
78-
.into_iter()
79-
.collect();
80-
let all_files = collect_all_files(&age_files, &decrypted_files);
63+
let rows = secret_file_statuses(
64+
crypto_engine,
65+
&repo_root,
66+
&repo_identifier,
67+
private_key.as_ref(),
68+
)?;
8169

82-
if all_files.is_empty() {
70+
if rows.is_empty() {
8371
println!("No secret files.");
8472
return Ok(());
8573
}
8674

87-
let secrets_dir = repo_root.join(REPO_SECRETS_DIR);
88-
let decrypted_dir = fs_helpers::decrypted_dir(&repo_identifier)?;
89-
9075
println!("Files:");
91-
for name in &all_files {
92-
let has_age = age_files.contains(name);
93-
let has_decrypted = decrypted_files.contains(name);
94-
95-
let status = match (has_age, has_decrypted) {
96-
(true, true) => {
97-
// Both exist — compare if we have a private key
98-
match &private_key {
99-
Some(key) => {
100-
let age_path = secrets_dir.join(format!("{name}.age"));
101-
let decrypted_path = decrypted_dir.join(name.as_str());
102-
let ciphertext = std::fs::read(&age_path)?;
103-
let decrypted_content = Zeroizing::new(std::fs::read(&decrypted_path)?);
104-
match crypto_engine.decrypt(&ciphertext, key) {
105-
Ok(decrypted)
106-
if decrypted.as_slice() == decrypted_content.as_slice() =>
107-
{
108-
"\u{2713} in sync"
109-
}
110-
Ok(_) => "\u{26a0} modified decrypted copy",
111-
Err(_) => "\u{26a0} cannot decrypt to compare",
112-
}
113-
}
114-
None => "? cannot compare (no private key)",
115-
}
116-
}
117-
(false, true) => "\u{2739} decrypted only",
118-
(true, false) => "\u{25c7} encrypted only",
119-
(false, false) => unreachable!(),
120-
};
121-
76+
for (name, status) in rows {
12277
println!(" {status} {name}");
12378
}
79+
println!();
80+
println!("{SECRET_FILE_STATUS_LEGEND}");
12481

12582
Ok(())
12683
}
127-
128-
#[cfg(test)]
129-
mod tests {
130-
use super::collect_all_files;
131-
use crate::fs_helpers::SecretFileName;
132-
use std::collections::BTreeSet;
133-
134-
#[test]
135-
fn collect_all_files_returns_sorted_union_without_duplicates() {
136-
let age = BTreeSet::from([
137-
SecretFileName::try_from("b.yml").unwrap(),
138-
SecretFileName::try_from("a.json").unwrap(),
139-
]);
140-
let decrypted = BTreeSet::from([
141-
SecretFileName::try_from("a.json").unwrap(),
142-
SecretFileName::try_from("c.toml").unwrap(),
143-
]);
144-
let all = collect_all_files(&age, &decrypted);
145-
let ordered: Vec<String> = all.iter().map(ToString::to_string).collect();
146-
assert_eq!(ordered, vec!["a.json", "b.yml", "c.toml"]);
147-
}
148-
}

0 commit comments

Comments
 (0)