@membox-cloud/membox 0.1.0 → 0.1.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
@@ -10,9 +10,15 @@ Keep your local Markdown memory (`MEMORY.md`, `memory/*.md`) as the source of tr
10
10
  openclaw plugins install @membox-cloud/membox
11
11
  ```
12
12
 
13
+ Optional skill install from ClawHub:
14
+
15
+ ```bash
16
+ clawhub install membox-cloud-sync
17
+ ```
18
+
13
19
  ## Prerequisites
14
20
 
15
- - OpenClaw >= 2026.3.7
21
+ - OpenClaw >= 2026.2.14
16
22
  - A [membox.cloud](https://membox.cloud) account (GitHub, Google, or email login)
17
23
 
18
24
  ## Quick Start
@@ -109,7 +115,8 @@ Security note:
109
115
 
110
116
  - Secret-bearing tools read from local files so the model does not need the vault passphrase or recovery code inline.
111
117
  - First-device tool-only setup should always provide a local `recovery_code_output_file` so the recovery code never needs to travel through the model.
112
- - Managed unlock is explicit opt-in. When enabled, the passphrase is kept in the local keychain for future auto-unlock on that machine.
118
+ - Setup and restore now persist device secrets in a local passphrase-encrypted bundle, so published npm installs do not depend on native `keytar`.
119
+ - Managed unlock is explicit opt-in. When enabled, the passphrase is kept only on the local machine, using the platform keychain when available or a private local fallback file when it is not.
113
120
  - On Unix-like systems, passphrase and recovery-code files should be private, for example `chmod 600 /path/to/file`.
114
121
 
115
122
  ## Configuration
@@ -145,8 +152,6 @@ For an agent-first install + pairing flow, see `./AGENT-WORKFLOW.md` and run `ba
145
152
 
146
153
  For release, production rollout, and final human-owned checks, see `./RELEASE-CHECKLIST.md`.
147
154
 
148
- If native `keytar` support is unavailable on your machine, setup/unlock will now fail closed by default instead of silently writing secrets in plaintext. For local sandbox testing only, you can opt into the old file-based fallback with `MEMBOX_ALLOW_INSECURE_FILE_KEYCHAIN=1`.
149
-
150
155
  ## Security Model
151
156
 
152
157
  - **Zero-knowledge**: encryption/decryption happens locally. The server never sees plaintext.
@@ -1,14 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { hostname } from "node:os";
3
3
  import { API_PREFIX, PLUGIN_VERSION } from "../constants.js";
4
- import { deriveURK } from "../crypto/kdf.js";
5
- import { wrapAMK } from "../crypto/keys.js";
6
4
  import { generateDeviceKeyPair, } from "../crypto/device-keys.js";
7
5
  import { getAccountMe } from "../api/account.js";
8
6
  import { MemboxApiClient } from "../api/client.js";
9
7
  import { pollDeviceFlow, pollDeviceFlowOnce, startDeviceFlow, } from "../api/device-flow.js";
10
- import { ACCOUNTS, getBytes, getString, storeBytes, storeString, clearPendingSetupSecrets, } from "../store/keychain.js";
8
+ import { clearPendingSetupSecrets, } from "../store/keychain.js";
9
+ import { clearPendingSetupSecretsFile, getPendingSetupRefreshToken, loadPendingSetupDeviceKeys as loadPendingSetupDeviceKeysFile, storePendingSetupDeviceKeys as storePendingSetupDeviceKeysFile, storePendingSetupRefreshToken, } from "../store/pending-setup-secrets.js";
11
10
  import { deletePendingSetup, isPendingSetupExpired, readPendingSetup, writePendingSetup, } from "../store/pending-setup.js";
11
+ import { persistVaultSecretsBundle } from "../store/vault-secrets.js";
12
12
  export function toB64(data) {
13
13
  return Buffer.from(data).toString("base64");
14
14
  }
@@ -55,7 +55,7 @@ function createAuthenticatedClient(params) {
55
55
  return new MemboxApiClient(params.baseUrl, async () => accessToken, async () => refreshToken, async (access, refresh) => {
56
56
  accessToken = access;
57
57
  refreshToken = refresh;
58
- await storeString(params.refreshTokenAccount, refresh);
58
+ await params.onRefreshTokenPersist?.(refresh);
59
59
  });
60
60
  }
