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, +}