### Adr/A 03 Separate Storage Rcs # A-03: Separate `Storage` and `RCS` interfaces **Status:** deferred — current implementation intentionally keeps them merged **Source:** CODE_QUALITY_REPORT.md § A-3 --- ## Background `backend.Storage` (defined in `internal/backend/storage.go`) currently embeds the unexported `rcs` interface (defined in `internal/backend/rcs.go`). This means every `Storage` implementation must satisfy ~15 VCS methods: `Add`, `Commit`, `Push`, `Pull`, `TryAdd`, `TryCommit`, `TryPush`, `InitConfig`, `AddRemote`, `RemoveRemote`, `Revisions`, `GetRevision`, `Status`, `Compact`. The `fs` backend (`internal/backend/storage/fs/`) has no VCS support and implements all of these as stubs that return `store.ErrGitNotInit` or `backend.ErrNotSupported`. This was a deliberate architectural choice: an earlier version of the codebase had separate `Storage` and `RCS` interfaces, but merging them simplified the leaf store significantly by eliminating a type-assertion at every VCS call site. Since the overwhelming majority of storage backends (`gitfs`, `fossilfs`, `jjfs`) are also RCS backends, the merged interface is **correct in practice** even if it is not strictly correct in theory. --- ## Trade-offs of the current merged design ### Pros - Single interface, no type-assertion boilerplate in `leaf.Store` or callers. - Adding a new backend that supports VCS is straightforward — implement one interface and you're done. - No layering confusion: `leaf.Store.storage` holds everything the store needs. ### Cons - Pure-storage backends (`fs`) must implement ~15 no-op stubs. - The `Storage` interface is larger than necessary, making it harder to mock in tests. - Conceptually misleading: the interface promises VCS capabilities that some backends cannot deliver. --- ## Implementation plan (if splitting is chosen in the future) ### Phase 1 — Export `rcs` as a standalone `RCS` interface **`internal/backend/rcs.go`** - Rename the unexported `rcs` interface to exported `RCS`. - Add a concrete `NopRCS` type in the same file that provides the same no-op behaviour currently in `fs/rcs.go`: - `TryAdd`, `TryCommit`, `TryPush`, `InitConfig`, `Compact` → `nil` - `Add`, `Commit`, `Push`, `Pull` → `store.ErrGitNotInit` - `Revisions` → single `{Hash:"latest", Date:time.Now()}` + `ErrNotSupported` - `GetRevision("HEAD"|"latest")` → delegates to a supplied getter func; other revisions → `ErrNotSupported` - `Status`, `AddRemote`, `RemoveRemote` → `ErrNotSupported` **`internal/backend/storage.go`** - Remove the `rcs` embed from `Storage` so it contains only file-operation methods: `Get`, `Set`, `Delete`, `Exists`, `Move`, `List`, `IsDir`, `Prune`, `Link`, `Name`, `Path`, `Version`, `Fsck`, `fmt.Stringer`. ### Phase 2 — Add an `rcs` field to `leaf.Store` **`internal/store/leaf/store.go`** - Add `rcs backend.RCS` field to the `Store` struct. - In `leaf.Init()`, after the storage backend is created, derive `rcs` via a type assertion: ```go if r, ok := st.(backend.RCS); ok { s.rcs = r } else { s.rcs = backend.NopRCS{} } ``` - Update `leaf.GitInit()` in `internal/store/leaf/rcs.go` to re-derive `s.rcs` after reassigning `s.storage`. **Leaf files that call RCS methods on `s.storage`** — change `s.storage.X` to `s.rcs.X` for all VCS operations: | File | Methods affected | |---|---| | `internal/store/leaf/write.go` | `TryAdd`, `TryCommit`, `TryPush` | | `internal/store/leaf/move.go` | `TryAdd`, `TryCommit`, `TryPush` | | `internal/store/leaf/link.go` | `Add` | | `internal/store/leaf/fsck.go` | `Compact`, `Push`, `TryCommit` | | `internal/store/leaf/reencrypt.go` | `TryAdd`, `TryCommit`, `TryPush` | | `internal/store/leaf/recipients.go` | `TryAdd`, `TryCommit`, `Push`, `Add` | | `internal/store/leaf/templates.go` | `TryAdd`, `Add` | | `internal/store/leaf/rcs.go` | `Revisions`, `GetRevision`, `Status` | ### Phase 3 — Delete `fs/rcs.go` `internal/backend/storage/fs/rcs.go` can be deleted in its entirety. The `fs.Store` type no longer needs to implement `RCS`. ### Phase 4 — Verify other consumers These locations hold a `backend.Storage` variable but call only file-op methods and require no changes beyond a successful compile check: - `internal/action/reorg.go` — `var storage backend.Storage` - `internal/create/wizard.go` — `backend.Storage` parameter The `StorageLoader` interface in `internal/backend/registry.go` returns `Storage` — no change needed. ### Phase 5 — Update and add tests - Delete `internal/backend/storage/fs/rcs_test.go` (tests for the removed stubs). - Any mock `Storage` type in leaf or other test packages can stop implementing RCS methods; embed or compose `backend.NopRCS` instead. - Add a small unit test for `NopRCS` in `internal/backend/rcs_test.go`. --- ## Files changed at a glance | File | Action | |---|---| | `internal/backend/rcs.go` | Export `RCS`; add `NopRCS` | | `internal/backend/storage.go` | Remove `rcs` embed from `Storage` | | `internal/store/leaf/store.go` | Add `rcs backend.RCS` field + init logic | | `internal/store/leaf/rcs.go` | Use `s.rcs`; re-derive in `GitInit` | | `internal/store/leaf/{write,move,link,fsck,reencrypt,recipients,templates}.go` | `s.storage.X` → `s.rcs.X` for VCS calls | | `internal/backend/storage/fs/rcs.go` | **Delete** | | `internal/backend/storage/fs/rcs_test.go` | **Delete** | | `internal/backend/rcs_test.go` | Add `NopRCS` tests | `gitfs`, `fossilfs`, and `jjfs` require no changes — they already implement the full `rcs` interface and will satisfy `backend.RCS` via the type assertion with zero modification. --- ### Adr/A 04 Grep Match Error Counters # A-04: Fix `grep` Match and Error Counters **Status:** open — not yet fixed **Source:** SECURITY_AUDIT_REPORT.md § M-1, § Q-5 --- ## Background `internal/action/grep.go` declares two integer counters, `matches` and `errors`, intended to tally the number of matching secrets and decryption failures respectively. Neither counter is ever incremented inside the loop: ```go var matches int var errors int for _, v := range haystack { sec, err := s.Store.Get(ctx, v) if err != nil { out.Errorf(ctx, "failed to decrypt %s: %v", v, err) // errors++ missing here continue } if matchFn(string(sec.Bytes())) { out.Printf(ctx, "%s matches", color.BlueString(v)) // matches++ missing here } } out.Printf(ctx, "\nScanned %d secrets. %d matches, %d errors", len(haystack), matches, errors) ``` As a result the summary line always reads `0 matches, 0 errors` regardless of what the search actually found. --- ## Impact This is a **correctness bug**, not a security vulnerability. Users who rely on the summary count to verify grep results will receive misleading output and cannot tell whether any secrets matched their query. --- ## Decision Fix by adding `matches++` inside the `matchFn` branch and `errors++` inside the error branch. This is a trivial one-line change per counter. No API or behaviour changes are required. --- ## Implementation In `internal/action/grep.go`, locate the loop body and add the two missing increment statements: ```go if err != nil { out.Errorf(ctx, "failed to decrypt %s: %v", v, err) errors++ // add this continue } if matchFn(string(sec.Bytes())) { out.Printf(ctx, "%s matches", color.BlueString(v)) matches++ // add this } ``` A test should assert that the summary line reports the correct counts after a search against a known fixture store. --- ### Adr/A 05 Template Engine Text Vs Html # A-05: Template Engine Uses `text/template` Instead of `html/template` **Status:** deferred — current design is acceptable given the constraints **Source:** SECURITY_AUDIT_REPORT.md § M-2 --- ## Background The gopass template engine (`internal/tpl/`) uses Go's `text/template` package. Go's standard library ships two closely related template packages: | Package | Auto-escaping | Intended use | |---------|--------------|--------------| | `text/template` | No | Arbitrary text generation | | `html/template` | Yes (HTML/JS/CSS contexts) | HTML document generation | `html/template` is often recommended for security-sensitive contexts because it prevents XSS by automatically escaping output that is interpolated into HTML attributes, script blocks, and URL parameters. --- ## Why `html/template` Does Not Directly Apply Here gopass templates generate **plain text secrets**, not HTML documents. Switching to `html/template` would: 1. **Corrupt non-HTML output.** Characters like `<`, `>`, `&`, `"`, and `'` are common in passwords and secret values. `html/template` would HTML-escape these on output (e.g. `&` → `&`), producing incorrect secrets. 2. **Provide no meaningful security benefit.** The auto-escaping in `html/template` protects against XSS injection into HTML pages. The gopass template engine renders secrets to a terminal or file — not a browser. There is no HTML parsing context to exploit. The current `text/template` approach is therefore **correct for the use case**. --- ## Residual Risk `text/template` allows calling methods on any value passed into the template. The current `payload` struct passed to templates contains only string fields, limiting the callable surface to string methods. The risk would escalate if: - `payload` gains a field or method that returns a complex type with side-effect-bearing methods. - A template function is added whose return type exposes dangerous methods. --- ## Decision Keep `text/template`. Document the constraint here so that future contributors: 1. Do **not** add methods with observable side effects to the `payload` struct. 2. Audit any new template function's return types for unexpectedly callable methods. 3. Reconsider this decision if the template engine ever grows an HTML rendering mode (e.g. for browser integrations), in which case a separate HTML-specific template path using `html/template` should be introduced rather than switching the existing engine wholesale. --- ### Adr/A 06 Minimum Password Length # A-06: Minimum Password Length Enforcement **Status:** deferred — user autonomy preserved; warning to be added **Source:** SECURITY_AUDIT_REPORT.md § M-4 --- ## Background The `gopass generate` command accepts a length argument that is ultimately stored in the `generate.length` config key or read from the `GOPASS_PW_DEFAULT_LENGTH` environment variable. Currently no lower bound is enforced below the generator's character-class minimum (which can be as low as 1 character). A user can therefore generate a single-character "password". --- ## Impact Accidentally generating very short passwords provides the appearance of security while offering none. Users who script gopass (e.g. in CI pipelines) may silently configure trivially weak credentials. --- ## Decision Enforcing an absolute minimum is a **policy decision** that gopass deliberately avoids making for users. Some legitimate use cases require short codes (e.g. 4-digit device PINs, legacy system constraints). A hard cutoff would break these workflows. The chosen approach is: 1. Display a **warning** when the requested length is below 12 characters, making the risk visible without blocking the operation. 2. Do **not** impose a hard minimum that silently rejects user input. --- ## Implementation In `internal/action/generate.go` (or wherever length is read and validated before the generator is called), add: ```go const warnBelowLength = 12 if length < warnBelowLength { out.Warningf(ctx, "Generating a password of only %d characters. This may be too weak for most uses.", length) } ``` The warning should be visible in non-interactive mode as well so that scripted invocations are not silently insecure. No config key changes are required. Users who want to suppress the warning can do so by setting a length ≥ 12 in their config. --- ### Adr/A 07 Hook System Dead Code # A-07: Hook System Dead Code and CVE-2023-24055 **Status:** deferred — hooks remain disabled; safe re-enablement path documented **Source:** SECURITY_AUDIT_REPORT.md § M-5 --- ## Background gopass ships a hook system in `internal/hook/hook.go` that was designed to allow users to run custom commands at lifecycle events (e.g. pre-commit, post-decrypt). The system was **disabled** by inserting a hardcoded early return: ```go if true { // TODO(GH-2546) disabled until further discussion, cf. CVE-2023-24055 return nil } ``` The code below this guard remains in the repository. It parses hook command strings using `shellquote.Split()` and then executes the result, which is the same pattern that was the root cause of CVE-2023-24055 in the KeePass ecosystem (untrusted config values leading to arbitrary command execution via shell-style parsing). --- ## Why It Was Disabled If an attacker can write to the gopass configuration file (e.g. via a malicious git repository that includes a synced config) they could set a hook to any command value and have gopass execute it on the next relevant lifecycle event. The `shellquote.Split` approach would allow argument injection even without shell metacharacters. --- ## Decision The hooks remain disabled. The dead code **should not** be removed yet because the feature is under active discussion (GH-2546). Removing it prematurely would force a larger re-implementation effort when the discussion concludes. The dead code is harmless as long as the early-return guard stays in place. --- ## Requirements for Safe Re-enablement Before hooks can be re-enabled the implementation must satisfy all of the following: 1. **No shell parsing of hook values.** Hook commands must be stored as structured config (e.g. an array of strings for binary + arguments) rather than a single shell-quoted string. `shellquote.Split` must not be used. 2. **Binary existence check.** The resolved hook binary must pass an `exec.LookPath` check before execution (consistent with the fix applied in H-3 for the editor command). 3. **Explicit user consent.** Hooks should require explicit opt-in through a first-class config key (e.g. `hooks.enabled = true`) that is **not** synced via git-managed config by default, so that a shared/cloned repository cannot silently activate hooks on a new machine. 4. **Audit trail.** Each hook execution should be logged at info level so users can observe what commands are being run on their behalf. --- ## Implementation Sketch ```go // safe hook execution — no shell parsing hookBin, err := exec.LookPath(hookConfig.Command[0]) if err != nil { return fmt.Errorf("hook binary %q not found: %w", hookConfig.Command[0], err) } cmd := exec.CommandContext(ctx, hookBin, hookConfig.Command[1:]...) ``` When GH-2546 reaches a resolution, this ADR should be updated with the chosen design and then closed. --- ### Adr/A 08 Shred Modern Storage Limitations # A-08: Shred Operation Is Ineffective on Modern Storage **Status:** accepted — limitation documented; advisory notice to be added **Source:** SECURITY_AUDIT_REPORT.md § M-6 --- ## Background `pkg/fsutil/fsutil.go` provides a `Shred()` function that overwrites a file with random bytes a configurable number of times before deleting it. The intent is to prevent recovery of the plaintext after deletion. --- ## Why Shred Does Not Work on Modern Storage The overwrite-before-delete approach relies on the assumption that writing to a file path overwrites the same physical storage blocks each time. This assumption has not held in practice for many years: | Storage / FS type | Reason shred fails | |-------------------|--------------------| | SSD with wear levelling | Controller may write to different physical cells; old data remains until garbage-collected | | ext4, NTFS, HFS+ (journaling) | Journal entries may contain copies of original data blocks | | ZFS, Btrfs (copy-on-write) | Old snapshot blocks are never overwritten; new blocks are written alongside the old | | APFS (macOS) | Copy-on-write; snapshots are created automatically by Time Machine | | Network filesystems | Local overwrite does not affect server-side block allocation | NIST SP 800-88 and academic literature (Gutmann, 1996; Wei et al., 2011) confirm that software overwrite is unreliable on solid-state media. --- ## Current Mitigations Already in Place gopass already uses the correct approach for the security-critical path: plaintext is written to a **ramdisk** (macOS) or **`/dev/shm`** (Linux) for editor sessions, so the sensitive data never reaches persistent storage where shred would apply. The `Shred()` function is called on other files (e.g. old encrypted files after re-encryption), where the data being removed is already encrypted ciphertext. Shredding ciphertext provides minimal additional security over simple deletion because the data is useless without the private key. --- ## Decision Do not remove or deprecate `Shred()`. It provides a marginal additional layer on rotating-disk (HDD) storage and its cost is low. However, it **must not be presented to users as a guaranteed secure-deletion mechanism**. Actions taken / to be taken: 1. Add a notice to the `Shred()` function's godoc comment explaining the limitation on SSDs and journaling/CoW filesystems. 2. If gopass ever presents a "securely deleted" user-facing message after calling `Shred()`, that message should be softened to "deleted" or include a caveat. 3. Sensitive plaintext must continue to be handled exclusively in ramdisk-backed temporary files (the existing behaviour) and never written to persistent disk in cleartext. --- ## References - Gutmann, P. (1996). "Secure Deletion of Data from Magnetic and Solid-State Memory" - Wei, M. et al. (2011). "Reliably Erasing Data from Flash-Based Solid State Drives" (FAST '11) - NIST SP 800-88 Rev. 1: Guidelines for Media Sanitization --- ### Adr/A 09 Low Severity Informational Findings # A-09: Low Severity and Informational Findings **Status:** accepted — risks documented; mitigations noted where applicable **Source:** SECURITY_AUDIT_REPORT.md § L-1 through L-6 --- ## L-1: GPG Ciphertext Logged to Debug Output **Location:** `internal/backend/crypto/gpg/cli/encrypt.go` Encrypted ciphertext is hex-dumped to the debug log via `io.MultiWriter`: ```go hexLogger := hex.Dumper(debug.LogWriter) cmd.Stdout = io.MultiWriter(buf, hexLogger) ``` While ciphertext is not plaintext, persisting it to a debug log could assist offline brute-force or analysis of the encryption scheme. **Mitigation:** The debug log is only written when `GOPASS_DEBUG_LOG` is explicitly set. Ciphertext should be logged at a higher verbosity level (e.g. `debug.V(2)`) rather than unconditionally, so that standard debug output does not include bulk ciphertext. --- ## L-2: `GOPASS_GPG_BINARY` Allows Binary Override Without Validation The `GOPASS_GPG_BINARY` environment variable allows the user to override the path to the GPG binary. An attacker with control over the process environment could point it to a malicious program. **Assessment:** If an attacker controls the environment of the gopass process, they already have equivalently powerful attack vectors (e.g. replacing `GOPATH`, `PATH`, or setting `LD_PRELOAD`). The incremental risk from this specific variable is low. **Mitigation:** No change required. The variable is documented as a developer/debugging aid and requires environment access that implies broader privilege. --- ## L-3: Updater Relies on CA-Signed TLS Without Certificate Pinning **Location:** `internal/updater/download.go` The built-in updater connects to `github.com` over TLS 1.3 (min version enforced) but does not pin the server certificate or its public key. A CA that issues a fraudulent certificate for `github.com` could perform a man-in-the-middle attack against the update download. **Assessment:** The risk is mitigated by defence-in-depth: the downloaded binary's SHA-256 checksum is verified against a checksum file, and that checksum file's GPG signature is verified against a **hardcoded** project public key embedded in the binary. An attacker would therefore need to compromise both a trusted CA **and** the project's GPG signing key, which is an implausible combination. Certificate pinning would add implementation complexity and create an operational burden (the pin must be updated with every certificate renewal) without meaningfully reducing the realistic threat surface. **Mitigation:** No change required. The current GPG-signed checksum verification provides sufficient defence-in-depth. --- ## L-4: `text/template` Panic on Nil Interface in Custom Functions If a custom template function receives a nil interface value and attempts a method call on it, the Go runtime will panic. The `process` command currently passes `nil` as the secret payload; the template engine handles `{{.Field}}` access on nil gracefully via reflection, but custom Go functions called from within templates are not similarly protected. **Assessment:** Exploitable only if a user deliberately authors a template that triggers the nil path — this is a denial-of-service against the user's own session rather than a privilege-escalation vector. **Mitigation:** Custom template functions should guard against nil receivers using explicit nil checks. New template functions added in the future should be reviewed for nil-safety. --- ## L-5: `DetectCrypto` Returns `nil, nil` **Location:** `internal/backend/registry.go` `DetectCrypto()` can return `(nil, nil)` when no matching backend is detected. The function has a `// TODO` comment acknowledging this. Callers that do not check for a nil crypto backend before using it will panic with a nil-pointer dereference. **Assessment:** All current call sites do check the error + nil before use. The risk is that a future caller forgets. **Mitigation:** The function should be updated to return an explicit `fmt.Errorf("no crypto backend detected for %s", path)` error instead of `nil, nil`. This is a small refactor that eliminates the ambiguous return and allows callers to use standard Go error-checking patterns without needing to special-case nil. --- ## L-6: HTTP Proxy Honoured for Update Downloads **Location:** `internal/updater/download.go` ```go Proxy: http.ProxyFromEnvironment, ``` The updater respects `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`. A malicious proxy could intercept or block the update download. **Assessment:** The GPG-signed checksum verification (see L-3) means a proxy cannot substitute a malicious binary without also forging the project GPG signature. Proxy support is a legitimate operational requirement in corporate environments where direct internet access may be disallowed. **Mitigation:** No change required. Proxy support is correct and expected behaviour in enterprise environments; GPG signature verification prevents payload substitution. --- ### Adr/A 10 Code Quality Findings # A-10: Code Quality Findings **Status:** open — improvements tracked here for future work **Source:** SECURITY_AUDIT_REPORT.md § Q-1 through Q-4 --- ## Q-1: Path Traversal Protection Inconsistency **Problem:** Path sanitisation is performed differently at each entry point: | Location | Approach | |----------|----------| | `leaf.Store.Set()` | Rejects names containing `//` | | `fs` storage layer | `filepath.Clean` + bounds check (added in C-1 fix) | | Binary action | `isInStore()` with `filepath.Abs()` | | `create` wizard | `CleanFilename()` | | `gopass show` | No explicit check | The inconsistency creates a maintenance risk: a new entry point that forgets to apply the correct check would silently reintroduce a path traversal vulnerability. **Recommendation:** Introduce a single exported function: ```go // internal/backend/storage/fs/validate.go func ValidateSecretName(storePath, name string) error { resolved := filepath.Join(storePath, filepath.Clean(name)) if !strings.HasPrefix(resolved, storePath+string(filepath.Separator)) { return fmt.Errorf("path traversal detected: %q escapes store root", name) } return nil } ``` Replace all divergent ad-hoc checks with calls to this function. This is a medium-effort refactor but significantly reduces the attack surface for future regressions. --- ## Q-2: Platform-Specific Behaviour Differences **Problem:** Several security-relevant behaviours differ across platforms in ways that are not always intentional or documented: | Behaviour | Linux/macOS | Windows | |-----------|-------------|---------| | Path traversal (`../`) | Rejected by C-1 fix | Rejected by C-1 fix (test expectation updated) | | Tempfile ramdisk | `/dev/shm` or macOS ramdisk | Falls back to OS temp dir — not a ramdisk | | Clipboard clear | Kills predecessor `unclip` processes | No predecessor killing | | Editor parsing | `shellquote.Split` + `LookPath` | Direct argument passing, no `LookPath` | The editor parsing inconsistency on Windows is notable: the H-3 fix added `exec.LookPath` validation on non-Windows platforms only (consistent with the existing `runtime.GOOS != "windows"` guard), so Windows users do not get the benefit of early binary validation. **Recommendation:** - Extend the `LookPath` check to Windows (the `exec.LookPath` function works on Windows; the guard is in the `shellquote.Split` branch which is Windows-only excluded). - Document the tempfile ramdisk limitation on Windows so users understand that plaintext may temporarily appear in the OS temp directory on that platform. --- ## Q-3: Error Handling Inconsistencies **Problem:** Several recurring patterns make errors invisible or misleading: 1. **`_ = os.Setenv(...)`** — environment variable set failures are silently ignored. A failed `Setenv` could mean secrets are not exported to the subprocess in `gopass env`, which would produce a confusing user experience without any diagnostic. 2. **`fsck.go` permission repair** — permission change failures are logged as warnings but not returned as errors, so `gopass fsck --fix` exits 0 even if it could not actually fix the issues it found. 3. **`DetectCrypto` returning `nil, nil`** — documented in L-5; should return an explicit error. 4. **Template functions returning `err.Error()` as a string** — partially addressed in H-1 (error strings are now generic), but the underlying pattern (swallowing the error and embedding it in the output) should be replaced with proper error propagation. **Recommendation:** Audit all `_ = ...` error discards and replace them with at minimum a `debug.Log` call. Review `fsck.go` to ensure errors from repair operations are aggregated and returned. --- ## Q-4: Dead Code **Problem:** Several code paths are permanently unreachable but remain in the repository, increasing maintenance burden and creating confusion about intent: 1. **Hook system** (`internal/hook/hook.go#L47–L91`) — unreachable below the hardcoded `if true { return nil }` guard. Documented in A-7; should be kept until GH-2546 is resolved, but the intent must be clearly stated. 2. **GitHub recipient support in age backend** — the `github:` key prefix was removed as a supported feature, but the age backend still contains handling code that logs a warning when it is encountered. If the feature is truly gone, this code should be removed. 3. **Passage identity loading** — deprecated format support for the `passage` fork's identity file layout. If this format is no longer supported, the parsing code should be removed to reduce the attack surface of the identity loading path. **Recommendation:** Each piece of dead code should be explicitly categorised: - **Keep with comment** (hook system — pending GH-2546) - **Remove** (github: prefix warning, passage identity loading) if those features are confirmed gone A `go vet` + `staticcheck` run in CI would catch some categories of dead code automatically and should be part of the standard `make codequality` target. --- ### Adr/A 11 Secret Service # A-11: Integrate `org.freedesktop.secrets` D-Bus Service **Status:** proposed **Source:** [GitHub Issue #3434](https://github.com/gopasspw/gopass/issues/3434) --- ## Background The [freedesktop.org Secret Service specification](https://specifications.freedesktop.org/secret-service/latest/) defines a D-Bus API that desktop applications (Firefox, Chrome, VS Code, Electron apps, NetworkManager, `secret-tool`, …) use to store and retrieve secrets. On most systems it is served by GNOME Keyring or KDE Wallet. Users who keep their passwords in gopass currently maintain two separate secret stores. The request is to add a `gopass secret-service` daemon subcommand that implements this D-Bus API on top of the existing gopass store, so that GUI and CLI applications write their secrets into the same GPG-encrypted, git-backed password store. --- ## Prior Art Two reference implementations inform the design: | Project | Language | License | Notes | |---------|----------|---------|-------| | [nikicat/gopass-secret-service](https://github.com/nikicat/gopass-secret-service) | Go (90 %) | MIT | Standalone daemon that invokes `gopass` CLI. **Most relevant.** | | [grimsteel/pass-secret-service](https://github.com/grimsteel/pass-secret-service) | Rust | GPL-3.0 | Standalone daemon for `pass`. Pure-Rust D-Bus. | `nikicat/gopass-secret-service` implements the full spec in Go and re-uses `gopass` via its CLI. The key difference for this ADR is that the integrated version will use the **gopass Go API** (`github.com/gopasspw/gopass/pkg/gopass/api`) directly rather than shelling out. --- ## Decision Implement `gopass secret-service` as: 1. A new **Linux-only** subcommand of the main `gopass` binary. 2. A long-running **daemon** that acquires `org.freedesktop.secrets` on the D-Bus session bus and serves the full Secret Service interface. 3. Uses `github.com/godbus/dbus/v5` (already in `go.mod` at v5.1.0, pure Go, BSD-2 licensed). 4. All crypto uses stdlib `crypto/...` packages — no CGo, no new external dependencies. 5. Secrets are stored under a configurable gopass path prefix (default: `secret-service`). --- ## Feasibility Summary * **Pure-Go, zero-CGo**: `godbus/dbus/v5` is already a direct dependency; all required crypto (`crypto/aes`, `crypto/sha256`, `math/big` for DH) is in the stdlib. * **No new external dependencies**: Only stdlib + existing `godbus` dependency needed. * **License-compatible**: `godbus/dbus/v5` is BSD-2 (≡ MIT compatible per `.license-lint.yml`). * **Linux-only build tag**: The entire feature is gated with `//go:build linux` (same pattern as `internal/notify/notify_dbus.go` and `pkg/clipboard/unclip_linux.go`). * **Architectural risk**: gopass is a short-lived CLI tool; this feature requires a persistent daemon process. This is handled by a blocking `gopass secret-service serve` subcommand (the user manages the lifecycle via systemd or similar). --- ## D-Bus Interface Mapping All objects live under the well-known service name `org.freedesktop.secrets`. | Interface | Object path | Implementation type | |-----------|-------------|---------------------| | `org.freedesktop.Secret.Service` | `/org/freedesktop/secrets` | `service.Service` | | `org.freedesktop.Secret.Collection` | `/org/freedesktop/secrets/collection/{name}` | `service.Collection` | | `org.freedesktop.Secret.Item` | `/org/freedesktop/secrets/collection/{name}/{id}` | `service.Item` | | `org.freedesktop.Secret.Session` | `/org/freedesktop/secrets/session/{id}` | `service.Session` | | `org.freedesktop.Secret.Prompt` | `/org/freedesktop/secrets/prompt/{id}` | `service.Prompt` | --- ## Storage Layout in gopass Secrets are stored under a configurable prefix (`secret-service` by default): ``` ~/.password-store/ └── secret-service/ ├── _aliases.age # Map of alias → collection name (JSON) ├── default/ │ ├── _meta.age # Collection metadata (label, created, modified) │ └── i.age # Secret items └── work/ ├── _meta.age └── i.age ``` Each item secret file uses the standard gopass multi-line format: ``` the-secret-value --- _ss_label: My GitHub Token _ss_created: 2026-01-15T10:30:00Z _ss_modified: 2026-01-15T10:30:00Z _ss_content_type: text/plain username: user@example.com service: github.com ``` The first line is the secret value; `_ss_*` keys are internal metadata; all other key/value pairs are user-visible item attributes (used for lookup by `SearchItems`). --- ## Crypto Sessions The spec defines two `OpenSession` algorithms: | Algorithm | Description | Implementation | |-----------|-------------|----------------| | `plain` | No transport encryption | Return empty bytes; secret value passed as-is | | `dh-ietf1024-sha256-aes128-cbc-pkcs7` | DH key exchange + AES-128-CBC | stdlib `crypto/aes`, `crypto/sha256`, `math/big` | The `plain` algorithm is secure because D-Bus session bus traffic is carried over a local UNIX socket with kernel-enforced access control. Implementing `dh-ietf1024-sha256-aes128-cbc-pkcs7` is required for compatibility with `libsecret`-based applications. DH parameters: [RFC 3526](https://www.rfc-editor.org/rfc/rfc3526) 1024-bit MODP group 2. The server: 1. Generates a DH ephemeral key pair on the MODP-1024 group. 2. Receives the client's public key in `OpenSession`. 3. Computes `shared = clientPub^serverPriv mod p`. 4. Left-pads `shared` to 128 bytes (a known pitfall — see grimsteel commit c781717). 5. Derives AES key: `aes_key = SHA256(shared)[0:16]`. 6. Each secret returned has its own random 16-byte IV prepended to the ciphertext. --- ## Package Structure All new code lives under `internal/secretservice/` (Linux-only files) and integrates via a new `secretservice_linux.go` action handler shim. ``` internal/secretservice/ ├── doc.go # Package doc ├── service.go # org.freedesktop.Secret.Service implementation ├── collection.go # org.freedesktop.Secret.Collection implementation ├── item.go # org.freedesktop.Secret.Item implementation ├── session.go # Session lifecycle + crypto dispatch ├── prompt.go # Prompt objects (required by spec for async ops) ├── crypto/ │ ├── crypto.go # Session interface + factory │ ├── plain.go # "plain" algorithm │ └── dh.go # "dh-ietf1024-sha256-aes128-cbc-pkcs7" ├── store.go # Adapter between Secret Service and gopass API ├── errors.go # D-Bus error definitions (org.freedesktop.DBus.Error.*) ├── types.go # D-Bus type aliases (Secret struct, path constants) └── service_test.go # Unit tests (mock D-Bus + mock gopass API) ``` CLI integration: ``` internal/action/ ├── secretservice_linux.go # SecretService() handler, registers via GetCommands() └── secretservice_other.go # Stub for non-Linux platforms that prints "linux only" ``` Systemd / D-Bus activation files (installed by `gopass secret-service install`): ``` contrib/secret-service/ ├── org.freedesktop.secrets.service # D-Bus session activation (ExecStart=gopass secret-service serve) └── gopass-secret-service.service # systemd user unit ``` --- ## Implementation Phases This feature is too large for a single prompt. Each phase below is self-contained and can be implemented and tested independently. Phases must be implemented in order because each phase depends on the previous. --- ### Phase 1 — Foundation: types, errors, session, crypto **Goal**: acquire the `org.freedesktop.secrets` bus name and negotiate a session. No collections or items yet; stub implementations may be used. **Files to create**: - `internal/secretservice/doc.go` - `internal/secretservice/types.go` - `internal/secretservice/errors.go` - `internal/secretservice/crypto/crypto.go` - `internal/secretservice/crypto/plain.go` - `internal/secretservice/crypto/dh.go` - `internal/secretservice/session.go` - `internal/secretservice/service.go` (skeleton: bus name, `OpenSession`, `CloseSession`) - `internal/secretservice/service_test.go` **`types.go`** — key D-Bus types: ```go //go:build linux package secretservice import "github.com/godbus/dbus/v5" const ( ServiceName = "org.freedesktop.secrets" ServicePath = dbus.ObjectPath("/org/freedesktop/secrets") ServiceIface = "org.freedesktop.Secret.Service" CollectionIface = "org.freedesktop.Secret.Collection" ItemIface = "org.freedesktop.Secret.Item" SessionIface = "org.freedesktop.Secret.Session" PromptIface = "org.freedesktop.Secret.Prompt" CollectionPathPrefix = "/org/freedesktop/secrets/collection/" SessionPathPrefix = "/org/freedesktop/secrets/session/" PromptPathPrefix = "/org/freedesktop/secrets/prompt/" ) // Secret is the D-Bus Secret struct as defined in the spec. // It is the wire format for passing secrets across D-Bus. type Secret struct { Session dbus.ObjectPath // session used for transport encryption Parameters []byte // IV (empty for "plain" algorithm) Value []byte // encrypted (or plaintext) secret value ContentType string // MIME type, e.g. "text/plain; charset=utf-8" } ``` **`errors.go`** — see spec §11 for all error names. Key ones: ```go //go:build linux package secretservice import "github.com/godbus/dbus/v5" var ( ErrNoSession = dbus.NewError("org.freedesktop.Secret.Error.NoSession", nil) ErrNoSuchObject = dbus.NewError("org.freedesktop.Secret.Error.NoSuchObject", nil) ErrIsLocked = dbus.NewError("org.freedesktop.Secret.Error.IsLocked", nil) ErrAlreadyExists = dbus.NewError("org.freedesktop.Secret.Error.AlreadyExists", nil) ErrNotSupported = dbus.NewError("org.freedesktop.Secret.Error.NotSupported", nil) ) ``` **`crypto/crypto.go`** — session interface: ```go //go:build linux package crypto // CryptoSession is an established client session. type CryptoSession interface { // Decrypt decrypts a Secret.Value using Parameters as IV. Decrypt(params, ciphertext []byte) ([]byte, error) // Encrypt encrypts plaintext and returns (params/IV, ciphertext). Encrypt(plaintext []byte) (params, ciphertext []byte, err error) } // NewSession negotiates a new CryptoSession. // algorithm is one of "plain" or "dh-ietf1024-sha256-aes128-cbc-pkcs7". // clientInput is the client public key (empty for "plain"). // Returns the CryptoSession and server output (empty for "plain", server pub key for DH). func NewSession(algorithm string, clientInput []byte) (CryptoSession, []byte, error) { ... } ``` **`crypto/plain.go`**: ```go //go:build linux package crypto type plainSession struct{} func (plainSession) Decrypt(_, ciphertext []byte) ([]byte, error) { return ciphertext, nil } func (plainSession) Encrypt(plaintext []byte) ([]byte, []byte, error) { return nil, plaintext, nil } ``` **`crypto/dh.go`** implementation outline: ```go //go:build linux package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "math/big" ) // RFC 3526 MODP 1024-bit group 2 var ( dhPrime, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFC90FDAA2...", 16) // full 1024-bit prime dhGen = big.NewInt(2) ) type dhSession struct{ aesKey []byte } // NewDHSession computes the shared secret and derives the AES key. // clientPubBytes is the client's public key. // Returns the dhSession and the server's public key bytes. func NewDHSession(clientPubBytes []byte) (*dhSession, []byte, error) { // 1. Generate server private key (random 128-byte big.Int) // 2. Compute serverPub = g^serverPriv mod p // 3. Compute shared = clientPub^serverPriv mod p // 4. Left-pad shared to 128 bytes (IMPORTANT: see grimsteel/pass-secret-service#24) // 5. aesKey = SHA256(paddedShared)[0:16] ... } func (s *dhSession) Decrypt(params, ciphertext []byte) ([]byte, error) { // AES-128-CBC with IV=params, key=s.aesKey, PKCS7 unpadding } func (s *dhSession) Encrypt(plaintext []byte) ([]byte, []byte, error) { // random 16-byte IV, AES-128-CBC with PKCS7 padding } ``` **`session.go`**: ```go //go:build linux package secretservice import ( "fmt" "sync" "github.com/godbus/dbus/v5" "github.com/gopasspw/gopass/internal/secretservice/crypto" ) type session struct { id string path dbus.ObjectPath crypto crypto.CryptoSession } type sessionManager struct { mu sync.RWMutex sessions map[string]*session } func (sm *sessionManager) Open(algorithm string, input []byte) (*session, []byte, error) { ... } func (sm *sessionManager) Get(path dbus.ObjectPath) (*session, error) { ... } func (sm *sessionManager) Close(path dbus.ObjectPath) error { ... } ``` **`service.go`** skeleton: ```go //go:build linux package secretservice import ( "context" "github.com/godbus/dbus/v5" ) // Service implements org.freedesktop.Secret.Service. type Service struct { conn *dbus.Conn sessions *sessionManager // collections added in Phase 2 } // New creates and starts the service. // It acquires the org.freedesktop.secrets bus name. func New(ctx context.Context) (*Service, error) { conn, err := dbus.SessionBus() ... reply, err := conn.RequestName(ServiceName, dbus.NameFlagDoNotQueue) ... svc := &Service{ conn: conn, sessions: &sessionManager{} } conn.Export(svc, ServicePath, ServiceIface) conn.Export(introspect.NewIntrospectable(svc), ServicePath, "org.freedesktop.DBus.Introspectable") return svc, nil } // OpenSession implements org.freedesktop.Secret.Service.OpenSession func (s *Service) OpenSession(algorithm string, input dbus.Variant) (dbus.Variant, dbus.ObjectPath, *dbus.Error) { ... } // CloseSession (called on Session object) is delegated to sessionManager. ``` **Testing approach for Phase 1**: - Use `dbus.SessionBusPrivate()` with `conn.Auth(nil)` + `conn.Hello()` to create a private peer-to-peer connection for unit tests, no real session bus needed. - Test `OpenSession("plain", ...)` returns an empty variant and a valid session path. - Test `OpenSession("dh-ietf1024-sha256-aes128-cbc-pkcs7", clientPub)` and verify a round-trip encrypt/decrypt. --- ### Phase 2 — Collections: CRUD and property management **Goal**: implement `org.freedesktop.Secret.Collection`, map collections to gopass subpaths under the `secret-service/` prefix. Add `CreateCollection`, `DeleteCollection`, and supporting `SearchItems` / `ReadAlias` / `SetAlias` on the Service. **Files to create/modify**: - `internal/secretservice/collection.go` (new) - `internal/secretservice/store.go` (new — gopass API adapter) - `internal/secretservice/service.go` (extend: CreateCollection, SearchItems, ReadAlias, SetAlias, GetSecrets) **`store.go`** — adapter between Secret Service and gopass API. Do NOT invoke the `gopass` CLI. Use `pkg/gopass/api` directly: ```go //go:build linux package secretservice import ( "context" "github.com/gopasspw/gopass/pkg/gopass/api" ) // Store wraps the gopass API and provides Secret-Service-specific operations. type Store struct { gp *api.Gopass prefix string // default: "secret-service" } func NewStore(ctx context.Context, prefix string) (*Store, error) { gp, err := api.New(ctx) ... } // CollectionPath returns the gopass path for a collection. func (s *Store) CollectionPath(name string) string { return s.prefix + "/" + name } // ItemPath returns the gopass path for an item. func (s *Store) ItemPath(collection, id string) string { return s.prefix + "/" + collection + "/i" + id } // MetaPath returns the gopass path for a collection's metadata. func (s *Store) MetaPath(collection string) string { return s.prefix + "/" + collection + "/_meta" } // AliasPath returns the gopass path for the aliases map. func (s *Store) AliasPath() string { return s.prefix + "/_aliases" } ``` **Collection metadata** is stored as a gopass secret at `MetaPath`: ``` --- label: Personal created: 2026-01-15T10:30:00Z modified: 2026-01-15T10:30:00Z locked: false ``` **Collection aliases** are stored at `AliasPath` in JSON on the first line: ```json {"default":"default","login":"default"} ``` **`collection.go`** key methods to implement: ```go // CreateItem, SearchItems, Delete // Properties: Items (ao), Label (s), Locked (b), Created (t), Modified (t) ``` D-Bus property access via `org.freedesktop.DBus.Properties.Get/Set/GetAll`. Use `godbus/dbus/v5`'s `prop` subpackage for property management. **Signal emission** (required by spec): - `Collection.ItemCreated(item: o)` - `Collection.ItemDeleted(item: o)` - `Collection.ItemChanged(item: o)` - `Service.CollectionCreated(collection: o)` - `Service.CollectionDeleted(collection: o)` - `Service.CollectionChanged(collection: o)` **Testing approach for Phase 2**: - Mock `Store` using an in-memory map (no real gopass/GPG needed for tests). - Test `CreateCollection` → verify gopass path created and D-Bus object exported. - Test `ReadAlias("default")` before and after creating collections. --- ### Phase 3 — Items: GetSecret, SetSecret, Delete, Search **Goal**: implement `org.freedesktop.Secret.Item`. Full item CRUD with attribute-based search. **Files to create/modify**: - `internal/secretservice/item.go` (new) - `internal/secretservice/store.go` (extend: item read/write/delete/search) - `internal/secretservice/collection.go` (extend: CreateItem, SearchItems) **Item storage format** (in gopass secret first-line + YAML): ``` secret-value-here --- _ss_label: GitHub Token _ss_created: 2026-05-01T12:00:00Z _ss_modified: 2026-05-01T12:00:00Z _ss_content_type: text/plain; charset=utf-8 username: octocat server: github.com ``` Rules: - Keys prefixed with `_ss_` are reserved for internal use. - All other key/value pairs are item attributes (arbitrary strings, per spec). - The secret value is the **first line** of the gopass secret (the standard gopass password field). **`item.go`** key methods: ```go // GetSecret(session: o) → secret: Secret // SetSecret(secret: Secret) → nothing // Delete() → prompt: o (return "/" as prompt path for immediate completion) // Properties: Locked (b), Attributes (a{ss}), Label (s), Created (t), Modified (t) ``` **Store item search**: ```go // SearchItems(attrs map[string]string) ([]dbus.ObjectPath, error) // Lists all items in a collection, loads each item's attributes, filters by attrs. // This is O(n) — acceptable given typical collection sizes. ``` **Locking**: In this implementation lock state is **in-memory only**. When a collection is "locked", `GetSecret` returns `ErrIsLocked`. The underlying GPG file is always accessible if the GPG agent has a cached key. **Testing approach for Phase 3**: - Create item, verify it appears in `Items` property of collection. - `SearchItems` with matching/non-matching attributes. - Round-trip: `SetSecret(plain)` → `GetSecret(plain)` → verify value. - Round-trip: `SetSecret(dh)` → `GetSecret(dh)` → verify value. - Delete item → verify gone from `Items` and gopass store. --- ### Phase 4 — Prompt, Lock/Unlock, GetSecrets (batch) **Goal**: complete the spec. Implement `Prompt` objects, `Service.Lock`, `Service.Unlock`, `Service.GetSecrets`. **Files to create/modify**: - `internal/secretservice/prompt.go` (new) - `internal/secretservice/service.go` (extend: Lock, Unlock, GetSecrets) **Prompts**: The spec uses `Prompt` objects for operations that may require user interaction. For Lock/Unlock in this implementation, the prompt completes immediately (no actual user interaction needed because GPG-agent handles passphrase caching). The pattern: ```go // Unlock(objects []dbus.ObjectPath) → (unlocked []dbus.ObjectPath, prompt dbus.ObjectPath) // Returns prompt "/" (null prompt) when all objects are already unlocked. // Returns a real prompt path when any object needs unlocking; the prompt // signals Completed(dismissed bool, result dbus.Variant) when done. type Prompt struct { path dbus.ObjectPath conn *dbus.Conn action func() ([]dbus.ObjectPath, error) } // Prompt.Dismiss() aborts the operation. // For an immediate-complete prompt: export the object, fire Completed signal in a goroutine. ``` **`Service.GetSecrets`**: ```go // GetSecrets(items []dbus.ObjectPath, session dbus.ObjectPath) → map[dbus.ObjectPath]Secret // Batch retrieval. For each path, resolve the item and call its GetSecret logic. ``` **Testing approach for Phase 4**: - Lock collection, verify `GetSecret` returns `ErrIsLocked`. - Unlock collection → prompt completes → verify `GetSecret` succeeds. - `GetSecrets` with mixed locked/unlocked items. - Prompt dismiss returns correct `dismissed=true`. --- ### Phase 5 — CLI integration: `gopass secret-service` **Goal**: wire everything into the gopass CLI as a new subcommand. **Files to create/modify**: - `internal/action/secretservice_linux.go` (new) - `internal/action/secretservice_other.go` (new — non-Linux stub) - `internal/action/commands.go` (add entry point, with build constraints) - `contrib/secret-service/org.freedesktop.secrets.service` (new — D-Bus activation) - `contrib/secret-service/gopass-secret-service.service` (new — systemd user unit) - `docs/commands/secret-service.md` (new — user documentation; expand separately) **CLI design**: ``` gopass secret-service serve [--replace] [--prefix=secret-service] [--notify-on-access] gopass secret-service install # installs systemd unit + D-Bus activation file gopass secret-service uninstall # removes systemd unit + D-Bus activation file gopass secret-service status # checks whether the service is running ``` **`secretservice_linux.go`** action handler: ```go //go:build linux package action import ( "context" "github.com/gopasspw/gopass/internal/secretservice" "github.com/urfave/cli/v3" ) func (s *Action) SecretService(ctx context.Context, cmd *cli.Command) error { prefix := cmd.String("prefix") replace := cmd.Bool("replace") svc, err := secretservice.New(ctx, secretservice.Config{ Prefix: prefix, Replace: replace, }) if err != nil { return err } return svc.Serve(ctx) // blocks until ctx is cancelled or fatal error } ``` **`secretservice_other.go`** stub (for Windows/macOS): ```go //go:build !linux package action import ( "context" "fmt" "github.com/urfave/cli/v3" ) func (s *Action) SecretService(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("secret-service is only supported on Linux") } ``` **`commands.go`** entry** — add to `GetCommands()`: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` **D-Bus activation file** (`org.freedesktop.secrets.service`): ```ini [D-BUS Service] Name=org.freedesktop.secrets Exec=/usr/bin/gopass secret-service serve ``` **systemd user unit** (`gopass-secret-service.service`): ```ini [Unit] Description=gopass Secret Service D-Bus daemon After=graphical-session.target PartOf=graphical-session.target [Service] Type=dbus BusName=org.freedesktop.secrets ExecStart=/usr/bin/gopass secret-service serve Restart=on-failure [Install] WantedBy=graphical-session.target ``` **GNOME Keyring conflict resolution**: `gopass secret-service serve --replace` passes `dbus.NameFlagReplaceExisting` to `conn.RequestName(...)`. Users must also disable GNOME Keyring's secret service component: ```sh cp /etc/xdg/autostart/gnome-keyring-secrets.desktop ~/.config/autostart/ echo "Hidden=true" >> ~/.config/autostart/gnome-keyring-secrets.desktop ``` The `install` subcommand should offer to do this automatically. --- ### Phase 6 — Tests and Documentation (expand in a separate prompt) **Goal**: integration tests, user documentation, and `make test-integration` compatibility. **Files to create/modify**: - `internal/secretservice/*_test.go` — expand unit tests - `tests/secret_service_test.go` — new integration test using `gptest` - `docs/commands/secret-service.md` — user-facing documentation **Integration test approach**: ```go // tests/secret_service_test.go // Uses gptest.NewGUnitTester to set up a real gopass store. // Starts the service on a private D-Bus connection (dbus.SessionBusPrivate). // Uses secret-tool (if available) or direct godbus calls to store/retrieve secrets. // Verifies: // - secret created via D-Bus appears in gopass (gopass show secret-service/default/i) // - secret created via gopass insert is visible via D-Bus GetSecret // - attributes are searched correctly by SearchItems ``` **Known caveats to document**: 1. **GPG circular dependency**: If `pinentry-gnome3` tries to check libsecret for cached passphrases at startup, a deadlock occurs. Solution: add `no-allow-external-cache` to `~/.gnupg/gpg-agent.conf`. Document this prominently (see nikicat/gopass-secret-service#troubleshooting). 2. **Lock state**: Lock/Unlock are in-memory only. Locking a collection does not evict GPG agent's cached passphrase. 3. **Linux only**: Ensure the subcommand entry in `commands.go` still compiles on all platforms (use the `secretservice_other.go` stub pattern). 4. **Session bus required**: `DBUS_SESSION_BUS_ADDRESS` must be set. The daemon should fail gracefully with a clear error if it is not. --- ## Key Implementation Notes for LLM Agents The following are common pitfalls to avoid: 1. **DH shared-secret left-padding**: `shared = clientPub^serverPriv mod p` may produce a `big.Int` whose byte representation is shorter than 128 bytes. It must be left-padded with zero bytes to exactly 128 bytes before SHA256. Failing to do this breaks compatibility with libsecret clients. See commit c781717 in grimsteel/pass-secret-service. 2. **godbus export pattern**: To export a Go struct as a D-Bus object, use `conn.Export(obj, path, iface)`. The exported methods must have the exact signature `func(args...) (returns..., *dbus.Error)`. The D-Bus method names are the Go method names exactly. Use `introspect.NewIntrospectable` to expose introspection. 3. **D-Bus properties**: Use the `prop` subpackage from `godbus/dbus/v5/prop` for the `org.freedesktop.DBus.Properties` interface. Mandatory for libsecret compatibility. 4. **Null prompts**: When an operation completes immediately (no user interaction needed), return `dbus.ObjectPath("/")` as the prompt path per spec §6. 5. **Object lifecycle**: When an item/collection is deleted, call `conn.Export(nil, path, iface)` to unexport the D-Bus object. 6. **`Replace` flag**: `conn.RequestName(ServiceName, dbus.NameFlagReplaceExisting)` asks the bus to evict the current holder. Without this flag, the second `gopass secret-service serve` invocation quietly fails to acquire the name. 7. **`_meta` and `_aliases` naming**: These use underscore prefix to avoid collisions with user-created secrets. In the `SearchItems` listing loop, always skip paths ending in `/_meta` and `/_aliases`. 8. **Build tags**: Every file in `internal/secretservice/` must start with `//go:build linux`. The action shims in `internal/action/` use the `_linux.go` / `_other.go` filename convention (Go's implicit build tag from filename suffix) which is equivalent, and consistent with `notify_dbus.go` and `unclip_linux.go` in the existing codebase. 9. **gopass API vs CLI**: Use `pkg/gopass/api.New(ctx)` (the public Go API), NOT `exec.Command("gopass", ...)`. The API is already used by gopass integrations and is stable enough for this purpose. 10. **Error wrapping**: D-Bus methods must return `*dbus.Error`, not `error`. Map internal errors to the appropriate `org.freedesktop.Secret.Error.*` D-Bus errors defined in `errors.go`. --- ## References - [Secret Service Spec (latest)](https://specifications.freedesktop.org/secret-service/latest/) - [godbus/dbus/v5 docs](https://pkg.go.dev/github.com/godbus/dbus/v5) - [nikicat/gopass-secret-service](https://github.com/nikicat/gopass-secret-service) — Go reference impl (MIT) - [grimsteel/pass-secret-service](https://github.com/grimsteel/pass-secret-service) — Rust reference impl (GPL-3.0, for reference only, not to be copied) - [RFC 3526 — MODP DH groups](https://www.rfc-editor.org/rfc/rfc3526) — 1024-bit group 2 - [pkg/gopass/api/api.go](../../pkg/gopass/api/api.go) — gopass public API - [internal/notify/notify_dbus.go](../../internal/notify/notify_dbus.go) — existing godbus usage pattern - [pkg/clipboard/unclip_linux.go](../../pkg/clipboard/unclip_linux.go) — existing Linux-only D-Bus pattern --- ### Adr/A 12 Pkg Api Stability # A-12: `pkg/gopass` API Stability Contract **Status:** accepted **Source:** [GitHub Issue #3414](https://github.com/gopasspw/gopass/issues/3414) --- ## Background `ARCHITECTURE.md` (lines 37–43) documents that gopass applies semantic versioning to the CLI tool only, not the Go module. `pkg/gopass` is the documented integration point for external consumers (gopass-hibp, gopass-jsonapi, git-credential-gopass, gopass-summon-provider, and others). There is currently no documented contract for what constitutes a breaking change or how consumers will be notified. A breaking change to `pkg/gopass.Store`, `pkg/gopass.Secret`, or related types can affect consumers silently — the module version does not increment, no changelog entry was mandated, and `go get` would pull the break without warning. --- ## Decision Implement **Options B and C**. They are complementary and together give consumers both runtime/source-level signals (C) and a reliable notification channel (B). Option A (full module semver / v2 path) is deferred — the current team size makes the coordination overhead infeasible. Option D (separate repository) is deferred for the same reasons. --- ## Option B — "Best-effort stable" policy with mandatory changelog tags ### Policy `pkg/gopass` and its sub-packages are declared **best-effort stable**: * Additive changes (new exported symbols, new optional parameters via functional options) may appear in any release without prior notice. * **Breaking changes** (removal or signature change of an exported symbol, change of error semantics, change of an interface method set) require: 1. A `PKG-BREAK:` footer on the commit, which produces a `[PKG-BREAK]`-prefixed entry in the `## [Unreleased]` section of `CHANGELOG.md` describing what changed and how consumers should migrate. 2. A minimum deprecation window of **two minor releases or three months**, whichever is longer, between the first deprecation notice and removal. During this window the old symbol must remain available (possibly with a `// Deprecated:` GoDoc comment pointing to the replacement). ### Changelog tag convention A breaking change to `pkg/` is declared with a `PKG-BREAK:` commit footer: ``` refactor(pkg/gopass): drop Store.GetRevision in favour of Store.History Deprecated since 1.17.0; the two-minor / three-month window has elapsed. PKG-BREAK: pkg/gopass: Remove Store.GetRevision — use Store.History instead Signed-off-by: Your Name ``` `helpers/commitmsg` reads that footer and `helpers/release` renders the entry with a `[PKG-BREAK]` prefix inside the appropriate Keep a Changelog subsection, normally `Changed`. A `Changelog-Section: Removed` footer moves it to `Removed` when the symbol is gone rather than changed. The footer is the machine-readable form of the tag. Writing `[PKG-BREAK]` by hand into `## [Unreleased]` also works and is preserved by the release helper. Note: `CHANGELOG.md` now follows Keep a Changelog 1.1.0, so `[SECURITY]`, `[BUGFIX]` and `[FEATURE]` are no longer tags — they are the `Security`, `Fixed` and `Added` subsections. `[PKG-BREAK]` remains a bullet prefix because it qualifies an entry rather than categorising it. See [docs/conventions.md](../conventions.md). ### Enforcement * Code reviewers must reject PRs that remove or change exported `pkg/` symbols without a corresponding `[PKG-BREAK]` changelog entry and a prior deprecation notice. * The `golangci-lint` `godot` and `godox` rules already catch missing doc-comment periods and stray TODO/FIXME markers; no additional tooling is introduced. --- ## Option C — Explicit stability annotations in package doc comments Each package under `pkg/gopass/` carries a doc comment that states its stability level. | Package | Level | Rationale | |---------|-------|-----------| | `pkg/gopass` | **best-effort stable** | Core interfaces; multiple known consumers | | `pkg/gopass/api` | **best-effort stable** | Primary API implementation | | `pkg/gopass/secrets` | **best-effort stable** | Secret types consumed by integrations | | `pkg/gopass/apimock` | **testing helper — no stability guarantee** | Internal test double; consumers should copy or vendor it | The standard `// Deprecated:` GoDoc convention is used to signal pending removal of individual symbols; `godoc` and `pkg.go.dev` render these prominently. --- ## Affected packages `pkg/gopass/`, `pkg/gopass/api/`, `pkg/gopass/apimock/`, `pkg/gopass/secrets/` --- ## Consequences * External integrators get a documented, reasonable stability promise without requiring a module-path change today. * The changelog `[PKG-BREAK]` tag gives integrators a single place to scan for migration work when upgrading. * The deprecation window gives integrators time to adapt before a symbol is removed. * The policy can be upgraded to full module semver in the future if maintainer capacity grows, without breaking any existing convention. --- ### Adr/A 13 Expired Gpg Key Handling # A-13: Expired GPG Key Handling and Recipient Validity Warnings **Status:** partially implemented — core silent-drop warning shipped; remaining work tracked below **Source:** [GitHub Issue #2885](https://github.com/gopasspw/gopass/issues/2885) --- ## Background When a recipient's GPG key expires, gopass silently drops that recipient from the encryption target list. Secrets written after the key expires can no longer be decrypted by that recipient. Neither the writing user nor the affected recipient receives any notification that this happened. The encryption path is: ``` Set() → useableKeys() → FindRecipients() → [expired key silently filtered] → Encrypt(filtered_list) [warning in Encrypt() never fires] ``` `FindRecipients()` calls `KeyList.UseableKeys()`, which returns only keys whose `ExpirationDate` is either zero or in the future. The difference between the original recipient list and the returned key list was never surfaced to the user. An existing `CheckRecipients()` function in `internal/store/leaf/recipients.go` already performs the correct per-recipient check and is called before `RecipientsAdd`, but was not called on the write path. --- ## Implemented fix (this branch) `useableKeys()` in `internal/store/leaf/store.go` now iterates over the original recipient list and, for each recipient that `FindRecipients` returns no useable key for, emits an `out.Warningf` message naming that recipient. The return value (the filtered key list used for encryption) is unchanged. `Encrypt()` in `internal/backend/crypto/gpg/cli/encrypt.go` was also updated to use `out.Warningf` instead of `out.Printf` for its own per-recipient check, so the severity is correct if that path is ever reached. A regression test (`TestSetWarnsAboutInvalidRecipient`) was added to `internal/store/leaf/write_test.go`. --- ## Remaining work ### R-1: Add recipient key-expiry check to `gopass audit` **Problem:** `gopass audit` checks only password strength (crunchy, HIBP). It does not check whether any recipient's key is expired or about to expire. A team could run `gopass audit` regularly and still have no indication that a re-encryption silently excluded a recipient. **Recommendation:** Add a recipient-validity check to `internal/audit/`. The `Auditor` type already has a `secretGetter` interface; a separate `RecipientAuditor` (or an additional pass in the existing `Batch` method) could: 1. Collect the union of all recipient IDs across all secrets (or from the `.gpg-id` files) without decrypting anything. 2. Call `crypto.FindRecipients(ctx, id)` for each one. 3. Report any ID with no useable key as an error and any ID whose `ExpirationDate` is within a configurable window (default 60 days) as a warning. This requires plumbing a `Crypto` backend reference into the audit path, which the current `secretGetter` interface does not expose. A separate interface or an `Auditor` constructor parameter is the cleanest extension point. **Configuration key (proposed):** `audit.recipient-expiry-warning-days` (default `60`; `0` disables the check). --- ### R-2: Proactive expiry warning on sync and fsck **Problem:** Users learn that a key has expired only when they attempt to write a secret. There is no early warning before expiry, and no warning to the affected recipient when they pull a store that now contains secrets they cannot decrypt. **Recommendation:** - In `gopass sync` (`internal/action/sync.go`) and `gopass fsck` (`internal/store/leaf/fsck.go`), call a lightweight `CheckRecipientExpiry(ctx, warningDays int)` helper (to be added to `internal/store/leaf/recipients.go`) that: 1. Loads all recipient IDs from the store's `.gpg-id` files. 2. Looks up each key via `FindRecipients`. 3. For keys that are already expired: `out.Warningf`. 4. For keys expiring within `warningDays`: `out.Warningf` including the expiry date and the recovery instructions (see R-3). - The check must be cheap: it reads only the local keyring and `.gpg-id` files; no network calls or decryption. **Configuration key (proposed):** `core.recipient-expiry-warning-days` (default `60`; `0` disables). Distinct from the audit key so that the two features can be tuned independently. --- ### R-3: Document the key-refresh recovery flow **Problem:** The recovery path when a key has expired is not obvious. The issue reporter required a multi-step manual process. A simpler path already exists but is undocumented: 1. Recipient L extends the key expiry locally: `gpg --edit-key ` → `expire`. 2. L exports the updated key: `gpg -a --export > L.pub.asc`. 3. L replaces `.public-keys/` in the store with the new export, commits, and pushes. 4. Any other recipient A runs `gopass recipients add ` (the existing `AddRecipient` handler already asks for confirmation to re-encrypt when the key is already in the store). Step 4 is the re-encryption trigger. Gopass already supports this workflow; it just needs to be documented. **Recommendation:** - Add a section "Recipient key expiry" to `docs/commands/recipients.md` describing the flow above. - Update the warning message emitted by R-1/R-2 to include a short hint, e.g.: `"Run 'gopass recipients add ' after the key is refreshed to re-encrypt."`. --- ### R-4: Detect that the committed public key differs from the local keyring **Problem:** A recipient may extend their key locally and not update the copy in `.public-keys/`. Conversely, another recipient may import an updated key from `.public-keys/` while the local keyring still has the old (expired) version. In both cases gopass has no way to tell the user that the two copies are out of sync. **Recommendation:** In `gopass fsck` or `gopass recipients`, compare the `ExpirationDate` of the key stored in `.public-keys/` against the key in the local GPG keyring. If they differ by more than a negligible delta (suggest: one day), emit a warning. This requires parsing the armored public key from `.public-keys/` without importing it, which can be done with `golang.org/x/crypto/openpgp` or `github.com/ProtonMail/go-crypto/openpgp` (already an indirect dependency via the age backend). Care must be taken not to introduce a new direct dependency on a CGo package; `go-crypto` is pure Go. --- ## Rejected alternatives **Return an error from `Set()` when a recipient is dropped:** This would be a breaking behaviour change. Existing stores that happen to have stale recipient entries (e.g. a team member who has left) would become unwritable. A warning is the correct signal; the operator must decide whether to remove the recipient or refresh the key. **Block writes entirely when any recipient has no useable key:** Same objection as above. A `--strict` flag could be added later if there is demand. **Check every recipient on every read (Get):** Unnecessary overhead; expiry affects the write path only. --- ### Adr/A 14 Team Workflows # A-14: Effortless Team Workflows **Status:** implemented (Stages 0–5 complete, June 2026) **Sources:** [#2762](https://github.com/gopasspw/gopass/issues/2762), [#2620](https://github.com/gopasspw/gopass/issues/2620), [#1430](https://github.com/gopasspw/gopass/issues/1430) **Related:** [ADR A-13](A-13-expired-gpg-key-handling.md), [use cases: team-workflows](../usecases/team-workflows.md) **Implementation:** branch `fix/issue-1430` (covers all three issues) --- ## 1. Context and problem statement gopass is widely used by teams, but the team lifecycle (bootstrap a store, join a team, add/remove members, rotate keys) is fragile. Three long-standing issues share a common root cause: **recipient identity is not canonical**, and several code paths handle the gap inconsistently. The supported workflows are defined in [docs/usecases/team-workflows.md](../usecases/team-workflows.md). This ADR analyses the current implementation against those use cases, identifies the defects, and proposes a staged plan. ### 1.1 How the store represents a team today * `.gpg-id` — newline-separated recipient IDs (the team). * `.public-keys/` — armored public key per recipient, **filename is the recipient ID verbatim**. Legacy fallback dir: `.gpg-keys/`. * Global config `recipients.hash.` — SHA256 of `.gpg-id` for tamper detection (only when `recipients.check` is enabled). ### 1.2 Relevant code map (verified) | Concern | Location | | --- | --- | | Recipient set type | `internal/recipients/recipients.go` (`Add` only `TrimSpace`s — no normalization) | | Init store | `internal/store/leaf/init.go` `Init()` — normalizes via `FindRecipients` → `rs.Add(kl[0])` | | Add recipient | `internal/store/leaf/recipients.go` `AddRecipient()` — adds **raw** `id` (no normalization) | | Remove recipient | `internal/store/leaf/recipients.go` `RemoveRecipient()` — 3-level fuzzy match | | Save recipients | `internal/store/leaf/recipients.go` `saveRecipients()` — writes `.gpg-id`, exports keys if `core.exportkeys`, autopush | | Export keys | `exportPublicKey()` / `addMissingKeys()` / `UpdateExportedPublicKeys()` — filename = `.public-keys/` | | Remove extra keys | `removeExtraKeys()` — **disabled by default** (`recipients.remove-extra-keys`, GH-2620) | | Import keys | `internal/store/leaf/crypto.go` `ImportMissingPublicKeys()`, `recipientCheck()`, `decodePublicKey()`, `getPublicKey()` | | Sync | `internal/action/sync.go` `syncMount()` → `syncImportKeys()` + `syncExportKeys()` | | Clone | `internal/action/clone.go` `Clone()` / `cloneCheckDecryptionKeys()` — exports only the cloner's key | | GPG backend | `internal/backend/crypto/gpg/cli/` `FindRecipients`, `ExportPublicKey`, `ImportPublicKey`, `GetFingerprint`, `ReadNamesFromKey` | --- ## 2. Root-cause analysis ### 2.1 #2762 — ID vs. filename mismatch (the central bug) `Init()` normalizes recipient IDs to the canonical key returned by `FindRecipients` before storing them: ```go // internal/store/leaf/init.go (Init) kl, _ := s.crypto.FindRecipients(ctx, id) rs.Add(kl[0]) // canonical ``` `AddRecipient()` does **not**: ```go // internal/store/leaf/recipients.go (AddRecipient) rs.Add(id) // raw user input: email, short ID, or fingerprint s.saveRecipients(...) ``` `saveRecipients` → `UpdateExportedPublicKeys` → `exportPublicKey` then writes: ```go filename := filepath.Join(keyDir, r) // r == raw id, e.g. "user@example.com" pk, _ := exp.ExportPublicKey(ctx, r) // gpg resolves the email fine s.storage.Set(ctx, filename, pk) // -> .public-keys/user@example.com ``` So after `gopass recipients add user@example.com`: * `.gpg-id` line: `user@example.com` * `.public-keys/` file: `user@example.com` On another machine, `ImportMissingPublicKeys` iterates `.gpg-id` IDs and calls `recipientCheck("user@example.com")` → `FindRecipients("user@example.com")`. If the email is ambiguous, missing, or the local gpg matched a *different* key than the store owner intended, the lookup fails or resolves to the wrong key, producing: ``` Failed to decode public key user@example.address: public key "..." not found ``` The blank-but-numbered line in `gopass recipients` output is the same symptom: an entry in `.gpg-id` that the local keyring cannot resolve to a name. **Fix direction:** make recipient identity canonical at *every* write path, not just `Init`. The `.gpg-id` entry and the `.public-keys/` filename must both be the canonical fingerprint. ### 2.2 #2620 — clone wipes other recipients' public keys Two divergent join paths exist: * `gopass setup --remote ... --alias x` → works. * `gopass clone x` → broke: after clone, a save/re-encrypt ran with only the keys the new member could resolve locally (their own), and the exported public keys ended up reduced to just the new member's key. The reporter in jonmz's comment showed the bug only triggered for members whose **root store was at the new default location**, confirming a path/flow divergence rather than a pure crypto bug. `removeExtraKeys()` (which deletes `.public-keys/` files not in the current recipient list) was disabled by default as a band-aid: ```go // TODO(GH-2620): Temporarily disabled by default until we fix the key cleanup. if cfg.GetGlobal("recipients.remove-extra-keys") == "true" { ... } ``` But disabling it only hides one of the deletion paths. The deeper issue is that a member who cannot resolve a recipient locally can still trigger a write that *regenerates* the exported key set from an incomplete view, and then `push` it. **Fix direction:** 1. Unify clone and setup onto one join code path (UC-3). 2. Never regenerate the full `.public-keys/` set from a partial local view; only *add* the member's own key and import others *from* the store. 3. Re-enable controlled, recipient-driven cleanup tied to explicit `recipients remove` only (UC-5), never as a side effect of sync/clone. ### 2.3 #1430 — no key-refresh path; expired keys not updated * `core.autoimport` and the import prompt only import keys that are **missing** from the keyring; they never **update** an existing-but-expired key. * `core.exportkeys` only exports keys that are **missing** from `.public-keys/` (`exportPublicKey` returns early if the file exists and `IsPubkeyUpdate(ctx)` is false). * There is no command to push a refreshed local key into the store. So when L extends an expired key, neither the store copy nor other members' keyrings ever update automatically. **Fix direction:** add a `gopass recipients update` command (UC-6) plus "update if newer/expired" semantics for both export and import, gated to avoid surprise overwrites. This dovetails with ADR A-13's expiry warnings. --- ## 3. Decision: a canonical-recipient model + unified join + key refresh We will make **canonical recipient identity** the backbone, then fix the three workflows on top of it. The work is staged so each stage is shippable and testable on its own. ### 3.1 Guiding invariants (the contract) For every store and every recipient `R`: 1. The `.gpg-id` entry for `R` equals the canonical ID (full fingerprint for GPG; the recipient string for age). 2. `.public-keys/` exists and contains `R`'s key (when `core.exportkeys` is on). 3. No member operation removes `R` from `.gpg-id` or `.public-keys/` unless the operator explicitly runs `recipients remove R`. 4. Sync only ever *adds* exported keys or *updates* an outdated one; it never deletes. --- ## 4. Implementation plan (staged) ### Stage 0 — Safety net (tests + diagnostics), no behavior change Goal: lock current behavior and make the bugs observable before changing code. * Add integration tests under `tests/` that reproduce each issue: * `tests/team_join_test.go` — clone-as-new-member must preserve all `.public-keys/` (red test for #2620). * `tests/recipients_email_test.go` — `recipients add ` then import on a second keyring (red test for #2762). * `tests/recipients_refresh_test.go` — refresh an expired key (red test for #1430). * Add a `gopass fsck`/`gopass doctor` diagnostic that reports recipient/key inconsistencies (see Stage 4). Read-only; safe to ship first. Pseudocode for the diagnostic (no decryption, local only): ```go func (s *Store) DiagnoseRecipients(ctx) []Finding { rs := s.GetRecipients(ctx, "") for _, id := range rs.IDs() { canonical := s.crypto.FindRecipients(ctx, id) // 0, 1, or many switch { case len(canonical) == 0 && !s.publicKeyFileExists(id): finding(Error, "%s: no key in keyring and not in .public-keys", id) case len(canonical) == 0: finding(Warn, "%s: only available via .public-keys (run import)", id) case canonical[0] != id: finding(Warn, "%s stored non-canonically; canonical is %s", id, canonical[0]) } // expiry check (A-13 R-4): compare .public-keys key vs keyring key } } ``` ### Stage 1 — Canonicalize recipient identity (fixes #2762) **1a. Normalize on add.** In `AddRecipient`, resolve the input to the canonical ID before storing, mirroring `Init`: ```go func (s *Store) AddRecipient(ctx, id string) error { canon, err := s.canonicalizeRecipient(ctx, id) // FindRecipients -> fingerprint if err != nil { return err } // ask user if ambiguous (>1) rs := s.GetRecipients(ctx, "") if rs.Has(canon) { /* re-encrypt/update path */ } rs.Add(canon) ... } ``` `canonicalizeRecipient`: ```go func (s *Store) canonicalizeRecipient(ctx, id string) (string, error) { kl, err := s.crypto.FindRecipients(ctx, id) switch { case len(kl) == 1: return kl[0], nil case len(kl) == 0: // fall back to .public-keys: read key, get fingerprint, that's canonical if pk, e := s.getPublicKey(ctx, id); e == nil { return s.crypto.GetFingerprint(ctx, pk) } return "", fmt.Errorf("no key found for %q", id) default: // ambiguous: prompt the user to pick one return askWhichKey(ctx, kl) } } ``` **1b. Canonical export filename.** `exportPublicKey` must derive the filename from the canonical ID, not the raw recipient string. Since 1a guarantees `.gpg-id` holds canonical IDs, the existing `filepath.Join(keyDir, r)` becomes correct automatically. Add a guard/assertion + debug log if `r` is non-canonical. **1c. Migration for existing stores.** Provide `gopass recipients canonicalize` (or fold into `gopass fsck --recipients`) that, for a store the operator can decrypt: ```text for each id in .gpg-id: canon = canonicalize(id) if canon != id: rename .public-keys/ -> .public-keys/ (if present) rewrite .gpg-id line id -> canon commit "Canonicalize recipient IDs" ``` Must be explicit and confirmed; it rewrites `.gpg-id` and therefore changes `recipients.hash`. **Backward compatibility:** lookups (`getPublicKey`, `decodePublicKey`, `RemoveRecipient`) keep their fuzzy fallback so old non-canonical stores still work read-side until migrated. Only the *write* paths become strict. ### Stage 2 — Unify and harden the join/clone flow (fixes #2620) **2a. Single join code path.** Extract the post-clone/post-setup logic into one function used by both `clone.go` and `setup.go`: ```go func joinTeam(ctx, sub *leaf.Store) error { // 1. import every recipient key that the store already ships sub.ImportMissingPublicKeys(ctx) // never deletes // 2. can we decrypt? if hasDecryptionKey(ctx, sub) { out.OK("You can decrypt this store.") return nil } // 3. no access yet: export ONLY our own key, additively self := crypto.ListIdentities(ctx)[0] sub.ExportSelfPublicKey(ctx, self) // adds .public-keys/, no removal out.Notice("Request access: ask an owner to run 'gopass recipients add %s'", self) // 4. push the added key } ``` **2b. Make `UpdateExportedPublicKeys` strictly additive on the sync/clone paths.** It must never call `removeExtraKeys` outside an explicit `recipients remove`. The blanket `recipients.remove-extra-keys` global flag is removed; cleanup moves to Stage 3 where it is recipient-scoped. **2c. Guard against partial-view writes.** Before any path *re-encrypts* or *regenerates* the exported key set, require that the operator can resolve all current recipients (keyring **or** `.public-keys/`). If some recipients are unresolved, gopass imports them from `.public-keys/` first; if that fails, it **refuses to rewrite** the key set and prints actionable guidance instead of silently pushing a reduced set. **2d. Path independence.** Add a test matrix covering root store at `~/.password-store` and at the new default location to ensure the join flow is identical (covers jonmz's reproduction). ### Stage 3 — Clean removal (completes #2620, supports UC-5) `RemoveRecipient` becomes the *only* writer that deletes from `.public-keys/`: ```go func (s *Store) RemoveRecipient(ctx, id string) error { canon := s.matchRecipient(ctx, id) // existing fuzzy match, returns canonical rs.Remove(canon) if rs.Len() == 0 { return errLastRecipient } s.saveRecipients(ctx, rs, "Remove Recipient "+canon) // recipient-scoped cleanup (NOT blanket): s.storage.Delete(ctx, filepath.Join(keyDir, canon)) s.storage.Delete(ctx, filepath.Join(oldKeyDir, canon)) // legacy return s.reencrypt(...) } ``` This restores key cleanup safely: only the explicitly removed recipient's file is deleted, never an unrelated one. The disabled `removeExtraKeys` path and its global flag are deleted. ### Stage 4 — Key refresh and expiry handling (fixes #1430, ties to A-13) **4a. New command `gopass recipients update [ ...]`.** Re-exports the named recipients' (default: own) current public keys from the local keyring into `.public-keys/`, overwriting stale copies, and commits: ```go func (s *Store) UpdateRecipientKeys(ctx, ids ...string) error { ctx = WithPubkeyUpdate(ctx, true) // force overwrite in exportPublicKey for _, id := range ids { canon := s.canonicalizeRecipient(ctx, id) s.exportPublicKey(ctx, exp, canon) // overwrites because IsPubkeyUpdate(ctx) } commit("Refreshed public keys") } ``` **4b. "Update if newer/expired" on import.** Extend `recipientCheck` so a key already in the keyring is still re-imported when the `.public-keys/` copy is newer or the keyring copy is expired: ```go func (s *Store) recipientCheck(ctx, r string) bool { kl := s.crypto.FindRecipients(ctx, r) if len(kl) == 0 { return false } // missing -> import storeKey := s.getPublicKey(ctx, r) // .public-keys copy if keyringExpired(kl[0]) && !parsedKeyExpired(storeKey) { return false // outdated -> import update } if newerThanKeyring(storeKey) { return false } // refreshed -> import update return true } ``` Importing an update still respects `core.autoimport` / the interactive prompt; the prompt text is adjusted to say "update" when a key already exists. **4c. Expiry warnings.** Implement ADR A-13 R-1..R-4 (audit check, sync/fsck warnings, recovery docs, store-vs-keyring drift detection) and have the warning message point at `gopass recipients update`. ### Stage 5 — Documentation and UX * `docs/commands/recipients.md`: document `add`, `remove`, `update`, canonicalize/migration, and the expiry recovery flow. * `docs/usecases/team-workflows.md` (added in this change) is the reference. * Improve the messages emitted on join ("request access"), on add ("confirm this key", "imported from store"), and on sync (clearer key import/export reporting). --- ## 5. Configuration summary | Key | Default | Effect | Change | | --- | --- | --- | --- | | `core.exportkeys` | true (root) / false (substore) | export recipient keys to `.public-keys/` | keep; document the substore default which surprises users (#2620) — consider defaulting **true** for substores too | | `core.autoimport` | false | import (now also *update*) keys without prompt | semantics extended (Stage 4b) | | `recipients.check` | false | validate `.gpg-id` against `recipients.hash` | keep | | `recipients.remove-extra-keys` | false (global only) | blanket cleanup of `.public-keys/` | **remove** (replaced by recipient-scoped removal, Stage 3) | Open question: flip `core.exportkeys` to default `true` for substores. It makes team substores work out of the box (most #2620 reporters used substores) at the cost of slightly larger repos. Recommended: yes, with a release note. --- ## 6. Backwards compatibility & migration * Read paths keep fuzzy matching, so existing non-canonical stores keep working. * Write paths become strict (canonical). The first owner-side `recipients add`/ `remove` on an old store will write canonical IDs for the touched recipient; full migration is via the explicit `recipients canonicalize`/`fsck` command. * `recipients.hash` changes when `.gpg-id` is rewritten by migration; `gopass recipients ack` already handles acknowledging a new hash. * Removing the `recipients.remove-extra-keys` flag is safe because it defaulted to off. --- ## 7. Testing strategy * Unit tests in `internal/store/leaf` for canonicalization, additive export, scoped removal, and the extended `recipientCheck`. * Integration tests in `tests/` (GPG-backed via `gptest`) for the full lifecycle UC-1..UC-7, including the three regression scenarios and the root-store-path matrix. * `make test`, `make codequality`, and `make test-integration` must pass. --- ## 8. Rejected / deferred alternatives * **Store emails in `.gpg-id` and rely on per-machine resolution.** This is the status quo and the source of #2762; rejected. * **Re-enable blanket `removeExtraKeys` with smarter heuristics.** Any blanket cleanup driven by a partial local view risks #2620 again; rejected in favor of recipient-scoped deletion on explicit removal. * **Auto-rotate secrets on member removal.** Out of scope; revocation is documented as non-retroactive. A future `gopass audit --rotate-after-removal` could help and is deferred. * **A central team-membership manifest / server.** Out of scope; gopass stays decentralized and relies on git hosting for transport-level access control. --- ## 9. Sequencing / dependencies ```mermaid flowchart TD S0[Stage 0: tests + diagnostics] --> S1[Stage 1: canonical IDs #2762] S1 --> S2[Stage 2: unified join #2620] S2 --> S3[Stage 3: scoped removal] S1 --> S4[Stage 4: key refresh #1430 + A-13] S3 --> S5[Stage 5: docs/UX] S4 --> S5 ``` Stage 1 is the keystone; Stages 2–4 depend on canonical identity to be robust. --- ### Adr/A 15 Screenshot Build Tag # A-15: `noscreenshot` Build Tag for OTP Screen-Capture Feature **Status:** accepted **Source:** [GitHub Issue #3415](https://github.com/gopasspw/gopass/issues/3415) --- ## Background `pkg/otp/screenshot_supported.go` imports `github.com/kbinani/screenshot` to capture display contents when the user runs `gopass otp --snip`. This allows gopass to locate an OTP QR code that is visible on screen and store the decoded `otpauth://` URL directly into a secret. `screenshot` is a notable dependency for a password manager because it grants the binary the ability to read the full screen. Users auditing binary capabilities or operating in policy-restricted environments (e.g., enterprise security reviews, MDM policies) may need to understand this surface or opt out of it entirely at compile time. ### Audit findings * **Which function?** `pkg/otp.ParseScreen` (implemented in `screenshot_supported.go`) is the sole caller of the `kbinani/screenshot` API. * **Caller?** `internal/action.(*otpHandler).OTP` in `internal/action/otp.go`, guarded by `if snip { … }`. * **User-visible trigger?** The `--snip` / `-s` flag on `gopass otp`. Screen capture **cannot** be triggered without the user explicitly passing this flag. * **Affected packages?** `pkg/passkey` does **not** use `screenshot`. The issue description was slightly inaccurate on this point. * **Platform scope?** Only compiled on `(arm|arm64|amd64|386) && (linux|windows|(cgo && darwin)|freebsd|netbsd)`. Other platforms already receive a no-op stub. --- ## Decision Implement the `noscreenshot` build tag (Option A below). It is low-risk, additive, and allows enterprise / policy-aware users to produce a binary without the screen-capture surface while keeping the default experience unchanged. --- ## Options considered ### A — Negative opt-out build tag `noscreenshot` ✅ (chosen) Add `&& !noscreenshot` to the existing build constraint in `screenshot_supported.go` and `|| noscreenshot` to the fallback `screenshot_others.go`. ``` go build -tags noscreenshot . ``` **Pros:** * No change to the default build; existing users and CI pipelines are unaffected. * Simple: one tag, two build-constraint lines. * Canonical Go pattern for feature opt-out (mirrors `nomsgpack`, `noasm`, etc.). **Cons:** * The tag name must be documented; there is no automated reminder to keep stubs in sync. ### B — Separate module / package `pkg/otp/screenshot` Move the screenshot logic into a sub-package and make `ParseScreen` a function variable that callers inject. **Pros:** clean separation. **Cons:** more invasive refactor for a minor gain; deferred. ### C — Runtime config flag `otp.screenshot: false` A config option to disable the feature at runtime without recompiling. **Pros:** no special build required. **Cons:** the library is still linked; does not address the "linked capability" concern. --- ## Consequences * `gopass otp --snip` continues to work for all users who build without the tag. * Builds with `-tags noscreenshot` will return `"not supported on your platform"` for `--snip`, consistent with unsupported-platform behaviour. * `github.com/kbinani/screenshot` is absent from the linked binary when the tag is set. * `docs/commands/otp.md` documents the tag and the screen-capture scope. --- ### Adr/README # Architecture Decision Records Each record states one architectural decision, the context it was made in, the options considered, and the outcome. Amend a record only to update its status. Never reuse a number: supersede a decision by writing a new record. Name records `A-NN-.md`, with `NN` zero-padded to two digits. Write the H1 as `# A-NN: `, matching the file name. Open the record with a `**Status:**` line — `proposed`, `accepted`, `deferred`, `implemented`, `partially implemented`, or `superseded by A-NN` — and a `**Source:**` line. Update this index in the same commit that adds or supersedes a record. ## Index | ADR | Title | Status | Added | |---|---|---|---| | [A-03](A-03-separate-storage-rcs.md) | Separate `Storage` and `RCS` interfaces | deferred | 2026-04-06 | | [A-04](A-04-grep-match-error-counters.md) | Fix `grep` match and error counters | open | 2026-04-06 | | [A-05](A-05-template-engine-text-vs-html.md) | Template engine uses `text/template` instead of `html/template` | deferred | 2026-04-06 | | [A-06](A-06-minimum-password-length.md) | Minimum password length enforcement | deferred | 2026-04-06 | | [A-07](A-07-hook-system-dead-code.md) | Hook system dead code and CVE-2023-24055 | deferred | 2026-04-06 | | [A-08](A-08-shred-modern-storage-limitations.md) | Shred operation is ineffective on modern storage | accepted | 2026-04-06 | | [A-09](A-09-low-severity-informational-findings.md) | Low severity and informational findings | accepted | 2026-04-06 | | [A-10](A-10-code-quality-findings.md) | Code quality findings | open | 2026-04-06 | | [A-11](A-11-secret-service.md) | Integrate `org.freedesktop.secrets` D-Bus service | proposed | 2026-05-24 | | [A-12](A-12-pkg-api-stability.md) | `pkg/gopass` API stability contract | accepted | 2026-05-24 | | [A-13](A-13-expired-gpg-key-handling.md) | Expired GPG key handling and recipient validity warnings | partially implemented | 2026-05-25 | | [A-14](A-14-team-workflows.md) | Effortless team workflows | implemented | 2026-06-06 | | [A-15](A-15-screenshot-build-tag.md) | `noscreenshot` build tag for OTP screen-capture feature | accepted | 2026-05-25 | Status values are taken from each record's `**Status:**` line. Dates are the authoring commit dates reported by `git log --diff-filter=A --follow`. ## Reserved and renumbered records **A-01 and A-02 are reserved and have no records.** Both numbers are cited from the `CHANGELOG.md` unreleased section: "Split Action handler into focused handler types (A-1)" and "Replace context-key config system with typed structs (A-2)". No file was written for either. Do not reuse these numbers; the changelog citations would then point at unrelated decisions. **A-15 was renumbered from A-13.** Two records carried the number A-13. `A-13-expired-gpg-key-handling.md` keeps it: it is cited from `docs/commands/recipients.md`, `docs/usecases/team-workflows.md`, and `docs/adr/A-14-team-workflows.md`. The screenshot record had no inbound citations and was renumbered to A-15. **A-03 through A-09 were zero-padded.** Their file names previously used a single digit, which sorts them after A-10 in any lexical listing. ## Unavailable sources `SECURITY_AUDIT_REPORT.md` and `CODE_QUALITY_REPORT.md` are not present in the working tree. Records A-03 through A-10 cite them in their `**Source:**` lines. Both files were removed in commit `77894053` ("Clean up") and are recoverable only from git history. The citations identify which finding each record answers, for example "§ M-4", and are therefore retained. --- ### Backends/Age # age crypto backend The `age` backend is an experimental crypto backend based on [age](https://age-encryption.org). It adds an encrypted keyring on top (using age in scrypt password mode). It also has (largely untested) support for specifying recipients as github users. This will use their ssh public keys for age encryption. It is well positioned to eventually replace `gpg` as the default crypto backend. ## Getting started WARNING: This backend is experimental and the on-disk format likely to change. To start using the `age` backend initialize a new (sub) store with the `--crypto=age` flag: ``` $ gopass age identities add [AGE-... age1...] <if you do not specify an age secret key, you'll be prompted for one> $ gopass init --crypto age ``` or use the wizard that will help you create a new age key: ``` $ gopass setup --crypto age ``` This will automatically create a new age keypair and initialize the new store. Existing stores can be migrated using `gopass convert --crypto age`. N.B. for a fully scripted or **non-interactive setup**, you can use the `GOPASS_AGE_PASSWORD` env variable to set your identity file secret passphrase, and specify the age identity and recipients that should be used for encrypting/decrypting passwords as follows: ``` $ gopass age identities add <AGE-...> <age1...> $ GOPASS_AGE_PASSWORD=mypassword gopass init --crypto age <age1...> ``` Notice the extra space in front of the command to skip most shell's history. You'll need to set your name and username using `git` directly if you're using it as storage backend (the default one). For test automation or other environments where `pinentry` is unavailable, set `GOPASS_AGE_STDIN_PASSPHRASE` to force gopass to read the passphrase from the terminal instead. You can also specify the ssh directory by setting environment variable ``` $ GOPASS_SSH_DIR=/Downloads/new_ssh_dir gopass init --crypto age <age1...> ``` ## Features * Encryption using `age` library, can be decrypted using the `age` CLI * Support for native age, ssh-ed25519 and ssh-rsa recipients * Support for encrypted ssh private keys * Support for using GitHub users' private keys, e.g. `github:user` as recipient * Automatic downloading and caching of SSH keys from GitHub * Encrypted keyring for age keypairs * Support for age plugins * Caching of passphrases via an agent ## Agent The age backend comes with an agent that can cache the passphrases for your age identities. The agent is started automatically by gopass if it's not already running. You can disable the agent by setting `age.agent-enabled` to `false` in your gopass config. The agent performs the decryption and the passphrase never leaves the agent process. The agent listens on a unix socket at `$XDG_RUNTIME_DIR/gopass/gopass-age-agent.sock`. You can interact with the agent using the following commands: - `gopass age agent`: starts the agent in the foreground. - `gopass age lock`: locks the agent, clearing all cached passphrases. ## Usage with a yubikey To use with a Yubikey, `age` requires the usage of the [age-plugin-yubikey plugin](https://github.com/str4d/age-plugin-yubikey/). Assuming you have Rust installed: ```bash $ cargo install age-plugin-yubikey $ age-plugin-yubikey -i <should be empty> $ age-plugin-yubikey ✨ Let's get your YubiKey set up for age! ✨ <follow instructions to setup a PIV slot> $ age-plugin-yubikey -i <should display your PIV slot information now> $ gopass age identities add Enter the age identity starting in AGE-: <paste the `AGE-PLUGIN-YUBIKEY-...` identity from the previous command> Provide the corresponding age recipient starting in age1: <paste the `age1yubikey1...` recipient from the previous command> ``` If gopass tells you `waiting on yubikey plugin...` when decrypting secrets, it probably is waiting for you to touch your Yubikey because you've set a Touch policy when setting up your PIV slot. ## Roadmap The future of this backend largely depends on what is happening in the `age` project itself. Assuming `age` is supporting this, we'd like to: * Finalize GitHub recipient support * Add Hardware token support * Make age the default gopass backend --- ### Backends/Cryptfs # cryptfs storage backend The `cryptfs` backend is an experimental storage backend **PREVIEW**. It hashes secret names and stores the mapping from names to actual file inside an `age` encrypted lookup table. The filesystem backing this storage backend is flexible, but by default uses `gitfs`. **WARNING**: Do not use unless you want to contribute to the development of this backend! --- ### Backends/Fossilfs # `fossilfs` storage backend This is an **EXPERIMENTAL** storage backend that uses the Fossil SCM. It isn't well tested and only exists to provide an example how a non-git backend could look like. --- ### Backends/Fs # fs storage backend The simplest storage backend, often used for testing. It stores data directly in the filesystem without any RCS support. --- ### Backends/Gitfs # `gitfs` storage backend This is the default storage backend. It stores the encrypted data directly in the filesystem. It uses an external git binary to provide history and remote sync operations. gopass configures git to use persistent ssh connections. If you do not want this set `GIT_SSH_COMMAND` to an empty string to override the built-in default. --- ### Backends/Gpg # gpg crypto backend The `gpgcli` backend is the default crypto backend based on the `gpg` CLI. It depends on the GPG installation to be working and having a properly initialized keyring. ## Getting started WARNING: This backend suffers from myriads of different configuration options, a poor scripting interface and not pure-Go libarary bindings being available. To start using the `gpgcli` backend initialize a new (sub) store with the `--crypto=gpgcli` flag: ``` gopass init --crypto gpgcli gopass recipients add 0xDEADBEEF ``` ## Features * Compatible with other password store implementations * Support for all GPG features, like smart-cards or hardware tokens ## Caveats * Using long key sizes (e.g. 4096 bit or longer) can make many operations a lot slower * Some GPG installations don't work well with concurrent operations ## Roadmap This backend is the single most annoying source of maintenance workload in this project. We try to keep this backend working as good as possible but there are a lot of reasons why we'd prefer eventually move beyond GPG. ### GPG Critism This section is a growing list of references why GPG is bad and why you should avoid it. That might sound like an unusual thing to say for the authors of a tool whose main use case relies on GPG but whenever we tried to move beyond GPG we got a lot of backlash. So I guess first we need to try to make use understand why you shouldn't hold on to GPG and by then we'll try to have a replacement ready for you. * [What's the matter with PGP](https://blog.cryptographyengineering.com/2014/08/13/whats-matter-with-pgp/) * [The PGP Problem](https://latacora.micro.blog/2019/07/16/the-pgp-problem.html) * [I'm giving up on PGP](https://blog.filippo.io/giving-up-on-long-term-pgp/) * [GPG and Me](https://moxie.org/2015/02/24/gpg-and-me.html) --- ### Backends/Jjfs # `jjfs` storage backend This is an **EXPERIMENTAL** storage backend that uses the JJ / Git. It isn't well tested and only exists to provide an example how a non-git backend could look like. --- ### Commands/Audit # `audit` command The `audit` command will decrypt all secrets and scan for weak passwords or other common flaws. ## Synopsis ``` $ gopass audit ``` ## Excludes You can exclude certain secrets from the audit by adding a `.gopass-audit-exclude` file to the secret. The file should contain a list of RE2 patters to exclude, one per line. For example: ``` # Lines starting with # are ignored. Trailing comments are not supported. # Exclude all secrets in the pin folder. # Note: These are RE2, not Glob patterns! pin/.* # Literal matches are also valid RE2 patterns test_folder/ignore_this # Gopass internally uses forward slashes as path separators, even on Windows. So no need to escape backslashes. ``` ## Exit codes | Code | Meaning | |-----:|---------| | 0 | No issues found | | 14 | One or more weak passwords or issues detected | See [docs/exit-codes.md](../exit-codes.md) for the full table. ## Password strength backends | Backend | Description | |-------------------------------------------------|------------------------------------------------------------------------| | [`crunchy`](https://github.com/muesli/crunchy) | Crunchy password strength checker | | `name` | Checks if password equals the name of the secret | --- ### Commands/Cat # `cat` command The `cat` command is used to pipe password in and out of STDIN and STDOUT respectively. As it is intended to be used with binary data, it encodes the data-stream to store it. ## Synopsis ```bash $ echo "test" | gopass cat test/new $ gopass cat test/new ``` ## Modes of operation * Create a new entry with data-stream from STDIN * Change an existing entry to data-stream from STDIN * Retrive encoded data from password-store and echo it to STDOUT Cat is intended to work with binary data, so it accepts any kind of stream from STDIN. It reads the binary-stream from STDIN and encodes it Base64 and saves it in the password store encoded, with some metadata about the input-stream and the used encoding (currently only Base64 supported). ### Example ``` $ echo "234" | gopass cat test/new $ gopass show -f test/new Secret: test/new content-disposition: attachment; filename="STDIN" content-transfer-encoding: Base64 MjM0Cg== $ gopass cat test/new 234 ``` ### Differences to `insert` In contrast to `insert` it handles any kind of data-stream from STDIN and encodes it. Drawback: you can not just simply read the password with `gopass show`. ## Flags This command has currently no supported flags except the gopass globals. --- ### Commands/Clone # `clone` command The `clone` command allows cloning and setting up a new password store from a remote location, e.g. a remote git repo. ## Synopsis ``` $ gopass clone git@example.com/store.git $ gopass clone git@example.com/store.git sub/store ``` ## Flags | Flag | Aliases | Description | |------------|---------|-----------------------------------------------------------------| | `--path` | | The path to clone the repo to. | | `--crypto` | | Override the crypto backend to use if the auto-detection fails. | --- ### Commands/Config # `config` command The config command allows displaying and altering configuration options. Note: To manage mounts use `gopass mounts`. ## Synopsis ```bash gopass config gopass config generate.autoclip gopass config generate.autoclip false ``` ## Flags | Flag | Description | |-----------|--------------------------------| | `--store` | Only sync a specific sub store | --- ### Commands/Convert # `convert` command The `convert` command exists to migrate stores between different backend implementations. Note: This command exists to enable a possible migration path. If we agree on a single set of backend implementations the multiple backend support might go away and this command as well. Warning: Converting between different RCS backends will loose part of the history. While we try to retain as much information as possible especially the commit timestamps will be set to the convert time. ## Synopsis ``` $ gopass convert --store=foo --move=true --storage=gitfs --crypto=age $ gopass convert --store=bar --move=false --storage=fs --crypto=plain ``` ## Flags Flag | Description ---- | ----------- `--store` | Substore to convert. `--move` | Remove backup after converting? (default: `false`) `--storage` | Target storage backend. `--crypto` | Target crypto backend. --- ### Commands/Create # `create` command The `create` command creates a new secret using a set of built-in or custom templates. It implements a wizard that guides inexperienced users through the secret creating. The main design goal of this command was to guide users through the creation of a secret and asking for the necessary information to create a reasonable secret location. ## Synopsis ```bash gopass create gopass create --store=foo ``` ## Modes of operation * Create a new secret using a wizard ## Templates `gopass create` will look for files ending in `.yml` in the folder `.gopass/create` inside the selected store (by default the root store). On first run, gopass writes two built-in templates (website login and PIN code) to this folder. You can modify them or add your own alongside them. To add a new template create a YAML file in `.gopass/create/` and commit it: ```bash # open the store directory cd "$(gopass config mounts.path)" mkdir -p .gopass/create $EDITOR .gopass/create/aws.yml git add .gopass/create/aws.yml && git commit -m "Add AWS credential template" ``` ## Template Structure Each template file is a YAML document with the following top-level fields: | Field | Type | Required | Description | |--------------|----------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `name` | string | yes | Human-readable name shown in the wizard's selection menu (e.g., `"Website login"`). | | `priority` | int | no | Sort order in the wizard menu. Lower numbers appear first. Default: `0`. The two built-in templates use priorities `0` (website) and `1` (PIN). | | `prefix` | string | yes | Directory inside the store where the new secret will be saved (e.g., `"websites"` → secret stored under `websites/<name>`). | | `name_from` | []string | no | List of attribute names whose values are joined to form the secret's file name. If empty, the user is prompted for a path. Values are sanitised with `CleanFilename`. | | `welcome` | string | no | Message printed at the start of the wizard for this template. Supports Unicode/emoji. | | `attributes` | list | yes | Ordered list of attribute definitions (see [Attribute Fields](#attribute-fields) below). | Example skeleton: ```yaml --- priority: 5 name: "AWS" prefix: "aws" name_from: - "org" - "user" welcome: "🧪 Creating AWS credentials" attributes: - name: "org" type: "string" prompt: "Organization" min: 1 - name: "user" type: "string" prompt: "User" min: 1 - name: "password" type: "password" prompt: "Password" charset: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*" min: 10 strict: true - name: "comment" type: "string" prompt: "Comments" ``` ## Attribute Fields Each entry in `attributes` supports the following fields: | Field | Type | Applies to | Description | |-----------------|--------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `name` | string | all | Key under which the value is stored in the secret's YAML body. Also used as the human-readable label when `prompt` is omitted. | | `type` | string | all | Controls the input behaviour. One of `string`, `hostname`, `password`, or `multiline` — see [Attribute Types](#attribute-types) below. | | `prompt` | string | all | Override the text shown to the user. Defaults to the `name` field with the first letter upper-cased. | | `min` | int | string, hostname, password | Minimum acceptable length. Validation is skipped when `0` (default). For `password` with auto-generation the minimum is passed to the generator. | | `max` | int | string, hostname, password | Maximum acceptable length. Validation is skipped when `0` (default). | | `charset` | string | password | Explicit character set for generated passwords. When omitted, the standard mixed-class generator is used. Ignored when the user opts out of generation. | | `always_prompt` | bool | password | When `true`, skip the "Generate Password?" prompt and always ask the user to type one in. Default: `false`. | | `strict` | bool | password | When `true` (and `charset` is set), every character class detected in `charset` (upper, lower, digit, symbol) must appear at least once in the generated password. Equivalent to `gopass generate --strict`. Default: `false`. | ## Attribute Types ### `string` Prompts for a single-line text value. The value is stored as-is under the attribute's `name` key in the secret's YAML body. ```yaml - name: "username" type: "string" prompt: "Username" min: 1 max: 64 ``` ### `hostname` Like `string`, but additionally: * Extracts the hostname component from the entered value (e.g., `https://example.com/login` → `example.com`). * The extracted hostname is used as the `name_from` component if this attribute is listed there. * Looks up password-change URLs via the built-in `pwrules` database and stores them as `password-change-url` if found. ```yaml - name: "url" type: "hostname" prompt: "Website URL" min: 1 ``` ### `password` Prompts the user `"Generate Password?"`. If yes, generates a password using the standard gopass generator (respecting `charset` and `strict`). If no, asks the user to type one (with confirmation) and applies `min`/`max` length validation. The password is stored as the **first line** of the secret (the gopass password field), not as a YAML key. ```yaml - name: "password" type: "password" prompt: "Password" charset: "0123456789" # digits only, e.g. for PIN codes min: 4 max: 8 always_prompt: true # skip the "generate?" question ``` ### `multiline` Opens the user's `$EDITOR` (or the editor configured via `gopass config core.editor`) with any existing gopass template for this attribute pre-filled. The full editor content is written verbatim to the secret body. Useful for SSH keys, certificates, or annotated notes. ```yaml - name: "notes" type: "multiline" prompt: "Additional notes" ``` ## File Naming Convention Template files must end in `.yml` or `.yaml`. gopass ignores files with other extensions. There is no enforced naming convention, but the built-in templates follow the pattern `<priority>-<prefix>.yml` (e.g., `0-websites.yml`, `1-pin.yml`). Using the same convention makes the order predictable in directory listings. ## Flags | Flag | Aliases | Description | |-----------|---------|------------------------------------------------------------------| | `--store` | `-s` | Select the store to use. Will be used to look up user templates. | | `--force` | `-f` | For overwriting existing entries. | | `--print` | `-p` | Print the password to STDOUT. | --- ### Commands/Delete # `delete` command The `delete` command is used to remove a single secret or a whole subtree. Note: Recursive operations crossing mount points are intentionally not supported. ## Synopsis ``` $ gopass delete entry $ gopass rm -r path/to/folder $ gopass rm -f entry $ gopass delete entry key ``` ## Modes of operation * Delete a single secret * Delete a single key from an existing secret * Delete a directoy of secrets ## Flags | Flag | Aliases | Description | |---------------|---------|---------------------------------------| | `--recursive` | `-r` | Recursively delete files and folders. | | `--force` | `-f` | Do not ask for confirmation. | ## Exit codes | Code | Meaning | |-----:|---------| | 0 | Secret deleted successfully | | 10 | Secret not found | See [docs/exit-codes.md](../exit-codes.md) for the full table. ## Details * Removing a single key will need to decrypt the secret --- ### Commands/Doctor # `doctor` command The `doctor` command checks your gopass installation for common configuration issues and reports the results. With the `--recipients` flag, it performs a detailed recipient consistency diagnostic across all stores. ## Synopsis ``` $ gopass doctor [--verbose] $ gopass doctor --recipients [--verbose] ``` ## Description Runs a series of diagnostic checks on the gopass installation. Exits with a non-zero status if any check fails, which makes it suitable for scripting. Checks performed: | Check | Description | |---|---| | GPG binary | Verifies `gpg` is in `PATH` when a store uses GPG encryption | | age binary | Verifies `age` is in `PATH` when a store uses age encryption | | git binary | Verifies `git` is in `PATH` when a store uses the gitfs backend | | git identity | Checks that `user.name` and `user.email` are set in git config for each git-backed store | | store permissions | Checks that each store directory exists and is not world-writable | | recipient keys | Checks that all recipient keys are valid and not expired | | git remote | Warns (but does not fail) if a git-backed store has no remote configured | ### `--recipients` mode When `--recipients` is passed, `gopass doctor` performs a detailed, per-store, per-recipient consistency diagnostic: - **Non-canonical IDs** — warns when a recipient is stored with an ambiguous identifier (e.g. email address) instead of its full fingerprint. Suggests running `gopass recipients canonicalize`. - **Missing keys** — errors when a recipient is neither in the local keyring nor in `.public-keys/`. - **`.public-keys/` only** — warns when a recipient's key is only available in the store's `.public-keys/` directory (not in the local keyring). Suggests running `gopass sync`. - **Expired / unusable keys** — detects when a key is present in the keyring by fingerprint but not usable by direct ID lookup (expired). Recommends `gopass recipients update` followed by `gopass sync`. The diagnostic is read-only (no decryption needed) and safe to run at any time. ## Flags | Flag | Aliases | Description | |---|---|---| | `--verbose` | `-v` | Show passing checks in addition to warnings and errors | | `--recipients` | | Run a detailed recipient consistency diagnostic across all stores | ## Exit codes | Code | Meaning | |---|---| | 0 | All checks passed | | non-zero | One or more checks failed | ## Examples ``` # Run all checks, show only failures $ gopass doctor # Run all checks, show every result $ gopass doctor --verbose # Run detailed recipient diagnostic $ gopass doctor --recipients # Run recipient diagnostic with full detail $ gopass doctor --recipients --verbose ``` --- ### Commands/Edit # `edit` command The `edit` command loads a new or existing secret into your `$EDITOR` (default: `vim`) and saves the resulting content in the password store. It will attempt to create secure temporary directory (depending on the OS) and will warn if insecure editor configuration (currently only `vim`) is detected. Native `gopass` MIME secrets are syntax checked and invalid encodings are rejected. Any other type of secret is accepted as is. `gopass` will honor templates when creating a new entry. ## Synopsis ``` $ gopass edit entry $ gopass edit -e /bin/nano entry $ EDITOR=/bin/nano gopass edit entry ``` ## Modes of operation * Create a new secret * Edit an existing secret ## Flags | Flag | Aliases | Description | |------------|---------|---------------------------------------------------------------------------------------------------------------------------------------| | `--editor` | `-e` | Specify the path to an editor. Must accept the filename as it's first argument. | | `--create` | `-c` | Create a new secret. You can create a new secret with `edit` with or without `-c`, but `-c` will skip searching for existing matches. | --- ### Commands/Env # `env` command > **Security warning:** Any mode that injects secrets as environment variables > (`default` and `--exec`) exposes those values to every process that can read > `/proc/<pid>/environ` on Linux or `ps eww` on macOS for the entire lifetime of > the subprocess. If secret exposure via the process environment is a concern, > use `--stdin` (single secret) or `--file` (ramdisk-backed temp file) instead. The `env` command runs a binary as a subprocess with a pre-populated environment. The environment of the subprocess is populated with a set of environment variables corresponding to the secret subtree specified on the command line. ## Synopsis ``` $ gopass env [options] secret-or-prefix command [args...] ``` ## Flags | Flag | Description | |------|-------------| | `--keep-case` / `-kc` | Do not uppercase the environment variable name (default: names are uppercased) | | `--stdin` | Pipe the secret's password to the subprocess's **stdin** instead of injecting it into the environment | | `--file` | Write each secret to a ramdisk temporary file and export `KEY_FILE=/path/to/file` instead of `KEY=value` | | `--exec` | Replace the current gopass process with the subprocess via `exec(3)` (Linux/macOS only; not supported on Windows) | `--stdin`, `--file`, and `--exec` are mutually exclusive. ## Modes ### Default (env injection) ``` $ gopass env db/prod psql -U admin mydb ``` Each secret key under the given prefix is exported as an uppercased environment variable (`DB_PASSWORD=secret`). The subprocess runs as a **child process** of gopass. > **Security caveat:** The injected variables are visible in `/proc/<pid>/environ` on Linux > and via `ps eww` on macOS for as long as the subprocess is running. Any local process > with read access to `/proc` (including other user processes on a shared system) can > observe these values. Prefer `--stdin` or `--file` for sensitive credentials. ### `--stdin` ``` $ gopass env --stdin db/password gpg --passphrase-fd 0 --decrypt file.gpg ``` The secret's password is written to the subprocess's **stdin**. No environment variable is set, so the secret is never visible in `/proc/<pid>/environ` or `ps` output. > **Caveats:** > - Only works with a **single** secret. Passing a directory/prefix is not supported. > - The subprocess must be designed to read credentials from stdin (e.g. via > `--passphrase-fd 0`, `--password-stdin`, or similar flags). Programs that do > not read from stdin at all will hang waiting for input. > - The password is written to stdin **without** a trailing newline. Most programs > (e.g. `gpg --passphrase-fd 0`) accept this, but a small number of programs > require a newline-terminated passphrase. Wrap with `printf '%s\n'` or a shell > heredoc in those cases. > - The subprocess's own stdin is replaced by the secret. If the subprocess also > needs interactive stdin input from the user, this mode is not suitable. ### `--file` ``` $ gopass env --file db/prod psql -U admin mydb ``` Each secret is written to a ramdisk-backed temporary file (on Linux via `/dev/shm`, on macOS via a RAM disk). The environment variable is set to `KEY_FILE=/path/to/tmpfile` following the `*_FILE` convention used by Docker Compose and HashiCorp Vault. All temporary files are removed automatically when the subprocess exits. > **Caveats:** > - On **Windows** there is no ramdisk support; temp files fall back to the regular OS > temporary directory, which resides on a persistent disk. The secret may be > recoverable from disk after deletion. > - Temp files are deleted (not shredded) on exit. On SSDs with wear leveling, > journaling filesystems, or copy-on-write filesystems (ZFS, Btrfs, APFS) the > original data may persist in reallocated blocks or journal entries. > - The `KEY_FILE` variable itself is visible in `/proc/<pid>/environ`, though it > exposes only the file path, not the secret value. ### `--exec` ``` $ gopass env --exec db/prod psql -U admin mydb ``` Uses `exec(3)` (via `syscall.Exec`) to **replace** the current gopass process with the subprocess. Because gopass disappears from the process table entirely, there is no lingering parent process whose `/proc/<pid>/environ` can be observed. > **Caveats:** > - **Not supported on Windows.** > - The injected variables are still present in the subprocess's own `/proc/<pid>/environ`. > `--exec` eliminates the *gopass parent* from the process table but does not prevent > the subprocess from exposing the variables. > - Because the gopass process is replaced, any deferred cleanup (e.g. temp files from > a previous `--file` call in the same invocation) will **not** run after the subprocess > exits. ## Choosing a mode | Scenario | Recommended mode | |----------|------------------| | Single secret for a program that reads stdin | `--stdin` | | Multiple secrets or program does not support stdin | `--file` | | Program requires env variables and secrets are low-sensitivity | default or `--exec` | | Must avoid a lingering gopass process in `ps` output | `--exec` | ## Security summary | Mode | Secret in env? | Visible in `/proc`? | Ramdisk? | |------|---------------|---------------------|----------| | Default | Yes (`KEY=value`) | Yes (subprocess PID) | No | | `--exec` | Yes (`KEY=value`) | Yes (subprocess PID) | No | | `--file` | No (path only) | File path only | Yes (Linux/macOS) | | `--stdin` | No | No | N/A | --- ### Commands/Find # `find` command The `find` command will attempt to do a simple substring match on the names of all secrets. If there is a single match it will directly invoke `show` and display the result. If there are multiple matches a selection will be shown. Note: The find command will not fall back to a fuzzy search. ## Synopsis ``` $ gopass find entry $ gopass find -f entry $ gopass find -c entry ``` ## Flags | Flag | Aliases | Description | |------------|---------|---------------------------------------------------------------| | `--clip` | `-c` | Copy the password into the clipboard. | | `--unsafe` | `-u` | Display any unsafe content, even if `safecontent` is enabled. | | `--regex` | `-r` | Interpret the pattern as a regular expression instead of a plain substring match. | ## Exit codes | Code | Meaning | |-----:|---------| | 0 | Matches found and displayed | | 10 | No matching secret found | See [docs/exit-codes.md](../exit-codes.md) for the full table. --- ### Commands/Fsck # `fsck` command `gopass` can check integrity of it's password stores with the `fsck` command. It will ensure proper file and directory permissions as well as proper recipient coverage (on supported crypto backends, only). ## Synopsis ``` $ gopass fsck ``` ## Modes of operation * Check the entire password store, incl. all mounts * Check only the specified mount ## Flags Flag | Aliases | Description ---- | ------- | ----------- `--decrypt` | | Decrypt and reencrypt all secrets. ## Exit codes | Code | Meaning | |-----:|---------| | 0 | Store integrity OK | | 15 | One or more integrity errors found | See [docs/exit-codes.md](../exit-codes.md) for the full table. --- ### Commands/Fscopy # `fscopy` command The `fscopy` command is used to copy a file from your filesystem into your password store, while keeping it in clear in your local filesystem after having stored it in your encrypted store. ## Synopsis ```bash $ gopass fscopy ~/test/file data/test/file-entry $ gopass fscopy data/test/file-entry ~/file ``` ## Modes of operation This command either reads a file from the filesystem and writes the encoded and encrypted version in the store or it decrypts and decodes a secret and writes the result to a file. Either source or destination must be a file and the other one a secret. If you want the source to be removed use 'gopass fsmove'. `fscopy` is intended to work with raw files. ### Example ``` $ gopass fscopy ~/test/file data/test/file-entry $ gopass cat data/test/file-entry ``` See also the docs for the [`cat` action](cat.md). ## Flags This command has currently no supported flags except the gopass globals. --- ### Commands/Fsmove # `fsmove` command The `fsmove` command is used to move a file from your filesystem into your password store, erasing it from your local filesystem after having stored it in your encrypted store. ## Synopsis ```bash $ gopass fsmove ~/test/file data/test/file-entry $ gopass fsmove data/test/file-entry ~/file ``` ## Modes of operation This command either reads a file from the filesystem and writes the encoded and encrypted version in the store or it decrypts and decodes a secret and writes the result to a file. Either source or destination must be a file and the other one a secret. The source will be wiped from disk or from the store after it has been copied successfully and validated. If you don't want the source to be removed use 'gopass fscopy'. `fsmove` is intended to work with raw files. ### Example ``` $ gopass fsmove ~/test/file data/test/file-entry $ gopass cat data/test/file-entry ``` See also the docs for the [`cat` action](cat.md). ## Flags This command has currently no supported flags except the gopass globals. --- ### Commands/Generate # `generate` command The `generate` command is used to generate a new password and store it into the password store. Note: If you only want generate a password without storing it in the store, use the `pwgen` command. ## Synopsis ```sh gopass generate entry [length] gopass generate entry key [length] ``` ## Modes of operation * Generate a new entry with a new password, e.g. a new login. Setting the `Password` field, `gopass generate entry [chars]` * Re-generating a new password and setting it in the `Password` field of an existing entry * Generate a new password and setting it to a new key of an existing secret, e.g. `gopass generate entry key [chars]` * Re-generate a new password for an existing key in an existing entry ## Flags | Flag | Aliases | Description | |---------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `--clip` | `-c` | Copy the generated password into the clipboard. Default: Value of `autoclip` | | `--print` | `-p` | Print the generated password to the terminal. Default: false. | | `--force` | `-f` | Force overwriting an existing entry. | | `--edit` | `-e` | Generate a password and open the entry for editing in `$EDITOR`. | | `--generator` | `-g` | Choose of of the available password generators, desribed below. Default: `cryptic` | | `--symbols` | `-s` | Include symbols in the generated password (default: `false`) | | `--strict` | | Ensure each requested character class is actually included. Without this option all requested classes can be included, but not necessarily are. (default: `false`) | | `--xkcd-sep` | `--sep`, `--xkcdsep` | Word separator for multi-word generators. | | `--xkcd-lang` | `--lang`, `--xkcdlang` | Language for word-based generators. | | `--xkcd-capitalize` | `--xkcdcapitalize` | Capitalize the first letter of each word when using the `xkcd` generator. Equivalent to setting `pwgen.xkcd-capitalize = true` in config. | | `--xkcd-numbers` | `--xkcdnumbers` | Append a random number to each word when using the `xkcd` generator. Equivalent to setting `pwgen.xkcd-numbers = true` in config. | ## Password Generators Use `--generator` to select one of the available password generators: | Generator | Description | |-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `cryptic` | The default generator yields cryptic passwords that should work with most sites. Use `--symbols` and `--strict` if the site has specific requirements. Please note that we auto-detect the correct rules for some sites. The length argument specifies the number of characters. | | `xkcd` | Use an [XKCD#936](https://xkcd.com/936/) style password. Use `--xkcd-lang` and `--xkcd-sep` to refine its behaviour. The length argument specifies the number of words. | | `memorable` | Generate a memorable password. The length argument specifies the minimum lenght of characters. Please note that the password might be longer if not all necessary rules were satisfied by the minimum length solution. | | `external` | Use the external generator from `$GOPASS_EXTERNAL_PWGEN` | ## Exit codes | Code | Meaning | |-----:|---------| | 0 | Password generated and stored successfully | | 12 | Generated secret could not be encrypted and saved | See [docs/exit-codes.md](../exit-codes.md) for the full table. ## Relevant configuration options * `autoclip` only applies to `generate`. If set the generated password is automatically copied to the clipboard - unless `--clip` is explicitly set to `--clip=false` * `safecontent` will suppress printing of the password, unless `-p` is set. The password will not be copied, unless `-c` or the `autoclip` option are set. ## Templates When creating a new entry gopass will look for the most specific template by going up in the secret path looking for a file called `.pass-template`. If any such file is found it will be used to pre-populate the generated secret. --- ### Commands/Gopass # `gopass` command Calling `gopass` without any command argument is a common entry point and has two different modes. ## Synopsis ``` $ gopass $ gopass entry $ gopass -c entry ``` ## Modes of operation * Invoked without any arguments `gopass` will start an interactive REPL shell. This includes zero-setup command completion and passphrase caching (for non-GPG backends). * Invoked with one argument it will perform a (fuzzy) search and display a list of matches or the secret directly (if exactly one match). * Invoked with two arguments it will do search and if there is a match display the named key. ## Flags Note: DO NOT use in scripts! Use `gopass show` instead. | Flag | Aliases | Description | |------------|---------|----------------------------------------------------------------------------------------------------------------------------| | `--clip` | `-c` | Copy the password value into the clipboard and don't show the content. | | `--unsafe` | `-u` | Display unsafe content (e.g. the password) even when the `safecontent` option is set. No-op when `safecontent` is `false`. | | `--yes` | | Assume yes on all yes/no questions or use the default on all others. | --- ### Commands/Grep # `grep` command The `grep` command works like the Unix `grep` tool. It decrypts all secrets and performs a substring or regexp match on the given pattern. ## Synopsis ``` $ gopass grep foobar ``` ## Modes of operations * Search for the given pattern in all secrets ## Flags None. Flag | Aliases | Description ---- | ------- | ----------- `--regexp` | | Parse the pattern as a RE2 regular expression. --- ### Commands/History # `history` command The `gopass history` command will show all revisions of a given secret. ## Synopsis ``` $ gopass history entry ``` ## Modes of operation * Display all revisions of the given secret. ## Flags None. --- ### Commands/Init # `init` command The `init` command is used to initialize a new password store. If no recipients are specified a useable existing private key is used. The `init` command must be used to initilize new mounts. `gopass mounts add` only supports adding existing mounts. Note: We do not support adding recipients using `init`. Please use `gopass recipients add` for that! ## Synopsis ``` $ gopass init $ gopass init --crypto [age|gpg] --storage=[fs|gitfs] ``` ## Flags | Flag | Aliases | Description | |-------------|---------|-------------------------------------------------------------------------------------------------------------| | `--path` | `-p` | Initialize the (sub) store in this location. | | `--store` | `-s` | Mount the newly initialized sub-store at this mount point | | `--crypto` | | Select the crypto backend. Choose one of: `gpgcli`, `age`, `xc` (deprecated) or `plain`. Default: `gpgcli` | | `--storage` | | Select the storage and RCS backend. Choose one of: `gitfs`, `fs`. Default: `gitfs` | See [backends.md](../backends.md) for more information on the available backends. --- ### Commands/Insert # `insert` command The `insert` command is used to manually set (insert, or change) a password in the store. It applies to either new or existing secrets. ## Synopsis ``` $ gopass insert entry $ gopass insert entry key ``` ## Modes of operation * Create a new entry with a user-supplied password, e.g. a new site with a user-generated password or one picked from `gopass pwgen`: `gopass insert entry` * Change an existing entry to a user-supplied password * Create and change any field of a new or existing secret: `gopass insert entry key` * Read data from STDIN and insert (or append) to a secret Insert is similar in effect to `gopass edit` with the advantage of not displaying any content of the secret when changing a key. ## Exit codes | Code | Meaning | |-----:|---------| | 0 | Secret inserted successfully | | 11 | Existing secret could not be read for append or key-insert | | 12 | Secret could not be encrypted and saved | See [docs/exit-codes.md](../exit-codes.md) for the full table. Note: `insert` will not change anything but the `Password` field (using the `insert entry` invocation) or the specified key (using the `insert entry key` invocation). ## Flags | Flag | Aliases | Description | |---------------|---------|------------------------------------------------------------------------------------------------------------------------| | `--echo` | `-e` | Display the secret while typing (default: `false`) | | `--multiline` | `-m` | Insert using `$EDITOR` (default: `false`). This identical to running `gopass edit entry`. All other flags are ignored. | | `--force` | `-f` | Overwrite any existing value and do not prompt. (default: `false`) | | `--append` | `-a` | Append to any existing data. Only applies if reading from STDIN. (default: `false`) | --- ### Commands/Link # `link` command The `link` (or `ln`) command is used to create a symlink from one secret in a store to a target in the same store. Note: Symlinks across different stores / mounts are currently not supported! Note: `audit` and `list` do not recognize symlinks, yet. They will treat symlinks as regular (different) entries. ## Synopsis ``` $ gopass ln foo/bar bar/baz $ gopass show foo/bar $ gopass show bar/baz ``` ## Modes of operations * Create a symlink from an existing secret to a new name, the target must not exist, yet Note: Use `gopass rm` to remove a symlink. ## Flags None. --- ### Commands/List # `list` command The `list` command is used to list all the entries in the password store or at a given prefix. ## Synopsis ```bash gopass ls gopass ls path/to/entries ``` - List all the entries in the password store including the one in mounted stores: `gopass list` - List all the entries in a given folder showing their relative path from the root: `gopass list path/to/entries` Note: `list` will not change anything, nor encrypt or decrypt anything. ## Flags | Flag | Aliases | Description | |------------------|------------|-----------------------------------------------------| | `--limit value` | `-l value` | Max tree depth (default: -1) | | `--flat` | `-f` | Print a flat list of secrets (default: false) | | `--folders` | `-d` | Print a flat list of folders (default: false) | | `--strip-prefix` | `-s` | Strip prefix from filtered entries (default: false) | The `--flat` and `--folders` flags provide a plaintext list of the entries located at the given prefix (default prefix being the root `/`). They are notably used to produce the completion results. The `--flat` one will list all entries, one per line, using its full path. The `--folders` one will display all the folders, one per line, recursively per level. For instance an entry `folder/sub/entry` would cause it to list both: ```bash $ gopass list --folders folder folder/sub ``` whereas `gopass list --flat` would have just displayed one line: `folder/sub/entry`. The `--strip-prefix` flag is meant to be used along with `--flat` or `--folders`. It will list the relative path from the current prefix, removing the said prefix, instead of listing the relative paths from the root. For instance on entry `folder/sub/entry`, running `gopass ls -f -s folder` would display only `sub/entry` instead of `folder/sub/entry`. The `--limit` flag starts counting its depth from the root store, which means that a depth of 0 only lists the items in the root gopass store: ```bash $ gopass list -l 0 gopass ├── bar/ ├── foo/ └── test (/home/user/.local/share/gopass/stores/substore1) ``` A value of 1 would list all the items in the root, plus their sub-items but no more: ```bash $ gopass list -l 1 gopass ├── bar/ │ └── bar ├── foo/ │ ├── bar │ └── foo └── test (/home/user/.local/share/gopass/stores/substore1) └── foo ``` A negative value lists all the items without any depth limit. ```bash $ gopass list -l -1 gopass ├── bar/ │ └── bar ├── foo/ │ ├── bar/ │ │ ├── bar/ │ │ │ └── bar │ │ └── baz │ └── foo └── test (/home/user/.local/share/gopass/stores/substore1) └── foo ``` The flags can be used together: `gopass -l 1 -d` will list only the folders up to a depth of 1: ```bash $ gopass list -l 1 -d bar/ foo/ foo/bar/ test/ test/foo/ ``` ## Shadowing It is possible to have a path that is both an entry and a folder. In that case the list command will display the folder with a marker of `(shadowed)`, it can still be accessed using `gopass show path/to/it`, while the content of the folder can be listed using `gopass list path/to/it`. It should also be noted that the `mount` command can completely "shadow" an entry in a password store, simply by having the same name and this entry and its subentries will not be visible using `ls` anymore until the substore is unmounted. The entries shadowed by a mount will not show up in a search and cannot be accessed at all without unmounting. For instance in our example above, maybe there is an entry test/zaz in the root store, but since the substore is mounted as `test/`, it only displays the content of the substore. Unmounting it reveals its shadowed entries: ```bash $ gopass list test test/ └── foo $ gopass mounts rm test $ gopass list test test/ └── zaz ``` --- ### Commands/Mounts # `mounts` commands The `mounts` commands allow managing mounted substores. This is one of the distinctive core features of `gopass` and we aim making working with substores as seamless as possible. Instead of support for encrypting different parts of a store for different recipients we instead encourage users to mount different stores - each encrypted to a uniform set of recipients - into a semless virtual tree structure. This feature is modeled after standard POSIX mount semantics. ## Synopsis ``` $ gopass mounts $ gopass mounts add mount/point /path/to/store $ gopass mounts remove mount/point ``` ## Modes of operation * Add a new mount * List existing mounts * Remove an existing mount ## Creating new mounts You can also create new mounts using `init` even if your store is already initialized: ``` gopass init --store mynewsubstore pgpkeyidentitfier ``` (You can also specify a specific local path using `--path`, just make sure to keep your PGP key identifier, e.g. its email or fingerprint, as the last argument.) --- ### Commands/Move # `move` command Note: The implementations for `copy` and `move` are exactly the same. The only difference is that `move` will remove the source after a successful copy. The `move` command works like the Unix `mv` or `rsync` binaries. It allows moving either single entries or whole folders around. Moving across mounts is supported. If the source is a directory, the source directory is re-created at the destination if no trailing slash is found. Otherwise the contained secrets are placed into the destination directory (similar to what `rsync` does). Please note that `move` will always decrypt the source and re-encrypt at the destination. Moving a secret onto itself is a no-op. ## Synopsis ``` # Overwrite new/leaf $ gopass move path/to/leaf new/leaf # Move the content of path/to/somedir to new/dir/somedir $ gopass move path/to/somedirdir new/dir # Does nothing $ gopass move entry entry ``` ## Modes of operation * Move a single secret from source to destination * Move a folder of secrets, possibly with sub folders, from source to destination ## Flags | Flag | Aliases | Description | |-----------|---------|------------------------------------------------| | `--force` | `-f` | Overwrite existing destination without asking. | ## Details * To simplify the implementation and support multiple backends a `copy` or `move` operation will always decrypt and re-encrypt all affected secrets. Even if moving encrypted files around might be possible. * You can move a secret to another secret, i.e. overwrite the destination. But `gopass` won't let you move a directory over a file. In that case you have to delete the destination first. --- ### Commands/Otp # `otp` command The `otp` command generates TOTP tokens from an OTP URL (`otpauth://`). The command tries to parse the password and the totp fields as an OTP URI. Note: HTOP is supported, but requires a `counter` field to keep track of it. Note: If `show.safecontent` is enabled, OTP URIs are hidden from the `show` command, see the [docs for show](show.md#parsing-and-secrets) to learn more about it. ## Screen capture dependency The `--snip` mode requires the `github.com/kbinani/screenshot` library to capture the contents of the display(s) so that gopass can locate and decode an OTP QR code. Screen capture is **only performed when the user explicitly passes `--snip`** (`-s`); it is never triggered automatically. This capability is compiled in by default on supported platforms (`arm`, `arm64`, `amd64`, `386` on Linux, Windows, FreeBSD, and NetBSD; CGo Darwin). Users who prefer to omit this surface (e.g. in enterprise or policy-restricted environments) can build gopass without it: ``` go build -tags noscreenshot . ``` When built with `noscreenshot`, the `--snip` flag will return an error on all platforms and the `github.com/kbinani/screenshot` package will not be linked into the binary. ## Modes of operation * Generate the current TOTP token from a valid OTP URL * Snip the screen to add a TOTP QR code as an OTP field to an entry. ## Flags | Flag | Aliases | Description | |--------------|---------|--------------------------------------------------------------------------| | `--clip` | `-c` | Copy the time-based token into the clipboard. | | `--alsoclip` | `-C` | Copy the time-based token into the clipboard and show it. | | `--qr` | `-q` | Write QR code to file. | | `--chained` | `-p` | chain the token to the password | | `--password` | `-o` | Only display the token. For use in scripts. | | `--snip` | `-s` | Try and find a QR code in the screen content to add as OTP to the entry. | ## Supported formats Your secret needs to either contain a `otpauth`, `hotp` or a `totp` field. When using the OTP code directly you can simply add it to a secret using `gopass insert your/entry totp`. The `otp` command also tries to parse the body of your secret to try and find a line starting by `otpauth://` in case you're not using the key-value format for your secret. Finally, if your secret contains nothing but a password on the first line, the `otp` command will try and use that password to generate an OTP code. This allows use-cases where you store your password in a given entry and your OTP code in another dedicated entry. The otpauth URIs are typically communicated through a QR code which can be read on Linux using the `gopass otp -s your/entry` flag. It should also work if they are added using `gopass insert your/entry otpauth`, but won't work if you add them under the `totp` or `hotp` keys. Steam OTP is supported, but requires using the `otpauth` URI input to specify the encoder, e.g. `otpauth://totp/username%20steam:username?secret=qlt6vmy6svfx4bt4rpmisaiyol6hihca&period=30&digits=5&issuer=username%20steam&encoder=steam`. --- ### Commands/Process # `process` command The `process` command extends the `gopass` templating to support user-supplied template files that will be processed. These templates can access the users credentials with the template functions documented below. That way users can store their full configuration files publicly accessible and have any of the recipients automatically populate it to generate a complete configuration file on the fly. `gopass process` writes the result to `STDOUT`. You'll likely want to redirect it to a file. ## Synopsis ``` $ gopass process <TEMPLATE> > <OUTPUT> ``` ## Flags None. ## Examples The templates are processed using Go's [`text/template`](https://pkg.go.dev/text/template) package. A set of helpful template functions is added to the template. See below for a list. ### Populate a MySQL configuration ``` $ cat /etc/mysql/my.cnf.tpl [client] host=127.0.0.1 port=3306 user={{ getval "server/local/mysql" "username" }} password={{ getpw "server/local/mysql" }} $ gopass process /etc/mysql/my.cnf.tpl [client] host=127.0.0.1 port=3306 user=admin password=hunter2 ``` ## Template functions Function | Example | Description -------- | ------- | ----------- `md5sum` | `{{ getpw "foo/bar" \| md5sum }}` | Calculate the hex md5sum of the input. `sha1sum` | `{{ getpw "foo/bar" \| sha1sum }}` | Calculate the hex sha1sum of the input. `md5crypt` | `{{ getpw "foo/bar" \| md5crypt }}` | Calculate the md5crypt of the input. `ssha` | `{{ getpw "foo/bar" \| ssha }}` | Calculate the salted SHA-1 of the input. `ssha256` | `{{ getpw "foo/bar" \| ssha256 }}` | Calculate the salted SHA-256 of the input. `ssha512` | `{{ getpw "foo/bar" \| ssha512 }}` | Calculate the salted SHA-512 of the input. `get` | `{{ get "foo/bar" }}` | Insert the full secret. `getpw` | `{{ getpw "foo/bar" }}` | Insert the value of the password field from the given secret. `getval` | `{{ getval "foo/bar" "baz" }}` | Insert the value of the named field from the given secret. `argon2i` | `{{ getpw "foo/bar" \| argon2i }}` | Calculate the Argon2i hash of the input. `argon2id` | `{{ getpw "foo/bar" \| argon2id }}` | Calculate the Argon2id hash of the input. `bcrypt` | `{{ getpw "foo/bar" \| bcrypt }}` | Calculate the Bcrypt hash of the input. `blake3` | `{{ getpw "foo/bar" \| blake3 }}` | Calculate the BLAKE-3 hash of the input. --- ### Commands/Pwgen # `pwgen` command The `pwgen` command implements a subset of the features of the Unix/Linux `pwgen` command line tool. It aims to eventually support most of the `pwgen` flags and mirror it's behaviour. It is mainly implemented as a curtosy for Windows users. ## Modes of operation * Generate a few dozen random passwords with the chosen length ## Usage ```bash gopass pwgen [optional length] ``` ## Synopsis ```bash gopass pwgen gopass pwgen 24 ``` ## Flags Flag | Aliases | Description ---- | ------- | ----------- `--no-numerals` | `-0` | Do not include numerals in the generated passwords. `--one-per-line` | `-1` | Print one password per line. `--xkcd` | `-x` | Use multiple random english words combined to a password. `--xkcd-sep` | `--sep`, `--xkcdsep` | Word separator for multi-word passwords. `--xkcd-lang` | `--lang`, `--xkcdlang` | Language to generate password from. Currently only supports english (en, default). `--xkcd-capitalize` | `--xkcdcapitalize` | Capitalize the first letter of each word in the generated xkcd password. `--xkcd-numbers` | `--xkcdnumbers` | Add a random number to the end of the generated xkcd password. `--memorable` | `-m` | Use the memorable (word-based) password generator. The length is a minimum (output may be longer). Incompatible with `--no-numerals`. `--memorable-capitalize` | `--memorablecapitalize` | Capitalize (some) words in the generated memorable password. Implies `--memorable`. ## Notes * With `--memorable`, the requested length is a **minimum** — the generated password is usually longer because whole words are concatenated. * `--memorable` always includes digits (one per word), so it is incompatible with `--no-numerals`; the command errors out instead of silently ignoring it. * `--memorable` ignores `--ambiguous`. `--memorable` and `--xkcd` are mutually exclusive (combining them is an error). --- ### Commands/Recipients # `recipients` commands The set of `recipients` commands allow managing public keys that are able to decrypt a given password store. These commands are one of the more unique `gopass` features and we aim to make working with teams as seamless as possible. For the full team workflow reference, see [docs/usecases/team-workflows.md](../usecases/team-workflows.md). ## Synopsis ``` $ gopass recipients $ gopass recipients add [--store=<store>] <recipient-id>... $ gopass recipients remove [--store=<store>] <recipient-id>... $ gopass recipients update [--store=<store>] [<recipient-id>...] $ gopass recipients canonicalize [--store=<store>] $ gopass recipients ack [--store=<store>] ``` ## Subcommands ### `recipients list` (default — no subcommand) Lists all existing recipients for every mounted store. ### `recipients add` (aliases: `authorize`) Adds one or more recipients to a store and re-encrypts all secrets so the new recipients can decrypt them. Recipient identifiers may be email addresses, short key IDs, or full fingerprints. gopass normalizes every identifier to its canonical form (full GPG fingerprint) before storing it in `.gpg-id`, ensuring the entry and the `.public-keys/<id>` filename always match (see [ADR A-14](../adr/A-14-team-workflows.md)). **Example:** ``` $ gopass recipients add --store team-a alice@example.com Resolved 'alice@example.com' to canonical key ID '0x1A2B3C4D5E6F' √ Added 1 recipients You need to run 'gopass sync' to push these changes ``` **Flags:** `--store` (store to operate on), `--force` (skip confirmation). ### `recipients remove` (aliases: `rm`, `deauthorize`) Removes a recipient from a store and re-encrypts all secrets. After removal, the removed recipient can no longer decrypt *new* changes — but they can still decrypt old revisions from the git history. Always rotate secrets after removing a recipient. Removal also performs **recipient-scoped cleanup**: the removed recipient's `.public-keys/<id>` and legacy `.gpg-keys/<id>` files are deleted. No other recipient's files are affected. **Flags:** `--store`, `--force`. ### `recipients update` (aliases: `refresh`) Re-exports the named recipients' public keys from the local keyring into `.public-keys/`, overwriting stale copies. Use this after extending an expired key or adding new subkeys. If no IDs are given, your own key is updated. **Example:** ``` $ gopass recipients update --store team-a Refreshing public keys in store "team-a" ... Updated public key for '0x1A2B3C4D5E6F'. Done. You may want to run 'gopass sync' to push the updated keys. ``` **Flags:** `--store`. ### `recipients canonicalize` (aliases: `canon`) Rewrites the `.gpg-id` file of a store so that every recipient ID is in its canonical (full-fingerprint) form and renames the corresponding `.public-keys/` files to match. Safe migration — no re-encryption required. Run this once on existing stores that use non-canonical IDs (email addresses or short key IDs). After running, use `gopass sync` to publish the changes. **Flags:** `--store`. ### `recipients ack` (aliases: `acknowledge`) Updates `recipients.hash` after manually validating changes to the recipients list. This is part of the experimental recipients hashing feature (see below). **Flags:** `--store`. ## Common flags | Flag | Description | |------|-------------| | `--store` | Store to operate on. | | `--force` | Skip confirmation prompts (supported on `add` and `remove`). | ## Important Remarks WARNING: Removing a recipient can only ever work for new or changed secrets. When a recipient is removed they will still be able to access anything that they used to have access to. As a logical consequence one **should** change all secrets when removing a recipient. ## Recipients hashing This is an experimental feature that will hash the content of each mount's recipients file (only the top most file) to display a warning when this is changed by anyone else (local changes update it without warning). This can happen either when a teammate modifies that file or when an attacker tries to modify the recipients file in the central storage to get themselves added to any newly modified secrets. ## Key refresh and expiry recovery When a GPG key expires, other team members will see warnings during sync. The key owner should extend the key locally and then run: ``` $ gopass recipients update --store <team-store> $ gopass sync --store <team-store> ``` Other members will pick up the refreshed key on their next `gopass sync`. You can check the health of your recipient keys at any time with: ``` $ gopass doctor --recipients ``` See also [ADR A-13](../adr/A-13-expired-gpg-key-handling.md) for details on expired key handling. --- ### Commands/Show # `show` command The `show` command is the most important and most frequently used command. It allows displaying and copying the content of the secrets managed by gopass. ## Synopsis ``` $ gopass show entry $ gopass show entry key $ gopass show entry --qr $ gopass show entry --password ``` ## Modes of operation * Show the whole entry: `gopass show entry` * Show a specific key of the given entry: `gopass show entry key` (only works for key-value or YAML secrets) ## Flags Flag | Aliases | Description ---- | ------- | ----------- `--clip` | `-c` | Copy the password value into the clipboard and don't show the content. `--alsoclip` | `-C` | Copy the password value into the clipboard and show the content. `--qr` | | Encode the password field as a QR code and print it. Note: When combining with `-c`/`-C` the unencoded password is copied. Not the QR code. `--qrbody` | | Encode the entire body (all lines after the first) as a QR code and print it. `--unsafe` | `-u` | Display unsafe content (e.g. the password) even when the `safecontent` option is set. No-op when `safecontent` is `false`. `--safe` | `-s` | Hide unsafe content (e.g. the password) even when the `safecontent` option is `false`. Overrides the config value for this invocation. `--password` | `-o` | Display only the password. For use in scripts. Takes precedence over other flags. `--revision` | `-r` | Display a specific revision of the entry. Use an exact version identifier from `gopass history` or the special `-<N>` syntax. Does not work with native (e.g. git) refs. `--noparsing` | `-n` | Do not parse the content, disable YAML and Key-Value functions. `--nofuzzysearch` | | Do not start fuzzy search if the requested entry is not found. `--nosync` | | Disable auto-sync for this invocation. `--chars` | | Display selected characters from the password. ## Details This section describes the expected behaviour of the `show` command with respect to different combinations of flags and config options. Note: This section describes the expected behaviour, not necessarily the observed behaviour. If you notice any discrepancies please file a bug and we will try to fix it. Note: The parser ensures every parsed secret contains a terminating newline, even if the stored content did not. When displaying via `gopass show` the trailing newline is suppressed so that copying output does not include a spurious newline character. When piping output to another command (`gopass show entry | …`), the trailing newline is preserved to make the output compatible with standard Unix text-processing tools. * When no flag is set the `show` command will display the full content of the secret and will parse it to support key-value lookup and YAML entries. If the `safecontent` option is set to `true` any secret fields (current default is only `password`) are replaced with a random number of '*' characters (length: 5-10). Using the `--unsafe` flag will reveal these fields even if `safecontent` is enabled. `--password` takes precedence of `safecontent=true` as well and displays only the password. * The `--noparsing` flag will disable all parsing of the output, this can help debugging YAML secrets for example, where `key: 0123` actually parses into octal for 83. * The `--clip` flag will copy the value of the `Password` field to the clipboard and doesn't display any part of the secret. * The `--alsoclip` option will copy the value of the `Password` field but also display the secret content depending on the `safecontent` setting, i.e. obstructing the `Password` field if `safecontent` is `true` or just displaying it if not. * The `--qr` flags operates complementary to other flags. It will *additionally* format the value of the `Password` entry as a QR code and display it. Other than that it will honor the other options, e.g. `gopass show --qr` will display the QR code *and* the whole secret content below. One special case is the `-o` flag, this flag doesn't make a lot of sense in combination, so if both `--qr` and `-o` are given only the QR code will be displayed. * When an entry is not found, `gopass show` can start an interactive fuzzy search by default. This can be disabled globally with `show.fuzzysearch=false` or for one invocation via `--nofuzzysearch`. * Since gopass plans to supports different RCS backends we do not support arbitrary git refs as arguments to the `--revision` flag. Using those might work, but this is explicitly not supported and bug reports will be closed as `wont-fix`. There are two issues with using arbitrary git refs is that (a) this doesn't work with non-git RCS backends and (b) git versions a whole repository, not single files. So the revision `HEAD^` might not have any changes for a given entry. Thus we only support specifc revisions obtained from `gopass history` or our custom syntax `-N` where N is an integer identifying a specific commit before `HEAD` (cf. `HEAD~N`). ## Exit codes | Code | Meaning | |-----:|---------| | 0 | Secret displayed successfully | | 10 | Secret not found | | 11 | Secret could not be decrypted | See [docs/exit-codes.md](../exit-codes.md) for the full table. ## Parsing and secrets Secrets are stored on disk as provided, but are parsed upon display to provide extra features such as the ability to show the value of a key using: `gopass show entry key`. The secrets are split into 3 categories: - the plain type, which is just a plain secret without key-value capabilities ``` this is a plain secret using multiple lines and that's it ``` gets parsed to the same value - the key-value type, which allows to query the value of a specific key. This does not preserve ordering. ``` this is a KV secret where: the first line is the password and: the keys are separated from their value by : and maybe we have a body text below it ``` will be parsed into (with `safecontent` enabled): ``` and: the keys are separated from their value by : where: the first line is the password and maybe we have a body text below it ``` - the YAML type which implements YAML support, which means that secrets are parsed as per YAML standard. ``` s3cret --- invoice: 0123 date : 2001-01-23 bill-to: &id001 given : Bob family : Doe ship-to: *id001 ``` will be parsed into (with `safecontent` enabled): ``` bill-to: map[family:Doe given:Bob] date: 2001-01-23 00:00:00 +0000 UTC invoice: 83 ship-to: map[family:Doe given:Bob] ``` Note how the `0123` is interpreted as octal for 83. If you want to store a string made of digits such as a numerical username, it should be enclosed in string delimiters: `username: "0123"` will always be parsed as the string `0123` and not as octal. By default, `safecontent` will remove the first line (the password), every line starting with `otpauth://` in the body, and every YAML values where the key is one of the following: `hotp`, `otpauth`, `password`, `totp`. Both the key-value and the YAML format support so-called "unsafe-keys", which is a key-value that allows you to specify keys that should be hidden when using `gopass show` with `gopass config safecontent` set to true. E.g: ``` supersecret --- age: 27 secret: The rabbit outran the tortoise name: John Smith unsafe-keys: age,secret ``` will display (with safecontent enabled): ``` age: ***** name: John Smith secret: ***** unsafe-keys: age,secret ``` unless it is called with `gopass show -n` that would disable parsing of the body, but still hide the password, or `gopass show -f` that would show everything that was hidden, including the password. You can read more about secrets formats in its [documentation](docs/secrets.md). Notice that if the option `parsing` is disabled in the config, then all secrets are handled as plain secrets. --- ### Commands/Sync # `sync` command The `sync` command is the preferred way to manually synchronize changes between your local stores and any configured remotes. You can also `cd` into a git-based store and manually perform git operations, or use the `gopass git` command to automatically run a command in the correct directory. Note: `gopass sync` only supports one remote per store. ## Flags | Flag | Description | |-----------|--------------------------------| | `--store` | Only sync a specific sub store | --- ### Commands/Templates # `templates` commands The template support is one of the more unique `gopass` features. It allows password stores to define templates that will automatically apply to any new secret create at or below the template path. For example this can be useful to generate a new email password and its salted hash at the same time. Or a PostgreSQL password with the custom salted hash. This is certainly a feature that's not used very often, but if used correctly it can greatly reduce the toil of some common operations. This uses Go's [text/template](https://pkg.go.dev/text/template) package. ## Synopsis ```shell gopass templates gopass templates show template gopass templates edit template gopass templates remove template ``` ## Flags None. ## Examples ### Compute the salted hash for the password ```text Password: {{ .Content }} SSHA256: {{ .Content | ssha256 }} ``` ### Compute the SQL statements to create a new PostgreSQL user ```text {{ .Content }} --- sql: | CREATE ROLE {{ .Name }} LOGIN PASSWORD '{{ .Content }}'; GRANT {{ .Name }} TO {{ .Name }}; ALTER USER {{ .Name }} SET search_path = '{{ .Name }}'; ``` ## Template functions Function | Example | Description -------- | ------- | ----------- `md5sum` | `{{ .Content \| md5sum }}` | Calculate the hex md5sum of the input. `sha1sum` | `{{ .Content \| sha1sum }}` | Calculate the hex sha1sum of the input. `md5crypt` | `{{ .Content \| md5crypt }}` | Calculate the md5crypt of the input. `ssha` | `{{ .Content \| ssha }}` | Calculate the salted SHA-1 of the input. `ssha256` | `{{ .Content \| ssha256 }}` | Calculate the salted SHA-256 of the input. `ssha512` | `{{ .Content \| ssha512 }}` | Calculate the salted SHA-512 of the input. `get` | `{{ get "foo/bar" }}` | Insert the full secret. `getpw` | `{{ getpw "foo/bar" }}` | Insert the value of the password field from the given secret. `getval` | `{{ getval "foo/bar" "baz" }}` | Insert the value of the named field from the given secret. `argon2i` | `{{ .Content \| argon2i }}` | Calculate the Argon2i hash of the input. `argon2id` | `{{ .Content \| argon2id }}` | Calculate the Argon2id hash of the input. `bcrypt` | `{{ .Content \| bcrypt }}` | Calculate the Bcrypt hash of the input. `blake3` | `{{ .Content \| blake3 }}` | Calculate the BLAKE-3 hash of the input. ## Template variables Note: These examples assume being evaluated for the secret `foo/bar/baz` and the generated password `VerySecure`. Name | Example | Description ---- | ------- | ----------- `Dir` | `foo/bar` | The directory containing the secret. `DirName` | `bar` | The directory name containing the secret. `Path` | `foo/bar/baz` | The path or full name of the secret. `Name` | `baz` | The last element of the path or short name of the secret. `Content` | `VerySecure` | The generated password. ---