@yefengr/remote-pi 0.9.7 → 0.9.9

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.
Files changed (53) hide show
  1. package/README.md +12 -2
  2. package/dist/pairing/identity-keyring.d.ts +22 -0
  3. package/dist/pairing/identity-keyring.js +94 -0
  4. package/dist/pairing/identity-keyring.js.map +1 -0
  5. package/dist/pairing/identity-lock.d.ts +14 -0
  6. package/dist/pairing/identity-lock.js +116 -0
  7. package/dist/pairing/identity-lock.js.map +1 -0
  8. package/dist/pairing/qr.d.ts +2 -3
  9. package/dist/pairing/qr.js +2 -3
  10. package/dist/pairing/qr.js.map +1 -1
  11. package/dist/pairing/storage.d.ts +19 -61
  12. package/dist/pairing/storage.js +196 -314
  13. package/dist/pairing/storage.js.map +1 -1
  14. package/dist/protocol/types.d.ts +2 -52
  15. package/dist/protocol/v2/codec.d.ts +5 -25
  16. package/dist/protocol/v2/codec.js +3 -206
  17. package/dist/protocol/v2/codec.js.map +1 -1
  18. package/dist/protocol/v2/index.d.ts +1 -0
  19. package/dist/protocol/v2/index.js +1 -0
  20. package/dist/protocol/v2/index.js.map +1 -1
  21. package/dist/protocol/v2/marker.d.ts +40 -0
  22. package/dist/protocol/v2/marker.js +47 -0
  23. package/dist/protocol/v2/marker.js.map +1 -0
  24. package/dist/protocol/v2/schemas.d.ts +3 -2024
  25. package/dist/protocol/v2/schemas.js +3 -433
  26. package/dist/protocol/v2/schemas.js.map +1 -1
  27. package/dist/runtime/owner_router.js +5 -1
  28. package/dist/runtime/owner_router.js.map +1 -1
  29. package/dist/transport/peer_channel.d.ts +2 -11
  30. package/dist/transport/peer_channel.js +7 -11
  31. package/dist/transport/peer_channel.js.map +1 -1
  32. package/dist/transport/relay_client.d.ts +2 -8
  33. package/dist/transport/relay_client.js +3 -2
  34. package/dist/transport/relay_client.js.map +1 -1
  35. package/dist/vendor/protocol/constants.d.ts +4 -0
  36. package/dist/vendor/protocol/constants.js +4 -0
  37. package/dist/vendor/protocol/encoding.d.ts +9 -0
  38. package/dist/vendor/protocol/encoding.js +66 -0
  39. package/dist/vendor/protocol/outer/codec.d.ts +13 -0
  40. package/dist/vendor/protocol/outer/codec.js +105 -0
  41. package/dist/vendor/protocol/outer/index.d.ts +4 -0
  42. package/dist/vendor/protocol/outer/index.js +4 -0
  43. package/dist/vendor/protocol/outer/types.d.ts +120 -0
  44. package/dist/vendor/protocol/outer/types.js +1 -0
  45. package/dist/vendor/protocol/session/codec.d.ts +41 -0
  46. package/dist/vendor/protocol/session/codec.js +236 -0
  47. package/dist/vendor/protocol/session/frames.d.ts +5750 -0
  48. package/dist/vendor/protocol/session/frames.js +406 -0
  49. package/dist/vendor/protocol/session/index.d.ts +3 -0
  50. package/dist/vendor/protocol/session/index.js +3 -0
  51. package/dist/vendor/protocol/session/schema.d.ts +1060 -0
  52. package/dist/vendor/protocol/session/schema.js +302 -0
  53. package/package.json +17 -25
@@ -1,391 +1,273 @@
1
- import { mkdir, readFile, writeFile, chmod, unlink } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, open, readFile, rename, unlink } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { generateEd25519Keypair } from "./crypto.js";
6
+ import { ensurePrivateIdentityDirectory, withIdentityLock, } from "./identity-lock.js";
7
+ import { NapiKeyringBackend, nativeBindingUnavailable, } from "./identity-keyring.js";
5
8
  import { listPeers } from "./owner_storage.js";
6
9
  export { addPeer, listPeers, listOwnerPubkeys, snapshotOwnerPubkeys, conditionalRemovePeer, conditionalRollbackPeer, removePeer, } from "./owner_storage.js";
