@noy-db/at-macos-keychain 0.1.0-pre.16
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/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/index.cjs +142 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +151 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +124 -0
- package/dist/index.js.map +1 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vLannaAi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# @noy-db/at-macos-keychain
|
|
2
|
+
|
|
3
|
+
**macOS Keychain sealing key provider for noy-db [managed-passphrase mode](https://github.com/vLannaAi/noy-db/issues/14).**
|
|
4
|
+
|
|
5
|
+
Desktop-app provider in the `at-*` family. Binds the sealing key to the user's macOS login Keychain — accessible only to processes running as the same user, optionally gated by Touch ID via Keychain Access UI.
|
|
6
|
+
|
|
7
|
+
The user never sees or types a passphrase. The 32-byte AES-256-GCM key lives in the Keychain; the vault opens only when the same OS user (and optionally same biometric) is present.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @noy-db/hub @noy-db/at-macos-keychain @napi-rs/keyring
|
|
13
|
+
# or: npm install @noy-db/hub @noy-db/at-macos-keychain @napi-rs/keyring
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`@napi-rs/keyring` is a peer dependency — it ships the native binding to the macOS Security framework via `SecItem*` calls. Install it alongside this package.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createNoydb } from '@noy-db/hub'
|
|
22
|
+
import { macosKeychainSealingProvider } from '@noy-db/at-macos-keychain'
|
|
23
|
+
|
|
24
|
+
const db = await createNoydb({
|
|
25
|
+
store,
|
|
26
|
+
user: 'alice',
|
|
27
|
+
passphraseMode: 'managed',
|
|
28
|
+
sealingKey: macosKeychainSealingProvider({
|
|
29
|
+
service: 'com.acme.app', // your app's bundle id
|
|
30
|
+
account: 'alice@acme.example', // per-user keychain item
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const vault = db.vault('acme')
|
|
35
|
+
// First open: provider generates a fresh 32-byte AES-256 key, stores it
|
|
36
|
+
// in the Keychain, hub uses it to seal a random passphrase. Subsequent
|
|
37
|
+
// opens (this process or any future process running as alice on this
|
|
38
|
+
// Mac) retrieve the same key, unseal transparently. No passphrase
|
|
39
|
+
// prompt ever appears.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## When to use this provider
|
|
43
|
+
|
|
44
|
+
- ✅ Desktop apps where the user expects "I'm logged into my Mac, the vault should be available."
|
|
45
|
+
- ✅ Apps where `at-env` is unsuitable — laptops or shared dev machines where other users with shell access can `echo $NOYDB_SEALING_KEY` and exfiltrate the key.
|
|
46
|
+
- ✅ Apps that want optional Touch ID upgrade (see "Touch ID" below) without writing platform-specific UI code.
|
|
47
|
+
|
|
48
|
+
## When NOT to use this provider
|
|
49
|
+
|
|
50
|
+
- ❌ Server-side / containerized deployments — there is no Keychain in a container. Use [`@noy-db/at-env`](../at-env) or `@noy-db/at-aws-kms` (when it ships).
|
|
51
|
+
- ❌ Browser apps — Keychain is a native OS feature; not exposed to browser sandboxes. Use `@noy-db/at-webauthn-prf` (when it ships).
|
|
52
|
+
- ❌ Cross-machine handover (where a SaaS hands a customer their bundle to open on their laptop). Keychain entries don't leave a Mac (except via iCloud Keychain sync between the same user's devices); this provider can't seal *for* an arbitrary recipient. Use a handover-capable cloud-KMS provider (`@noy-db/at-aws-kms` asymmetric, when it ships).
|
|
53
|
+
|
|
54
|
+
## Provider id format
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
macos-keychain:<service>/<account>
|
|
58
|
+
```
|
|
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.
|
|
61
|
+
|
|
62
|
+
## Touch ID
|
|
63
|
+
|
|
64
|
+
macOS lets users add Touch ID gating to a Keychain entry via **Keychain Access.app → search for the entry → right-click → Get Info → Access Control → Confirm before allowing access → "Ask for Touch ID"**. This is a one-time user-controlled action; this package does not gate it programmatically.
|
|
65
|
+
|
|
66
|
+
After the user enables Touch ID on the entry, every subsequent `seal`/`unseal` call surfaces the system Touch ID prompt. The provider's API surface does not change; the gating is transparent.
|
|
67
|
+
|
|
68
|
+
## Threat model
|
|
69
|
+
|
|
70
|
+
The Keychain entry IS the security boundary. Strength is bounded by:
|
|
71
|
+
|
|
72
|
+
- **macOS user-account isolation.** Processes running as a different OS user cannot read the entry. App Sandboxing (App Store apps) further restricts; unsandboxed apps must trust co-resident processes.
|
|
73
|
+
- **Login keychain lock state.** When the user's login keychain is locked, reads surface a prompt or fail. Apps needing operation while locked should implement explicit unlock UX.
|
|
74
|
+
- **Optional Touch ID per entry.** Users add this via Keychain Access UI as described above.
|
|
75
|
+
|
|
76
|
+
**Does NOT protect against:**
|
|
77
|
+
- Malware running as the same user with Keychain access.
|
|
78
|
+
- A physically present attacker who knows the user's login password and can unlock the keychain.
|
|
79
|
+
- macOS itself being compromised below the Keychain Services layer.
|
|
80
|
+
|
|
81
|
+
For full architectural background see the [at-* sealing dimension foundation doc](../../docs/superpowers/specs/2026-05-23-sealing-at-dimension-foundation.md), §11.2 (capability matrix) and §2 (threat-model honesty table).
|
|
82
|
+
|
|
83
|
+
## API
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
function macosKeychainSealingProvider(opts: {
|
|
87
|
+
service: string // your app bundle id / namespace
|
|
88
|
+
account: string // per-user identifier
|
|
89
|
+
entry?: KeychainEntry // internal test injection — leave undefined in production
|
|
90
|
+
}): SealingKeyProvider
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Returns a [`SealingKeyProvider`](../hub/src/team/managed-passphrase.ts) — the contract `@noy-db/hub`'s managed-passphrase mode consumes.
|
|
94
|
+
|
|
95
|
+
Throws at construction when `service` or `account` is empty, or when running on a non-darwin platform without a test stub.
|
|
96
|
+
|
|
97
|
+
The provider exposes only `SealingKeyProvider` — not `RecipientSealer`. By the [capability matrix](../../docs/superpowers/specs/2026-05-23-sealing-at-dimension-foundation.md#112-provider-capability-matrix), this provider 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.
|
|
98
|
+
|
|
99
|
+
## Key lifecycle
|
|
100
|
+
|
|
101
|
+
- **First call ever for a given `(service, account)`**: generates a fresh 32-byte AES-256 key via `crypto.getRandomValues`, base64-encodes it, stores in the Keychain. The `seal`/`unseal` operation then proceeds using that key.
|
|
102
|
+
- **Subsequent calls (same process or any future process under the same OS user)**: read the stored base64, decode, import as a `CryptoKey`, perform AES-256-GCM.
|
|
103
|
+
- **Different `(service, account)` → different Keychain entry → different key**. Sealed outputs from one pair cannot be unsealed by another.
|
|
104
|
+
- **Cache**: the imported `CryptoKey` is cached for the lifetime of the provider instance. Long-running processes do not pay a Keychain round-trip per seal/unseal.
|
|
105
|
+
|
|
106
|
+
If a vault is being retired, call `entry.deletePassword()` directly on `@napi-rs/keyring`. This package does not wrap that — the Keychain entry's lifecycle is decoupled from the vault's lifecycle, and the explicit `@napi-rs/keyring` call is the cleanest way to do it.
|
|
107
|
+
|
|
108
|
+
## Testing
|
|
109
|
+
|
|
110
|
+
For platform-independent unit tests, inject a memory-backed entry via the `entry` option:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { macosKeychainSealingProvider, type KeychainEntry } from '@noy-db/at-macos-keychain'
|
|
114
|
+
|
|
115
|
+
function memoryEntry(): KeychainEntry {
|
|
116
|
+
let stored: string | null = null
|
|
117
|
+
return {
|
|
118
|
+
getPassword: () => stored,
|
|
119
|
+
setPassword: (v) => { stored = v },
|
|
120
|
+
deletePassword: () => { const had = stored !== null; stored = null; return had },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const provider = macosKeychainSealingProvider({
|
|
125
|
+
service: 'com.acme.test',
|
|
126
|
+
account: 'test',
|
|
127
|
+
entry: memoryEntry(),
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The injected entry bypasses the real Keychain entirely. The seal/unseal AES-GCM pipeline is fully exercised; only the OS-Keychain integration is stubbed.
|
|
132
|
+
|
|
133
|
+
For real-Keychain integration tests on darwin CI runners, leave `entry` undefined and ensure each test uses a unique `service` (e.g., `com.noydb.test-${randomUUID()}`) with cleanup in `afterEach` (`entry.deletePassword()`). Otherwise Keychain entries leak across runs.
|
|
134
|
+
|
|
135
|
+
## Related
|
|
136
|
+
|
|
137
|
+
- [`@noy-db/at-env`](../at-env) — env-var sealing for server / container deployments.
|
|
138
|
+
- [`@noy-db/hub`](../hub) — the database core that consumes `SealingKeyProvider`.
|
|
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)
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
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
|
|
@@ -0,0 +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":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
// src/index.ts
|
|
9
|
+
function macosKeychainSealingProvider(opts) {
|
|
10
|
+
if (!opts.service || typeof opts.service !== "string") {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'@noy-db/at-macos-keychain: `service` is required, must be a non-empty string. Typically your app bundle id (e.g., "com.acme.app").'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
if (!opts.account || typeof opts.account !== "string") {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'@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").'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (opts.entry === void 0 && process.platform !== "darwin") {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`@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).`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
let resolvedEntry = opts.entry;
|
|
26
|
+
const getEntry = () => {
|
|
27
|
+
if (resolvedEntry) return resolvedEntry;
|
|
28
|
+
let Entry;
|
|
29
|
+
try {
|
|
30
|
+
const mod = __require("@napi-rs/keyring");
|
|
31
|
+
Entry = mod.Entry;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"@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))
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
resolvedEntry = new Entry(opts.service, opts.account);
|
|
38
|
+
return resolvedEntry;
|
|
39
|
+
};
|
|
40
|
+
let cachedKey = null;
|
|
41
|
+
const getKey = () => {
|
|
42
|
+
if (!cachedKey) {
|
|
43
|
+
cachedKey = (async () => {
|
|
44
|
+
const entry = getEntry();
|
|
45
|
+
let stored = entry.getPassword();
|
|
46
|
+
if (stored === null) {
|
|
47
|
+
const fresh = new Uint8Array(32);
|
|
48
|
+
globalThis.crypto.getRandomValues(fresh);
|
|
49
|
+
entry.setPassword(bytesToBase64(fresh));
|
|
50
|
+
stored = entry.getPassword();
|
|
51
|
+
if (stored === null) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"@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."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const keyBytes = base64ToBytes(stored);
|
|
58
|
+
if (keyBytes.length !== 32) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`@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.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return globalThis.crypto.subtle.importKey(
|
|
64
|
+
"raw",
|
|
65
|
+
keyBytes,
|
|
66
|
+
"AES-GCM",
|
|
67
|
+
false,
|
|
68
|
+
["encrypt", "decrypt"]
|
|
69
|
+
);
|
|
70
|
+
})();
|
|
71
|
+
}
|
|
72
|
+
return cachedKey;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
id: `macos-keychain:${opts.service}/${opts.account}`,
|
|
76
|
+
async seal(passphrase) {
|
|
77
|
+
const key = await getKey();
|
|
78
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
79
|
+
const ciphertext = await globalThis.crypto.subtle.encrypt(
|
|
80
|
+
{ name: "AES-GCM", iv },
|
|
81
|
+
key,
|
|
82
|
+
passphrase
|
|
83
|
+
);
|
|
84
|
+
const out = new Uint8Array(12 + ciphertext.byteLength);
|
|
85
|
+
out.set(iv, 0);
|
|
86
|
+
out.set(new Uint8Array(ciphertext), 12);
|
|
87
|
+
return out;
|
|
88
|
+
},
|
|
89
|
+
async unseal(sealed) {
|
|
90
|
+
if (sealed.length < 12 + 16) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`@noy-db/at-macos-keychain: sealed bytes too short (${sealed.length} < 28). Input is not a valid at-macos-keychain-sealed envelope.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const iv = sealed.subarray(0, 12);
|
|
96
|
+
const body = sealed.subarray(12);
|
|
97
|
+
const key = await getKey();
|
|
98
|
+
const plaintext = await globalThis.crypto.subtle.decrypt(
|
|
99
|
+
{ name: "AES-GCM", iv },
|
|
100
|
+
key,
|
|
101
|
+
body
|
|
102
|
+
);
|
|
103
|
+
return new Uint8Array(plaintext);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function bytesToBase64(bytes) {
|
|
108
|
+
let binary = "";
|
|
109
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
110
|
+
return btoa(binary);
|
|
111
|
+
}
|
|
112
|
+
function base64ToBytes(b64) {
|
|
113
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64.trim())) {
|
|
114
|
+
throw new Error("input contains characters outside the base64 alphabet");
|
|
115
|
+
}
|
|
116
|
+
const binary = atob(b64);
|
|
117
|
+
const out = new Uint8Array(binary.length);
|
|
118
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
export {
|
|
122
|
+
macosKeychainSealingProvider
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noy-db/at-macos-keychain",
|
|
3
|
+
"version": "0.1.0-pre.16",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "vLannaAi <vicio@lanna.ai>",
|
|
7
|
+
"homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/at-macos-keychain#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vLannaAi/noy-db.git",
|
|
11
|
+
"directory": "packages/at-macos-keychain"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vLannaAi/noy-db/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
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
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"os": [
|
|
39
|
+
"darwin"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@napi-rs/keyring": "^1.1.0",
|
|
46
|
+
"@noy-db/hub": "0.1.0-pre.16"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@napi-rs/keyring": {
|
|
50
|
+
"optional": false
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@napi-rs/keyring": "^1.1.0",
|
|
55
|
+
"@types/node": "^22.0.0",
|
|
56
|
+
"@noy-db/to-memory": "0.1.0-pre.16",
|
|
57
|
+
"@noy-db/hub": "0.1.0-pre.16"
|
|
58
|
+
},
|
|
59
|
+
"keywords": [
|
|
60
|
+
"noy-db",
|
|
61
|
+
"at-macos-keychain",
|
|
62
|
+
"sealing-key-provider",
|
|
63
|
+
"managed-passphrase",
|
|
64
|
+
"macos",
|
|
65
|
+
"keychain",
|
|
66
|
+
"aes-256-gcm",
|
|
67
|
+
"rubber-hose-resistant",
|
|
68
|
+
"encryption",
|
|
69
|
+
"zero-knowledge"
|
|
70
|
+
],
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public",
|
|
73
|
+
"tag": "latest"
|
|
74
|
+
},
|
|
75
|
+
"scripts": {
|
|
76
|
+
"build": "tsup",
|
|
77
|
+
"test": "vitest run",
|
|
78
|
+
"lint": "eslint src/",
|
|
79
|
+
"typecheck": "tsc --noEmit"
|
|
80
|
+
}
|
|
81
|
+
}
|