@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.
- package/README.md +12 -2
- package/dist/pairing/identity-keyring.d.ts +22 -0
- package/dist/pairing/identity-keyring.js +94 -0
- package/dist/pairing/identity-keyring.js.map +1 -0
- package/dist/pairing/identity-lock.d.ts +14 -0
- package/dist/pairing/identity-lock.js +116 -0
- package/dist/pairing/identity-lock.js.map +1 -0
- package/dist/pairing/qr.d.ts +2 -3
- package/dist/pairing/qr.js +2 -3
- package/dist/pairing/qr.js.map +1 -1
- package/dist/pairing/storage.d.ts +19 -61
- package/dist/pairing/storage.js +196 -314
- package/dist/pairing/storage.js.map +1 -1
- package/dist/protocol/types.d.ts +2 -52
- package/dist/protocol/v2/codec.d.ts +5 -25
- package/dist/protocol/v2/codec.js +3 -206
- package/dist/protocol/v2/codec.js.map +1 -1
- package/dist/protocol/v2/index.d.ts +1 -0
- package/dist/protocol/v2/index.js +1 -0
- package/dist/protocol/v2/index.js.map +1 -1
- package/dist/protocol/v2/marker.d.ts +40 -0
- package/dist/protocol/v2/marker.js +47 -0
- package/dist/protocol/v2/marker.js.map +1 -0
- package/dist/protocol/v2/schemas.d.ts +3 -2024
- package/dist/protocol/v2/schemas.js +3 -433
- package/dist/protocol/v2/schemas.js.map +1 -1
- package/dist/runtime/owner_router.js +5 -1
- package/dist/runtime/owner_router.js.map +1 -1
- package/dist/transport/peer_channel.d.ts +2 -11
- package/dist/transport/peer_channel.js +7 -11
- package/dist/transport/peer_channel.js.map +1 -1
- package/dist/transport/relay_client.d.ts +2 -8
- package/dist/transport/relay_client.js +3 -2
- package/dist/transport/relay_client.js.map +1 -1
- package/dist/vendor/protocol/constants.d.ts +4 -0
- package/dist/vendor/protocol/constants.js +4 -0
- package/dist/vendor/protocol/encoding.d.ts +9 -0
- package/dist/vendor/protocol/encoding.js +66 -0
- package/dist/vendor/protocol/outer/codec.d.ts +13 -0
- package/dist/vendor/protocol/outer/codec.js +105 -0
- package/dist/vendor/protocol/outer/index.d.ts +4 -0
- package/dist/vendor/protocol/outer/index.js +4 -0
- package/dist/vendor/protocol/outer/types.d.ts +120 -0
- package/dist/vendor/protocol/outer/types.js +1 -0
- package/dist/vendor/protocol/session/codec.d.ts +41 -0
- package/dist/vendor/protocol/session/codec.js +236 -0
- package/dist/vendor/protocol/session/frames.d.ts +5750 -0
- package/dist/vendor/protocol/session/frames.js +406 -0
- package/dist/vendor/protocol/session/index.d.ts +3 -0
- package/dist/vendor/protocol/session/index.js +3 -0
- package/dist/vendor/protocol/session/schema.d.ts +1060 -0
- package/dist/vendor/protocol/session/schema.js +302 -0
- package/package.json +17 -25
package/dist/pairing/storage.js
CHANGED
|
@@ -1,391 +1,273 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
60
|
+
let backend = null;
|
|
61
|
+
let keyringExpectedOverride = null;
|
|
62
|
+
function getBackend() {
|
|
63
|
+
if (!backend)
|
|
64
|
+
backend = new NapiKeyringBackend();
|
|
65
|
+
return backend;
|
|
175
66
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
_backend = backend;
|
|
67
|
+
export function _setKeyStoreBackendForTest(value) {
|
|
68
|
+
backend = value;
|
|
179
69
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
210
|
-
return ms > 0 ? new Promise((
|
|
88
|
+
function sleep(ms) {
|
|
89
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
211
90
|
}
|
|
212
|
-
function
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
127
|
+
async function readKeypairFromFile() {
|
|
128
|
+
let raw;
|
|
228
129
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
141
|
+
throw new IdentityFileError(IDENTITY_FILE, "invalid_format");
|
|
234
142
|
}
|
|
235
143
|
}
|
|
236
|
-
async function
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
144
|
+
async function syncIdentityDirectory() {
|
|
145
|
+
if (process.platform === "win32")
|
|
146
|
+
return;
|
|
147
|
+
const directory = await open(PI_DIR, "r");
|
|
240
148
|
try {
|
|
241
|
-
await
|
|
149
|
+
await directory.sync();
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await directory.close();
|
|
242
153
|
}
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 <
|
|
191
|
+
for (let attempt = 0; attempt < keyringReadAttempts; attempt++) {
|
|
302
192
|
try {
|
|
303
|
-
const existing = await
|
|
193
|
+
const existing = await store.read(NEW_SERVICE, ACCOUNT);
|
|
304
194
|
if (existing)
|
|
305
|
-
return
|
|
306
|
-
const legacy = await
|
|
307
|
-
if (legacy)
|
|
308
|
-
|
|
309
|
-
|
|
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 (
|
|
325
|
-
keyringError =
|
|
326
|
-
if (attempt <
|
|
327
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
|
245
|
+
await writeKeypairToFile(fresh);
|
|
379
246
|
return fresh;
|
|
380
247
|
}
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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 {
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (!isNodeErrorWithCode(error, "ENOENT"))
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
390
272
|
};
|
|
391
273
|
//# sourceMappingURL=storage.js.map
|