Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
86 changes: 85 additions & 1 deletion __test__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})
64 changes: 60 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<Credential>

/** find credentials by service name */
export declare function findCredentialsAsync(service: string, target?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise<Array<Credential>>

/** 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';
87 changes: 10 additions & 77 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -137,7 +123,6 @@ function requireNative() {
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'ia32') {
try {
return require('./keyring.win32-ia32-msvc.node')
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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`)
Expand All @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
],
"napi": {
"binaryName": "keyring",
"constEnum": false,
"targets": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
Expand Down
Loading
Loading