@noy-db/at-macos-keychain 0.6.0 → 0.7.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/dist/index.d.ts +8 -8
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -19,13 +19,13 @@ pnpm add @noy-db/hub @noy-db/at-macos-keychain @napi-rs/keyring
|
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
import { createNoydb } from '@noy-db/hub'
|
|
22
|
-
import {
|
|
22
|
+
import { atMacosKeychain } from '@noy-db/at-macos-keychain'
|
|
23
23
|
|
|
24
24
|
const db = await createNoydb({
|
|
25
25
|
store,
|
|
26
26
|
user: 'alice',
|
|
27
27
|
secretMode: 'managed',
|
|
28
|
-
sealingKey:
|
|
28
|
+
sealingKey: atMacosKeychain({
|
|
29
29
|
service: 'com.acme.app', // your app's bundle id
|
|
30
30
|
account: 'alice@acme.example', // per-user keychain item
|
|
31
31
|
}),
|
|
@@ -86,18 +86,18 @@ of the project makes.
|
|
|
86
86
|
## API
|
|
87
87
|
|
|
88
88
|
```ts
|
|
89
|
-
function
|
|
89
|
+
function atMacosKeychain(opts: {
|
|
90
90
|
service: string // your app bundle id / namespace
|
|
91
91
|
account: string // per-user identifier
|
|
92
92
|
entry?: KeychainEntry // internal test injection — leave undefined in production
|
|
93
|
-
}):
|
|
93
|
+
}): NoydbSealer
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Returns a [`
|
|
96
|
+
Returns a [`NoydbSealer`](../hub/src/port/at/index.ts) — importable as `@noy-db/hub/at` — the contract `@noy-db/hub`'s managed-secret mode consumes.
|
|
97
97
|
|
|
98
98
|
Throws at construction when `service` or `account` is empty, or when running on a non-darwin platform without a test stub.
|
|
99
99
|
|
|
100
|
-
The provider exposes only `
|
|
100
|
+
The provider exposes only `NoydbSealer` — not `RecipientSealer`. It is **self-targeted only**: it can seal and unseal locally, but cannot seal for an arbitrary recipient (no public-half to publish). Bundle-handover delivery to arbitrary recipients requires a handover-capable cloud-KMS provider.
|
|
101
101
|
|
|
102
102
|
## Key lifecycle
|
|
103
103
|
|
|
@@ -113,7 +113,7 @@ If a vault is being retired, call `entry.deletePassword()` directly on `@napi-rs
|
|
|
113
113
|
For platform-independent unit tests, inject a memory-backed entry via the `entry` option:
|
|
114
114
|
|
|
115
115
|
```ts
|
|
116
|
-
import {
|
|
116
|
+
import { atMacosKeychain, type KeychainEntry } from '@noy-db/at-macos-keychain'
|
|
117
117
|
|
|
118
118
|
function memoryEntry(): KeychainEntry {
|
|
119
119
|
let stored: string | null = null
|
|
@@ -124,7 +124,7 @@ function memoryEntry(): KeychainEntry {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
const provider =
|
|
127
|
+
const provider = atMacosKeychain({
|
|
128
128
|
service: 'com.acme.test',
|
|
129
129
|
account: 'test',
|
|
130
130
|
entry: memoryEntry(),
|
|
@@ -138,7 +138,7 @@ For real-Keychain integration tests on darwin CI runners, leave `entry` undefine
|
|
|
138
138
|
## Related
|
|
139
139
|
|
|
140
140
|
- [`@noy-db/at-env`](../at-env) — env-var sealing for server / container deployments.
|
|
141
|
-
- [`@noy-db/hub`](../hub) — the database core that consumes `
|
|
141
|
+
- [`@noy-db/hub`](../hub) — the database core that consumes `NoydbSealer`.
|
|
142
142
|
- [Sealing pid stability rule](https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/sealing-pid-stability.md)
|
|
143
143
|
|
|
144
144
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NoydbSealer } from '@noy-db/hub/at';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* **@noy-db/at-macos-keychain** — macOS Keychain sealing key provider
|
|
@@ -35,13 +35,13 @@ import { SealingKeyProvider } from '@noy-db/hub';
|
|
|
35
35
|
*
|
|
36
36
|
* ```ts
|
|
37
37
|
* import { createNoydb } from '@noy-db/hub'
|
|
38
|
-
* import {
|
|
38
|
+
* import { atMacosKeychain } from '@noy-db/at-macos-keychain'
|
|
39
39
|
*
|
|
40
40
|
* const db = await createNoydb({
|
|
41
41
|
* store,
|
|
42
42
|
* user: 'alice',
|
|
43
43
|
* secretMode: 'managed',
|
|
44
|
-
* sealingKey:
|
|
44
|
+
* sealingKey: atMacosKeychain({
|
|
45
45
|
* service: 'com.acme.app', // your bundle id / app namespace
|
|
46
46
|
* account: 'alice@acme.example', // per-user keychain item
|
|
47
47
|
* }),
|
|
@@ -103,7 +103,7 @@ interface KeychainEntry {
|
|
|
103
103
|
deletePassword(): boolean;
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
|
-
* Options for {@link
|
|
106
|
+
* Options for {@link atMacosKeychain}.
|
|
107
107
|
*
|
|
108
108
|
* The pair `(service, account)` becomes the Keychain lookup key. By
|
|
109
109
|
* convention:
|
|
@@ -114,7 +114,7 @@ interface KeychainEntry {
|
|
|
114
114
|
* user id, e.g. `'alice@acme.example'`. Different users = different
|
|
115
115
|
* Keychain entries.
|
|
116
116
|
*/
|
|
117
|
-
interface
|
|
117
|
+
interface AtMacosKeychainOptions {
|
|
118
118
|
/** Keychain service identifier — typically your app's bundle id. */
|
|
119
119
|
readonly service: string;
|
|
120
120
|
/** Per-user account identifier inside the service. */
|
|
@@ -130,7 +130,7 @@ interface MacosKeychainSealingProviderOptions {
|
|
|
130
130
|
readonly entry?: KeychainEntry;
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
* Build a {@link
|
|
133
|
+
* Build a {@link NoydbSealer} backed by a macOS Keychain entry.
|
|
134
134
|
*
|
|
135
135
|
* The 32-byte AES-256-GCM sealing key is generated on first use and
|
|
136
136
|
* persisted in the Keychain under `(service, account)`. Subsequent
|
|
@@ -146,6 +146,6 @@ interface MacosKeychainSealingProviderOptions {
|
|
|
146
146
|
* or on non-darwin platforms (with a pointer to platform-appropriate
|
|
147
147
|
* providers).
|
|
148
148
|
*/
|
|
149
|
-
declare function
|
|
149
|
+
declare function atMacosKeychain(opts: AtMacosKeychainOptions): NoydbSealer;
|
|
150
150
|
|
|
151
|
-
export { type
|
|
151
|
+
export { type AtMacosKeychainOptions, type KeychainEntry, atMacosKeychain };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
var require2 = createRequire(import.meta.url);
|
|
4
|
-
function
|
|
4
|
+
function atMacosKeychain(opts) {
|
|
5
5
|
if (!opts.service || typeof opts.service !== "string") {
|
|
6
6
|
throw new Error(
|
|
7
7
|
'@noy-db/at-macos-keychain: `service` is required, must be a non-empty string. Typically your app bundle id (e.g., "com.acme.app").'
|
|
@@ -114,6 +114,6 @@ function base64ToBytes(b64) {
|
|
|
114
114
|
return out;
|
|
115
115
|
}
|
|
116
116
|
export {
|
|
117
|
-
|
|
117
|
+
atMacosKeychain
|
|
118
118
|
};
|
|
119
119
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/at-macos-keychain** — macOS Keychain sealing key provider\n * for noy-db [managed-secret mode](https://github.com/vLannaAi/noy-db/issues/14).\n *\n * Desktop-app provider in the `at-*` family. Binds the sealing key to\n * the user's macOS login Keychain — accessible only to processes\n * running as the same user, optionally gated by Touch ID via Keychain\n * Access UI (a one-time per-entry toggle the user enables themselves;\n * this package does not gate that programmatically).\n *\n * ## When to use\n *\n * - Desktop apps where the user expects \"log into my Mac = open the\n * vault.\" The Keychain entry persists across reboots, scoped to\n * the user account.\n * - Apps where `at-env` is unsuitable — laptops or shared dev machines\n * where other users with shell access can `echo $NOYDB_SEALING_KEY`\n * and exfiltrate the key.\n *\n * ## When NOT to use\n *\n * - Server-side / containerized deployments — there is no Keychain\n * there. Use `@noy-db/at-env` or `@noy-db/at-aws-kms` (when it\n * ships).\n * - Browser apps — Keychain is a native OS feature, not exposed to\n * browser sandboxes. Use `@noy-db/at-webauthn-prf` (when it ships).\n *\n * ## Setup\n *\n * ```bash\n * pnpm add @noy-db/hub @noy-db/at-macos-keychain @napi-rs/keyring\n * ```\n *\n * ```ts\n * import { createNoydb } from '@noy-db/hub'\n * import { macosKeychainSealingProvider } from '@noy-db/at-macos-keychain'\n *\n * const db = await createNoydb({\n * store,\n * user: 'alice',\n * secretMode: 'managed',\n * sealingKey: macosKeychainSealingProvider({\n * service: 'com.acme.app', // your bundle id / app namespace\n * account: 'alice@acme.example', // per-user keychain item\n * }),\n * })\n * ```\n *\n * First call generates a fresh 32-byte AES-256 key, stores it in the\n * Keychain under `(service, account)`, and uses it to seal the\n * hub-generated managed secret. Subsequent process launches\n * retrieve the same key and unseal transparently.\n *\n * ## Threat model\n *\n * The Keychain entry IS the security boundary. Strength bounded by:\n *\n * - **macOS user-account isolation.** Other processes running as the\n * same user can read the entry. macOS App Sandboxing limits this\n * for App Store apps; unsandboxed apps must trust co-resident\n * processes.\n * - **Login keychain lock state.** When the user's login keychain\n * is locked (default: never, unless explicitly configured), reads\n * surface a prompt or fail. Apps that need to operate when the\n * keychain is locked should consider explicit unlock UX.\n * - **Touch ID upgrade.** Users may add Touch ID gating to the\n * Keychain entry via Keychain Access.app → right-click → Get Info\n * → Access Control. This is opt-in per entry and out of band of\n * this package's API.\n *\n * Does NOT protect against:\n *\n * - Malware running as the same user with Keychain access.\n * - A physically present attacker who knows the user's login\n * password and can unlock the keychain.\n * - macOS itself being compromised below the Keychain Services\n * layer.\n *\n * @packageDocumentation\n */\n\nimport { createRequire } from 'node:module'\n\nimport type { SealingKeyProvider } from '@noy-db/hub'\n\n// ESM has no ambient `require`. Synthesize one bound to this module's URL so\n// the native `@napi-rs/keyring` addon (a CJS-only N-API binary) can still be\n// lazily loaded on darwin. This package is Node/darwin-only by definition, so\n// node:module is always available here.\nconst require = createRequire(import.meta.url)\n\n/**\n * Structural shape of `@napi-rs/keyring`'s `Entry` class.\n *\n * Defined here as a structural type rather than importing the\n * concrete class so that:\n *\n * 1. Test code can inject a memory-backed stub for cross-platform\n * unit tests (the real Entry only works on darwin/win32/linux).\n * 2. We don't pin a specific version of `@napi-rs/keyring`'s\n * exported types — the structural match is exact across 1.x.\n *\n * @public\n */\nexport interface KeychainEntry {\n /** Returns the stored secret, or `null` when no entry exists. */\n getPassword(): string | null\n /** Persists the secret. Overwrites any existing value. */\n setPassword(value: string): void\n /** Removes the entry. Returns `true` if an entry was removed. */\n deletePassword(): boolean\n}\n\n/**\n * Options for {@link macosKeychainSealingProvider}.\n *\n * The pair `(service, account)` becomes the Keychain lookup key. By\n * convention:\n *\n * - `service` is your app's bundle id or stable app namespace, e.g.\n * `'com.acme.app'`. Stable across users; identifies the app.\n * - `account` is per-user, typically the user's email or stable\n * user id, e.g. `'alice@acme.example'`. Different users = different\n * Keychain entries.\n */\nexport interface MacosKeychainSealingProviderOptions {\n /** Keychain service identifier — typically your app's bundle id. */\n readonly service: string\n /** Per-user account identifier inside the service. */\n readonly account: string\n /**\n * Test-injection hook. Pass a pre-constructed `KeychainEntry`\n * (e.g., a memory-backed stub) to bypass the real Keychain. The\n * production code path defaults to `new Entry(service, account)`\n * from `@napi-rs/keyring`.\n *\n * @internal — production callers should leave undefined.\n */\n readonly entry?: KeychainEntry\n}\n\n/**\n * Build a {@link SealingKeyProvider} backed by a macOS Keychain entry.\n *\n * The 32-byte AES-256-GCM sealing key is generated on first use and\n * persisted in the Keychain under `(service, account)`. Subsequent\n * calls (this process or any future process running as the same\n * user) retrieve the same key — the vault round-trips across\n * restarts.\n *\n * Provider `id` format: `macos-keychain:{service}/{account}`. The\n * format is semver-frozen per the §11.9.1 stability rule; see\n * `https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/sealing-pid-stability.md`.\n *\n * @throws Error at construction if `service` or `account` is empty,\n * or on non-darwin platforms (with a pointer to platform-appropriate\n * providers).\n */\nexport function macosKeychainSealingProvider(\n opts: MacosKeychainSealingProviderOptions,\n): SealingKeyProvider {\n if (!opts.service || typeof opts.service !== 'string') {\n throw new Error(\n '@noy-db/at-macos-keychain: `service` is required, must be a non-empty string. '\n + 'Typically your app bundle id (e.g., \"com.acme.app\").',\n )\n }\n if (!opts.account || typeof opts.account !== 'string') {\n throw new Error(\n '@noy-db/at-macos-keychain: `account` is required, must be a non-empty string. '\n + 'Typically the user\\'s email or stable user id (e.g., \"alice@acme.example\").',\n )\n }\n\n // Hard platform check unless the caller supplied a test stub.\n // Per §11.9.1 spec Q.3: fail loudly on the wrong platform.\n if (opts.entry === undefined && process.platform !== 'darwin') {\n throw new Error(\n `@noy-db/at-macos-keychain: refusing to construct provider on `\n + `platform \"${process.platform}\". This package only operates on `\n + 'darwin (macOS). For other platforms use @noy-db/at-env (server), '\n + '@noy-db/at-wincred (Windows desktop), @noy-db/at-libsecret (Linux desktop), '\n + 'or @noy-db/at-webauthn-prf (browser).',\n )\n }\n\n // Resolved lazily so that production callers don't pay the require\n // cost (or fail loudly on wrong platform) until they actually invoke\n // seal/unseal. Construction-site failure of new Entry() on unsupported\n // platforms is caught and rethrown with a clearer message.\n let resolvedEntry: KeychainEntry | undefined = opts.entry\n const getEntry = (): KeychainEntry => {\n if (resolvedEntry) return resolvedEntry\n // Dynamic require so non-darwin imports don't crash on package load.\n // The platform check above guards production callers; this branch\n // only runs for production (opts.entry was undefined) on darwin.\n /* eslint-disable @typescript-eslint/no-require-imports */\n let Entry: new (service: string, account: string) => KeychainEntry\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod = require('@napi-rs/keyring') as { Entry: any }\n Entry = mod.Entry\n } catch (err) {\n throw new Error(\n '@noy-db/at-macos-keychain: failed to load `@napi-rs/keyring`. '\n + 'Ensure it is installed (`pnpm add @napi-rs/keyring`) and that '\n + 'the platform-specific binary for darwin is available. '\n + 'Original error: '\n + (err instanceof Error ? err.message : String(err)),\n )\n }\n /* eslint-enable */\n resolvedEntry = new Entry(opts.service, opts.account)\n return resolvedEntry\n }\n\n // Cache the imported CryptoKey for the lifetime of this provider\n // instance. Matches at-env's pattern; avoids a Keychain round-trip\n // per seal/unseal operation in long-running processes.\n let cachedKey: Promise<CryptoKey> | null = null\n const getKey = (): Promise<CryptoKey> => {\n if (!cachedKey) {\n cachedKey = (async (): Promise<CryptoKey> => {\n const entry = getEntry()\n let stored = entry.getPassword()\n if (stored === null) {\n // First call ever for this (service, account) — generate a\n // fresh 32-byte AES-256 key, base64-encode for string\n // storage, persist.\n const fresh = new Uint8Array(32)\n globalThis.crypto.getRandomValues(fresh)\n entry.setPassword(bytesToBase64(fresh))\n stored = entry.getPassword()\n if (stored === null) {\n throw new Error(\n '@noy-db/at-macos-keychain: setPassword succeeded but '\n + 'getPassword returned null immediately after. '\n + 'Keychain may be denying access or the entry was '\n + 'deleted out from under us.',\n )\n }\n }\n const keyBytes = base64ToBytes(stored)\n if (keyBytes.length !== 32) {\n throw new Error(\n `@noy-db/at-macos-keychain: stored key for service=\"${opts.service}\" `\n + `account=\"${opts.account}\" decodes to ${keyBytes.length} bytes; `\n + 'expected 32. The Keychain entry may have been tampered with — '\n + 'delete the entry (Keychain Access.app → search → right-click → Delete) '\n + 'and reopen the vault to regenerate.',\n )\n }\n return globalThis.crypto.subtle.importKey(\n 'raw',\n keyBytes as unknown as BufferSource,\n 'AES-GCM',\n false,\n ['encrypt', 'decrypt'],\n )\n })()\n }\n return cachedKey\n }\n\n return {\n id: `macos-keychain:${opts.service}/${opts.account}`,\n\n async seal(secret: Uint8Array): Promise<Uint8Array> {\n const key = await getKey()\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const ciphertext = await globalThis.crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n key,\n secret as unknown as BufferSource,\n )\n // Output format: [12-byte IV][ciphertext + 16-byte GCM tag]\n // Identical wire layout to at-env, by design — the hub envelope\n // dispatches on pid, not on per-provider format.\n const out = new Uint8Array(12 + ciphertext.byteLength)\n out.set(iv, 0)\n out.set(new Uint8Array(ciphertext), 12)\n return out\n },\n\n async unseal(sealed: Uint8Array): Promise<Uint8Array> {\n // 12-byte IV + ≥ 16-byte GCM tag minimum.\n if (sealed.length < 12 + 16) {\n throw new Error(\n `@noy-db/at-macos-keychain: sealed bytes too short (${sealed.length} < 28). `\n + 'Input is not a valid at-macos-keychain-sealed envelope.',\n )\n }\n const iv = sealed.subarray(0, 12)\n const body = sealed.subarray(12)\n const key = await getKey()\n const plaintext = await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n key,\n body as unknown as BufferSource,\n )\n return new Uint8Array(plaintext)\n },\n }\n}\n\n// ─── base64 helpers (browser + node compatible) ──────────────────────────\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let binary = ''\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)\n return btoa(binary)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64.trim())) {\n throw new Error('input contains characters outside the base64 alphabet')\n }\n const binary = atob(b64)\n const out = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":["require"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/at-macos-keychain** — macOS Keychain sealing key provider\n * for noy-db [managed-secret mode](https://github.com/vLannaAi/noy-db/issues/14).\n *\n * Desktop-app provider in the `at-*` family. Binds the sealing key to\n * the user's macOS login Keychain — accessible only to processes\n * running as the same user, optionally gated by Touch ID via Keychain\n * Access UI (a one-time per-entry toggle the user enables themselves;\n * this package does not gate that programmatically).\n *\n * ## When to use\n *\n * - Desktop apps where the user expects \"log into my Mac = open the\n * vault.\" The Keychain entry persists across reboots, scoped to\n * the user account.\n * - Apps where `at-env` is unsuitable — laptops or shared dev machines\n * where other users with shell access can `echo $NOYDB_SEALING_KEY`\n * and exfiltrate the key.\n *\n * ## When NOT to use\n *\n * - Server-side / containerized deployments — there is no Keychain\n * there. Use `@noy-db/at-env` or `@noy-db/at-aws-kms` (when it\n * ships).\n * - Browser apps — Keychain is a native OS feature, not exposed to\n * browser sandboxes. Use `@noy-db/at-webauthn-prf` (when it ships).\n *\n * ## Setup\n *\n * ```bash\n * pnpm add @noy-db/hub @noy-db/at-macos-keychain @napi-rs/keyring\n * ```\n *\n * ```ts\n * import { createNoydb } from '@noy-db/hub'\n * import { atMacosKeychain } from '@noy-db/at-macos-keychain'\n *\n * const db = await createNoydb({\n * store,\n * user: 'alice',\n * secretMode: 'managed',\n * sealingKey: atMacosKeychain({\n * service: 'com.acme.app', // your bundle id / app namespace\n * account: 'alice@acme.example', // per-user keychain item\n * }),\n * })\n * ```\n *\n * First call generates a fresh 32-byte AES-256 key, stores it in the\n * Keychain under `(service, account)`, and uses it to seal the\n * hub-generated managed secret. Subsequent process launches\n * retrieve the same key and unseal transparently.\n *\n * ## Threat model\n *\n * The Keychain entry IS the security boundary. Strength bounded by:\n *\n * - **macOS user-account isolation.** Other processes running as the\n * same user can read the entry. macOS App Sandboxing limits this\n * for App Store apps; unsandboxed apps must trust co-resident\n * processes.\n * - **Login keychain lock state.** When the user's login keychain\n * is locked (default: never, unless explicitly configured), reads\n * surface a prompt or fail. Apps that need to operate when the\n * keychain is locked should consider explicit unlock UX.\n * - **Touch ID upgrade.** Users may add Touch ID gating to the\n * Keychain entry via Keychain Access.app → right-click → Get Info\n * → Access Control. This is opt-in per entry and out of band of\n * this package's API.\n *\n * Does NOT protect against:\n *\n * - Malware running as the same user with Keychain access.\n * - A physically present attacker who knows the user's login\n * password and can unlock the keychain.\n * - macOS itself being compromised below the Keychain Services\n * layer.\n *\n * @packageDocumentation\n */\n\nimport { createRequire } from 'node:module'\n\nimport type { NoydbSealer } from '@noy-db/hub/at'\n\n// ESM has no ambient `require`. Synthesize one bound to this module's URL so\n// the native `@napi-rs/keyring` addon (a CJS-only N-API binary) can still be\n// lazily loaded on darwin. This package is Node/darwin-only by definition, so\n// node:module is always available here.\nconst require = createRequire(import.meta.url)\n\n/**\n * Structural shape of `@napi-rs/keyring`'s `Entry` class.\n *\n * Defined here as a structural type rather than importing the\n * concrete class so that:\n *\n * 1. Test code can inject a memory-backed stub for cross-platform\n * unit tests (the real Entry only works on darwin/win32/linux).\n * 2. We don't pin a specific version of `@napi-rs/keyring`'s\n * exported types — the structural match is exact across 1.x.\n *\n * @public\n */\nexport interface KeychainEntry {\n /** Returns the stored secret, or `null` when no entry exists. */\n getPassword(): string | null\n /** Persists the secret. Overwrites any existing value. */\n setPassword(value: string): void\n /** Removes the entry. Returns `true` if an entry was removed. */\n deletePassword(): boolean\n}\n\n/**\n * Options for {@link atMacosKeychain}.\n *\n * The pair `(service, account)` becomes the Keychain lookup key. By\n * convention:\n *\n * - `service` is your app's bundle id or stable app namespace, e.g.\n * `'com.acme.app'`. Stable across users; identifies the app.\n * - `account` is per-user, typically the user's email or stable\n * user id, e.g. `'alice@acme.example'`. Different users = different\n * Keychain entries.\n */\nexport interface AtMacosKeychainOptions {\n /** Keychain service identifier — typically your app's bundle id. */\n readonly service: string\n /** Per-user account identifier inside the service. */\n readonly account: string\n /**\n * Test-injection hook. Pass a pre-constructed `KeychainEntry`\n * (e.g., a memory-backed stub) to bypass the real Keychain. The\n * production code path defaults to `new Entry(service, account)`\n * from `@napi-rs/keyring`.\n *\n * @internal — production callers should leave undefined.\n */\n readonly entry?: KeychainEntry\n}\n\n/**\n * Build a {@link NoydbSealer} backed by a macOS Keychain entry.\n *\n * The 32-byte AES-256-GCM sealing key is generated on first use and\n * persisted in the Keychain under `(service, account)`. Subsequent\n * calls (this process or any future process running as the same\n * user) retrieve the same key — the vault round-trips across\n * restarts.\n *\n * Provider `id` format: `macos-keychain:{service}/{account}`. The\n * format is semver-frozen per the §11.9.1 stability rule; see\n * `https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/sealing-pid-stability.md`.\n *\n * @throws Error at construction if `service` or `account` is empty,\n * or on non-darwin platforms (with a pointer to platform-appropriate\n * providers).\n */\nexport function atMacosKeychain(\n opts: AtMacosKeychainOptions,\n): NoydbSealer {\n if (!opts.service || typeof opts.service !== 'string') {\n throw new Error(\n '@noy-db/at-macos-keychain: `service` is required, must be a non-empty string. '\n + 'Typically your app bundle id (e.g., \"com.acme.app\").',\n )\n }\n if (!opts.account || typeof opts.account !== 'string') {\n throw new Error(\n '@noy-db/at-macos-keychain: `account` is required, must be a non-empty string. '\n + 'Typically the user\\'s email or stable user id (e.g., \"alice@acme.example\").',\n )\n }\n\n // Hard platform check unless the caller supplied a test stub.\n // Per §11.9.1 spec Q.3: fail loudly on the wrong platform.\n if (opts.entry === undefined && process.platform !== 'darwin') {\n throw new Error(\n `@noy-db/at-macos-keychain: refusing to construct provider on `\n + `platform \"${process.platform}\". This package only operates on `\n + 'darwin (macOS). For other platforms use @noy-db/at-env (server), '\n + '@noy-db/at-wincred (Windows desktop), @noy-db/at-libsecret (Linux desktop), '\n + 'or @noy-db/at-webauthn-prf (browser).',\n )\n }\n\n // Resolved lazily so that production callers don't pay the require\n // cost (or fail loudly on wrong platform) until they actually invoke\n // seal/unseal. Construction-site failure of new Entry() on unsupported\n // platforms is caught and rethrown with a clearer message.\n let resolvedEntry: KeychainEntry | undefined = opts.entry\n const getEntry = (): KeychainEntry => {\n if (resolvedEntry) return resolvedEntry\n // Dynamic require so non-darwin imports don't crash on package load.\n // The platform check above guards production callers; this branch\n // only runs for production (opts.entry was undefined) on darwin.\n /* eslint-disable @typescript-eslint/no-require-imports */\n let Entry: new (service: string, account: string) => KeychainEntry\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod = require('@napi-rs/keyring') as { Entry: any }\n Entry = mod.Entry\n } catch (err) {\n throw new Error(\n '@noy-db/at-macos-keychain: failed to load `@napi-rs/keyring`. '\n + 'Ensure it is installed (`pnpm add @napi-rs/keyring`) and that '\n + 'the platform-specific binary for darwin is available. '\n + 'Original error: '\n + (err instanceof Error ? err.message : String(err)),\n )\n }\n /* eslint-enable */\n resolvedEntry = new Entry(opts.service, opts.account)\n return resolvedEntry\n }\n\n // Cache the imported CryptoKey for the lifetime of this provider\n // instance. Matches at-env's pattern; avoids a Keychain round-trip\n // per seal/unseal operation in long-running processes.\n let cachedKey: Promise<CryptoKey> | null = null\n const getKey = (): Promise<CryptoKey> => {\n if (!cachedKey) {\n cachedKey = (async (): Promise<CryptoKey> => {\n const entry = getEntry()\n let stored = entry.getPassword()\n if (stored === null) {\n // First call ever for this (service, account) — generate a\n // fresh 32-byte AES-256 key, base64-encode for string\n // storage, persist.\n const fresh = new Uint8Array(32)\n globalThis.crypto.getRandomValues(fresh)\n entry.setPassword(bytesToBase64(fresh))\n stored = entry.getPassword()\n if (stored === null) {\n throw new Error(\n '@noy-db/at-macos-keychain: setPassword succeeded but '\n + 'getPassword returned null immediately after. '\n + 'Keychain may be denying access or the entry was '\n + 'deleted out from under us.',\n )\n }\n }\n const keyBytes = base64ToBytes(stored)\n if (keyBytes.length !== 32) {\n throw new Error(\n `@noy-db/at-macos-keychain: stored key for service=\"${opts.service}\" `\n + `account=\"${opts.account}\" decodes to ${keyBytes.length} bytes; `\n + 'expected 32. The Keychain entry may have been tampered with — '\n + 'delete the entry (Keychain Access.app → search → right-click → Delete) '\n + 'and reopen the vault to regenerate.',\n )\n }\n return globalThis.crypto.subtle.importKey(\n 'raw',\n keyBytes as unknown as BufferSource,\n 'AES-GCM',\n false,\n ['encrypt', 'decrypt'],\n )\n })()\n }\n return cachedKey\n }\n\n return {\n id: `macos-keychain:${opts.service}/${opts.account}`,\n\n async seal(secret: Uint8Array): Promise<Uint8Array> {\n const key = await getKey()\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const ciphertext = await globalThis.crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n key,\n secret as unknown as BufferSource,\n )\n // Output format: [12-byte IV][ciphertext + 16-byte GCM tag]\n // Identical wire layout to at-env, by design — the hub envelope\n // dispatches on pid, not on per-provider format.\n const out = new Uint8Array(12 + ciphertext.byteLength)\n out.set(iv, 0)\n out.set(new Uint8Array(ciphertext), 12)\n return out\n },\n\n async unseal(sealed: Uint8Array): Promise<Uint8Array> {\n // 12-byte IV + ≥ 16-byte GCM tag minimum.\n if (sealed.length < 12 + 16) {\n throw new Error(\n `@noy-db/at-macos-keychain: sealed bytes too short (${sealed.length} < 28). `\n + 'Input is not a valid at-macos-keychain-sealed envelope.',\n )\n }\n const iv = sealed.subarray(0, 12)\n const body = sealed.subarray(12)\n const key = await getKey()\n const plaintext = await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n key,\n body as unknown as BufferSource,\n )\n return new Uint8Array(plaintext)\n },\n }\n}\n\n// ─── base64 helpers (browser + node compatible) ──────────────────────────\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let binary = ''\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)\n return btoa(binary)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64.trim())) {\n throw new Error('input contains characters outside the base64 alphabet')\n }\n const binary = atob(b64)\n const out = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":["require"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/at-macos-keychain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-pre.1",
|
|
4
4
|
"description": "macOS Keychain sealing key provider for noy-db managed-secret mode — AES-256-GCM under a 32-byte key stored in the user's login Keychain. Desktop-app provider in the at-* family; pairs with Touch ID via Keychain Access UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@napi-rs/keyring": "^1.1.0",
|
|
39
|
-
"@noy-db/hub": "0.
|
|
39
|
+
"@noy-db/hub": "0.7.0-pre.1"
|
|
40
40
|
},
|
|
41
41
|
"peerDependenciesMeta": {
|
|
42
42
|
"@napi-rs/keyring": {
|
|
@@ -46,9 +46,10 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@napi-rs/keyring": "^1.1.0",
|
|
48
48
|
"@types/node": "^22.0.0",
|
|
49
|
-
"@noy-db/
|
|
50
|
-
"@noy-db/
|
|
51
|
-
"@noy-db/
|
|
49
|
+
"@noy-db/hub": "0.7.0-pre.1",
|
|
50
|
+
"@noy-db/on-shamir": "0.7.0-pre.1",
|
|
51
|
+
"@noy-db/test-sealer-conformance": "0.7.0-pre.1",
|
|
52
|
+
"@noy-db/to-memory": "0.7.0-pre.1"
|
|
52
53
|
},
|
|
53
54
|
"keywords": [
|
|
54
55
|
"noy-db",
|