7
- /**
8
- * Pi-secret storage (plan/27 Wave E1).
9
- *
10
- * The Ed25519 long-term identity of this Pi lives in the platform keyring
11
- * via `@napi-rs/keyring` (Keychain on macOS, libsecret on Linux desktop,
12
- * Credential Manager on Windows — DPAPI-backed). When the keyring is
13
- * unavailable (headless Linux without a D-Bus session, Docker containers,
14
- * VPS without GNOME Keyring/KWallet running) we fall back to a
15
- * file-backed store at `~/.pi/remote/identity.json` with `0o600`
16
- * permissions and the parent dir at `0o700`.
17
- *
18
- * **Migration**: previous builds used `keytar` against service
19
- * `dev.remotepi.mac`. This module reads from the old service if the new
20
- * service is empty, copies the entry to the new service `dev.remotepi.pi`,
21
- * and deletes the old one. Both keytar and `@napi-rs/keyring` address the
22
- * same OS-level credential store on every supported platform, so the read
23
- * succeeds without keeping the deprecated `keytar` dependency.
24
- */
25
- const NEW_SERVICE = "dev.remotepi.pi"; // platform-neutral
26
- const OLD_SERVICE = "dev.remotepi.mac"; // legacy keytar service (pre-2026-05-25)
10
+ export { _setKeyringOperationTimeoutForTest, _setNativeBindingErrorForTest, KeyringMutationTimeoutError, KeyringReadTimeoutError, } from "./identity-keyring.js";
11
+ export { IdentityLockTimeoutError } from "./identity-lock.js";
12
+ const NEW_SERVICE = "dev.remotepi.pi";
13
+ const OLD_SERVICE = "dev.remotepi.mac";
27
14
  const ACCOUNT = "longterm-ed25519";
28
- /**
29
- * The keyring read can THROW transiently rather than permanently — most
30
- * notably a macOS Keychain that's still locked right after login/wake (the
31
- * machine sat idle for days). Treating that throw as "backend unavailable"
32
- * and minting a fresh identity silently orphans the paired key (the
33
- * "lost pairing after a week idle" failure). So we retry the read a few times
34
- * before ever concluding the keyring is truly unavailable. Overridable for
35
- * tests via `_setKeyringRetryForTest`. */
36
- let _keyringReadAttempts = 3;
37
- let _keyringRetryDelayMs = 300;
38
- /** Raised when the keyring is unreadable on a platform where it's a core OS
39
- * service (macOS Keychain, Windows Credential Manager) AND no prior file
40
- * identity exists. We refuse to generate a NEW identity here because that
41
- * would break existing pairing — the caller surfaces this so the user can
42
- * unlock the keychain and retry instead of silently re-pairing. */
15
+ const PI_DIR = join(homedir(), ".pi", "remote");
16
+ const IDENTITY_FILE = join(PI_DIR, "identity.json");
17
+ const IDENTITY_LOCK_FILE = join(PI_DIR, "identity.lock");
18
+ let keyringReadAttempts = 3;
19
+ let keyringRetryDelayMs = 300;
20
+ let identityLockWaitTimeoutMs = 15_000;
21
+ let identityLockPollIntervalMs = 50;
43
22
  export class KeyringUnavailableError extends Error {
44
23
  constructor(cause) {
45
24
  super("Platform keyring is unreadable and no file-backed identity exists. " +
46
- "Refusing to generate a NEW identity (that would break existing " +
47
- "pairing). Unlock your keychain / start your secret service and retry. " +
25
+ "Refusing to generate a NEW identity (that would break existing pairing). " +
26
+ "Unlock your keychain / start your secret service and retry. " +
48
27
  "Set REMOTE_PI_ALLOW_FILE_IDENTITY=1 to force a file-backed identity. " +
49
28
  `Cause: ${String(cause)}`);
50
29
  this.name = "KeyringUnavailableError";
51
30
  }
52
31
  }
