You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: README.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -121,16 +121,16 @@ On employee offboarding (or when rotating CI’s key), treat **age keys** (`keys
121
121
122
122
### What `keys rotate` does
123
123
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.
125
125
126
126
### Recommended order
127
127
128
128
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/`.
130
130
3.**Update Secret Store / CI** (or equivalent) with the new private key; notify the team to `keys import` when the dev key changed.
131
131
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).
132
132
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.
134
134
135
135
5. Team runs `a8c-secrets keys import && a8c-secrets decrypt` where needed.
" • 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)."
59
60
);
60
61
println!();
61
62
println!("{PUBLIC_KEY_LIST_LEGEND}");
@@ -84,21 +85,25 @@ fn print_confirmation_plan(
84
85
" - Update `{}` to replace the chosen public key line (other lines and comments unchanged)",
85
86
keys_pub_path.display()
86
87
);
88
+
let decrypted_hint = decrypted_dir_display
89
+
.as_deref()
90
+
.unwrap_or("~/.a8c-secrets/<repo-id>/");
87
91
if age_files.is_empty(){
88
92
println!(
89
93
" - (No `.age` files under `{}` to re-encrypt)",
90
94
secrets_dir.display()
91
95
);
92
96
}else{
93
97
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
96
101
);
97
102
for name in age_files {
98
103
println!(" - {name}.age");
99
104
}
100
105
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`.)"
102
107
);
103
108
}
104
109
println!(" - Print the new private key to stdout");
@@ -131,17 +136,17 @@ fn print_confirmation_plan(
131
136
}
132
137
133
138
/// 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.
135
140
///
136
141
/// `old_public_key` must be exactly one of the recipient lines currently in `keys.pub` (as
137
142
/// returned by [`keys::load_public_keys`]). Recipients used for re-encryption are read from
138
143
/// disk again after updating `keys.pub`.
139
144
///
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.
145
150
///
146
151
/// Used by [`run`] and by unit tests (inquire’s `Select`/`Confirm` prompts are not wired for
147
152
/// non-interactive subprocess tests; see crate tests in this module).
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");
0 commit comments