@rikaidev/yomi 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +81 -0
- package/PRIVACY.md +39 -0
- package/README.md +659 -0
- package/dist/auth/credential-store.js +395 -0
- package/dist/auth/keychain.js +256 -0
- package/dist/cli/login.js +142 -0
- package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
- package/dist/line/auth/protocol/index.js +3 -0
- package/dist/line/auth/protocol/login-metadata.js +159 -0
- package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
- package/dist/line/auth/pwless/index.js +163 -0
- package/dist/line/auth/pwless/steps.js +213 -0
- package/dist/line/client/auth-service/client.js +240 -0
- package/dist/line/client/e2ee-service/client.js +69 -0
- package/dist/line/client/e2ee-service/requests.js +92 -0
- package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
- package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
- package/dist/line/client/index.js +213 -0
- package/dist/line/client/obs-media-client.js +140 -0
- package/dist/line/client/parsers.js +69 -0
- package/dist/line/client/shop-service/client.js +63 -0
- package/dist/line/client/sticker-meta.js +46 -0
- package/dist/line/client/sync-service/client.js +317 -0
- package/dist/line/client/talk-service/chat-query.js +122 -0
- package/dist/line/client/talk-service/command-client.js +242 -0
- package/dist/line/client/talk-service/contact-query.js +77 -0
- package/dist/line/client/talk-service/message-box-query.js +137 -0
- package/dist/line/client/talk-service/message-id-query.js +241 -0
- package/dist/line/client/talk-service/query-client.js +219 -0
- package/dist/line/client/talk-service/requests.js +383 -0
- package/dist/line/client/transport.js +138 -0
- package/dist/line/core/auth-session-logout.js +55 -0
- package/dist/line/core/auth-session-runtime.js +231 -0
- package/dist/line/core/auth-session-service.js +85 -0
- package/dist/line/core/chat-runtime-service.js +343 -0
- package/dist/line/core/client-runtime.js +17 -0
- package/dist/line/core/config.js +67 -0
- package/dist/line/core/constants.js +56 -0
- package/dist/line/core/contact-query-service.js +20 -0
- package/dist/line/core/directory-runtime-service.js +83 -0
- package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
- package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
- package/dist/line/core/e2ee/index.js +6 -0
- package/dist/line/core/e2ee/keys/decrypt.js +110 -0
- package/dist/line/core/e2ee/keys/encrypt.js +117 -0
- package/dist/line/core/e2ee/keys/group-key.js +260 -0
- package/dist/line/core/e2ee/keys/key-manager.js +231 -0
- package/dist/line/core/e2ee/keys/key-payload.js +130 -0
- package/dist/line/core/e2ee/keys/key-types.js +1 -0
- package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
- package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
- package/dist/line/core/e2ee/keys/peer-key.js +191 -0
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
- package/dist/line/core/e2ee/types.js +4 -0
- package/dist/line/core/group-query-service.js +20 -0
- package/dist/line/core/media-decrypt.js +78 -0
- package/dist/line/core/media-encrypt.js +97 -0
- package/dist/line/core/mention.js +89 -0
- package/dist/line/core/message-command-service.js +454 -0
- package/dist/line/core/message-query-service.js +161 -0
- package/dist/line/core/service.js +134 -0
- package/dist/line/core/session-state/index.js +243 -0
- package/dist/line/core/session-state/persist.js +192 -0
- package/dist/line/core/store.js +8 -0
- package/dist/line/core/thrift/binary/binary-reader.js +151 -0
- package/dist/line/core/thrift/binary/binary-writer.js +157 -0
- package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
- package/dist/line/core/thrift/compact/compact-reader.js +180 -0
- package/dist/line/core/thrift/compact/compact-writer.js +161 -0
- package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
- package/dist/line/core/thrift/fields/builders.js +124 -0
- package/dist/line/core/thrift/index.js +12 -0
- package/dist/line/core/thrift/message/decoder.js +55 -0
- package/dist/line/core/thrift/message/encoder.js +25 -0
- package/dist/line/core/thrift/primitives/varint.js +64 -0
- package/dist/line/core/thrift/struct/reader.js +172 -0
- package/dist/line/core/thrift/struct/writer.js +189 -0
- package/dist/line/core/thrift/types.js +23 -0
- package/dist/line/core/video-thumbnail.js +104 -0
- package/dist/mcp/handlers/contacts.js +166 -0
- package/dist/mcp/handlers/conversations.js +193 -0
- package/dist/mcp/handlers/groups.js +123 -0
- package/dist/mcp/handlers/index.js +15 -0
- package/dist/mcp/handlers/login.js +473 -0
- package/dist/mcp/handlers/media.js +69 -0
- package/dist/mcp/handlers/message-actions.js +62 -0
- package/dist/mcp/handlers/scope.js +120 -0
- package/dist/mcp/handlers/search.js +196 -0
- package/dist/mcp/handlers/send.js +523 -0
- package/dist/mcp/handlers/shared.js +84 -0
- package/dist/mcp/handlers/stickers.js +87 -0
- package/dist/mcp/media.js +228 -0
- package/dist/mcp/names.js +133 -0
- package/dist/mcp/phi-guard.js +105 -0
- package/dist/mcp/policy.js +34 -0
- package/dist/mcp/server.js +301 -0
- package/dist/mcp/tools.js +761 -0
- package/dist/mcp/ui/capability.js +26 -0
- package/dist/mcp/ui/login-app-protocol.js +118 -0
- package/dist/mcp/ui/login-app-styles.js +142 -0
- package/dist/mcp/ui/login-app-view.js +361 -0
- package/dist/mcp/ui/login-app.js +72 -0
- package/dist/mcp/ui/login-structured-content.js +1 -0
- package/dist/mcp/ui/resource.js +35 -0
- package/dist/mcp/ui/tools-with-ui.js +40 -0
- package/dist/search/bigram.js +86 -0
- package/dist/search/capture.js +140 -0
- package/dist/search/collector.js +176 -0
- package/dist/search/default-embedder.js +101 -0
- package/dist/search/embedder.js +13 -0
- package/dist/search/scope.js +142 -0
- package/dist/search/sqlite.js +141 -0
- package/dist/search/store.js +469 -0
- package/dist/util/data-dir.js +55 -0
- package/dist/util/log.js +86 -0
- package/dist/version.js +7 -0
- package/package.json +63 -0
- package/run.mjs +71 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialStore — namespace-scoped secure credential storage
|
|
3
|
+
*
|
|
4
|
+
* Vendored from the host app's credential-store module. Stores all
|
|
5
|
+
* credentials for a given namespace as a single JSON blob in one keychain
|
|
6
|
+
* entry (macOS) or one JSON file (other platforms).
|
|
7
|
+
*
|
|
8
|
+
* Yomi owns its LINE credentials: the `login` MCP tool performs a
|
|
9
|
+
* first-party passwordless login and this store persists the resulting
|
|
10
|
+
* session (auth token, certificate, refresh token, mid, E2EE NaCl
|
|
11
|
+
* keypair). The `set`/`save`/`clearAll` methods below are also exercised
|
|
12
|
+
* internally by the vendored LINE session-state code (e.g. persisting a
|
|
13
|
+
* rotated auth token during `resumeSession`), not only by a direct login.
|
|
14
|
+
*
|
|
15
|
+
* Keychain account: namespace (e.g. 'line')
|
|
16
|
+
* KeychainService service name: see keychain.ts — writes go to Yomi's own
|
|
17
|
+
* canonical namespace; a legacy namespace from before this split is read
|
|
18
|
+
* as a fallback and migrated forward, never written to or deleted.
|
|
19
|
+
*/
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { getKeychainService } from './keychain.js';
|
|
22
|
+
/**
|
|
23
|
+
* Parse a JSON blob into the cache map. Ignores corrupt blobs silently.
|
|
24
|
+
*
|
|
25
|
+
* @param blob - JSON string to parse.
|
|
26
|
+
* @param cache - Map to populate with parsed entries.
|
|
27
|
+
*/
|
|
28
|
+
function hydrateCacheFromBlob(blob, cache) {
|
|
29
|
+
try {
|
|
30
|
+
const obj = JSON.parse(blob);
|
|
31
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
32
|
+
cache.set(k, v);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Corrupt blob — start fresh, will be overwritten on next write
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse a JSON credential blob.
|
|
41
|
+
*
|
|
42
|
+
* @param blob - JSON string to parse.
|
|
43
|
+
* @returns Parsed credential object.
|
|
44
|
+
*/
|
|
45
|
+
function parseCredentialBlob(blob) {
|
|
46
|
+
if (!blob) {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(blob);
|
|
51
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** In-memory credential store for testing and non-persistent scenarios. */
|
|
58
|
+
export class InMemoryStore {
|
|
59
|
+
store;
|
|
60
|
+
constructor() {
|
|
61
|
+
this.store = new Map();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Gets a credential value.
|
|
65
|
+
* @param key - Credential key.
|
|
66
|
+
* @returns Stored value or null.
|
|
67
|
+
*/
|
|
68
|
+
async get(key) {
|
|
69
|
+
return this.store.get(key) ?? null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Sets a credential value.
|
|
73
|
+
* @param key - Credential key.
|
|
74
|
+
* @param value - Value to store.
|
|
75
|
+
*/
|
|
76
|
+
async set(key, value) {
|
|
77
|
+
this.store.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Deletes a credential.
|
|
81
|
+
* @param key - Credential key to delete.
|
|
82
|
+
*/
|
|
83
|
+
async delete(key) {
|
|
84
|
+
this.store.delete(key);
|
|
85
|
+
}
|
|
86
|
+
/** Wipes all credentials. */
|
|
87
|
+
async clearAll() {
|
|
88
|
+
this.store.clear();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Key prefixes for VOLATILE, recomputable E2EE cache — peer public keys and
|
|
93
|
+
* group shared keys. These are written on nearly every message decrypt, so
|
|
94
|
+
* they MUST NOT share the durable credential store: on macOS that would rewrite
|
|
95
|
+
* the single keychain entry holding the auth token hundreds of times per read,
|
|
96
|
+
* and any torn/racy write there can strand the session. They live in a plain
|
|
97
|
+
* JSON file instead; losing them just means refetching keys from LINE.
|
|
98
|
+
*/
|
|
99
|
+
const VOLATILE_KEY_PREFIXES = ['line_e2ee_public_', 'line_e2ee_group_by_mid'];
|
|
100
|
+
/** Whether a credential key is volatile E2EE cache rather than durable state. */
|
|
101
|
+
function isVolatileKey(key) {
|
|
102
|
+
return VOLATILE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* File-backed store for volatile E2EE cache. In-memory map is the source of
|
|
106
|
+
* truth for the process; the file is a best-effort persistence layer written
|
|
107
|
+
* atomically (temp + rename) and coalesced so a burst of decrypt-time writes
|
|
108
|
+
* flushes once. Never touches the keychain, so it cannot endanger credentials.
|
|
109
|
+
*/
|
|
110
|
+
class VolatileCacheStore {
|
|
111
|
+
filePath;
|
|
112
|
+
cache = new Map();
|
|
113
|
+
loaded = false;
|
|
114
|
+
dirty = false;
|
|
115
|
+
writing = false;
|
|
116
|
+
constructor(filePath) {
|
|
117
|
+
this.filePath = filePath;
|
|
118
|
+
}
|
|
119
|
+
async ensureLoaded() {
|
|
120
|
+
if (this.loaded) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.loaded = true;
|
|
124
|
+
try {
|
|
125
|
+
const fs = await import('node:fs/promises');
|
|
126
|
+
const blob = await fs.readFile(this.filePath, 'utf-8');
|
|
127
|
+
const obj = JSON.parse(blob);
|
|
128
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
129
|
+
this.cache.set(k, String(v));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// No file yet, or corrupt — start empty and overwrite on next flush.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async get(key) {
|
|
137
|
+
await this.ensureLoaded();
|
|
138
|
+
return this.cache.get(key) ?? null;
|
|
139
|
+
}
|
|
140
|
+
async set(key, value) {
|
|
141
|
+
await this.ensureLoaded();
|
|
142
|
+
if (value === undefined || value === null) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.cache.set(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
146
|
+
this.scheduleFlush();
|
|
147
|
+
}
|
|
148
|
+
async delete(key) {
|
|
149
|
+
await this.ensureLoaded();
|
|
150
|
+
if (this.cache.delete(key)) {
|
|
151
|
+
this.scheduleFlush();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async clear() {
|
|
155
|
+
this.cache.clear();
|
|
156
|
+
this.loaded = true;
|
|
157
|
+
try {
|
|
158
|
+
const fs = await import('node:fs/promises');
|
|
159
|
+
await fs.unlink(this.filePath);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Already gone — fine.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Coalesce bursts of writes into a single atomic flush cycle. */
|
|
166
|
+
scheduleFlush() {
|
|
167
|
+
this.dirty = true;
|
|
168
|
+
if (this.writing) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.writing = true;
|
|
172
|
+
void (async () => {
|
|
173
|
+
const fs = await import('node:fs/promises');
|
|
174
|
+
while (this.dirty) {
|
|
175
|
+
this.dirty = false;
|
|
176
|
+
try {
|
|
177
|
+
const dir = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
|
|
178
|
+
if (dir) {
|
|
179
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
180
|
+
}
|
|
181
|
+
const tmp = `${this.filePath}.tmp`;
|
|
182
|
+
// Group shared keys live here — they decrypt group messages. The
|
|
183
|
+
// default 0644 made them readable by every other account on the
|
|
184
|
+
// machine. `mode` only applies when the file is created, and rename
|
|
185
|
+
// preserves the source's mode, so the temp file must be created
|
|
186
|
+
// restricted rather than tightened afterwards.
|
|
187
|
+
await fs.writeFile(tmp, JSON.stringify(Object.fromEntries(this.cache)), {
|
|
188
|
+
mode: 0o600,
|
|
189
|
+
});
|
|
190
|
+
await fs.rename(tmp, this.filePath);
|
|
191
|
+
// Repair installs whose cache predates the mode above.
|
|
192
|
+
await fs.chmod(this.filePath, 0o600).catch(() => { });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Best-effort cache persistence; in-memory copy still serves reads.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.writing = false;
|
|
199
|
+
})();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Stores all credentials for a namespace as a single JSON blob in one keychain
|
|
204
|
+
* entry (macOS) or one JSON file (other platforms). Volatile E2EE cache keys
|
|
205
|
+
* (see isVolatileKey) are transparently routed to a separate file store so
|
|
206
|
+
* decrypt-time writes never touch the durable credential entry.
|
|
207
|
+
*
|
|
208
|
+
* @param namespace - Clean identifier like 'line'.
|
|
209
|
+
* @param fallbackFilePath - File path used on non-macOS platforms.
|
|
210
|
+
*/
|
|
211
|
+
export class CredentialStore {
|
|
212
|
+
loaded;
|
|
213
|
+
cache;
|
|
214
|
+
filePath;
|
|
215
|
+
keychainService;
|
|
216
|
+
secureStorageEnabled;
|
|
217
|
+
keychainAccount;
|
|
218
|
+
lastPersistedBlob;
|
|
219
|
+
persistQueue;
|
|
220
|
+
volatile;
|
|
221
|
+
constructor(namespace, fallbackFilePath) {
|
|
222
|
+
this.filePath = fallbackFilePath;
|
|
223
|
+
this.cache = new Map();
|
|
224
|
+
this.loaded = false;
|
|
225
|
+
this.secureStorageEnabled = process.platform === 'darwin';
|
|
226
|
+
this.keychainService = this.secureStorageEnabled
|
|
227
|
+
? getKeychainService()
|
|
228
|
+
: null;
|
|
229
|
+
this.keychainAccount = namespace;
|
|
230
|
+
this.lastPersistedBlob = null;
|
|
231
|
+
this.persistQueue = Promise.resolve();
|
|
232
|
+
// Volatile E2EE cache lives beside the fallback credential file, never in
|
|
233
|
+
// the keychain. Derived with `path` rather than by slicing on '/': the old
|
|
234
|
+
// string-slice found no separator in a Windows path and fell back to a bare
|
|
235
|
+
// filename, i.e. cwd-relative — the exact failure this path was moved to an
|
|
236
|
+
// absolute per-user directory to escape.
|
|
237
|
+
this.volatile = new VolatileCacheStore(join(dirname(fallbackFilePath), 'line-e2ee-cache.json'));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Read the persisted credential blob.
|
|
241
|
+
* @returns Serialized credential blob or null when missing.
|
|
242
|
+
*/
|
|
243
|
+
async readPersistedBlob() {
|
|
244
|
+
if (this.secureStorageEnabled && this.keychainService) {
|
|
245
|
+
const result = await this.keychainService.getCredential(this.keychainAccount);
|
|
246
|
+
if (result.success && result.password) {
|
|
247
|
+
return result.password;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const fs = await import('node:fs/promises');
|
|
253
|
+
return await fs.readFile(this.filePath, 'utf-8');
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Load all credentials into cache from keychain or file.
|
|
261
|
+
* @param options - Loading behavior.
|
|
262
|
+
* @param options.force - Reload persisted credentials even when cache exists.
|
|
263
|
+
*/
|
|
264
|
+
async load(options = {}) {
|
|
265
|
+
if (this.loaded && !options.force) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const blob = await this.readPersistedBlob();
|
|
269
|
+
this.cache.clear();
|
|
270
|
+
if (blob) {
|
|
271
|
+
hydrateCacheFromBlob(blob, this.cache);
|
|
272
|
+
this.lastPersistedBlob = blob;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
this.lastPersistedBlob = null;
|
|
276
|
+
}
|
|
277
|
+
this.loaded = true;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Persist one serialized credential blob exactly once in write order.
|
|
281
|
+
*
|
|
282
|
+
* @param blob - Serialized credential blob.
|
|
283
|
+
*/
|
|
284
|
+
async persistBlob(blob) {
|
|
285
|
+
if (blob === this.lastPersistedBlob) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (this.secureStorageEnabled && this.keychainService) {
|
|
289
|
+
await this.keychainService.setCredential(this.keychainAccount, blob);
|
|
290
|
+
this.lastPersistedBlob = blob;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const fs = await import('node:fs/promises');
|
|
295
|
+
const dir = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
|
|
296
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
297
|
+
await fs.writeFile(this.filePath, blob, { mode: 0o600 });
|
|
298
|
+
// `mode` is ignored when the file already exists, so repair older installs.
|
|
299
|
+
await fs.chmod(this.filePath, 0o600).catch(() => { });
|
|
300
|
+
this.lastPersistedBlob = blob;
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
console.error('[Yomi] Failed to save credentials:', error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Persist the full cache as a single JSON blob.
|
|
308
|
+
*
|
|
309
|
+
* @param mutatePersisted - Optional mutation applied inside the write queue.
|
|
310
|
+
*/
|
|
311
|
+
async save(mutatePersisted) {
|
|
312
|
+
this.persistQueue = this.persistQueue.then(async () => {
|
|
313
|
+
if (mutatePersisted) {
|
|
314
|
+
const persisted = parseCredentialBlob(await this.readPersistedBlob());
|
|
315
|
+
const next = mutatePersisted(persisted) || persisted;
|
|
316
|
+
const blob = JSON.stringify(next);
|
|
317
|
+
this.cache = new Map(Object.entries(next));
|
|
318
|
+
await this.persistBlob(blob);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const blob = JSON.stringify(Object.fromEntries(this.cache));
|
|
322
|
+
await this.persistBlob(blob);
|
|
323
|
+
});
|
|
324
|
+
await this.persistQueue;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get a credential value by key.
|
|
328
|
+
* @param key - Credential key.
|
|
329
|
+
* @returns Stored value or null.
|
|
330
|
+
*/
|
|
331
|
+
async get(key) {
|
|
332
|
+
if (isVolatileKey(key)) {
|
|
333
|
+
return this.volatile.get(key);
|
|
334
|
+
}
|
|
335
|
+
await this.load({ force: true });
|
|
336
|
+
return this.cache.get(key) ?? null;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Set a credential value and persist.
|
|
340
|
+
*
|
|
341
|
+
* `undefined`/`null` are rejected rather than silently dropped: passing
|
|
342
|
+
* either through `JSON.stringify` yields either the literal value
|
|
343
|
+
* `undefined` (which the outer blob `JSON.stringify` then omits with no
|
|
344
|
+
* error) or the string `"null"` masquerading as a real credential. A
|
|
345
|
+
* credential that cannot be stored is a bug at the call site, not a
|
|
346
|
+
* silent no-op — callers must fix the missing value, not have it vanish.
|
|
347
|
+
*
|
|
348
|
+
* @param key - Credential key.
|
|
349
|
+
* @param value - Value to store.
|
|
350
|
+
*/
|
|
351
|
+
async set(key, value) {
|
|
352
|
+
if (isVolatileKey(key)) {
|
|
353
|
+
return this.volatile.set(key, value);
|
|
354
|
+
}
|
|
355
|
+
if (value === undefined || value === null) {
|
|
356
|
+
throw new Error(`CredentialStore.set('${key}'): refusing to persist ${value === undefined ? 'undefined' : 'null'} — this would silently drop the key from the stored blob`);
|
|
357
|
+
}
|
|
358
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
359
|
+
await this.save((persisted) => {
|
|
360
|
+
persisted[key] = stringValue;
|
|
361
|
+
return persisted;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Delete a credential and persist.
|
|
366
|
+
* @param key - Credential key to delete.
|
|
367
|
+
*/
|
|
368
|
+
async delete(key) {
|
|
369
|
+
if (isVolatileKey(key)) {
|
|
370
|
+
return this.volatile.delete(key);
|
|
371
|
+
}
|
|
372
|
+
await this.save((persisted) => {
|
|
373
|
+
delete persisted[key];
|
|
374
|
+
return persisted;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** Wipe all credentials atomically — one keychain delete covers everything. */
|
|
378
|
+
async clearAll() {
|
|
379
|
+
this.cache.clear();
|
|
380
|
+
this.loaded = false;
|
|
381
|
+
this.lastPersistedBlob = null;
|
|
382
|
+
await this.volatile.clear();
|
|
383
|
+
if (this.secureStorageEnabled && this.keychainService) {
|
|
384
|
+
await this.keychainService.deleteCredential(this.keychainAccount);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const fs = await import('node:fs/promises');
|
|
389
|
+
await fs.unlink(this.filePath);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// File already gone — that's fine
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS Keychain backing store for Yomi credentials.
|
|
3
|
+
*
|
|
4
|
+
* Vendored from the host app's system/auth keychain service. Used by
|
|
5
|
+
* CredentialStore when running on darwin; other platforms fall back to a
|
|
6
|
+
* plain JSON file (see credential-store.ts).
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { createCliLogger } from '../util/log.js';
|
|
10
|
+
/**
|
|
11
|
+
* Yomi's own Keychain service name. Yomi performs a first-party
|
|
12
|
+
* passwordless LINE login (see the `login` MCP tool) and persists the
|
|
13
|
+
* resulting session here — auth token, certificate, refresh token, mid,
|
|
14
|
+
* and the E2EE NaCl keypair. All writes go through this namespace only.
|
|
15
|
+
*/
|
|
16
|
+
const SERVICE_NAME = 'com.yomi.credentials';
|
|
17
|
+
/**
|
|
18
|
+
* Legacy keychain service name, read-only. Predates Yomi owning its own
|
|
19
|
+
* credentials; kept solely so a live session created before this split
|
|
20
|
+
* keeps working without forcing the operator through a phone-PIN
|
|
21
|
+
* re-login. Entries here are read and migrated forward into
|
|
22
|
+
* `SERVICE_NAME`, never written to and never deleted.
|
|
23
|
+
*/
|
|
24
|
+
const LEGACY_SERVICE_NAME = 'com.inboxd.credentials';
|
|
25
|
+
const authLog = createCliLogger('AUTH');
|
|
26
|
+
/**
|
|
27
|
+
* Wrapper for macOS `security` keychain CLI commands.
|
|
28
|
+
*/
|
|
29
|
+
class KeychainCommands {
|
|
30
|
+
serviceName;
|
|
31
|
+
constructor(serviceName) {
|
|
32
|
+
this.serviceName = serviceName;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Store a credential in Keychain.
|
|
36
|
+
*
|
|
37
|
+
* @param account - Account name.
|
|
38
|
+
* @param password - Password to store.
|
|
39
|
+
* @returns Promise resolving to credential result.
|
|
40
|
+
*/
|
|
41
|
+
async set(account, password) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const child = spawn('security', [
|
|
44
|
+
'add-generic-password',
|
|
45
|
+
'-s',
|
|
46
|
+
this.serviceName,
|
|
47
|
+
'-a',
|
|
48
|
+
account,
|
|
49
|
+
'-w',
|
|
50
|
+
password,
|
|
51
|
+
'-U',
|
|
52
|
+
]);
|
|
53
|
+
let stderr = '';
|
|
54
|
+
child.stderr.on('data', (data) => {
|
|
55
|
+
stderr += data.toString();
|
|
56
|
+
});
|
|
57
|
+
child.on('close', (code) => {
|
|
58
|
+
if (code === 0) {
|
|
59
|
+
authLog.debug('keychain.store.complete', { account });
|
|
60
|
+
resolve({ success: true, account });
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
authLog.error('keychain.store.failed', {
|
|
64
|
+
account,
|
|
65
|
+
error: stderr || 'Failed to store credential',
|
|
66
|
+
});
|
|
67
|
+
resolve({
|
|
68
|
+
success: false,
|
|
69
|
+
error: stderr || 'Failed to store credential',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
child.on('error', (err) => {
|
|
74
|
+
authLog.error('keychain.store.error', { account, error: err.message });
|
|
75
|
+
resolve({ success: false, error: err.message });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Retrieve a credential from Keychain.
|
|
81
|
+
*
|
|
82
|
+
* @param account - Account name.
|
|
83
|
+
* @returns Promise resolving to credential result.
|
|
84
|
+
*/
|
|
85
|
+
async get(account) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const child = spawn('security', [
|
|
88
|
+
'find-generic-password',
|
|
89
|
+
'-s',
|
|
90
|
+
this.serviceName,
|
|
91
|
+
'-a',
|
|
92
|
+
account,
|
|
93
|
+
'-w',
|
|
94
|
+
]);
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
child.stdout.on('data', (data) => {
|
|
98
|
+
stdout += data.toString();
|
|
99
|
+
});
|
|
100
|
+
child.stderr.on('data', (data) => {
|
|
101
|
+
stderr += data.toString();
|
|
102
|
+
});
|
|
103
|
+
child.on('close', (code) => {
|
|
104
|
+
if (code === 0 && stdout.trim()) {
|
|
105
|
+
resolve({
|
|
106
|
+
success: true,
|
|
107
|
+
account,
|
|
108
|
+
password: stdout.trim(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
resolve({
|
|
113
|
+
success: false,
|
|
114
|
+
account,
|
|
115
|
+
error: stderr.includes('could not be found')
|
|
116
|
+
? 'Credential not found'
|
|
117
|
+
: stderr || 'Failed to retrieve credential',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
child.on('error', (err) => {
|
|
122
|
+
resolve({ success: false, account, error: err.message });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Delete a credential from Keychain.
|
|
128
|
+
*
|
|
129
|
+
* @param account - Account name.
|
|
130
|
+
* @returns Promise resolving to credential result.
|
|
131
|
+
*/
|
|
132
|
+
async delete(account) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const child = spawn('security', [
|
|
135
|
+
'delete-generic-password',
|
|
136
|
+
'-s',
|
|
137
|
+
this.serviceName,
|
|
138
|
+
'-a',
|
|
139
|
+
account,
|
|
140
|
+
]);
|
|
141
|
+
let stderr = '';
|
|
142
|
+
child.stderr.on('data', (data) => {
|
|
143
|
+
stderr += data.toString();
|
|
144
|
+
});
|
|
145
|
+
child.on('close', (code) => {
|
|
146
|
+
if (code === 0) {
|
|
147
|
+
authLog.debug('keychain.delete.complete', { account });
|
|
148
|
+
resolve({ success: true, account });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
resolve({
|
|
152
|
+
success: false,
|
|
153
|
+
account,
|
|
154
|
+
error: stderr.includes('could not be found')
|
|
155
|
+
? 'Credential not found'
|
|
156
|
+
: stderr || 'Failed to delete credential',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
child.on('error', (err) => {
|
|
161
|
+
resolve({ success: false, account, error: err.message });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Service for managing macOS Keychain credentials.
|
|
168
|
+
*
|
|
169
|
+
* Reads try the canonical namespace first, then fall back to the legacy
|
|
170
|
+
* namespace and migrate a hit forward. Writes and deletes only ever touch
|
|
171
|
+
* the canonical namespace — the legacy entry is never deleted.
|
|
172
|
+
*/
|
|
173
|
+
export class KeychainService {
|
|
174
|
+
commands;
|
|
175
|
+
legacyCommands;
|
|
176
|
+
// Accounts whose canonical entry this process has already seen (read or
|
|
177
|
+
// written). Once seen, a later read miss is treated as transient and must
|
|
178
|
+
// NOT fall back to the legacy namespace — see getCredential.
|
|
179
|
+
canonicalSeen = new Set();
|
|
180
|
+
constructor() {
|
|
181
|
+
this.commands = new KeychainCommands(SERVICE_NAME);
|
|
182
|
+
this.legacyCommands = new KeychainCommands(LEGACY_SERVICE_NAME);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Stores a credential in Keychain.
|
|
186
|
+
*
|
|
187
|
+
* @param account - Account name.
|
|
188
|
+
* @param password - Password to store.
|
|
189
|
+
* @returns Promise resolving to credential result.
|
|
190
|
+
*/
|
|
191
|
+
async setCredential(account, password) {
|
|
192
|
+
// `add-generic-password -U` updates the item in place when it exists, so
|
|
193
|
+
// this is a single atomic keychain op. Do NOT delete-then-add: that leaves
|
|
194
|
+
// a window where the entry is absent, during which a concurrent read falls
|
|
195
|
+
// through to the legacy namespace and resurrects stale credentials over the
|
|
196
|
+
// current ones (the root of the "session silently logged out" bug).
|
|
197
|
+
const result = await this.commands.set(account, password);
|
|
198
|
+
if (result.success) {
|
|
199
|
+
this.canonicalSeen.add(account);
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Retrieves a credential from Keychain.
|
|
205
|
+
*
|
|
206
|
+
* Tries the canonical namespace first. Only if this process has NEVER seen
|
|
207
|
+
* the canonical entry does it fall back to the legacy namespace and migrate a
|
|
208
|
+
* hit forward (one-time). Once the canonical entry has been seen, a later
|
|
209
|
+
* miss is transient (item mid-update) and returns the miss as-is rather than
|
|
210
|
+
* resurrecting a stale legacy copy on top of the live session.
|
|
211
|
+
*
|
|
212
|
+
* @param account - Account name.
|
|
213
|
+
* @returns Promise resolving to credential result.
|
|
214
|
+
*/
|
|
215
|
+
async getCredential(account) {
|
|
216
|
+
const primary = await this.commands.get(account);
|
|
217
|
+
if (primary.success) {
|
|
218
|
+
this.canonicalSeen.add(account);
|
|
219
|
+
return primary;
|
|
220
|
+
}
|
|
221
|
+
if (this.canonicalSeen.has(account)) {
|
|
222
|
+
return primary;
|
|
223
|
+
}
|
|
224
|
+
const legacy = await this.legacyCommands.get(account);
|
|
225
|
+
if (legacy.success && legacy.password) {
|
|
226
|
+
await this.commands.set(account, legacy.password);
|
|
227
|
+
this.canonicalSeen.add(account);
|
|
228
|
+
authLog.info('keychain.migrated_from_legacy', { account });
|
|
229
|
+
}
|
|
230
|
+
return legacy;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Deletes a credential from Keychain.
|
|
234
|
+
*
|
|
235
|
+
* Operates on the canonical namespace only. The legacy namespace is
|
|
236
|
+
* never deleted, even here.
|
|
237
|
+
*
|
|
238
|
+
* @param account - Account name.
|
|
239
|
+
* @returns Promise resolving to credential result.
|
|
240
|
+
*/
|
|
241
|
+
async deleteCredential(account) {
|
|
242
|
+
return await this.commands.delete(account);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
let keychainServiceInstance = null;
|
|
246
|
+
/**
|
|
247
|
+
* Gets the singleton keychain service instance.
|
|
248
|
+
*
|
|
249
|
+
* @returns The KeychainService instance.
|
|
250
|
+
*/
|
|
251
|
+
export function getKeychainService() {
|
|
252
|
+
if (!keychainServiceInstance) {
|
|
253
|
+
keychainServiceInstance = new KeychainService();
|
|
254
|
+
}
|
|
255
|
+
return keychainServiceInstance;
|
|
256
|
+
}
|