From 69063f7af2ca369f2d5b9cab15108c902e611a2d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 13 Sep 2026 23:46:51 +0800 Subject: [PATCH] feat: allow selecting the Linux credential store explicitly (#142) Add an options bag to Entry/AsyncEntry constructors and withTarget: new Entry(svc, user, { linux: { store: 'secret-service' } }) new Entry(svc, user, { linux: { store: 'keyutils' } }) When a store is pinned, only that store is constructed and any failure propagates, so requiring an unavailable store throws instead of silently falling back to the kernel keyring. Without the option the existing auto-fallback selection is unchanged. The option is accepted on every platform but only meaningful on Linux; for the keyutils store a withTarget target maps to the kernel key description. Also regenerate the napi-js binding loader and type definitions with the current @napi-rs/cli and emit string enums as union types (napi.constEnum=false) so plain string literals typecheck. --- README.md | 26 ++++++++ __test__/index.spec.ts | 86 ++++++++++++++++++++++++- index.d.ts | 64 +++++++++++++++++-- index.js | 87 +++----------------------- package.json | 1 + src/async_entry.rs | 119 ++++++++--------------------------- src/entry.rs | 112 +++++++-------------------------- src/entry_builder.rs | 139 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/options.rs | 38 +++++++++++ 10 files changed, 408 insertions(+), 266 deletions(-) create mode 100644 src/entry_builder.rs create mode 100644 src/options.rs diff --git a/README.md b/README.md index 1c5cd66..22e956d 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,29 @@ const password = entry.getPassword() console.log('My password is ', password) entry.deletePassword() ``` + +# Linux backend selection + +On Linux, `Entry` and `AsyncEntry` pick a credential store automatically: the +[Secret Service](https://crates.io/crates/dbus-secret-service-keyring-store) (gnome-keyring, KWallet, +keepassxc, ...) is tried first, and the binding silently falls back to the +[kernel keyutils keyring](https://crates.io/crates/linux-keyutils-keyring-store) when no Secret Service +is available. + +You can pin an entry to one specific store by passing the options bag as the last argument (the +option is accepted on every platform but only meaningful on Linux): + +```js +new Entry('my_service', 'my_name', { linux: { store: 'secret-service' } }) // require Secret Service +new Entry('my_service', 'my_name', { linux: { store: 'keyutils' } }) // require the kernel keyring +Entry.withTarget('target', 'my_service', 'my_name', { linux: { store: 'keyutils' } }) +``` + +When a store is required, the constructor throws if that store is unavailable — there is no silent +fallback. When `withTarget` is combined with the keyutils store, the target is used as the kernel key +description, so distinct targets keep distinct credentials. + +> **Note:** the keyutils store keeps credentials in kernel memory: per the +> [linux-keyutils-keyring-store](https://crates.io/crates/linux-keyutils-keyring-store) docs, the key +> management facility "is completely in-memory and will not persist across reboots". Prefer +> `secret-service` for credentials that must survive a restart. diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts index 99487c7..57a26ed 100644 --- a/__test__/index.spec.ts +++ b/__test__/index.spec.ts @@ -65,7 +65,7 @@ test('Should handle binary data correctly with setSecret/getSecret', (t) => { test('Should handle binary data correctly with setSecret/getSecret async', async (t) => { const entry = new AsyncEntry(testService, testUser) - // Test with binary data that includes null bytes and high values + // Test with binary data that includes null bytes and high values const binaryData = new Uint8Array([0x00, 0x01, 0x7F, 0x80, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF]) await t.notThrowsAsync(() => entry.setSecret(binaryData)) const retrievedSecret = await entry.getSecret() @@ -154,8 +154,92 @@ if (testTarget && !(process.env.CI && (platform === 'linux' || platform === 'fre t.is(account, testUser) await t.notThrowsAsync(() => entry.deleteCredential()) }) + + test('Entry.withTarget() accepts the options bag', (t) => { + const entry = Entry.withTarget(testTarget!, testService, testUser, { linux: { store: 'keyutils' } }) + t.notThrows(() => entry.setPassword(testPassword)) + t.is(entry.getPassword(), testPassword) + t.notThrows(() => entry.deleteCredential()) + }) } else { test.skip(`Skip testing Entry.withTarget() because of non-supported operating system: ${platform}`, (t) => { t.fail() }) } + +const testLinuxSecretServiceService = 'keyring-node-test-service-linux-secret-service' +const testLinuxKeyutilsService = 'keyring-node-test-service-linux-keyutils' + +if (platform === 'linux') { + test('linux store option: secret-service round-trips', (t) => { + const entry = new Entry(testLinuxSecretServiceService, testUser, { linux: { store: 'secret-service' } }) + t.notThrows(() => entry.setPassword(testPassword)) + t.is(entry.getPassword(), testPassword) + t.true(entry.deleteCredential(), 'clean up the pinned-store credential') + }) + + test('linux store option: keyutils round-trips', (t) => { + const entry = new Entry(testLinuxKeyutilsService, testUser, { linux: { store: 'keyutils' } }) + t.notThrows(() => entry.setPassword(testPassword)) + t.is(entry.getPassword(), testPassword) + t.true(entry.deleteCredential(), 'clean up the pinned-store credential') + }) + + test('linux store option: AsyncEntry secret-service round-trips', async (t) => { + const entry = new AsyncEntry(testLinuxSecretServiceService, testUser, { + linux: { store: 'secret-service' }, + }) + await t.notThrowsAsync(() => entry.setPassword(testPassword)) + t.is(await entry.getPassword(), testPassword) + t.true(await entry.deleteCredential(), 'clean up the pinned-store credential') + }) + + test('linux store option: keyutils targets stay isolated', (t) => { + // The kernel keyring has no `target` modifier; withTarget maps the target + // to the key `description` so distinct targets must address distinct + // kernel credentials even when service and username are identical. + const first = Entry.withTarget('keyring-node-test-keyutils-t1', testLinuxKeyutilsService, testUser, { + linux: { store: 'keyutils' }, + }) + const second = Entry.withTarget('keyring-node-test-keyutils-t2', testLinuxKeyutilsService, testUser, { + linux: { store: 'keyutils' }, + }) + t.notThrows(() => first.setPassword(testPassword)) + t.is(first.getPassword(), testPassword) + t.is(second.getPassword(), null, 'a different target must not see the credential') + t.false(second.deleteCredential(), 'deleting another target must not remove the credential') + t.true(first.deleteCredential(), 'clean up the pinned-store credential') + }) +} else { + test('linux store option is accepted and ignored on non-Linux platforms', (t) => { + const entry = new Entry(testService, testUser, { linux: { store: 'keyutils' } }) + t.notThrows(() => entry.setPassword(testPassword)) + t.is(entry.getPassword(), testPassword) + t.notThrows(() => entry.deleteCredential()) + }) + + test('linux store option is accepted and ignored on non-Linux platforms async', async (t) => { + const entry = new AsyncEntry(testService, testUser, { linux: { store: 'secret-service' } }) + await t.notThrowsAsync(() => entry.setPassword(testPassword)) + t.is(await entry.getPassword(), testPassword) + await t.notThrowsAsync(() => entry.deleteCredential()) + }) +} + +test('linux store option: unknown store values are rejected', (t) => { + // napi-rs validates the string_enum at the JS boundary, before any + // platform-specific logic runs. + t.throws( + () => new Entry(testService, testUser, { linux: { store: 'made-up-store' } } as any), + { message: /does not match any variant of enum/ }, + 'throws on a store value outside the LinuxStore union', + ) +}) + +test('linux store option: store value is constrained to the LinuxStore union at compile time', (t) => { + if (false) { + // @ts-expect-error 'made-up-store' is not a member of the LinuxStore union + new Entry(testService, testUser, { linux: { store: 'made-up-store' } }) + } + t.pass() +}) diff --git a/index.d.ts b/index.d.ts index efdaf87..d0bb6ca 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,14 +5,22 @@ export declare class AsyncEntry { * Create an entry for the given service and username. * * The default credential builder is used. + * + * An optional [EntryOptions] bag controls platform-specific behavior; it is + * accepted on all platforms but currently only used on Linux, where it can + * pin the entry to a specific credential store. */ - constructor(service: string, username: string) + constructor(service: string, username: string, options?: EntryOptions | undefined | null) /** * Create an entry for the given target, service, and username. * * The default credential builder is used. + * + * An optional [EntryOptions] bag controls platform-specific behavior; it is + * accepted on all platforms but currently only used on Linux, where it can + * pin the entry to a specific credential store. */ - static withTarget(target: string, service: string, username: string): AsyncEntry + static withTarget(target: string, service: string, username: string, options?: EntryOptions | undefined | null): AsyncEntry /** * Set the password for this entry. * @@ -91,14 +99,22 @@ export declare class Entry { * Create an entry for the given service and username. * * The default credential builder is used. + * + * An optional [EntryOptions] bag controls platform-specific behavior; it is + * accepted on all platforms but currently only used on Linux, where it can + * pin the entry to a specific credential store. */ - constructor(service: string, username: string) + constructor(service: string, username: string, options?: EntryOptions | undefined | null) /** * Create an entry for the given target, service, and username. * * The default credential builder is used. + * + * An optional [EntryOptions] bag controls platform-specific behavior; it is + * accepted on all platforms but currently only used on Linux, where it can + * pin the entry to a specific credential store. */ - static withTarget(target: string, service: string, username: string): Entry + static withTarget(target: string, service: string, username: string, options?: EntryOptions | undefined | null): Entry /** * Set the password for this entry. * @@ -177,8 +193,48 @@ export interface Credential { password: string } +/** + * Options for creating an `Entry` or `AsyncEntry`. + * + * All options are platform-specific: they are accepted on every platform but + * only take effect where documented. Leaving an option absent keeps the + * current default behavior. + */ +export interface EntryOptions { + /** Linux-only options; ignored on other platforms. */ + linux?: LinuxEntryOptions +} + /** find credentials by service name */ export declare function findCredentials(service: string, target?: string | undefined | null): Array /** find credentials by service name */ export declare function findCredentialsAsync(service: string, target?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise> + +/** Linux-only entry options; ignored on other platforms. */ +export interface LinuxEntryOptions { + /** + * Require a specific Linux credential store. When absent, the default + * auto-fallback selection is used (Secret Service, falling back to the + * kernel keyring). Requiring a store that is unavailable throws instead of + * falling back. + */ + store?: LinuxStore +} + +/** + * A Linux credential store that entries can be pinned to. + * + * Linux only; ignored on other platforms. Requiring a store that is + * unavailable on this machine throws instead of falling back. + */ +export type LinuxStore = /** + * The freedesktop Secret Service (D-Bus) as provided by gnome-keyring or + * KWallet. Persistent daemon-backed storage. + */ +'secret-service'| +/** + * The Linux kernel keyring via keyutils. In-memory only: credentials + * vanish on reboot. + */ +'keyutils'; diff --git a/index.js b/index.js index 39bf307..61c83db 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,9 @@ // @ts-nocheck /* auto-generated by NAPI-RS */ +const { createRequire } = require('node:module') +require = createRequire(__filename) + const { readFileSync } = require('node:fs') let nativeBinding = null const loadErrors = [] @@ -63,7 +66,7 @@ const isMuslFromChildProcess = () => { function requireNative() { if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { try { - return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + nativeBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); } catch (err) { loadErrors.push(err) } @@ -105,24 +108,7 @@ function requireNative() { } } else if (process.platform === 'win32') { if (process.arch === 'x64') { - if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') { - try { - return require('./keyring.win32-x64-gnu.node') - } catch (e) { - loadErrors.push(e) - } try { - const binding = require('@napi-rs/keyring-win32-x64-gnu') - const bindingPackageVersion = require('@napi-rs/keyring-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { return require('./keyring.win32-x64-msvc.node') } catch (e) { loadErrors.push(e) @@ -137,7 +123,6 @@ function requireNative() { } catch (e) { loadErrors.push(e) } - } } else if (process.arch === 'ia32') { try { return require('./keyring.win32-ia32-msvc.node') @@ -363,40 +348,6 @@ function requireNative() { loadErrors.push(e) } } - } else if (process.arch === 'loong64') { - if (isMusl()) { - try { - return require('./keyring.linux-loong64-musl.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@napi-rs/keyring-linux-loong64-musl') - const bindingPackageVersion = require('@napi-rs/keyring-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./keyring.linux-loong64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@napi-rs/keyring-linux-loong64-gnu') - const bindingPackageVersion = require('@napi-rs/keyring-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } } else if (process.arch === 'riscv64') { if (isMusl()) { try { @@ -526,36 +477,22 @@ function requireNative() { nativeBinding = requireNative() if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { - let wasiBinding = null - let wasiBindingError = null try { - wasiBinding = require('./keyring.wasi.cjs') - nativeBinding = wasiBinding + nativeBinding = require('./keyring.wasi.cjs') } catch (err) { if (process.env.NAPI_RS_FORCE_WASI) { - wasiBindingError = err + loadErrors.push(err) } } - if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + if (!nativeBinding) { try { - wasiBinding = require('@napi-rs/keyring-wasm32-wasi') - nativeBinding = wasiBinding + nativeBinding = require('@napi-rs/keyring-wasm32-wasi') } catch (err) { if (process.env.NAPI_RS_FORCE_WASI) { - if (!wasiBindingError) { - wasiBindingError = err - } else { - wasiBindingError.cause = err - } loadErrors.push(err) } } } - if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { - const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') - error.cause = wasiBindingError - throw error - } } if (!nativeBinding) { @@ -564,12 +501,7 @@ if (!nativeBinding) { `Cannot find native binding. ` + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', - { - cause: loadErrors.reduce((err, cur) => { - cur.cause = err - return cur - }), - }, + { cause: loadErrors } ) } throw new Error(`Failed to load native binding`) @@ -580,3 +512,4 @@ module.exports.AsyncEntry = nativeBinding.AsyncEntry module.exports.Entry = nativeBinding.Entry module.exports.findCredentials = nativeBinding.findCredentials module.exports.findCredentialsAsync = nativeBinding.findCredentialsAsync +module.exports.LinuxStore = nativeBinding.LinuxStore diff --git a/package.json b/package.json index 0f17c58..fdae448 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ ], "napi": { "binaryName": "keyring", + "constEnum": false, "targets": [ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", diff --git a/src/async_entry.rs b/src/async_entry.rs index b004d3d..98594fa 100644 --- a/src/async_entry.rs +++ b/src/async_entry.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use napi::bindgen_prelude::*; use napi_derive::napi; -#[cfg(target_os = "linux")] -use crate::linux_credential_builder::LinuxCredentialBuilder; +use crate::entry_builder::create_entry; +use crate::options::EntryOptions; use crate::result::{into_deleted, into_optional}; #[napi] @@ -12,64 +12,19 @@ pub struct AsyncEntry { inner: Arc, } -#[cfg(target_os = "linux")] -fn setup_linux_store() -> anyhow::Result<()> { - let builder = LinuxCredentialBuilder::new()?; - keyring_core::set_default_store(builder.get_store()); - Ok(()) -} - -#[cfg(target_os = "macos")] -fn setup_macos_store() -> anyhow::Result<()> { - use std::collections::HashMap; - - use apple_native_keyring_store::keychain::Store; - - let store = Store::new_with_configuration(&HashMap::new())?; - keyring_core::set_default_store(store); - Ok(()) -} - -#[cfg(target_os = "windows")] -fn setup_windows_store() -> anyhow::Result<()> { - use std::collections::HashMap; - - use windows_native_keyring_store::Store; - - let store = Store::new_with_configuration(&HashMap::new())?; - keyring_core::set_default_store(store); - Ok(()) -} - -#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] -fn setup_bsd_store() -> anyhow::Result<()> { - use std::collections::HashMap; - - use dbus_secret_service_keyring_store::Store; - - let store = Store::new_with_configuration(&HashMap::new())?; - keyring_core::set_default_store(store); - Ok(()) -} - #[napi] impl AsyncEntry { #[napi(constructor)] /// Create an entry for the given service and username. /// /// The default credential builder is used. - pub fn new(service: String, username: String) -> Result { - #[cfg(target_os = "linux")] - setup_linux_store()?; - #[cfg(target_os = "macos")] - setup_macos_store()?; - #[cfg(target_os = "windows")] - setup_windows_store()?; - #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] - setup_bsd_store()?; - + /// + /// An optional [EntryOptions] bag controls platform-specific behavior; it is + /// accepted on all platforms but currently only used on Linux, where it can + /// pin the entry to a specific credential store. + pub fn new(service: String, username: String, options: Option) -> Result { Ok(Self { - inner: Arc::new(keyring_core::Entry::new(&service, &username).map_err(anyhow::Error::from)?), + inner: Arc::new(create_entry(&service, &username, None, options.as_ref())?), }) } @@ -77,46 +32,24 @@ impl AsyncEntry { /// Create an entry for the given target, service, and username. /// /// The default credential builder is used. - pub fn with_target(target: String, service: String, username: String) -> Result { - #[cfg(target_os = "linux")] - setup_linux_store()?; - #[cfg(target_os = "macos")] - setup_macos_store()?; - #[cfg(target_os = "windows")] - setup_windows_store()?; - #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] - setup_bsd_store()?; - - let entry = Self { - inner: Arc::new( - keyring_core::Entry::new_with_modifiers(&service, &username, &{ - let mut mods = std::collections::HashMap::new(); - #[cfg(target_os = "macos")] - mods.insert("keychain", target.as_str()); - #[cfg(not(target_os = "macos"))] - mods.insert("target", target.as_str()); - mods - }) - .map_err(anyhow::Error::from)?, - ), - }; - - // On Windows, when using the target modifier, the username needs to be preserved - // by creating a placeholder credential and setting the username attribute explicitly. - // This is because credentials with explicit targets don't have specifiers in keyring v4. - // When the actual password is set later, set_secret will read and preserve these attributes. - #[cfg(target_os = "windows")] - { - // Create a temporary credential with empty password - if let Ok(_) = entry.inner.set_secret(&[]) { - // Set the username attribute so it's preserved when the real password is set - let mut attrs = std::collections::HashMap::new(); - attrs.insert("username", username.as_str()); - entry.inner.update_attributes(&attrs).ok(); - } - } - - Ok(entry) + /// + /// An optional [EntryOptions] bag controls platform-specific behavior; it is + /// accepted on all platforms but currently only used on Linux, where it can + /// pin the entry to a specific credential store. + pub fn with_target( + target: String, + service: String, + username: String, + options: Option, + ) -> Result { + Ok(Self { + inner: Arc::new(create_entry( + &service, + &username, + Some(&target), + options.as_ref(), + )?), + }) } #[napi(ts_return_type = "Promise")] diff --git a/src/entry.rs b/src/entry.rs index 5ba00c6..7ae8e9e 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -1,8 +1,8 @@ use napi::bindgen_prelude::*; use napi_derive::napi; -#[cfg(target_os = "linux")] -use crate::linux_credential_builder::LinuxCredentialBuilder; +use crate::entry_builder::create_entry; +use crate::options::EntryOptions; use crate::result::{into_deleted, into_optional}; #[napi] @@ -10,64 +10,19 @@ pub struct Entry { inner: keyring_core::Entry, } -#[cfg(target_os = "linux")] -fn setup_linux_store() -> anyhow::Result<()> { - let builder = LinuxCredentialBuilder::new()?; - keyring_core::set_default_store(builder.get_store()); - Ok(()) -} - -#[cfg(target_os = "macos")] -fn setup_macos_store() -> anyhow::Result<()> { - use std::collections::HashMap; - - use apple_native_keyring_store::keychain::Store; - - let store = Store::new_with_configuration(&HashMap::new())?; - keyring_core::set_default_store(store); - Ok(()) -} - -#[cfg(target_os = "windows")] -fn setup_windows_store() -> anyhow::Result<()> { - use std::collections::HashMap; - - use windows_native_keyring_store::Store; - - let store = Store::new_with_configuration(&HashMap::new())?; - keyring_core::set_default_store(store); - Ok(()) -} - -#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] -fn setup_bsd_store() -> anyhow::Result<()> { - use std::collections::HashMap; - - use dbus_secret_service_keyring_store::Store; - - let store = Store::new_with_configuration(&HashMap::new())?; - keyring_core::set_default_store(store); - Ok(()) -} - #[napi] impl Entry { #[napi(constructor)] /// Create an entry for the given service and username. /// /// The default credential builder is used. - pub fn new(service: String, username: String) -> Result { - #[cfg(target_os = "linux")] - setup_linux_store()?; - #[cfg(target_os = "macos")] - setup_macos_store()?; - #[cfg(target_os = "windows")] - setup_windows_store()?; - #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] - setup_bsd_store()?; - + /// + /// An optional [EntryOptions] bag controls platform-specific behavior; it is + /// accepted on all platforms but currently only used on Linux, where it can + /// pin the entry to a specific credential store. + pub fn new(service: String, username: String, options: Option) -> Result { Ok(Self { - inner: keyring_core::Entry::new(&service, &username).map_err(anyhow::Error::from)?, + inner: create_entry(&service, &username, None, options.as_ref())?, }) } @@ -75,44 +30,19 @@ impl Entry { /// Create an entry for the given target, service, and username. /// /// The default credential builder is used. - pub fn with_target(target: String, service: String, username: String) -> Result { - #[cfg(target_os = "linux")] - setup_linux_store()?; - #[cfg(target_os = "macos")] - setup_macos_store()?; - #[cfg(target_os = "windows")] - setup_windows_store()?; - #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] - setup_bsd_store()?; - - let entry = Self { - inner: keyring_core::Entry::new_with_modifiers(&service, &username, &{ - let mut mods = std::collections::HashMap::new(); - #[cfg(target_os = "macos")] - mods.insert("keychain", target.as_str()); - #[cfg(not(target_os = "macos"))] - mods.insert("target", target.as_str()); - mods - }) - .map_err(anyhow::Error::from)?, - }; - - // On Windows, when using the target modifier, the username needs to be preserved - // by creating a placeholder credential and setting the username attribute explicitly. - // This is because credentials with explicit targets don't have specifiers in keyring v4. - // When the actual password is set later, set_secret will read and preserve these attributes. - #[cfg(target_os = "windows")] - { - // Create a temporary credential with empty password - if let Ok(_) = entry.inner.set_secret(&[]) { - // Set the username attribute so it's preserved when the real password is set - let mut attrs = std::collections::HashMap::new(); - attrs.insert("username", username.as_str()); - entry.inner.update_attributes(&attrs).ok(); - } - } - - Ok(entry) + /// + /// An optional [EntryOptions] bag controls platform-specific behavior; it is + /// accepted on all platforms but currently only used on Linux, where it can + /// pin the entry to a specific credential store. + pub fn with_target( + target: String, + service: String, + username: String, + options: Option, + ) -> Result { + Ok(Self { + inner: create_entry(&service, &username, Some(&target), options.as_ref())?, + }) } #[napi] diff --git a/src/entry_builder.rs b/src/entry_builder.rs new file mode 100644 index 0000000..f87a3a4 --- /dev/null +++ b/src/entry_builder.rs @@ -0,0 +1,139 @@ +use std::collections::HashMap; + +use anyhow::Result; + +use crate::options::EntryOptions; + +/// Create a `keyring_core::Entry` from the given specifiers, honoring the +/// optional platform-specific `options`. +/// +/// On Linux, `options.linux.store` pins the entry to one specific credential +/// store: the named store is constructed directly and any failure to do so is +/// propagated, so requiring an unavailable store throws instead of falling +/// back. Without the option, the default auto-fallback selection is used. +/// On all other platforms the options are accepted and ignored. +#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] +pub(crate) fn create_entry( + service: &str, + user: &str, + target: Option<&str>, + options: Option<&EntryOptions>, +) -> Result { + #[cfg(target_os = "linux")] + if let Some(store) = options + .and_then(|o| o.linux.as_ref()) + .and_then(|l| l.store.as_ref()) + { + return create_entry_with_linux_store(service, user, target, store); + } + + #[cfg(target_os = "linux")] + setup_linux_store()?; + #[cfg(target_os = "macos")] + setup_macos_store()?; + #[cfg(target_os = "windows")] + setup_windows_store()?; + #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] + setup_bsd_store()?; + + let entry = match target { + Some(target) => keyring_core::Entry::new_with_modifiers(service, user, &{ + let mut mods = HashMap::new(); + #[cfg(target_os = "macos")] + mods.insert("keychain", target); + #[cfg(not(target_os = "macos"))] + mods.insert("target", target); + mods + }) + .map_err(anyhow::Error::from)?, + None => keyring_core::Entry::new(service, user).map_err(anyhow::Error::from)?, + }; + + // On Windows, when using the target modifier, the username needs to be preserved + // by creating a placeholder credential and setting the username attribute explicitly. + // This is because credentials with explicit targets don't have specifiers in keyring v4. + // When the actual password is set later, set_secret will read and preserve these attributes. + #[cfg(target_os = "windows")] + if target.is_some() { + // Create a temporary credential with empty password + if entry.set_secret(&[]).is_ok() { + // Set the username attribute so it's preserved when the real password is set + let mut attrs = HashMap::new(); + attrs.insert("username", user); + entry.update_attributes(&attrs).ok(); + } + } + + Ok(entry) +} + +/// Build an entry directly from the store pinned in the options, failing +/// loudly if that store cannot be constructed. +/// +/// The kernel keyring store has no `target` modifier; its equivalent identity +/// knob is the `description` modifier, so a given target is mapped to it to +/// keep distinct targets addressing distinct kernel credentials. +#[cfg(target_os = "linux")] +fn create_entry_with_linux_store( + service: &str, + user: &str, + target: Option<&str>, + store: &crate::options::LinuxStore, +) -> Result { + use keyring_core::api::CredentialStoreApi; + + use crate::options::LinuxStore; + + match store { + LinuxStore::SecretService => { + let store = dbus_secret_service_keyring_store::Store::new_with_configuration(&HashMap::new()) + .map_err(anyhow::Error::from)?; + let modifiers = target.map(|t| HashMap::from([("target", t)])); + store + .build(service, user, modifiers.as_ref()) + .map_err(anyhow::Error::from) + } + LinuxStore::Keyutils => { + let store = linux_keyutils_keyring_store::Store::new_with_configuration(&HashMap::new()) + .map_err(anyhow::Error::from)?; + let modifiers = target.map(|t| HashMap::from([("description", t)])); + store + .build(service, user, modifiers.as_ref()) + .map_err(anyhow::Error::from) + } + } +} + +#[cfg(target_os = "linux")] +fn setup_linux_store() -> anyhow::Result<()> { + let builder = crate::linux_credential_builder::LinuxCredentialBuilder::new()?; + keyring_core::set_default_store(builder.get_store()); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn setup_macos_store() -> anyhow::Result<()> { + use apple_native_keyring_store::keychain::Store; + + let store = Store::new_with_configuration(&HashMap::new())?; + keyring_core::set_default_store(store); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn setup_windows_store() -> anyhow::Result<()> { + use windows_native_keyring_store::Store; + + let store = Store::new_with_configuration(&HashMap::new())?; + keyring_core::set_default_store(store); + Ok(()) +} + +#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] +fn setup_bsd_store() -> anyhow::Result<()> { + use dbus_secret_service_keyring_store::Store; + + let store = Store::new_with_configuration(&HashMap::new())?; + keyring_core::set_default_store(store); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index f72e2a7..b303cfa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,9 @@ pub mod async_entry; pub mod entry; +mod entry_builder; #[cfg(target_os = "linux")] mod linux_credential_builder; +pub mod options; mod result; diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..cf4122e --- /dev/null +++ b/src/options.rs @@ -0,0 +1,38 @@ +use napi_derive::napi; + +#[napi(object)] +/// Options for creating an `Entry` or `AsyncEntry`. +/// +/// All options are platform-specific: they are accepted on every platform but +/// only take effect where documented. Leaving an option absent keeps the +/// current default behavior. +pub struct EntryOptions { + /// Linux-only options; ignored on other platforms. + pub linux: Option, +} + +#[napi(object)] +/// Linux-only entry options; ignored on other platforms. +pub struct LinuxEntryOptions { + /// Require a specific Linux credential store. When absent, the default + /// auto-fallback selection is used (Secret Service, falling back to the + /// kernel keyring). Requiring a store that is unavailable throws instead of + /// falling back. + pub store: Option, +} + +#[napi(string_enum)] +/// A Linux credential store that entries can be pinned to. +/// +/// Linux only; ignored on other platforms. Requiring a store that is +/// unavailable on this machine throws instead of falling back. +pub enum LinuxStore { + /// The freedesktop Secret Service (D-Bus) as provided by gnome-keyring or + /// KWallet. Persistent daemon-backed storage. + #[napi(value = "secret-service")] + SecretService, + /// The Linux kernel keyring via keyutils. In-memory only: credentials + /// vanish on reboot. + #[napi(value = "keyutils")] + Keyutils, +}