@noy-db/at-macos-keychain 0.2.0-pre.9 → 0.3.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 CHANGED
@@ -57,7 +57,7 @@ const vault = db.vault('acme')
57
57
  macos-keychain:<service>/<account>
58
58
  ```
59
59
 
60
- E.g., `macos-keychain:com.acme.app/alice@acme.example`. This format is **frozen** per the [sealing pid stability rule](../../docs/subsystems/sealing-pid-stability.md) — once shipped, it never changes. The hub uses it as the dispatch key when reading an existing `_meta/sealed-passphrase` envelope.
60
+ E.g., `macos-keychain:com.acme.app/alice@acme.example`. This format is **frozen** per the [sealing pid stability rule](https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/sealing-pid-stability.md) — once shipped, it never changes. The hub uses it as the dispatch key when reading an existing `_meta/sealed-passphrase` envelope.
61
61
 
62
62
  ## Touch ID
63
63
 
@@ -137,7 +137,7 @@ For real-Keychain integration tests on darwin CI runners, leave `entry` undefine
137
137
  - [`@noy-db/at-env`](../at-env) — env-var sealing for server / container deployments.
138
138
  - [`@noy-db/hub`](../hub) — the database core that consumes `SealingKeyProvider`.
139
139
  - [Foundation doc — at-* sealing dimension](../../docs/superpowers/specs/2026-05-23-sealing-at-dimension-foundation.md)
140
- - [Sealing pid stability rule](../../docs/subsystems/sealing-pid-stability.md)
140
+ - [Sealing pid stability rule](https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/sealing-pid-stability.md)
141
141
 
142
142
  ## License
143
143
 
package/dist/index.d.ts CHANGED
@@ -140,7 +140,7 @@ interface MacosKeychainSealingProviderOptions {
140
140
  *
141
141
  * Provider `id` format: `macos-keychain:{service}/{account}`. The
142
142
  * format is semver-frozen per the §11.9.1 stability rule; see
143
- * `docs/subsystems/sealing-pid-stability.md`.
143
+ * `https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/sealing-pid-stability.md`.
144
144
  *
145
145
  * @throws Error at construction if `service` or `account` is empty,
146
146
  * or on non-darwin platforms (with a pointer to platform-appropriate
package/dist/index.js CHANGED
@@ -1,11 +1,6 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
1
  // src/index.ts
2
+ import { createRequire } from "module";
3
+ var require2 = createRequire(import.meta.url);
9
4
  function macosKeychainSealingProvider(opts) {
10
5
  if (!opts.service || typeof opts.service !== "string") {
11
6
  throw new Error(
@@ -27,7 +22,7 @@ function macosKeychainSealingProvider(opts) {
27
22
  if (resolvedEntry) return resolvedEntry;
28
23
  let Entry;
29
24
  try {
30
- const mod = __require("@napi-rs/keyring");
25
+ const mod = require2("@napi-rs/keyring");
31
26
  Entry = mod.Entry;
32
27
  } catch (err) {
33
28
  throw new Error(
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-passphrase 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 * passphraseMode: '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 passphrase. 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 type { SealingKeyProvider } from '@noy-db/hub'\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 * `docs/subsystems/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(passphrase: 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 passphrase 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":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/at-macos-keychain** — macOS Keychain sealing key provider\n * for noy-db [managed-passphrase 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 * passphraseMode: '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 passphrase. 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(passphrase: 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 passphrase 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.2.0-pre.9",
3
+ "version": "0.3.0-pre.1",
4
4
  "description": "macOS Keychain sealing key provider for noy-db managed-passphrase 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>",
@@ -17,17 +17,10 @@
17
17
  "sideEffects": false,
18
18
  "exports": {
19
19
  ".": {
20
- "import": {
21
- "types": "./dist/index.d.ts",
22
- "default": "./dist/index.js"
23
- },
24
- "require": {
25
- "types": "./dist/index.d.cts",
26
- "default": "./dist/index.cjs"
27
- }
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
28
22
  }
29
23
  },
30
- "main": "./dist/index.cjs",
31
24
  "module": "./dist/index.js",
32
25
  "types": "./dist/index.d.ts",
33
26
  "files": [
@@ -39,11 +32,11 @@
39
32
  "darwin"
40
33
  ],
41
34
  "engines": {
42
- "node": ">=18.0.0"
35
+ "node": ">=22.0.0"
43
36
  },
44
37
  "peerDependencies": {
45
38
  "@napi-rs/keyring": "^1.1.0",
46
- "@noy-db/hub": "0.2.0-pre.9"
39
+ "@noy-db/hub": "0.3.0-pre.1"
47
40
  },
48
41
  "peerDependenciesMeta": {
49
42
  "@napi-rs/keyring": {
@@ -53,9 +46,9 @@
53
46
  "devDependencies": {
54
47
  "@napi-rs/keyring": "^1.1.0",
55
48
  "@types/node": "^22.0.0",
56
- "@noy-db/on-shamir": "0.2.0-pre.9",
57
- "@noy-db/hub": "0.2.0-pre.9",
58
- "@noy-db/to-memory": "0.2.0-pre.9"
49
+ "@noy-db/on-shamir": "0.3.0-pre.1",
50
+ "@noy-db/hub": "0.3.0-pre.1",
51
+ "@noy-db/to-memory": "0.3.0-pre.1"
59
52
  },
60
53
  "keywords": [
61
54
  "noy-db",
package/dist/index.cjs DELETED
@@ -1,142 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
23
- macosKeychainSealingProvider: () => macosKeychainSealingProvider
24
- });
25
- module.exports = __toCommonJS(index_exports);
26
- function macosKeychainSealingProvider(opts) {
27
- if (!opts.service || typeof opts.service !== "string") {
28
- throw new Error(
29
- '@noy-db/at-macos-keychain: `service` is required, must be a non-empty string. Typically your app bundle id (e.g., "com.acme.app").'
30
- );
31
- }
32
- if (!opts.account || typeof opts.account !== "string") {
33
- throw new Error(
34
- '@noy-db/at-macos-keychain: `account` is required, must be a non-empty string. Typically the user\'s email or stable user id (e.g., "alice@acme.example").'
35
- );
36
- }
37
- if (opts.entry === void 0 && process.platform !== "darwin") {
38
- throw new Error(
39
- `@noy-db/at-macos-keychain: refusing to construct provider on platform "${process.platform}". This package only operates on darwin (macOS). For other platforms use @noy-db/at-env (server), @noy-db/at-wincred (Windows desktop), @noy-db/at-libsecret (Linux desktop), or @noy-db/at-webauthn-prf (browser).`
40
- );
41
- }
42
- let resolvedEntry = opts.entry;
43
- const getEntry = () => {
44
- if (resolvedEntry) return resolvedEntry;
45
- let Entry;
46
- try {
47
- const mod = require("@napi-rs/keyring");
48
- Entry = mod.Entry;
49
- } catch (err) {
50
- throw new Error(
51
- "@noy-db/at-macos-keychain: failed to load `@napi-rs/keyring`. Ensure it is installed (`pnpm add @napi-rs/keyring`) and that the platform-specific binary for darwin is available. Original error: " + (err instanceof Error ? err.message : String(err))
52
- );
53
- }
54
- resolvedEntry = new Entry(opts.service, opts.account);
55
- return resolvedEntry;
56
- };
57
- let cachedKey = null;
58
- const getKey = () => {
59
- if (!cachedKey) {
60
- cachedKey = (async () => {
61
- const entry = getEntry();
62
- let stored = entry.getPassword();
63
- if (stored === null) {
64
- const fresh = new Uint8Array(32);
65
- globalThis.crypto.getRandomValues(fresh);
66
- entry.setPassword(bytesToBase64(fresh));
67
- stored = entry.getPassword();
68
- if (stored === null) {
69
- throw new Error(
70
- "@noy-db/at-macos-keychain: setPassword succeeded but getPassword returned null immediately after. Keychain may be denying access or the entry was deleted out from under us."
71
- );
72
- }
73
- }
74
- const keyBytes = base64ToBytes(stored);
75
- if (keyBytes.length !== 32) {
76
- throw new Error(
77
- `@noy-db/at-macos-keychain: stored key for service="${opts.service}" account="${opts.account}" decodes to ${keyBytes.length} bytes; expected 32. The Keychain entry may have been tampered with \u2014 delete the entry (Keychain Access.app \u2192 search \u2192 right-click \u2192 Delete) and reopen the vault to regenerate.`
78
- );
79
- }
80
- return globalThis.crypto.subtle.importKey(
81
- "raw",
82
- keyBytes,
83
- "AES-GCM",
84
- false,
85
- ["encrypt", "decrypt"]
86
- );
87
- })();
88
- }
89
- return cachedKey;
90
- };
91
- return {
92
- id: `macos-keychain:${opts.service}/${opts.account}`,
93
- async seal(passphrase) {
94
- const key = await getKey();
95
- const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
96
- const ciphertext = await globalThis.crypto.subtle.encrypt(
97
- { name: "AES-GCM", iv },
98
- key,
99
- passphrase
100
- );
101
- const out = new Uint8Array(12 + ciphertext.byteLength);
102
- out.set(iv, 0);
103
- out.set(new Uint8Array(ciphertext), 12);
104
- return out;
105
- },
106
- async unseal(sealed) {
107
- if (sealed.length < 12 + 16) {
108
- throw new Error(
109
- `@noy-db/at-macos-keychain: sealed bytes too short (${sealed.length} < 28). Input is not a valid at-macos-keychain-sealed envelope.`
110
- );
111
- }
112
- const iv = sealed.subarray(0, 12);
113
- const body = sealed.subarray(12);
114
- const key = await getKey();
115
- const plaintext = await globalThis.crypto.subtle.decrypt(
116
- { name: "AES-GCM", iv },
117
- key,
118
- body
119
- );
120
- return new Uint8Array(plaintext);
121
- }
122
- };
123
- }
124
- function bytesToBase64(bytes) {
125
- let binary = "";
126
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
127
- return btoa(binary);
128
- }
129
- function base64ToBytes(b64) {
130
- if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64.trim())) {
131
- throw new Error("input contains characters outside the base64 alphabet");
132
- }
133
- const binary = atob(b64);
134
- const out = new Uint8Array(binary.length);
135
- for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
136
- return out;
137
- }
138
- // Annotate the CommonJS export names for ESM import in node:
139
- 0 && (module.exports = {
140
- macosKeychainSealingProvider
141
- });
142
- //# sourceMappingURL=index.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/at-macos-keychain** — macOS Keychain sealing key provider\n * for noy-db [managed-passphrase 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 * passphraseMode: '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 passphrase. 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 type { SealingKeyProvider } from '@noy-db/hub'\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 * `docs/subsystems/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(passphrase: 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 passphrase 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":[]}
package/dist/index.d.cts DELETED
@@ -1,151 +0,0 @@
1
- import { SealingKeyProvider } from '@noy-db/hub';
2
-
3
- /**
4
- * **@noy-db/at-macos-keychain** — macOS Keychain sealing key provider
5
- * for noy-db [managed-passphrase mode](https://github.com/vLannaAi/noy-db/issues/14).
6
- *
7
- * Desktop-app provider in the `at-*` family. Binds the sealing key to
8
- * the user's macOS login Keychain — accessible only to processes
9
- * running as the same user, optionally gated by Touch ID via Keychain
10
- * Access UI (a one-time per-entry toggle the user enables themselves;
11
- * this package does not gate that programmatically).
12
- *
13
- * ## When to use
14
- *
15
- * - Desktop apps where the user expects "log into my Mac = open the
16
- * vault." The Keychain entry persists across reboots, scoped to
17
- * the user account.
18
- * - Apps where `at-env` is unsuitable — laptops or shared dev machines
19
- * where other users with shell access can `echo $NOYDB_SEALING_KEY`
20
- * and exfiltrate the key.
21
- *
22
- * ## When NOT to use
23
- *
24
- * - Server-side / containerized deployments — there is no Keychain
25
- * there. Use `@noy-db/at-env` or `@noy-db/at-aws-kms` (when it
26
- * ships).
27
- * - Browser apps — Keychain is a native OS feature, not exposed to
28
- * browser sandboxes. Use `@noy-db/at-webauthn-prf` (when it ships).
29
- *
30
- * ## Setup
31
- *
32
- * ```bash
33
- * pnpm add @noy-db/hub @noy-db/at-macos-keychain @napi-rs/keyring
34
- * ```
35
- *
36
- * ```ts
37
- * import { createNoydb } from '@noy-db/hub'
38
- * import { macosKeychainSealingProvider } from '@noy-db/at-macos-keychain'
39
- *
40
- * const db = await createNoydb({
41
- * store,
42
- * user: 'alice',
43
- * passphraseMode: 'managed',
44
- * sealingKey: macosKeychainSealingProvider({
45
- * service: 'com.acme.app', // your bundle id / app namespace
46
- * account: 'alice@acme.example', // per-user keychain item
47
- * }),
48
- * })
49
- * ```
50
- *
51
- * First call generates a fresh 32-byte AES-256 key, stores it in the
52
- * Keychain under `(service, account)`, and uses it to seal the
53
- * hub-generated managed passphrase. Subsequent process launches
54
- * retrieve the same key and unseal transparently.
55
- *
56
- * ## Threat model
57
- *
58
- * The Keychain entry IS the security boundary. Strength bounded by:
59
- *
60
- * - **macOS user-account isolation.** Other processes running as the
61
- * same user can read the entry. macOS App Sandboxing limits this
62
- * for App Store apps; unsandboxed apps must trust co-resident
63
- * processes.
64
- * - **Login keychain lock state.** When the user's login keychain
65
- * is locked (default: never, unless explicitly configured), reads
66
- * surface a prompt or fail. Apps that need to operate when the
67
- * keychain is locked should consider explicit unlock UX.
68
- * - **Touch ID upgrade.** Users may add Touch ID gating to the
69
- * Keychain entry via Keychain Access.app → right-click → Get Info
70
- * → Access Control. This is opt-in per entry and out of band of
71
- * this package's API.
72
- *
73
- * Does NOT protect against:
74
- *
75
- * - Malware running as the same user with Keychain access.
76
- * - A physically present attacker who knows the user's login
77
- * password and can unlock the keychain.
78
- * - macOS itself being compromised below the Keychain Services
79
- * layer.
80
- *
81
- * @packageDocumentation
82
- */
83
-
84
- /**
85
- * Structural shape of `@napi-rs/keyring`'s `Entry` class.
86
- *
87
- * Defined here as a structural type rather than importing the
88
- * concrete class so that:
89
- *
90
- * 1. Test code can inject a memory-backed stub for cross-platform
91
- * unit tests (the real Entry only works on darwin/win32/linux).
92
- * 2. We don't pin a specific version of `@napi-rs/keyring`'s
93
- * exported types — the structural match is exact across 1.x.
94
- *
95
- * @public
96
- */
97
- interface KeychainEntry {
98
- /** Returns the stored secret, or `null` when no entry exists. */
99
- getPassword(): string | null;
100
- /** Persists the secret. Overwrites any existing value. */
101
- setPassword(value: string): void;
102
- /** Removes the entry. Returns `true` if an entry was removed. */
103
- deletePassword(): boolean;
104
- }
105
- /**
106
- * Options for {@link macosKeychainSealingProvider}.
107
- *
108
- * The pair `(service, account)` becomes the Keychain lookup key. By
109
- * convention:
110
- *
111
- * - `service` is your app's bundle id or stable app namespace, e.g.
112
- * `'com.acme.app'`. Stable across users; identifies the app.
113
- * - `account` is per-user, typically the user's email or stable
114
- * user id, e.g. `'alice@acme.example'`. Different users = different
115
- * Keychain entries.
116
- */
117
- interface MacosKeychainSealingProviderOptions {
118
- /** Keychain service identifier — typically your app's bundle id. */
119
- readonly service: string;
120
- /** Per-user account identifier inside the service. */
121
- readonly account: string;
122
- /**
123
- * Test-injection hook. Pass a pre-constructed `KeychainEntry`
124
- * (e.g., a memory-backed stub) to bypass the real Keychain. The
125
- * production code path defaults to `new Entry(service, account)`
126
- * from `@napi-rs/keyring`.
127
- *
128
- * @internal — production callers should leave undefined.
129
- */
130
- readonly entry?: KeychainEntry;
131
- }
132
- /**
133
- * Build a {@link SealingKeyProvider} backed by a macOS Keychain entry.
134
- *
135
- * The 32-byte AES-256-GCM sealing key is generated on first use and
136
- * persisted in the Keychain under `(service, account)`. Subsequent
137
- * calls (this process or any future process running as the same
138
- * user) retrieve the same key — the vault round-trips across
139
- * restarts.
140
- *
141
- * Provider `id` format: `macos-keychain:{service}/{account}`. The
142
- * format is semver-frozen per the §11.9.1 stability rule; see
143
- * `docs/subsystems/sealing-pid-stability.md`.
144
- *
145
- * @throws Error at construction if `service` or `account` is empty,
146
- * or on non-darwin platforms (with a pointer to platform-appropriate
147
- * providers).
148
- */
149
- declare function macosKeychainSealingProvider(opts: MacosKeychainSealingProviderOptions): SealingKeyProvider;
150
-
151
- export { type KeychainEntry, type MacosKeychainSealingProviderOptions, macosKeychainSealingProvider };