53
- /** Raised when no identity can be resolved (keyring unreadable, no identity
54
- * file) BUT `peers.json` already lists paired devices. Minting a fresh key
55
- * here would make those existing pairings unusable — see issues #95 / #69. */
56
32
  export class PairedIdentityMissingError extends Error {
57
33
  constructor(pairedCount, cause) {
58
- super(`No identity could be read, but ${pairedCount} device(s) are already ` +
59
- "paired. Refusing to generate a NEW identity that would revoke every " +
60
- "paired device. This usually means this process cannot reach the same " +
61
- "keyring as the session that paired (for example, a headless process vs. " +
62
- "your desktop session). Fix the process keyring access, or pin the " +
63
- "identity by copying the paired keypair to ~/.pi/remote/identity.json " +
64
- "(0600), which both contexts read first. " +
34
+ super(`No identity could be read, but ${pairedCount} device(s) are already paired. ` +
35
+ "Refusing to generate a NEW identity - that would revoke every paired device. " +
36
+ "Fix this process's keyring access, or pin the established identity to " +
37
+ "~/.pi/remote/identity.json (0600). " +
65
38
  `Cause: ${String(cause)}`);
66
39
  this.name = "PairedIdentityMissingError";
67
40
  }
68
41
  }
69
- const PI_DIR = join(homedir(), ".pi", "remote");
70
- const IDENTITY_FILE = join(PI_DIR, "identity.json");
71
- /**
72
- * Per-operation timeout for native keyring calls (gnome-keyring / libsecret
73
- * via @napi-rs/keyring). A healthy secret service settles in milliseconds; 3s
74
- * is generous. The point is NOT speed — it is converting a HANG into a thrown
75
- * error. The native getPassword()/setPassword() can block indefinitely when
76
- * gnome-keyring waits on a GUI authorization prompt that cannot be shown in
77
- * a headless / tmux / non-interactive context. A hang never settles, so
78
- * without this guard the promise never rejects and the retry + file-fallback
79
- * logic in getOrCreateEd25519Keypair() is unreachable — freezing the entire
80
- * /remote-pi pair bootstrap. Raising a real error here lets that fallback
81
- * chain run as designed.
82
- */
83
- const KEYRING_OP_TIMEOUT_MS = 3_000;
84
- function _withTimeout(p, op, ms = KEYRING_OP_TIMEOUT_MS) {
85
- return Promise.race([
86
- p,
87
- new Promise((_, reject) => setTimeout(() => reject(new Error(`keyring ${op} timed out after ${ms}ms`)), ms)),
88
- ]);
89
- }
90
- /**
91
- * Lazily loaded `@napi-rs/keyring` binding — issue #113.
92
- *
93
- * A STATIC `import { AsyncEntry } from "@napi-rs/keyring"` is evaluated when
94
- * the extension module is loaded, so a native binding that cannot be resolved
95
- * takes the WHOLE extension down at load time ("Failed to load extension …:
96
- * Cannot find native binding"). That happens on a Bun-compiled `pi`: the
97
- * loader's first branch (`require("./keyring.<triple>.node")`) is fine, but its
98
- * fallback (`require("@napi-rs/keyring-<triple>")`, a bare package whose `main`
99
- * IS the .node file) resolves under Node and not under Bun — and the message it
100
- * prints then blames npm's optional-dependency bug, sending users off to delete
101
- * node_modules and losing their other pi packages.
102
- *
103
- * Loading on first use turns that fatal load error into an ordinary backend
104
- * failure, which the existing retry + file-identity fallback already handles.
105
- */
106
- let _asyncEntryCtor = null;
107
- let _nativeBindingError = null;
108
- async function _loadAsyncEntry() {
109
- if (_asyncEntryCtor)
110
- return _asyncEntryCtor;
111
- if (_nativeBindingError)
112
- throw _nativeBindingError;
113
- try {
114
- const mod = await import("@napi-rs/keyring");
115
- _asyncEntryCtor = mod.AsyncEntry;
116
- return _asyncEntryCtor;
42
+ export class IdentityFileError extends Error {
43
+ identityPath;
44
+ category;
45
+ constructor(identityPath, category) {
46
+ super(`Identity file at ${identityPath} is unsafe to use (${category}). Refusing to replace it.`);
47
+ this.name = "IdentityFileError";
48
+ this.identityPath = identityPath;
49
+ this.category = category;
117
50
  }
118
- catch (err) {
119
- _nativeBindingError = err;
120
- throw err;
121
- }
122
- }
123
- /**
124
- * Did the native binding fail to LOAD (as opposed to an operation failing)?
125
- *
126
- * A load failure is deterministic and platform-wide: no retry, no unlock, no
127
- * amount of waiting brings the keyring back in this process. So it must NOT be
128
- * treated like a transiently locked Keychain — on macOS/Windows that would
129
- * throw `KeyringUnavailableError` and leave the user with no working path at
130
- * all. The file-identity fallback (with its loud warning) is the only usable
131
- * route here, exactly as on headless Linux.
132
- */
133
- function _nativeBindingUnavailable() {
134
- return _nativeBindingError !== null;
135
51
  }
136
- /** Test-only: force (or clear with `null`) a memoized binding-load failure, so
137
- * the Bun/no-native-binding branch is reachable without a Bun host. */
138
- export function _setNativeBindingErrorForTest(err) {
139
- _asyncEntryCtor = null;
140
- _nativeBindingError = err;
141
- }
142
- class NapiKeyringBackend {
143
- async read(service, account) {
144
- const AsyncEntry = await _loadAsyncEntry();
145
- const entry = new AsyncEntry(service, account);
146
- return _withTimeout(entry.getPassword(), `read(${service})`); // undefined on no-entry
147
- }
148
- async write(service, account, value) {
149
- const AsyncEntry = await _loadAsyncEntry();
150
- const entry = new AsyncEntry(service, account);
151
- await _withTimeout(entry.setPassword(value), `write(${service})`);
152
- }
153
- async delete(service, account) {
154
- let entry;
155
- try {
156
- const AsyncEntry = await _loadAsyncEntry();
157
- entry = new AsyncEntry(service, account);
158
- }
159
- catch {
160
- return false;
161
- }
162
- try {
163
- return await _withTimeout(entry.deleteCredential(), `delete(${service})`);
164
- }
165
- catch {
166
- return false;
167
- }
52
+ export class KeyringIdentityError extends Error {
53
+ service;
54
+ constructor(service) {
55
+ super(`Stored identity in platform keyring service ${service} is invalid. Refusing to replace it.`);
56
+ this.name = "KeyringIdentityError";
57
+ this.service = service;
168
58
  }
169
59
  }
170
- let _backend = null;
171
- function _getBackend() {
172
- if (!_backend)
173
- _backend = new NapiKeyringBackend();
174
- return _backend;
60
+ let backend = null;
61
+ let keyringExpectedOverride = null;
62
+ function getBackend() {
63
+ if (!backend)
64
+ backend = new NapiKeyringBackend();
65
+ return backend;
175
66
  }
176
- /** Test-only: swap (or clear with `null`) the keyring backend. */
177
- export function _setKeyStoreBackendForTest(backend) {
178
- _backend = backend;
67
+ export function _setKeyStoreBackendForTest(value) {
68
+ backend = value;
179
69
  }
180
- /**
181
- * Is the platform keyring a CORE OS service we should expect to be present?
182
- * macOS (Keychain) and Windows (Credential Manager) always have one, so a read
183
- * that throws there is transient/locked, NOT "headless" — we must not mint a
184
- * new identity. On Linux/other the secret service may be genuinely absent
185
- * (headless, no D-Bus), so the documented file fallback applies. Overridable
186
- * for tests via `_setKeyringExpectedForTest`. */
187
- let _keyringExpectedOverride = null;
188
- function _keyringExpectedAvailable() {
189
- if (_keyringExpectedOverride !== null)
190
- return _keyringExpectedOverride;
191
- // Binding never loaded (Bun-built pi, issue #113) → there is no keyring on
192
- // this platform *for this process*, whatever the OS normally offers.
193
- if (_nativeBindingUnavailable())
70
+ function keyringExpectedAvailable() {
71
+ if (keyringExpectedOverride !== null)
72
+ return keyringExpectedOverride;
73
+ if (nativeBindingUnavailable())
194
74
  return false;
195
75
  return process.platform === "darwin" || process.platform === "win32";
196
76
  }
197
- /** Test-only: force `_keyringExpectedAvailable()` (so a darwin test host can
198
- * exercise the Linux/headless branch and vice-versa). `null` restores the
199
- * real platform check. */
200
77
  export function _setKeyringExpectedForTest(value) {
201
- _keyringExpectedOverride = value;
78
+ keyringExpectedOverride = value;
202
79
  }
203
- /** Test-only: shrink retry attempts/delay so the persistent-failure path is
204
- * fast. `null`/omitted restores defaults. */
205
80
  export function _setKeyringRetryForTest(attempts, delayMs) {
206
- _keyringReadAttempts = attempts ?? 3;
207
- _keyringRetryDelayMs = delayMs ?? 300;
81
+ keyringReadAttempts = attempts ?? 3;
82
+ keyringRetryDelayMs = delayMs ?? 300;
83
+ }
84
+ export function _setIdentityLockTimingForTest(waitTimeoutMs, pollIntervalMs) {
85
+ identityLockWaitTimeoutMs = waitTimeoutMs ?? 15_000;
86
+ identityLockPollIntervalMs = pollIntervalMs ?? 50;
208
87
  }
209
- function _sleep(ms) {
210
- return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
88
+ function sleep(ms) {
89
+ return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
211
90
  }
212
- function _serialize(kp) {
91
+ function serialize(kp) {
213
92
  const payload = {
214
93
  pk: Buffer.from(kp.publicKey).toString("base64"),
215
94
  sk: Buffer.from(kp.secretKey).toString("base64"),
216
95
  };
217
96
  return JSON.stringify(payload);
218
97
  }
219
- function _deserialize(stored) {
98
+ function decodeCanonicalBase64(value, field) {
99
+ if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
100
+ throw new Error(`invalid ${field}`);
101
+ }
102
+ const decoded = Buffer.from(value, "base64");
103
+ if (decoded.toString("base64") !== value)
104
+ throw new Error(`invalid ${field}`);
105
+ return decoded;
106
+ }
107
+ function deserializeUnsafe(stored) {
220
108
  const parsed = JSON.parse(stored);
221
- return {
222
- publicKey: Buffer.from(parsed.pk, "base64"),
223
- secretKey: Buffer.from(parsed.sk, "base64"),
224
- };
109
+ const publicKey = decodeCanonicalBase64(parsed.pk, "pk");
110
+ const secretKey = decodeCanonicalBase64(parsed.sk, "sk");
111
+ if (publicKey.length !== 32 || (secretKey.length !== 32 && secretKey.length !== 64))
112
+ throw new Error("invalid key length");
113
+ return { publicKey, secretKey };
114
+ }
115
+ function deserializeKeyringIdentity(stored, service) {
116
+ try {
117
+ return deserializeUnsafe(stored);
118
+ }
119
+ catch {
120
+ throw new KeyringIdentityError(service);
121
+ }
122
+ }
123
+ function isNodeErrorWithCode(error, code) {
124
+ return typeof error === "object" && error !== null && "code" in error &&
125
+ error.code === code;
225
126
  }
226
- // ── File fallback (headless Linux) ──────────────────────────────────────────
227
- async function _readKeypairFromFile() {
127
+ async function readKeypairFromFile() {
128
+ let raw;
228
129
  try {
229
- const raw = await readFile(IDENTITY_FILE, "utf8");
230
- return _deserialize(raw);
130
+ raw = await readFile(IDENTITY_FILE, "utf8");
131
+ }
132
+ catch (error) {
133
+ if (isNodeErrorWithCode(error, "ENOENT"))
134
+ return null;
135
+ throw new IdentityFileError(IDENTITY_FILE, "read_failed");
136
+ }
137
+ try {
138
+ return deserializeUnsafe(raw);
231
139
  }
232
140
  catch {
233
- return null;
141
+ throw new IdentityFileError(IDENTITY_FILE, "invalid_format");
234
142
  }
235
143
  }
236
- async function _writeKeypairToFile(kp) {
237
- await mkdir(PI_DIR, { recursive: true, mode: 0o700 });
238
- // Best-effort tighten of the dir in case it pre-existed with looser
239
- // permissions (mkdir's mode is only applied to NEW dirs).
144
+ async function syncIdentityDirectory() {
145
+ if (process.platform === "win32")
146
+ return;
147
+ const directory = await open(PI_DIR, "r");
240
148
  try {
241
- await chmod(PI_DIR, 0o700);
149
+ await directory.sync();
150
+ }
151
+ finally {
152
+ await directory.close();
242
153
  }
243
- catch { /* not fatal */ }
244
- await writeFile(IDENTITY_FILE, _serialize(kp), { mode: 0o600 });
154
+ }
155
+ async function writeKeypairToFile(kp) {
156
+ await ensurePrivateIdentityDirectory(PI_DIR);
157
+ const temporaryPath = join(PI_DIR, `.identity.json.${process.pid}.${randomUUID()}.tmp`);
158
+ let handle = null;
159
+ let published = false;
245
160
  try {
161
+ handle = await open(temporaryPath, "wx", 0o600);
162
+ await handle.writeFile(serialize(kp), "utf8");
163
+ await handle.sync();
164
+ await handle.close();
165
+ handle = null;
166
+ await rename(temporaryPath, IDENTITY_FILE);
167
+ published = true;
246
168
  await chmod(IDENTITY_FILE, 0o600);
169
+ await syncIdentityDirectory();
170
+ }
171
+ finally {
172
+ if (handle)
173
+ await handle.close().catch(() => undefined);
174
+ if (!published) {
175
+ await unlink(temporaryPath).catch((error) => {
176
+ if (!isNodeErrorWithCode(error, "ENOENT"))
177
+ throw error;
178
+ });
179
+ }
247
180
  }
248
- catch { /* not fatal */ }
249
181
  }
250
- // ── Public API ──────────────────────────────────────────────────────────────
251
- /**
252
- * Returns the Pi-secret Ed25519 keypair, generating + persisting one on
253
- * first call. Resolution order:
254
- * 1. Existing file `~/.pi/remote/identity.json`, if present — it WINS over
255
- * the keyring. A file identity is only ever written by the headless/
256
- * degraded fallback (step 4) or an explicit `REMOTE_PI_ALLOW_FILE_IDENTITY`
257
- * opt-in, so its mere presence means this machine established its identity
258
- * as a file and the browser PWA paired against THAT pubkey. If the
259
- * platform keyring later becomes readable (D-Bus/libsecret installed, a
260
- * desktop session, or a stale/other entry from another install), reading
261
- * it first would mask the file identity — returning a DIFFERENT key, or
262
- * (when the keyring is empty) minting a fresh one and persisting it —
263
- * silently breaking the existing pairing. So when both exist, file wins.
264
- * 2. New keyring service `dev.remotepi.pi` (read retried — a transiently
265
- * locked Keychain throws; we don't treat that as "no key")
266
- * 3. Old keyring service `dev.remotepi.mac` (migrate → step 2, delete old)
267
- * 4. Generate a fresh keypair, BUT only when it's safe to: either both
268
- * keyring reads succeeded and returned nothing (genuine first run), or
269
- * the keyring is genuinely unavailable on a platform without a core one
270
- * (headless Linux → a file identity is minted here). On macOS/Windows a
271
- * persistent read failure with no file identity throws
272
- * `KeyringUnavailableError` instead of minting a new key — generating
273
- * there silently breaks existing pairing (the "lost pairing after idle"
274
- * bug). `REMOTE_PI_ALLOW_FILE_IDENTITY=1` opts back into a file identity
275
- * for headless macOS/Windows hosts.
276
- *
277
- * Idempotent: subsequent calls return the same identity. The migration
278
- * runs at most once per machine (the old entry is deleted after copy).
279
- */
280
- export async function getOrCreateEd25519Keypair() {
281
- const backend = _getBackend();
282
- // ── Path 0: an existing file-backed identity wins ──────────────────────
283
- // `~/.pi/remote/identity.json` is only ever written by the headless/degraded
284
- // fallback below (or an operator who set REMOTE_PI_ALLOW_FILE_IDENTITY=1) —
285
- // never on a keyring-backed install. So its presence means THIS machine
286
- // paired against the file key, and the keyring (readable or not, matching or
287
- // not) must not be allowed to mask it. Short-circuit before touching the
288
- // keyring so a keyring that later comes online can't return a different key,
289
- // nor mint a fresh one over the file identity. No file → normal keyring
290
- // resolution below; a headless first run still reaches Path B and mints one.
291
- const existingFile = await _readKeypairFromFile();
292
- if (existingFile)
293
- return existingFile;
294
- // ── Path A: keyring (retried) ──────────────────────────────────────────
295
- // A throw here means the keyring op FAILED — but on macOS/Windows that is
296
- // almost always a transiently locked Keychain (idle/just-woke machine), not
297
- // a missing backend. `read` returns `undefined` for "no such entry" (the
298
- // genuine first-run signal). So we retry on throw, and only a throw that
299
- // SURVIVES every attempt drops us to Path B.
182
+ async function assertGenerationIsSafe(forceFile, cause) {
183
+ if (forceFile)
184
+ return;
185
+ const paired = await listPeers();
186
+ if (paired.length > 0)
187
+ throw new PairedIdentityMissingError(paired.length, cause);
188
+ }
189
+ async function resolveKeyringReads(store) {
300
190
  let keyringError;
301
- for (let attempt = 0; attempt < _keyringReadAttempts; attempt++) {
191
+ for (let attempt = 0; attempt < keyringReadAttempts; attempt++) {
302
192
  try {
303
- const existing = await backend.read(NEW_SERVICE, ACCOUNT);
193
+ const existing = await store.read(NEW_SERVICE, ACCOUNT);
304
194
  if (existing)
305
- return _deserialize(existing);
306
- const legacy = await backend.read(OLD_SERVICE, ACCOUNT);
307
- if (legacy) {
308
- const kp = _deserialize(legacy);
309
- await backend.write(NEW_SERVICE, ACCOUNT, legacy);
310
- await backend.delete(OLD_SERVICE, ACCOUNT);
311
- // Silent migration: writing the chat surface would be premature
312
- // (Pi SDK isn't bound yet at this point in boot) and console
313
- // output bleeds outside the TUI. The presence of an entry under
314
- // NEW_SERVICE is itself the audit signal — re-running migration
315
- // is idempotent and harmless.
316
- return kp;
317
- }
318
- // Both reads SUCCEEDED and returned nothing → genuine first run on a
319
- // working keyring. Generate and save to the new service.
320
- const fresh = generateEd25519Keypair();
321
- await backend.write(NEW_SERVICE, ACCOUNT, _serialize(fresh));
322
- return fresh;
195
+ return { resolution: { kind: "new", stored: existing }, error: undefined };
196
+ const legacy = await store.read(OLD_SERVICE, ACCOUNT);
197
+ if (legacy)
198
+ return { resolution: { kind: "legacy", stored: legacy }, error: undefined };
199
+ return { resolution: { kind: "empty" }, error: undefined };
323
200
  }
324
- catch (err) {
325
- keyringError = err;
326
- if (attempt < _keyringReadAttempts - 1) {
327
- // Linear backoff a locked Keychain usually frees within seconds.
328
- await _sleep(_keyringRetryDelayMs * (attempt + 1));
201
+ catch (error) {
202
+ keyringError = error;
203
+ if (attempt < keyringReadAttempts - 1) {
204
+ await sleep(keyringRetryDelayMs * (attempt + 1));
329
205
  }
330
206
  }
331
207
  }
332
- // ── Path B: keyring threw on every attempt ─────────────────────────────
333
- // Path 0 already returned any pre-existing file identity; this defensive
334
- // re-check catches a file written concurrently by another Pi process during
335
- // the keyring-retry window (still: use it, never regenerate).
336
- const fromFile = await _readKeypairFromFile();
208
+ return { resolution: null, error: keyringError };
209
+ }
210
+ async function getOrCreateUnderLock() {
211
+ const existingFile = await readKeypairFromFile();
212
+ if (existingFile)
213
+ return existingFile;
214
+ const store = getBackend();
215
+ const { resolution, error: keyringError } = await resolveKeyringReads(store);
216
+ const forceFile = process.env.REMOTE_PI_ALLOW_FILE_IDENTITY === "1";
217
+ if (resolution?.kind === "new") {
218
+ return deserializeKeyringIdentity(resolution.stored, NEW_SERVICE);
219
+ }
220
+ if (resolution?.kind === "legacy") {
221
+ const keypair = deserializeKeyringIdentity(resolution.stored, OLD_SERVICE);
222
+ await store.write(NEW_SERVICE, ACCOUNT, resolution.stored);
223
+ await store.delete(OLD_SERVICE, ACCOUNT);
224
+ return keypair;
225
+ }
226
+ if (resolution?.kind === "empty") {
227
+ await assertGenerationIsSafe(forceFile, undefined);
228
+ const fresh = generateEd25519Keypair();
229
+ await store.write(NEW_SERVICE, ACCOUNT, serialize(fresh));
230
+ return fresh;
231
+ }
232
+ const fromFile = await readKeypairFromFile();
337
233
  if (fromFile)
338
234
  return fromFile;
339
- // No file identity AND the keyring is unreadable. CRITICAL FORK:
340
- //
341
- // - On a platform without a guaranteed keyring (headless Linux, no D-Bus),
342
- // minting a file-backed identity is the documented, correct first-run
343
- // behavior.
344
- // - On macOS/Windows the keyring is a core OS service, so a persistent read
345
- // failure means it's LOCKED/denied — NOT that we're a fresh install.
346
- // Generating a new key here is exactly what silently broke pairing after
347
- // a week idle, and the new key then masks the real Keychain identity via
348
- // the file. So we FAIL LOUD instead, unless the operator explicitly
349
- // opts into a file identity.
350
- const forceFile = process.env.REMOTE_PI_ALLOW_FILE_IDENTITY === "1";
351
- if (_keyringExpectedAvailable() && !forceFile) {
235
+ if (keyringExpectedAvailable() && !forceFile) {
352
236
  throw new KeyringUnavailableError(keyringError);
353
237
  }
354
- // Issues #95 / #69 — minting a NEW identity when Owners are already paired
355
- // is destructive on every platform. A separate process can resolve a
356
- // different secret store than the interactive process which created the machine key; a fresh
357
- // identity would no longer authenticate as the paired Host. Keep peers.json
358
- // intact and fail loudly so the operator can restore access or pin the
359
- // established identity to the file-backed store.
360
- if (!forceFile) {
361
- const paired = await listPeers();
362
- if (paired.length > 0) {
363
- throw new PairedIdentityMissingError(paired.length, keyringError);
364
- }
365
- }
366
- console.warn(_nativeBindingUnavailable()
367
- // Issue #113: the @napi-rs/keyring native binding could not be loaded at
368
- // all (typically a Bun-compiled pi). Name it explicitly — the error the
369
- // loader prints blames npm's optional-dependency bug and sends people off
370
- // to delete node_modules, which takes their other pi packages with it.
238
+ await assertGenerationIsSafe(forceFile, keyringError);
239
+ console.warn(nativeBindingUnavailable()
371
240
  ? "[remote-pi] @napi-rs/keyring native binding could not be loaded in this " +
372
- `runtime; using file-backed identity at ${IDENTITY_FILE} (0600) instead. ` +
373
- "This is expected on a Bun-built pi. Paired devices keyed to a previous " +
374
- `keyring identity must be re-paired. ${String(keyringError)}`
241
+ `runtime; using file-backed identity at ${IDENTITY_FILE} (0600) instead. ${String(keyringError)}`
375
242
  : "[remote-pi] keyring unavailable; using file-backed identity at " +
376
243
  `${IDENTITY_FILE}. ${String(keyringError)}`);
377
244
  const fresh = generateEd25519Keypair();
378
- await _writeKeypairToFile(fresh);
245
+ await writeKeypairToFile(fresh);
379
246
  return fresh;
380
247
  }
381
- // ── Test-only helpers ────────────────────────────────────────────────────────
382
- /** Test-only: expose the identity-file path so tests can clean it. */
248
+ /**
249
+ * Returns the stable Host identity. Existing file identity wins; otherwise all
250
+ * keyring reads, migration, generation, and persistence run under the fixed
251
+ * cross-process identity lock. Every source is read again after lock acquisition.
252
+ */
253
+ export async function getOrCreateEd25519Keypair() {
254
+ const existingFile = await readKeypairFromFile();
255
+ if (existingFile)
256
+ return existingFile;
257
+ return withIdentityLock(PI_DIR, getOrCreateUnderLock, {
258
+ waitTimeoutMs: identityLockWaitTimeoutMs,
259
+ pollIntervalMs: identityLockPollIntervalMs,
260
+ });
261
+ }
383
262
  export const _IDENTITY_FILE_FOR_TEST = IDENTITY_FILE;
384
- /** Test-only: expose unlink for cleanup. */
263
+ export const _IDENTITY_LOCK_FILE_FOR_TEST = IDENTITY_LOCK_FILE;
385
264
  export const _unlinkIdentityFileForTest = async () => {
386
265
  try {
387
266
  await unlink(IDENTITY_FILE);
388
267
  }
389
- catch { /* fine if missing */ }
268
+ catch (error) {
269
+ if (!isNodeErrorWithCode(error, "ENOENT"))
270
+ throw error;
271
+ }
390
272
  };
391
273
  //# sourceMappingURL=storage.js.map