Summary
deleteCredential() / deletePassword() collapse every backend error into false via .is_ok(). Because false is also the legitimate "there was no such credential" result, callers cannot distinguish "nothing to delete" from "the delete failed and the secret is still in the OS keyring".
The same erasure applies to getSecret() via .ok().
PR #136 fixes exactly this class of bug, but only for getPassword(). The delete and secret paths are untouched, so the most security-relevant instance remains.
Why this one matters more than the read path
A read that silently returns undefined degrades into a cache miss or a re-auth prompt — annoying, but fail-safe.
A delete that silently returns false is fail-open: the calling application believes a credential has been revoked and reports success to its user, while the secret remains readable in the Keychain / Credential Manager / secret-service. Callers implementing "log out", "rotate token", or "forget this account" have no way to detect the failure, and no amount of care on the JavaScript side can recover information that was already discarded in Rust.
This is not hypothetical for us: we consume @napi-rs/keyring@1.3.0 as the default backend of a secret store, and we recently hardened our delete() to classify and rethrow keyring rejections — only to find that the native binding never rejects here, so the hardening cannot fire.
Affected sites (v1.3.0, unchanged on main @ 3e7bcc4)
For comparison, PR #136 covers only entry.rs#L156-L158 and async_entry.rs#L253.
As in #136, the information is available and is discarded only at this wrapper layer: keyring-core returns a full Result, and the platform stores map errSecItemNotFound to NoEntry while mapping access failures to NoStorageAccess and other failures to PlatformFailure.
The doc comments already describe the intended behaviour
Every one of these functions carries a doc comment that the signature makes unreachable:
/// Delete the underlying credential for this entry.
///
/// Returns a [NoEntry](Error::NoEntry) error if there isn't one.
///
/// Can return an [Ambiguous](Error::Ambiguous) error
/// if there is more than one platform credential
/// that matches this entry.
pub fn delete_credential(&self) -> bool {
Ambiguous is worth calling out: it means a third-party app wrote a colliding credential and the store cannot tell which one to delete. That is precisely the case where silently answering false is most damaging, and the doc comment promises it will surface.
Reproduction
Any backend error works; the easiest deterministic one is the same error class asserted in #136's own new test (NoStorageAccess):
- Store a credential.
- Put the store into a state where deletion fails but the item still exists — for example lock the login keychain on macOS, or deny the process access to the item's ACL.
- Call
await new AsyncEntry(service, account).deleteCredential().
Expected: rejection (or at minimum something distinguishable from "absent").
Actual: resolves false, identical to a delete of a never-existing entry. Re-reading the entry afterwards still returns the secret.
Proposed fix
Mirror #136's into_optional_password helper for the delete path, so NoEntry remains the benign case and everything else propagates:
pub(crate) fn into_delete_result(result: keyring_core::Result<()>) -> Result<bool> {
match result {
Ok(()) => Ok(true),
Err(keyring_core::Error::NoEntry) => Ok(false),
Err(error) => Err(anyhow::Error::from(error).into()),
}
}
and the analogous into_optional_secret for sites 5 and 6.
This preserves the existing boolean return type and keeps the common "deleted" / "wasn't there" outcomes exactly as they are today. It is still a behavioural break for anyone currently relying on a failed delete resolving false, so it likely belongs in the same release as #136 — a single release that makes error propagation consistent across read and delete seems much better for consumers than two staggered breaks.
Happy to open a PR for this if you would like, either standalone or rebased on top of #136 so the helpers live together.
Secondary findings
Lower severity, same theme, listed for completeness rather than as part of the ask:
AsyncEntry::delete_password is typed Promise<unknown>. It lacks the #[napi(ts_return_type = "Promise<boolean>")] attribute that delete_credential carries (async_entry.rs#L224-L227), so the generated index.d.ts declares deletePassword(signal?): Promise<unknown> right next to deleteCredential(signal?): Promise<boolean>. This is the alias the bundled keytar.js shim calls, so the keytar-compat surface gets both the erased error and an unusable type.
- Windows
with_target discards attribute-preservation failures. entry.rs#L104-L113 and async_entry.rs#L108-L117 use if let Ok(_) = set_secret(&[]) plus update_attributes(&attrs).ok(), so a failure to persist the username attribute is invisible and surfaces later as a credential that cannot be found by username.
- Linux store selection falls back silently.
linux_credential_builder.rs#L17-L20 drops the secret-service error with Err(_) and falls back to keyutils. Since the kernel keyutils store is typically session-scoped rather than persistent, a user whose secret-service is unavailable silently gets non-persistent credential storage with no diagnostic. Even keeping the fallback, logging or exposing which store was selected would help.
- macOS
find_credentials silently drops unreadable entries. The filter_map at entry.rs#L340-L370 omits any account whose get_generic_password fails, so an inaccessible credential is indistinguishable from a nonexistent one in the returned array.
Thanks for maintaining this — the binding is otherwise a very clean keytar replacement, which is exactly why the silent-delete case caught us off guard.
Summary
deleteCredential()/deletePassword()collapse every backend error intofalsevia.is_ok(). Becausefalseis also the legitimate "there was no such credential" result, callers cannot distinguish "nothing to delete" from "the delete failed and the secret is still in the OS keyring".The same erasure applies to
getSecret()via.ok().PR #136 fixes exactly this class of bug, but only for
getPassword(). The delete and secret paths are untouched, so the most security-relevant instance remains.Why this one matters more than the read path
A read that silently returns
undefineddegrades into a cache miss or a re-auth prompt — annoying, but fail-safe.A delete that silently returns
falseis fail-open: the calling application believes a credential has been revoked and reports success to its user, while the secret remains readable in the Keychain / Credential Manager / secret-service. Callers implementing "log out", "rotate token", or "forget this account" have no way to detect the failure, and no amount of care on the JavaScript side can recover information that was already discarded in Rust.This is not hypothetical for us: we consume
@napi-rs/keyring@1.3.0as the default backend of a secret store, and we recently hardened ourdelete()to classify and rethrow keyring rejections — only to find that the native binding never rejects here, so the hardening cannot fire.Affected sites (v1.3.0, unchanged on
main@ 3e7bcc4)Entry::delete_credential.is_ok()->boolEntry::delete_passwordEntryTask::compute(DeleteCredential).is_ok()->Option<bool>AsyncEntry::delete_passwordEntry::get_secret.ok()->Option<Vec<u8>>SecretTask::compute.ok()->Option<Vec<u8>>For comparison, PR #136 covers only
entry.rs#L156-L158andasync_entry.rs#L253.As in #136, the information is available and is discarded only at this wrapper layer:
keyring-corereturns a fullResult, and the platform stores maperrSecItemNotFoundtoNoEntrywhile mapping access failures toNoStorageAccessand other failures toPlatformFailure.The doc comments already describe the intended behaviour
Every one of these functions carries a doc comment that the signature makes unreachable:
Ambiguousis worth calling out: it means a third-party app wrote a colliding credential and the store cannot tell which one to delete. That is precisely the case where silently answeringfalseis most damaging, and the doc comment promises it will surface.Reproduction
Any backend error works; the easiest deterministic one is the same error class asserted in #136's own new test (
NoStorageAccess):await new AsyncEntry(service, account).deleteCredential().Expected: rejection (or at minimum something distinguishable from "absent").
Actual: resolves
false, identical to a delete of a never-existing entry. Re-reading the entry afterwards still returns the secret.Proposed fix
Mirror #136's
into_optional_passwordhelper for the delete path, soNoEntryremains the benign case and everything else propagates:and the analogous
into_optional_secretfor sites 5 and 6.This preserves the existing
booleanreturn type and keeps the common "deleted" / "wasn't there" outcomes exactly as they are today. It is still a behavioural break for anyone currently relying on a failed delete resolvingfalse, so it likely belongs in the same release as #136 — a single release that makes error propagation consistent across read and delete seems much better for consumers than two staggered breaks.Happy to open a PR for this if you would like, either standalone or rebased on top of #136 so the helpers live together.
Secondary findings
Lower severity, same theme, listed for completeness rather than as part of the ask:
AsyncEntry::delete_passwordis typedPromise<unknown>. It lacks the#[napi(ts_return_type = "Promise<boolean>")]attribute thatdelete_credentialcarries (async_entry.rs#L224-L227), so the generatedindex.d.tsdeclaresdeletePassword(signal?): Promise<unknown>right next todeleteCredential(signal?): Promise<boolean>. This is the alias the bundledkeytar.jsshim calls, so the keytar-compat surface gets both the erased error and an unusable type.with_targetdiscards attribute-preservation failures.entry.rs#L104-L113andasync_entry.rs#L108-L117useif let Ok(_) = set_secret(&[])plusupdate_attributes(&attrs).ok(), so a failure to persist the username attribute is invisible and surfaces later as a credential that cannot be found by username.linux_credential_builder.rs#L17-L20drops the secret-service error withErr(_)and falls back to keyutils. Since the kernel keyutils store is typically session-scoped rather than persistent, a user whose secret-service is unavailable silently gets non-persistent credential storage with no diagnostic. Even keeping the fallback, logging or exposing which store was selected would help.find_credentialssilently drops unreadable entries. Thefilter_mapatentry.rs#L340-L370omits any account whoseget_generic_passwordfails, so an inaccessible credential is indistinguishable from a nonexistent one in the returned array.Thanks for maintaining this — the binding is otherwise a very clean keytar replacement, which is exactly why the silent-delete case caught us off guard.