61
61
  function pendingSetupBaseUrl(cfg) {
@@ -65,37 +65,14 @@ function describeAccount(account) {
65
65
  return account.display_name ?? account.primary_email ?? account.user_id;
66
66
  }
67
67
  async function storePendingSetupDeviceKeys(deviceKeys) {
68
- await storeBytes(ACCOUNTS.PENDING_SETUP_ED25519_PRIVATE, deviceKeys.ed25519.privateKey);
69
- await storeBytes(ACCOUNTS.PENDING_SETUP_ED25519_PUBLIC, deviceKeys.ed25519.publicKey);
70
- await storeBytes(ACCOUNTS.PENDING_SETUP_X25519_PRIVATE, deviceKeys.x25519.privateKey);
71
- await storeBytes(ACCOUNTS.PENDING_SETUP_X25519_PUBLIC, deviceKeys.x25519.publicKey);
68
+ await storePendingSetupDeviceKeysFile(deviceKeys);
72
69
  }
73
70
  async function loadPendingSetupDeviceKeys() {
74
- const [ed25519Private, ed25519Public, x25519Private, x25519Public,] = await Promise.all([
75
- getBytes(ACCOUNTS.PENDING_SETUP_ED25519_PRIVATE),
76
- getBytes(ACCOUNTS.PENDING_SETUP_ED25519_PUBLIC),
77
- getBytes(ACCOUNTS.PENDING_SETUP_X25519_PRIVATE),
78
- getBytes(ACCOUNTS.PENDING_SETUP_X25519_PUBLIC),
79
- ]);
80
- if (!ed25519Private ||
81
- !ed25519Public ||
82
- !x25519Private ||
83
- !x25519Public) {
84
- throw new Error("Pending setup secrets are incomplete. Start setup again to regenerate device authorization.");
85
- }
86
- return {
87
- ed25519: {
88
- privateKey: ed25519Private,
89
- publicKey: ed25519Public,
90
- },
91
- x25519: {
92
- privateKey: x25519Private,
93
- publicKey: x25519Public,
94
- },
95
- };
71
+ return loadPendingSetupDeviceKeysFile();
96
72
  }
97
73
  export async function clearPendingSetupArtifacts() {
98
74
  await clearPendingSetupSecrets();
75
+ await clearPendingSetupSecretsFile();
99
76
  await deletePendingSetup();
100
77
  }
101
78
  async function loadActivePendingSetup(cfg) {
@@ -158,12 +135,12 @@ export async function ensurePendingDeviceAuthorization(cfg) {
158
135
  return { pendingSetup: created, created: true };
159
136
  }
160
137
  async function finalizePendingAuthorization(cfg, pendingSetup, tokens) {
161
- await storeString(ACCOUNTS.PENDING_SETUP_REFRESH_TOKEN, tokens.refresh_token);
138
+ await storePendingSetupRefreshToken(tokens.refresh_token);
162
139
  const client = createAuthenticatedClient({
163
140
  baseUrl: pendingSetupBaseUrl(cfg),
164
141
  accessToken: tokens.access_token,
165
142
  refreshToken: tokens.refresh_token,
166
- refreshTokenAccount: ACCOUNTS.PENDING_SETUP_REFRESH_TOKEN,
143
+ onRefreshTokenPersist: storePendingSetupRefreshToken,
167
144
  });
168
145
  const account = await getAccountMe(client);
169
146
  const authorizedSetup = {
@@ -239,7 +216,7 @@ async function buildAuthorizedContextFromPending(cfg, pendingSetup, options) {
239
216
  throw new Error("Device authorization has not completed yet. Finish browser authorization first.");
240
217
  }
241
218
  const refreshToken = options?.refreshToken ??
242
- (await getString(ACCOUNTS.PENDING_SETUP_REFRESH_TOKEN));
219
+ (await getPendingSetupRefreshToken());
243
220
  if (!refreshToken) {
244
221
  throw new Error("Pending setup is missing its refresh token. Start setup again.");
245
222
  }
@@ -248,7 +225,7 @@ async function buildAuthorizedContextFromPending(cfg, pendingSetup, options) {
248
225
  baseUrl: pendingSetupBaseUrl(cfg),
249
226
  accessToken: options?.accessToken,
250
227
  refreshToken,
251
- refreshTokenAccount: ACCOUNTS.PENDING_SETUP_REFRESH_TOKEN,
228
+ onRefreshTokenPersist: storePendingSetupRefreshToken,
252
229
  });
253
230
  const account = options?.account ?? (await getAccountMe(client));
254
231
  return {
@@ -307,20 +284,5 @@ export async function authorizeDevice(cfg) {
307
284
  return waitForPendingAuthorization(cfg, pendingSetup);
308
285
  }
309
286
  export async function persistVaultSecrets(params) {
310
- const { urk, salt } = await deriveURK(params.passphrase);
311
- try {
312
- const wrappedAmk = wrapAMK(urk, params.amk);
313
- await storeBytes(ACCOUNTS.ED25519_PRIVATE, params.deviceKeys.ed25519.privateKey);
314
- await storeBytes(ACCOUNTS.ED25519_PUBLIC, params.deviceKeys.ed25519.publicKey);
315
- await storeBytes(ACCOUNTS.X25519_PRIVATE, params.deviceKeys.x25519.privateKey);
316
- await storeBytes(ACCOUNTS.X25519_PUBLIC, params.deviceKeys.x25519.publicKey);
317
- await storeBytes(ACCOUNTS.WRAPPED_AMK, wrappedAmk.encrypted_amk);
318
- await storeBytes(ACCOUNTS.WRAPPED_AMK_SALT, salt);
319
- await storeBytes(ACCOUNTS.WRAPPED_AMK_IV, wrappedAmk.iv);
320
- await storeBytes(ACCOUNTS.WRAPPED_AMK_TAG, wrappedAmk.tag);
321
- await storeString(ACCOUNTS.REFRESH_TOKEN, params.refreshToken);
322
- }
323
- finally {
324
- urk.fill(0);
325
- }
287
+ await persistVaultSecretsBundle(params);
326
288
  }
@@ -1,7 +1,6 @@
1
1
  import { createAuthenticatedClient } from "./helpers.js";
2
2
  import { vaultSession } from "../store/session.js";
3
3
  import { getState } from "../sync/state.js";
4
- import { ACCOUNTS, getBytes, } from "../store/keychain.js";
5
4
  import { createDeviceGrant } from "../crypto/grant.js";
6
5
  import { computeSha256Hex } from "../crypto/manifest.js";
7
6
  import { approveGrant, getPendingGrants, } from "../api/devices.js";
@@ -28,11 +27,7 @@ export async function approvePendingGrants() {
28
27
  approvedGrantIds: [],
29
28
  };
30
29
  }
31
- const sourceEdPrivateKey = await getBytes(ACCOUNTS.ED25519_PRIVATE);
32
- const sourceXPrivateKey = await getBytes(ACCOUNTS.X25519_PRIVATE);
33
- if (!sourceEdPrivateKey || !sourceXPrivateKey) {
34
- throw new Error("Device keypair missing from local keychain. Re-run setup on this device.");
35
- }
30
+ const deviceKeys = vaultSession.getDeviceKeys();
36
31
  const amk = vaultSession.getAMK();
37
32
  let approved = 0;
38
33
  let skipped = 0;
@@ -44,8 +39,8 @@ export async function approvePendingGrants() {
44
39
  continue;
45
40
  }
46
41
  const { payloadBytes, signature } = createDeviceGrant({
47
- sourceEdPrivateKey,
48
- sourceXPrivateKey,
42
+ sourceEdPrivateKey: deviceKeys.ed25519.privateKey,
43
+ sourceXPrivateKey: deviceKeys.x25519.privateKey,
49
44
  sourceDeviceId: state.device_id,
50
45
  targetDeviceId: grant.target_device_id,
51
46
  targetKexPublicKey: fromB64(grant.target_device_kex_public_key_b64),
@@ -1,13 +1,25 @@
1
1
  import { MemboxApiClient } from "../api/client.js";
2
- import { getString, storeString, ACCOUNTS } from "../store/keychain.js";
3
2
  import { API_PREFIX } from "../constants.js";
3
+ import { vaultSession } from "../store/session.js";
4
+ import { persistVaultSecretsWithKey } from "../store/vault-secrets.js";
4
5
  let cachedAccessToken = null;
5
6
  /**
6
7
  * Create an authenticated API client using stored tokens.
7
8
  */
8
9
  export async function createAuthenticatedClient(state) {
9
- return new MemboxApiClient(state.server_url + API_PREFIX, async () => cachedAccessToken, async () => getString(ACCOUNTS.REFRESH_TOKEN), async (access, refresh) => {
10
+ return new MemboxApiClient(state.server_url + API_PREFIX, async () => cachedAccessToken, async () => vaultSession.getRefreshToken(), async (access, refresh) => {
10
11
  cachedAccessToken = access;
11
- await storeString(ACCOUNTS.REFRESH_TOKEN, refresh);
12
+ await vaultSession.updateRefreshToken(refresh);
13
+ const persistenceKey = vaultSession.getPersistenceKey();
14
+ const persistenceSalt = vaultSession.getPersistenceSalt();
15
+ if (persistenceKey && persistenceSalt) {
16
+ await persistVaultSecretsWithKey({
17
+ amk: vaultSession.getAMK(),
18
+ deviceKeys: vaultSession.getDeviceKeys(),
19
+ refreshToken: refresh,
20
+ persistenceKey,
21
+ salt: persistenceSalt,
22
+ });
23
+ }
12
24
  });
13
25
  }
@@ -3,6 +3,8 @@ import { clearPendingSetupArtifacts, } from "./bootstrap.js";
3
3
  import { clearStoredSecrets } from "../store/keychain.js";
4
4
  import { deleteState } from "../store/local-state.js";
5
5
  import { vaultSession } from "../store/session.js";
6
+ import { deleteVaultSecretsFile } from "../store/vault-secrets.js";
7
+ import { disableManagedUnlock } from "../store/managed-unlock.js";
6
8
  export async function rollbackIncompleteProvisioning(auth) {
7
9
  vaultSession.lock();
8
10
  try {
@@ -12,6 +14,8 @@ export async function rollbackIncompleteProvisioning(auth) {
12
14
  console.warn("[membox] Failed to revoke incomplete device authorization:", error);
13
15
  }
14
16
  await clearStoredSecrets();
17
+ await deleteVaultSecretsFile();
18
+ await disableManagedUnlock().catch(() => { });
15
19
  await clearPendingSetupArtifacts();
16
20
  await deleteState();
17
21
  }
@@ -10,6 +10,7 @@ import { pullAction } from "./pull.js";
10
10
  import { readPassphraseWithConfirm } from "./passphrase-input.js";
11
11
  import { authorizeDevice, clearPendingSetupArtifacts, persistVaultSecrets, } from "./bootstrap.js";
12
12
  import { runWithProvisioningRollback } from "./provisioning.js";
13
+ import { loadVaultSecretsWithPassphrase } from "../store/vault-secrets.js";
13
14
  async function readRecoveryCode() {
14
15
  const rl = createInterface({ input, output });
15
16
  try {
@@ -54,7 +55,17 @@ export async function restoreAuthorizedDeviceFromRecovery(cfg, auth, params) {
54
55
  await writeState(state);
55
56
  await clearPendingSetupArtifacts();
56
57
  markProvisioned();
57
- vaultSession.unlock(amk);
58
+ const loadedSecrets = await loadVaultSecretsWithPassphrase(params.passphrase);
59
+ if (!loadedSecrets) {
60
+ throw new Error("Persisted vault secrets are missing after restore.");
61
+ }
62
+ vaultSession.unlock({
63
+ amk: loadedSecrets.amk,
64
+ deviceKeys: loadedSecrets.deviceKeys,
65
+ refreshToken: loadedSecrets.refreshToken,
66
+ persistenceKey: loadedSecrets.persistenceKey,
67
+ persistenceSalt: loadedSecrets.salt,
68
+ });
58
69
  console.log("Recovery successful. Pulling encrypted memory...");
59
70
  await pullAction({ onConflict: "conflict-copy" });
60
71
  console.log("Restore complete! Vault is ready.");
@@ -16,6 +16,7 @@ import { updateFileVersion } from "../sync/state.js";
16
16
  import { pullAction } from "./pull.js";
17
17
  import { authorizeDevice, clearPendingSetupArtifacts, fromB64, persistVaultSecrets, toB64, } from "./bootstrap.js";
18
18
  import { runWithProvisioningRollback } from "./provisioning.js";
19
+ import { loadVaultSecretsWithPassphrase } from "../store/vault-secrets.js";
19
20
  export function selectGrantCapableDevices(devices, currentDeviceId) {
20
21
  return devices.filter((device) => device.device_id !== currentDeviceId &&
21
22
  device.status === "active" &&
@@ -131,7 +132,17 @@ export async function finishAuthorizedSetup(cfg, auth, params) {
131
132
  await enableManagedUnlock(params.passphrase);
132
133
  }
133
134
  await clearPendingSetupArtifacts();
134
- vaultSession.unlock(amk);
135
+ const loadedSecrets = await loadVaultSecretsWithPassphrase(params.passphrase);
136
+ if (!loadedSecrets) {
137
+ throw new Error("Persisted vault secrets are missing after setup.");
138
+ }
139
+ vaultSession.unlock({
140
+ amk: loadedSecrets.amk,
141
+ deviceKeys: loadedSecrets.deviceKeys,
142
+ refreshToken: loadedSecrets.refreshToken,
143
+ persistenceKey: loadedSecrets.persistenceKey,
144
+ persistenceSalt: loadedSecrets.salt,
145
+ });
135
146
  if (initialMode === "pull") {
136
147
  console.log("Pulling existing encrypted memory...");
137
148
  const pullResult = await pullAction({ onConflict: "conflict-copy" });
@@ -1,15 +1,20 @@
1
1
  import { readState } from "../store/local-state.js";
2
2
  import { getBytes, ACCOUNTS } from "../store/keychain.js";
3
+ import { getString } from "../store/keychain.js";
3
4
  import { deriveURK } from "../crypto/kdf.js";
4
5
  import { unwrapAMK } from "../crypto/keys.js";
5
6
  import { vaultSession } from "../store/session.js";
6
7
  import { disableManagedUnlock, enableManagedUnlock, getManagedUnlockPassphrase, getManagedUnlockStatus, } from "../store/managed-unlock.js";
7
8
  import { readPassphrase } from "./passphrase-input.js";
9
+ import { hasPersistedVaultSecrets, loadVaultSecretsWithPassphrase, } from "../store/vault-secrets.js";
8
10
  async function canUnlockVault() {
9
11
  const state = await readState();
10
12
  if (!state?.setup_complete) {
11
13
  return false;
12
14
  }
15
+ if (await hasPersistedVaultSecrets()) {
16
+ return true;
17
+ }
13
18
  const wrappedAmk = await getBytes(ACCOUNTS.WRAPPED_AMK);
14
19
  const salt = await getBytes(ACCOUNTS.WRAPPED_AMK_SALT);
15
20
  const iv = await getBytes(ACCOUNTS.WRAPPED_AMK_IV);
@@ -27,11 +32,55 @@ export async function unlockWithPassphrase(passphrase, options) {
27
32
  }
28
33
  return false;
29
34
  }
35
+ if (await hasPersistedVaultSecrets()) {
36
+ if (options?.announceDeriving) {
37
+ console.log("Deriving key...");
38
+ }
39
+ try {
40
+ const loaded = await loadVaultSecretsWithPassphrase(passphrase);
41
+ if (!loaded) {
42
+ if (options?.announceFailure) {
43
+ console.error("Encrypted local secrets are missing. Re-run setup or use recovery.");
44
+ }
45
+ return false;
46
+ }
47
+ vaultSession.unlock({
48
+ amk: loaded.amk,
49
+ deviceKeys: loaded.deviceKeys,
50
+ refreshToken: loaded.refreshToken,
51
+ persistenceKey: loaded.persistenceKey,
52
+ persistenceSalt: loaded.salt,
53
+ });
54
+ if (options?.announceSuccess) {
55
+ console.log("Vault unlocked.");
56
+ }
57
+ return true;
58
+ }
59
+ catch {
60
+ if (options?.announceFailure) {
61
+ console.error("Wrong passphrase.");
62
+ }
63
+ return false;
64
+ }
65
+ }
30
66
  const wrappedAmk = await getBytes(ACCOUNTS.WRAPPED_AMK);
31
67
  const salt = await getBytes(ACCOUNTS.WRAPPED_AMK_SALT);
32
68
  const iv = await getBytes(ACCOUNTS.WRAPPED_AMK_IV);
33
69
  const tag = await getBytes(ACCOUNTS.WRAPPED_AMK_TAG);
34
- if (!wrappedAmk || !salt || !iv || !tag) {
70
+ const sourceEdPrivateKey = await getBytes(ACCOUNTS.ED25519_PRIVATE);
71
+ const sourceEdPublicKey = await getBytes(ACCOUNTS.ED25519_PUBLIC);
72
+ const sourceXPrivateKey = await getBytes(ACCOUNTS.X25519_PRIVATE);
73
+ const sourceXPublicKey = await getBytes(ACCOUNTS.X25519_PUBLIC);
74
+ const refreshToken = await getString(ACCOUNTS.REFRESH_TOKEN);
75
+ if (!wrappedAmk ||
76
+ !salt ||
77
+ !iv ||
78
+ !tag ||
79
+ !sourceEdPrivateKey ||
80
+ !sourceEdPublicKey ||
81
+ !sourceXPrivateKey ||
82
+ !sourceXPublicKey ||
83
+ !refreshToken) {
35
84
  if (options?.announceFailure) {
36
85
  console.error("Keychain data missing. Re-run setup or use recovery.");
37
86
  }
@@ -43,7 +92,20 @@ export async function unlockWithPassphrase(passphrase, options) {
43
92
  const { urk } = await deriveURK(passphrase, salt);
44
93
  try {
45
94
  const amk = unwrapAMK(urk, { encrypted_amk: wrappedAmk, iv, tag });
46
- vaultSession.unlock(amk);
95
+ vaultSession.unlock({
96
+ amk,
97
+ deviceKeys: {
98
+ ed25519: {
99
+ privateKey: sourceEdPrivateKey,
100
+ publicKey: sourceEdPublicKey,
101
+ },
102
+ x25519: {
103
+ privateKey: sourceXPrivateKey,
104
+ publicKey: sourceXPublicKey,
105
+ },
106
+ },
107
+ refreshToken,
108
+ });
47
109
  if (options?.announceSuccess) {
48
110
  console.log("Vault unlocked.");
49
111
  }
@@ -6,6 +6,9 @@ export declare const KEYCHAIN_SERVICE: string;
6
6
  export declare const STATE_DIR = ".membox";
7
7
  export declare const STATE_FILE = "state.json";
8
8
  export declare const PENDING_SETUP_FILE = "pending-setup.json";
9
+ export declare const PENDING_SETUP_SECRETS_FILE = "pending-setup-secrets.json";
10
+ export declare const VAULT_SECRETS_FILE = "vault-secrets.json";
11
+ export declare const MANAGED_UNLOCK_FILE = "managed-unlock.json";
9
12
  export declare const STATE_ROOT_ENV = "MEMBOX_STATE_ROOT";
10
13
  export declare function resolveStateRoot(): string;
11
14
  export declare const MANIFEST_VERSION = 1;
@@ -8,6 +8,9 @@ export const KEYCHAIN_SERVICE = process.env.MEMBOX_KEYCHAIN_SERVICE || "membox.c
8
8
  export const STATE_DIR = ".membox";
9
9
  export const STATE_FILE = "state.json";
10
10
  export const PENDING_SETUP_FILE = "pending-setup.json";
11
+ export const PENDING_SETUP_SECRETS_FILE = "pending-setup-secrets.json";
12
+ export const VAULT_SECRETS_FILE = "vault-secrets.json";
13
+ export const MANAGED_UNLOCK_FILE = "managed-unlock.json";
11
14
  export const STATE_ROOT_ENV = "MEMBOX_STATE_ROOT";
12
15
  export function resolveStateRoot() {
13
16
  return process.env[STATE_ROOT_ENV] || join(homedir(), STATE_DIR);
@@ -1,4 +1,7 @@
1
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
1
3
  import { ACCOUNTS, deleteSecret, getString, storeString, } from "./keychain.js";
4
+ import { MANAGED_UNLOCK_FILE, resolveStateRoot } from "../constants.js";
2
5
  import { readState, writeState } from "./local-state.js";
3
6
  async function requireSetupState() {
4
7
  const state = await readState();
@@ -7,6 +10,52 @@ async function requireSetupState() {
7
10
  }
8
11
  return state;
9
12
  }
13
+ function fallbackPath() {
14
+ return join(resolveStateRoot(), MANAGED_UNLOCK_FILE);
15
+ }
16
+ async function readFallbackPassphrase() {
17
+ try {
18
+ const raw = await readFile(fallbackPath(), "utf-8");
19
+ const parsed = JSON.parse(raw);
20
+ return parsed.passphrase;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function writeFallbackPassphrase(passphrase) {
27
+ const path = fallbackPath();
28
+ await mkdir(dirname(path), { recursive: true });
29
+ const tmp = `${path}.tmp.${Date.now()}`;
30
+ await writeFile(tmp, JSON.stringify({ version: 1, passphrase }, null, 2), { mode: 0o600 });
31
+ await rename(tmp, path);
32
+ await chmod(path, 0o600).catch(() => { });
33
+ }
34
+ async function deleteFallbackPassphrase() {
35
+ await unlink(fallbackPath()).catch(() => { });
36
+ }
37
+ async function loadManagedUnlockSecret() {
38
+ try {
39
+ return await getString(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE);
40
+ }
41
+ catch {
42
+ return readFallbackPassphrase();
43
+ }
44
+ }
45
+ async function persistManagedUnlockSecret(passphrase) {
46
+ try {
47
+ await storeString(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE, passphrase);
48
+ }
49
+ catch {
50
+ await writeFallbackPassphrase(passphrase);
51
+ }
52
+ }
53
+ async function removeManagedUnlockSecret() {
54
+ await Promise.allSettled([
55
+ deleteSecret(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE),
56
+ deleteFallbackPassphrase(),
57
+ ]);
58
+ }
10
59
  export async function getManagedUnlockStatus() {
11
60
  const state = await readState();
12
61
  if (!state?.setup_complete) {
@@ -15,7 +64,7 @@ export async function getManagedUnlockStatus() {
15
64
  secret_present: false,
16
65
  };
17
66
  }
18
- const secret = await getString(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE);
67
+ const secret = await loadManagedUnlockSecret();
19
68
  return {
20
69
  enabled: state.managed_unlock_enabled === true,
21
70
  secret_present: !!secret,
@@ -26,11 +75,11 @@ export async function getManagedUnlockPassphrase() {
26
75
  if (!status.enabled) {
27
76
  return null;
28
77
  }
29
- return getString(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE);
78
+ return loadManagedUnlockSecret();
30
79
  }
31
80
  export async function enableManagedUnlock(passphrase) {
32
81
  const state = await requireSetupState();
33
- await storeString(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE, passphrase);
82
+ await persistManagedUnlockSecret(passphrase);
34
83
  if (!state.managed_unlock_enabled) {
35
84
  state.managed_unlock_enabled = true;
36
85
  await writeState(state);
@@ -38,7 +87,7 @@ export async function enableManagedUnlock(passphrase) {
38
87
  }
39
88
  export async function disableManagedUnlock() {
40
89
  const state = await readState();
41
- await deleteSecret(ACCOUNTS.MANAGED_UNLOCK_PASSPHRASE).catch(() => { });
90
+ await removeManagedUnlockSecret();
42
91
  if (state?.setup_complete && state.managed_unlock_enabled) {
43
92
  state.managed_unlock_enabled = false;
44
93
  await writeState(state);
@@ -0,0 +1,6 @@
1
+ import type { DeviceKeyPair } from "../crypto/device-keys.js";
2
+ export declare function storePendingSetupDeviceKeys(deviceKeys: DeviceKeyPair): Promise<void>;
3
+ export declare function loadPendingSetupDeviceKeys(): Promise<DeviceKeyPair>;
4
+ export declare function storePendingSetupRefreshToken(refreshToken: string): Promise<void>;
5
+ export declare function getPendingSetupRefreshToken(): Promise<string | null>;
6
+ export declare function clearPendingSetupSecretsFile(): Promise<void>;
@@ -0,0 +1,73 @@
1
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { PENDING_SETUP_SECRETS_FILE, resolveStateRoot } from "../constants.js";
4
+ function toB64(data) {
5
+ return Buffer.from(data).toString("base64");
6
+ }
7
+ function fromB64(data) {
8
+ return new Uint8Array(Buffer.from(data, "base64"));
9
+ }
10
+ function secretsPath() {
11
+ return join(resolveStateRoot(), PENDING_SETUP_SECRETS_FILE);
12
+ }
13
+ async function readFileState() {
14
+ try {
15
+ const raw = await readFile(secretsPath(), "utf-8");
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ async function writeFileState(state) {
23
+ const path = secretsPath();
24
+ await mkdir(dirname(path), { recursive: true });
25
+ const tmp = `${path}.tmp.${Date.now()}`;
26
+ await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
27
+ await rename(tmp, path);
28
+ await chmod(path, 0o600).catch(() => { });
29
+ }
30
+ export async function storePendingSetupDeviceKeys(deviceKeys) {
31
+ const existing = await readFileState();
32
+ await writeFileState({
33
+ version: 1,
34
+ ed25519_private_b64: toB64(deviceKeys.ed25519.privateKey),
35
+ ed25519_public_b64: toB64(deviceKeys.ed25519.publicKey),
36
+ x25519_private_b64: toB64(deviceKeys.x25519.privateKey),
37
+ x25519_public_b64: toB64(deviceKeys.x25519.publicKey),
38
+ refresh_token: existing?.refresh_token,
39
+ });
40
+ }
41
+ export async function loadPendingSetupDeviceKeys() {
42
+ const state = await readFileState();
43
+ if (!state) {
44
+ throw new Error("Pending setup secrets are missing. Start setup again to regenerate device authorization.");
45
+ }
46
+ return {
47
+ ed25519: {
48
+ privateKey: fromB64(state.ed25519_private_b64),
49
+ publicKey: fromB64(state.ed25519_public_b64),
50
+ },
51
+ x25519: {
52
+ privateKey: fromB64(state.x25519_private_b64),
53
+ publicKey: fromB64(state.x25519_public_b64),
54
+ },
55
+ };
56
+ }
57
+ export async function storePendingSetupRefreshToken(refreshToken) {
58
+ const state = await readFileState();
59
+ if (!state) {
60
+ throw new Error("Pending setup secrets are missing. Start setup again before storing the refresh token.");
61
+ }
62
+ await writeFileState({
63
+ ...state,
64
+ refresh_token: refreshToken,
65
+ });
66
+ }
67
+ export async function getPendingSetupRefreshToken() {
68
+ const state = await readFileState();
69
+ return state?.refresh_token ?? null;
70
+ }
71
+ export async function clearPendingSetupSecretsFile() {
72
+ await unlink(secretsPath()).catch(() => { });
73
+ }
@@ -1,12 +1,28 @@
1
+ import type { DeviceKeyPair } from "../crypto/device-keys.js";
1
2
  /**
2
3
  * In-memory vault session holding the unlocked AMK.
3
4
  * Exists for the lifetime of the gateway process.
4
5
  */
5
6
  export declare class VaultSession {
6
7
  private amk;
8
+ private deviceKeys;
9
+ private refreshToken;
10
+ private persistenceKey;
11
+ private persistenceSalt;
7
12
  isUnlocked(): boolean;
8
13
  getAMK(): Uint8Array;
9
- unlock(amk: Uint8Array): void;
14
+ getDeviceKeys(): DeviceKeyPair;
15
+ getRefreshToken(): string;
16
+ updateRefreshToken(refreshToken: string): Promise<void>;
17
+ getPersistenceKey(): Uint8Array | null;
18
+ getPersistenceSalt(): Uint8Array | null;
19
+ unlock(params: {
20
+ amk: Uint8Array;
21
+ deviceKeys: DeviceKeyPair;
22
+ refreshToken: string;
23
+ persistenceKey?: Uint8Array;
24
+ persistenceSalt?: Uint8Array;
25
+ }): void;
10
26
  lock(): void;
11
27
  }
12
28
  /** Singleton session for the gateway process. */
@@ -4,6 +4,10 @@
4
4
  */
5
5
  export class VaultSession {
6
6
  amk = null;
7
+ deviceKeys = null;
8
+ refreshToken = null;
9
+ persistenceKey = null;
10
+ persistenceSalt = null;
7
11
  isUnlocked() {
8
12
  return this.amk !== null;
9
13
  }
@@ -13,15 +17,76 @@ export class VaultSession {
13
17
  }
14
18
  return this.amk;
15
19
  }
16
- unlock(amk) {
20
+ getDeviceKeys() {
21
+ if (!this.deviceKeys) {
22
+ throw new Error("Vault device keys are unavailable. Unlock the vault again or re-run setup.");
23
+ }
24
+ return {
25
+ ed25519: {
26
+ privateKey: new Uint8Array(this.deviceKeys.ed25519.privateKey),
27
+ publicKey: new Uint8Array(this.deviceKeys.ed25519.publicKey),
28
+ },
29
+ x25519: {
30
+ privateKey: new Uint8Array(this.deviceKeys.x25519.privateKey),
31
+ publicKey: new Uint8Array(this.deviceKeys.x25519.publicKey),
32
+ },
33
+ };
34
+ }
35
+ getRefreshToken() {
36
+ if (!this.refreshToken) {
37
+ throw new Error("Vault refresh token is unavailable. Unlock the vault again or re-run setup.");
38
+ }
39
+ return this.refreshToken;
40
+ }
41
+ async updateRefreshToken(refreshToken) {
42
+ this.refreshToken = refreshToken;
43
+ }
44
+ getPersistenceKey() {
45
+ return this.persistenceKey ? new Uint8Array(this.persistenceKey) : null;
46
+ }
47
+ getPersistenceSalt() {
48
+ return this.persistenceSalt ? new Uint8Array(this.persistenceSalt) : null;
49
+ }
50
+ unlock(params) {
17
51
  // Store a copy so the caller can zero their original
18
- this.amk = new Uint8Array(amk);
52
+ this.amk = new Uint8Array(params.amk);
53
+ this.deviceKeys = {
54
+ ed25519: {
55
+ privateKey: new Uint8Array(params.deviceKeys.ed25519.privateKey),
56
+ publicKey: new Uint8Array(params.deviceKeys.ed25519.publicKey),
57
+ },
58
+ x25519: {
59
+ privateKey: new Uint8Array(params.deviceKeys.x25519.privateKey),
60
+ publicKey: new Uint8Array(params.deviceKeys.x25519.publicKey),
61
+ },
62
+ };
63
+ this.refreshToken = params.refreshToken;
64
+ this.persistenceKey = params.persistenceKey
65
+ ? new Uint8Array(params.persistenceKey)
66
+ : null;
67
+ this.persistenceSalt = params.persistenceSalt
68
+ ? new Uint8Array(params.persistenceSalt)
69
+ : null;
19
70
  }
20
71
  lock() {
21
72
  if (this.amk) {
22
73
  this.amk.fill(0);
23
74
  this.amk = null;
24
75
  }
76
+ if (this.deviceKeys) {
77
+ this.deviceKeys.ed25519.privateKey.fill(0);
78
+ this.deviceKeys.x25519.privateKey.fill(0);
79
+ this.deviceKeys = null;
80
+ }
81
+ this.refreshToken = null;
82
+ if (this.persistenceKey) {
83
+ this.persistenceKey.fill(0);
84
+ this.persistenceKey = null;
85
+ }
86
+ if (this.persistenceSalt) {
87
+ this.persistenceSalt.fill(0);
88
+ this.persistenceSalt = null;
89
+ }
25
90
  }
26
91
  }
27
92
  /** Singleton session for the gateway process. */
@@ -0,0 +1,24 @@
1
+ import type { DeviceKeyPair } from "../crypto/device-keys.js";
2
+ export interface LoadedVaultSecrets {
3
+ amk: Uint8Array;
4
+ deviceKeys: DeviceKeyPair;
5
+ refreshToken: string;
6
+ persistenceKey: Uint8Array;
7
+ salt: Uint8Array;
8
+ }
9
+ export declare function hasPersistedVaultSecrets(): Promise<boolean>;
10
+ export declare function persistVaultSecretsBundle(params: {
11
+ deviceKeys: DeviceKeyPair;
12
+ passphrase: string;
13
+ amk: Uint8Array;
14
+ refreshToken: string;
15
+ }): Promise<void>;
16
+ export declare function persistVaultSecretsWithKey(params: {
17
+ deviceKeys: DeviceKeyPair;
18
+ amk: Uint8Array;
19
+ refreshToken: string;
20
+ persistenceKey: Uint8Array;
21
+ salt: Uint8Array;
22
+ }): Promise<void>;
23
+ export declare function loadVaultSecretsWithPassphrase(passphrase: string): Promise<LoadedVaultSecrets | null>;
24
+ export declare function deleteVaultSecretsFile(): Promise<void>;
@@ -0,0 +1,149 @@
1
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { decrypt, encrypt } from "../crypto/aes-gcm.js";
4
+ import { deriveURK } from "../crypto/kdf.js";
5
+ import { wrapAMK, unwrapAMK } from "../crypto/keys.js";
6
+ import { VAULT_SECRETS_FILE, resolveStateRoot } from "../constants.js";
7
+ function toB64(data) {
8
+ return Buffer.from(data).toString("base64");
9
+ }
10
+ function fromB64(data) {
11
+ return new Uint8Array(Buffer.from(data, "base64"));
12
+ }
13
+ function secretsPath() {
14
+ return join(resolveStateRoot(), VAULT_SECRETS_FILE);
15
+ }
16
+ async function writeFileState(state) {
17
+ const path = secretsPath();
18
+ await mkdir(dirname(path), { recursive: true });
19
+ const tmp = `${path}.tmp.${Date.now()}`;
20
+ await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
21
+ await rename(tmp, path);
22
+ await chmod(path, 0o600).catch(() => { });
23
+ }
24
+ async function readFileState() {
25
+ try {
26
+ const raw = await readFile(secretsPath(), "utf-8");
27
+ return JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function serializePayload(params) {
34
+ return {
35
+ version: 1,
36
+ refresh_token: params.refreshToken,
37
+ wrapped_amk_b64: toB64(params.wrappedAmk.encrypted_amk),
38
+ wrapped_amk_iv_b64: toB64(params.wrappedAmk.iv),
39
+ wrapped_amk_tag_b64: toB64(params.wrappedAmk.tag),
40
+ ed25519_private_b64: toB64(params.deviceKeys.ed25519.privateKey),
41
+ ed25519_public_b64: toB64(params.deviceKeys.ed25519.publicKey),
42
+ x25519_private_b64: toB64(params.deviceKeys.x25519.privateKey),
43
+ x25519_public_b64: toB64(params.deviceKeys.x25519.publicKey),
44
+ };
45
+ }
46
+ async function persistPayload(params) {
47
+ const plaintext = new TextEncoder().encode(JSON.stringify(params.payload));
48
+ try {
49
+ const sealed = encrypt(params.urk, plaintext);
50
+ await writeFileState({
51
+ version: 1,
52
+ salt_b64: toB64(params.salt),
53
+ ciphertext_b64: toB64(sealed.ciphertext),
54
+ iv_b64: toB64(sealed.iv),
55
+ tag_b64: toB64(sealed.tag),
56
+ });
57
+ }
58
+ finally {
59
+ plaintext.fill(0);
60
+ }
61
+ }
62
+ function deserializePayload(params) {
63
+ const amk = unwrapAMK(params.urk, {
64
+ encrypted_amk: fromB64(params.payload.wrapped_amk_b64),
65
+ iv: fromB64(params.payload.wrapped_amk_iv_b64),
66
+ tag: fromB64(params.payload.wrapped_amk_tag_b64),
67
+ });
68
+ return {
69
+ amk,
70
+ refreshToken: params.payload.refresh_token,
71
+ deviceKeys: {
72
+ ed25519: {
73
+ privateKey: fromB64(params.payload.ed25519_private_b64),
74
+ publicKey: fromB64(params.payload.ed25519_public_b64),
75
+ },
76
+ x25519: {
77
+ privateKey: fromB64(params.payload.x25519_private_b64),
78
+ publicKey: fromB64(params.payload.x25519_public_b64),
79
+ },
80
+ },
81
+ };
82
+ }
83
+ export async function hasPersistedVaultSecrets() {
84
+ return (await readFileState()) !== null;
85
+ }
86
+ export async function persistVaultSecretsBundle(params) {
87
+ const { urk, salt } = await deriveURK(params.passphrase);
88
+ try {
89
+ const wrappedAmk = wrapAMK(urk, params.amk);
90
+ const payload = serializePayload({
91
+ deviceKeys: params.deviceKeys,
92
+ refreshToken: params.refreshToken,
93
+ wrappedAmk,
94
+ });
95
+ await persistPayload({
96
+ urk,
97
+ salt,
98
+ payload,
99
+ });
100
+ }
101
+ finally {
102
+ urk.fill(0);
103
+ }
104
+ }
105
+ export async function persistVaultSecretsWithKey(params) {
106
+ const wrappedAmk = wrapAMK(params.persistenceKey, params.amk);
107
+ const payload = serializePayload({
108
+ deviceKeys: params.deviceKeys,
109
+ refreshToken: params.refreshToken,
110
+ wrappedAmk,
111
+ });
112
+ await persistPayload({
113
+ urk: params.persistenceKey,
114
+ salt: params.salt,
115
+ payload,
116
+ });
117
+ }
118
+ export async function loadVaultSecretsWithPassphrase(passphrase) {
119
+ const state = await readFileState();
120
+ if (!state) {
121
+ return null;
122
+ }
123
+ const salt = fromB64(state.salt_b64);
124
+ const { urk } = await deriveURK(passphrase, salt);
125
+ try {
126
+ const plaintext = decrypt(urk, fromB64(state.ciphertext_b64), fromB64(state.iv_b64), fromB64(state.tag_b64));
127
+ try {
128
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
129
+ const loaded = deserializePayload({ payload, urk });
130
+ const persistenceKey = new Uint8Array(urk);
131
+ urk.fill(0);
132
+ return {
133
+ ...loaded,
134
+ persistenceKey,
135
+ salt: new Uint8Array(salt),
136
+ };
137
+ }
138
+ finally {
139
+ plaintext.fill(0);
140
+ }
141
+ }
142
+ catch {
143
+ urk.fill(0);
144
+ throw new Error("Wrong passphrase.");
145
+ }
146
+ }
147
+ export async function deleteVaultSecretsFile() {
148
+ await unlink(secretsPath()).catch(() => { });
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@membox-cloud/membox",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -20,7 +20,7 @@
20
20
  "keytar": "^7.9.0"
21
21
  },
22
22
  "peerDependencies": {
23
- "openclaw": ">=2026.3.7"
23
+ "openclaw": ">=2026.2.14"
24
24
  },
25
25
  "peerDependenciesMeta": {
26
26
  "openclaw": {