Store initialization ignores hierarchical .gpg-id and passless's own scope
Summary
storage/pass/init/* (the type-state initialization flow) treats the
physical root of the password store (store_path) as if it were also
passless's own operating scope. It never receives the path value (e.g.
"fido2") that PassStorageAdapter otherwise uses for everything else
(get_fido2_path() = store_path.join(&self.path)).
This causes two related problems on any store where passless's scope is a
subdirectory with its own .gpg-id, rather than the store root:
-
False "not initialized" prompt. Uninitialized::check_if_initialized
only checks store_path.join(".gpg-id") directly. If that file doesn't
exist — even though store_path/<path>/.gpg-id does — it concludes the
store isn't initialized and proceeds to prompt_user, which shows a
"Password Store Not Initialized" notification even though the store is
fully functional and already has a valid .gpg-id for passless's scope.
-
Risk of writing a spurious root .gpg-id. If the user goes through
with the (unnecessary) prompt, GpgKeySelected::write_gpg_id writes
unconditionally to store_path.join(".gpg-id"). For a store where other
subtrees already manage independent .gpg-id policies (a common pass
pattern — see resolve_recipients_for_target / find_nearest_gpg_id,
which already implement hierarchical, closest-wins resolution for
reading recipients), this introduces a new ancestor .gpg-id that acts
as a fallback for any target under store_path that doesn't have a more
specific one — an unintended, implicit change to the crypto policy of
parts of the store passless has no business touching.
Root cause
ensure_initialized (storage/pass/init/mod.rs) has this signature:
pub fn ensure_initialized(
store_path: &Path,
gpg_backend: GpgBackend,
allow_create_without_prompt: bool,
) -> Result<()>
It's called from storage/pass/mod.rs as:
self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?;
path (the adapter's own scope, used everywhere else via get_fido2_path())
is simply never passed in. Every state in the chain
(Uninitialized → DirectoryCreated → GpgKeySelected → StoreInitialized → Complete) only carries store_path, so nothing in the initialization flow
can distinguish "the physical root of the repo" from "the directory passless
actually operates on."
Meanwhile, resolve_recipients_for_target / find_nearest_gpg_id in
storage/pass/gpg_id.rs already implement the correct hierarchical lookup
for resolving recipients of a file — closest .gpg-id wins, walking up to
store_root. Initialization doesn't reuse this at all.
Reproduction
Given a store like:
~/.password-store/
├── fido2/
│ └── .gpg-id
├── ID-Pessoal/
│ └── .../.gpg-id
└── ID-Work/
└── .../.gpg-id
(no .gpg-id directly at ~/.password-store/), with passless configured
with path = "fido2":
- Expected: passless recognizes
fido2/.gpg-id as its applicable config and
starts up silently.
- Actual:
check_if_initialized finds no ~/.password-store/.gpg-id,
concludes "not initialized", and fires a desktop notification via dunst
("Password Store Not Initialized") on every relevant startup, even though
the store is correctly configured and has been in use for a while.
Suggested direction
- Thread
path through ensure_initialized and every state struct in
storage/pass/init/ (Uninitialized, DirectoryCreated,
GpgKeySelected, StoreInitialized).
check_if_initialized and write_gpg_id should resolve against
store_path.join(path) using the same walk-up-to-root logic already used
for recipient resolution (a directory-oriented variant that also checks
the scope directory itself, not just its parent, since
find_nearest_gpg_id is written for a file target).
- Only write a new
.gpg-id at store_path.join(path) if nothing
applicable is found anywhere between there and store_path — never write
at store_path directly just because it's the physical root.
initialize_git_repo's git add .gpg-id assumes the file lives directly
at store_path too; it should git add whatever path the .gpg-id
actually ended up at.
Happy to submit a PR with this if it's useful, but wanted to flag the
architecture question first since I don't have visibility into every caller
of ensure_initialized or the test suite.
Unrelated minor findings in gpg_id.rs::parse_gpg_id_content
Not the cause of the above, but noticed while reading:
- The
0x/0X prefix is stripped for hex validation, but the stored
fingerprint keeps whatever prefix form the user wrote — worth deciding on
one canonical internal format.
- The check that rejects short key IDs only checks for exactly 8 hex chars;
IDs of, say, 9–15 characters pass through even though the error message
implies only 16-char long IDs or 40-char fingerprints are accepted.
Note: I used Claude to help me inspect the code and locate the point of failure; the problem is real and exists in my current pass file structure. I discovered this after deciding to delete the .gpg-id from the root and create a new one inside the fido2 directory.
Store initialization ignores hierarchical
.gpg-idand passless's own scopeSummary
storage/pass/init/*(the type-state initialization flow) treats thephysical root of the password store (
store_path) as if it were alsopassless's own operating scope. It never receives the
pathvalue (e.g."fido2") thatPassStorageAdapterotherwise uses for everything else(
get_fido2_path()=store_path.join(&self.path)).This causes two related problems on any store where passless's scope is a
subdirectory with its own
.gpg-id, rather than the store root:False "not initialized" prompt.
Uninitialized::check_if_initializedonly checks
store_path.join(".gpg-id")directly. If that file doesn'texist — even though
store_path/<path>/.gpg-iddoes — it concludes thestore isn't initialized and proceeds to
prompt_user, which shows a"Password Store Not Initialized" notification even though the store is
fully functional and already has a valid
.gpg-idfor passless's scope.Risk of writing a spurious root
.gpg-id. If the user goes throughwith the (unnecessary) prompt,
GpgKeySelected::write_gpg_idwritesunconditionally to
store_path.join(".gpg-id"). For a store where othersubtrees already manage independent
.gpg-idpolicies (a commonpasspattern — see
resolve_recipients_for_target/find_nearest_gpg_id,which already implement hierarchical, closest-wins resolution for
reading recipients), this introduces a new ancestor
.gpg-idthat actsas a fallback for any target under
store_paththat doesn't have a morespecific one — an unintended, implicit change to the crypto policy of
parts of the store passless has no business touching.
Root cause
ensure_initialized(storage/pass/init/mod.rs) has this signature:It's called from
storage/pass/mod.rsas:path(the adapter's own scope, used everywhere else viaget_fido2_path())is simply never passed in. Every state in the chain
(
Uninitialized → DirectoryCreated → GpgKeySelected → StoreInitialized → Complete) only carriesstore_path, so nothing in the initialization flowcan distinguish "the physical root of the repo" from "the directory passless
actually operates on."
Meanwhile,
resolve_recipients_for_target/find_nearest_gpg_idinstorage/pass/gpg_id.rsalready implement the correct hierarchical lookupfor resolving recipients of a file — closest
.gpg-idwins, walking up tostore_root. Initialization doesn't reuse this at all.Reproduction
Given a store like:
(no
.gpg-iddirectly at~/.password-store/), with passless configuredwith
path = "fido2":fido2/.gpg-idas its applicable config andstarts up silently.
check_if_initializedfinds no~/.password-store/.gpg-id,concludes "not initialized", and fires a desktop notification via
dunst("Password Store Not Initialized") on every relevant startup, even though
the store is correctly configured and has been in use for a while.
Suggested direction
paththroughensure_initializedand every state struct instorage/pass/init/(Uninitialized,DirectoryCreated,GpgKeySelected,StoreInitialized).check_if_initializedandwrite_gpg_idshould resolve againststore_path.join(path)using the same walk-up-to-root logic already usedfor recipient resolution (a directory-oriented variant that also checks
the scope directory itself, not just its parent, since
find_nearest_gpg_idis written for a file target)..gpg-idatstore_path.join(path)if nothingapplicable is found anywhere between there and
store_path— never writeat
store_pathdirectly just because it's the physical root.initialize_git_repo'sgit add .gpg-idassumes the file lives directlyat
store_pathtoo; it shouldgit addwhatever path the.gpg-idactually ended up at.
Happy to submit a PR with this if it's useful, but wanted to flag the
architecture question first since I don't have visibility into every caller
of
ensure_initializedor the test suite.Unrelated minor findings in
gpg_id.rs::parse_gpg_id_contentNot the cause of the above, but noticed while reading:
0x/0Xprefix is stripped for hex validation, but the storedfingerprint keeps whatever prefix form the user wrote — worth deciding on
one canonical internal format.
IDs of, say, 9–15 characters pass through even though the error message
implies only 16-char long IDs or 40-char fingerprints are accepted.
Note: I used Claude to help me inspect the code and locate the point of failure; the problem is real and exists in my current
passfile structure. I discovered this after deciding to delete the.gpg-idfrom the root and create a new one inside thefido2directory.