@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 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 { macosKeychainSealingProvider } from '@noy-db/at-macos-keychain'
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: macosKeychainSealingProvider({
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 macosKeychainSealingProvider(opts: {
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
- }): SealingKeyProvider
93
+ }): NoydbSealer
94
94
  ```
95
95
 
96
- Returns a [`SealingKeyProvider`](../hub/src/team/managed-secret.ts) — the contract `@noy-db/hub`'s managed-secret mode consumes.
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 `SealingKeyProvider` — 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.
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 { macosKeychainSealingProvider, type KeychainEntry } from '@noy-db/at-macos-keychain'
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 = macosKeychainSealingProvider({
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 `SealingKeyProvider`.
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 { SealingKeyProvider } from '@noy-db/hub';
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 { macosKeychainSealingProvider } from '@noy-db/at-macos-keychain'
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: macosKeychainSealingProvider({
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 macosKeychainSealingProvider}.
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 MacosKeychainSealingProviderOptions {
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 SealingKeyProvider} backed by a macOS Keychain entry.
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 macosKeychainSealingProvider(opts: MacosKeychainSealingProviderOptions): SealingKeyProvider;
149
+ declare function atMacosKeychain(opts: AtMacosKeychainOptions): NoydbSealer;
150
150
 
151
- export { type KeychainEntry, type MacosKeychainSealingProviderOptions, macosKeychainSealingProvider };
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 macosKeychainSealingProvider(opts) {
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
- macosKeychainSealingProvider
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.6.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.6.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/to-memory": "0.6.0",
50
- "@noy-db/hub": "0.6.0",
51
- "@noy-db/on-shamir": "0.6.0"
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",