feat: resolve agenix age.identityPaths and "classic" rules for read-only secrets view and decryption - #653
Conversation
age.identityPaths and "classic" rules for read-only secrets view and decryption
🎨 Storybook previewUpdated for df62437 🧭 Story changesCompared to ✏️ Changed stories (1)
|
There was a problem hiding this comment.
Warning
apps/native/src-tauri/src/secrets/recipients.rs [behavior_change]: load_agenix_rules returns Err when a conventional-location secrets.nix or secrets/secrets.nix exists but fails Nix evaluation — for example when the file is a NixOS module (a function value rather than an attrset), causing builtins.mapAttrs in the --apply expression to throw a type error. That error propagates through load_recipients → load_secrets_vault via ?, breaking the entire Secrets Management view for users who have SOPS secrets and an identically-named file used for other purposes. The explicit RULES override path deliberately fails hard (documented in the function comment), but the conventional-path discovery (secrets.nix, secrets/secrets.nix) should degrade to Ok(AgenixRules::default()) on evaluation failure rather than fatally failing the vault load.
There was a problem hiding this comment.
Pull request overview
This PR completes the read-only agenix support in the Secrets Management view. Previously the app could list agenix secrets but could not resolve their recipients or decrypt them (several TODO(agenix-read) placeholders). It now projects cfg.age.identityPaths as local decryption identities, evaluates the "classic" agenix rules file (honoring $RULES, then secrets.nix/secrets/secrets.nix) to build a per-secret recipient inventory, and adds an agenix decrypt path that shells out to age --decrypt with the configured identities. The reveal RPC is now backend-qualified end-to-end, and the detail view adapts its layout for agenix (no SOPS-key box, "age" wording).
Changes:
- Backend: split
decrypt_secretintodecrypt_sops_secret/decrypt_agenix_secret, addage_decrypt_command, evaluate agenix rules into aRecipientInventory+ repository recipient registrations, and materialize agenix SSH/native identities. - Identity projection: expose
agenixIdentityPathsand per-host-keyusedByAgenix; threadbackendandsecret_entriesthroughdecrypt_secret,load_recipients, andapply_recipients_to_secrets_with_identities. - Frontend:
DecryptSecretInputgainsbackend; detail view conditionally renders the SOPS-key box and swaps SOPS/age wording; snapshot updated.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
apps/native/src-tauri/src/secrets/secrets_management.rs |
Backend-qualified decrypt split; new age_decrypt_command; wires agenix inventory into vault load |
apps/native/src-tauri/src/secrets/recipients.rs |
Evaluates classic agenix rules, builds recipient inventory/registrations, materializes agenix identities |
apps/native/src-tauri/src/secrets/identities.rs |
Projects age.identityPaths and usedByAgenix; new struct fields + tests |
apps/native/src-tauri/src/orpc/secrets.rs |
Adds backend to DecryptSecretInput and passes it through |
apps/native/src/ipc/orpc-bindings.ts |
Generated type update for DecryptSecretInput.backend |
apps/native/src/components/widget/secrets/secret-detail-view.tsx |
Sends backend; adapts grid/wording for agenix vs sops |
apps/native/src/components/widget/secrets/__snapshots__/secrets-management.stories.tsx.snap |
Updated Secret Detail snapshot for agenix layout |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
📋 PR Overview
🔬 Coverage
|
045ed63 to
feddd2d
Compare
I addressed this comment, see |
There was a problem hiding this comment.
Caution
load_agenix_rules uses raw config_dir.join(path) for a relative $RULES value, and build_agenix_rules uses rules_dir.join(secret_path) / config_dir.join(secret_path) for relative secret-fil...
apps/native/src-tauri/src/secrets/recipients.rs:530
1 finding(s) posted as inline comments.
f45afc8 to
6500519
Compare
0523e39 to
0dc059a
Compare
|
Prelint reached the review limit for this pull request (5 reviews of each kind). New pushes do not start a review.
|
darkmatteragent
left a comment
There was a problem hiding this comment.
Review — REQUEST CHANGES
0dc059afb54d · 4 findings
Implements agenix read-only support end to end — identity projection, classic-rules evaluation, backend-qualified reveal, UI — but requesting changes because the reveal path contradicts the capability the vault reports.
age1…(ssh-to-age) rules show Available, yet reveal hands the raw SSH private key toage -i, which cannot unwrap that stanza.availablestats the identity file while reveal requires opening it — unreadable root-owned host keys still show Available, then reveal errors.- Minor: suffix-based 'basename' matching can leak the wrong rule's recipients, and a broken explicit $RULES fails SOPS-only reveals.
Findings
Caution
blocker · correctness — Agenix reveal passes raw SSH keys to age, but capability logic claims ssh-to-age age1… recipients are decryptable
apps/native/src-tauri/src/secrets/secrets_management.rs:140
age_decrypt_command (gutter 140, command.args(["--identity", identity_path])) passes each cfg.age.identityPaths entry as-is. An OpenSSH private key given to age --identity is parsed as an ssh::Identity and can only unwrap ssh-ed25519/ssh-rsa stanzas — it cannot unwrap the X25519 stanza produced when a rules file lists the host's recipient as the ssh-to-age-converted age1… key (the
Warning
major · correctness — available uses is_file() while reveal requires File::open, so unreadable private keys are reported Available then dropped at reveal
apps/native/src-tauri/src/secrets/recipients.rs:1089
The new agenix block sets available = path.is_file() (gutter 1089), and host keys newly admitted by the used_by_sops || used_by_agenix filter (gutter 887, availability at 902) do the same. But the consumer this patch adds, readable_agenix_identity_paths (secrets_management.rs:78, std::fs::File::open(identity_path).is_ok()), filters identities by readability before decrypt. The predicates d
Important
minor · correctness — Basename fallback is a string-suffix match, so my-password.age inherits the recipients of rule password.age
apps/native/src-tauri/src/secrets/recipients.rs:738
The fallback at gutter 738 filters with entry.file.ends_with(basename) — Rust's str::ends_with, a raw string-suffix test, though the comment (725-728) promises a 'unique string-basename match'. Concrete input: rule keyed password.age and an evaluated declaration at /nix/store/<hash>-my-password.age (which ends with the string password.age) — the wrong rule's encrypted_for is attributed
Important
minor · correctness — SOPS secret reveal now fails when an explicit $RULES agenix file is invalid
apps/native/src-tauri/src/secrets/secrets_management.rs:107
decrypt_sops_secret calls load_recipients(host_attr, config_dir, &[])? (gutter 107). This patch made load_recipients evaluate the classic agenix rules file and propagate errors for an explicitly configured $RULES (recipients.rs:540-569, 638-653). Deterministic path: set $RULES to a file with a nix error → evaluate_agenix_rules (601-620) fails → handle_agenix_rules_result passes the e
Warning major · correctness — Raw SSH recipients from agenix rules don't resolve to the local identity in the secret detail view Line 731 ( reviewed: 0dc059a — 2026-09-01T03:10Z verdict: request_changes findings: 4
Run details
|
||||||||||||||||||||||||||||
0dc059a to
0766016
Compare
… readability checks and fallback mechanisms and $RULES rules
darkmatteragent
left a comment
There was a problem hiding this comment.
Review — REQUEST CHANGES
6db3da238818 · 1 finding
Feature looks correct end-to-end on the Rust side, but one integration gap blocks merge. - The new backend-qualified reveal RPC is fed by a UI that still selects secrets by id alone, so same-named SOPS/agenix declarations route to the SOPS entry and reveal the wrong secret's plaintext. - Previously this collision failed loudly ("ambiguous across backends"); the patch removes that guard without completing the addressing fix the removed TODO described. - All other consumer traces (inventory keys, merge/dedupe, age fallback, bindings, snapshot, tests) check out.
Findings
Caution
blocker · correctness — Reveal RPC is backend-qualified but selection still keys on id alone; same-named SOPS/agenix secrets reveal the wrong plaintext
apps/native/src/components/widget/secrets/secret-detail-view.tsx:72
The patch sends secretId: secret.id, / backend: secret.backend, (diff lines 70-73) and the Rust decrypt_secret correctly dispatches per backend — but nothing upstream guarantees secret is the entry the user clicked. vault-view.tsx:180 calls onOpenSecret(secret.id) (signature (secretId: string) => void, vault-view.tsx:23) with key={secret.id} (vault-view.tsx:178), `secrets-managemen
darkmatteragent
left a comment
There was a problem hiding this comment.
Review — COMMENT
df62437d0e93 · 1 finding
Backend wiring is solid — backend-qualified reveal, rules evaluation, and inventory keying all trace cleanly to their consumers — but one consumer gap undercuts the PR's own alias invariant. - Agenix rules written with raw SSH keys (the classic form) produce detail-view rows that no longer resolve to the local identity recipient, losing its label and 'local identity' chip.
Findings
Warning
major · correctness — Raw SSH recipients from agenix rules don't resolve to the local identity in the secret detail view
apps/native/src-tauri/src/secrets/recipients.rs:731
Line 731 (RecipientIdentity::Age(value) | RecipientIdentity::Ssh(value) => value.clone()) canonicalizes an agenix rule's publicKeys, so a rule written with a raw SSH key keeps the ssh-ed25519 AAAA… form. That raw form flows into the per-secret inventory (encrypted_for, lines 720-724) and becomes entry.publicRecipients (recipients.rs:210-217). When that raw key belongs to a configured loc
| ); | ||
| for identity in &encrypted_for { | ||
| let canonical = match identity { | ||
| RecipientIdentity::Age(value) | RecipientIdentity::Ssh(value) => value.clone(), |
There was a problem hiding this comment.
[major] Raw SSH recipients from agenix rules don't resolve to the local identity in the secret detail view
Line 731 (RecipientIdentity::Age(value) | RecipientIdentity::Ssh(value) => value.clone()) canonicalizes an agenix rule's publicKeys, so a rule written with a raw SSH key keeps the ssh-ed25519 AAAA… form. That raw form flows into the per-secret inventory (encrypted_for, lines 720-724) and becomes entry.publicRecipients (recipients.rs:210-217). When that raw key belongs to a configured local identity, merge_config_recipients (recipients.rs:903-918) matches it via identities_for_recipient_with_aliases and folds the registration into the local recipient — whose displayed publicKey is the ssh-to-age age1… form; the raw SSH alias lives only in DecryptionIdentity.public_keys (lines 1199-1209). The consumer, secret-detail-view.tsx:162-165, resolves each publicRecipients entry with an exact candidate.publicKey === publicKey find, which finds nothing for the raw SSH string: the row falls back to the raw key as its label, renders the unknown-kind icon, and drops the recipientHasLocalIdentity 'local identity' chip — even though recipientIds did match th…






Summary
This removes the previous TODO's about retrieving agenix secrets in the read-only Secrets Management view, including the decryption support.
For purposes here, recipients need to be identifiable from one of three locations:
If we can't identify any from these locations, the secrets remain visible but their public recipients and local capability will show as "Unknown" in the UI.
The rest of the functionality in this PR is pretty self-explanatory, I believe.
Screenshots:
Test Plan
New unit tests where appropriate, plus manual testing with my test config repo.
Docs