@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,260 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { aesDecrypt, computeSharedSecret, deriveKeyAndIV, } from '../crypto/crypto-primitives.js';
|
|
3
|
+
import { getPeerPublicKey } from './peer-key.js';
|
|
4
|
+
/**
|
|
5
|
+
* Read one field from a raw shared-key payload using thrift or named access.
|
|
6
|
+
*
|
|
7
|
+
* @param shared - Raw shared-key payload.
|
|
8
|
+
* @param tupleField - Numeric thrift field id.
|
|
9
|
+
* @param namedField - Named object field.
|
|
10
|
+
* @returns Raw field value or null.
|
|
11
|
+
*/
|
|
12
|
+
function readSharedField(shared, tupleField, namedField) {
|
|
13
|
+
return shared?.[tupleField] ?? shared?.[namedField] ?? null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read one shared-key field and coerce it into a string with fallback support.
|
|
17
|
+
*
|
|
18
|
+
* @param shared - Raw shared-key payload.
|
|
19
|
+
* @param tupleField - Numeric thrift field id.
|
|
20
|
+
* @param namedField - Named object field.
|
|
21
|
+
* @param fallback - Fallback string when the field is absent.
|
|
22
|
+
* @returns String value.
|
|
23
|
+
*/
|
|
24
|
+
function readSharedString(shared, tupleField, namedField, fallback = '') {
|
|
25
|
+
return String(readSharedField(shared, tupleField, namedField) ?? fallback);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extract structured fields from a raw LINE group shared key payload.
|
|
29
|
+
* Accepts both thrift tuple and named-property shapes.
|
|
30
|
+
*
|
|
31
|
+
* @param shared - Raw shared key payload
|
|
32
|
+
* @param receiverKeyId - Fallback receiver key ID
|
|
33
|
+
* @param senderMid - Fallback creator MID
|
|
34
|
+
* @param senderKeyId - Fallback creator key ID
|
|
35
|
+
* @returns Normalized fields needed for key derivation
|
|
36
|
+
*/
|
|
37
|
+
function extractSharedKeyFields(shared, receiverKeyId, senderMid, senderKeyId) {
|
|
38
|
+
const sharedReceiverKeyId = readSharedString(shared, 6, 'receiverKeyId', receiverKeyId);
|
|
39
|
+
const groupKeyId = readSharedString(shared, 2, 'groupKeyId', sharedReceiverKeyId);
|
|
40
|
+
const rawCreator = readSharedField(shared, 3, 'creator');
|
|
41
|
+
const creator = rawCreator == null ? senderMid : String(rawCreator);
|
|
42
|
+
const creatorKeyId = readSharedString(shared, 4, 'creatorKeyId', senderKeyId ?? '');
|
|
43
|
+
const encryptedSharedKey = readSharedField(shared, 7, 'encryptedSharedKey');
|
|
44
|
+
return {
|
|
45
|
+
creator,
|
|
46
|
+
creatorKeyId,
|
|
47
|
+
sharedReceiverKeyId,
|
|
48
|
+
encryptedSharedKey,
|
|
49
|
+
groupKeyId,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check memory and persisted store for a cached group key.
|
|
54
|
+
*
|
|
55
|
+
* @param ctx - KeyManager context
|
|
56
|
+
* @param chatMid - Group chat MID
|
|
57
|
+
* @param receiverKeyId - Expected key ID
|
|
58
|
+
* @returns Cached group key or null
|
|
59
|
+
*/
|
|
60
|
+
async function lookupCachedGroupKey(ctx, chatMid, receiverKeyId) {
|
|
61
|
+
// Epoch-aware cache: keys are stored per (chat, groupKeyId) and never
|
|
62
|
+
// overwritten, so once this device has seen a group-key epoch it keeps
|
|
63
|
+
// decrypting that epoch's messages even after the group rekeys. Without a
|
|
64
|
+
// specific epoch (the send path asks for "latest"), skip the cache and let
|
|
65
|
+
// the caller fetch the current key from LINE.
|
|
66
|
+
if (!receiverKeyId) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const cacheKey = `${chatMid}:${receiverKeyId}`;
|
|
70
|
+
const cached = ctx.groupKeys.get(cacheKey);
|
|
71
|
+
if (cached) {
|
|
72
|
+
ctx.logGroupKeyEvent('e2ee.group_key.cache_hit', {
|
|
73
|
+
chat: chatMid,
|
|
74
|
+
source: 'memory',
|
|
75
|
+
receiver_key_id: receiverKeyId,
|
|
76
|
+
group_key_id: cached.keyId,
|
|
77
|
+
});
|
|
78
|
+
return cached;
|
|
79
|
+
}
|
|
80
|
+
const store = ctx.getStore();
|
|
81
|
+
const persisted = await store?.get?.(`line_e2ee_group_by_mid:${cacheKey}`);
|
|
82
|
+
if (!persisted) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const parsed = JSON.parse(persisted);
|
|
86
|
+
const groupKey = {
|
|
87
|
+
keyId: String(parsed.keyId),
|
|
88
|
+
privateKey: Buffer.from(parsed.privateKey, 'base64'),
|
|
89
|
+
};
|
|
90
|
+
ctx.groupKeys.set(cacheKey, groupKey);
|
|
91
|
+
ctx.logGroupKeyEvent('e2ee.group_key.cache_hit', {
|
|
92
|
+
chat: chatMid,
|
|
93
|
+
source: 'persisted',
|
|
94
|
+
receiver_key_id: receiverKeyId,
|
|
95
|
+
group_key_id: groupKey.keyId,
|
|
96
|
+
});
|
|
97
|
+
return groupKey;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Return a stable fetch key for one group-key resolution request.
|
|
101
|
+
*
|
|
102
|
+
* @param chatMid - Group chat MID
|
|
103
|
+
* @param receiverKeyId - Expected receiver key id
|
|
104
|
+
* @returns Stable in-flight fetch key
|
|
105
|
+
*/
|
|
106
|
+
function getGroupKeyFetchKey(chatMid, receiverKeyId) {
|
|
107
|
+
return `${chatMid}:${receiverKeyId}`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Derive and cache a group key from the shared key payload.
|
|
111
|
+
*
|
|
112
|
+
* @param ctx - KeyManager context
|
|
113
|
+
* @param chatMid - Group chat MID
|
|
114
|
+
* @param shared - Raw shared key payload from LINE
|
|
115
|
+
* @param receiverKeyId - Receiver key ID fallback
|
|
116
|
+
* @param senderMid - Sender MID fallback
|
|
117
|
+
* @param senderKeyId - Sender key ID fallback
|
|
118
|
+
* @param resolutionSource - Label used in diagnostic logs
|
|
119
|
+
* @returns Derived group key
|
|
120
|
+
*/
|
|
121
|
+
async function deriveAndCacheGroupKey(ctx, chatMid, shared, receiverKeyId, senderMid, senderKeyId, resolutionSource) {
|
|
122
|
+
const { creator, creatorKeyId, sharedReceiverKeyId, encryptedSharedKey, groupKeyId, } = extractSharedKeyFields(shared, receiverKeyId, senderMid, senderKeyId);
|
|
123
|
+
const selfKey = ctx.getSelfKeyById(sharedReceiverKeyId);
|
|
124
|
+
if (!selfKey) {
|
|
125
|
+
ctx.raiseWarning('missing_self_key', {
|
|
126
|
+
receiverKeyId: sharedReceiverKeyId,
|
|
127
|
+
chatMid,
|
|
128
|
+
});
|
|
129
|
+
throw new Error(`Missing self E2EE key for receiverKeyId=${sharedReceiverKeyId}`);
|
|
130
|
+
}
|
|
131
|
+
if (!creator || !creatorKeyId || !encryptedSharedKey) {
|
|
132
|
+
throw new Error('Incomplete group shared key payload');
|
|
133
|
+
}
|
|
134
|
+
const selfMid = ctx.getProfileMid();
|
|
135
|
+
const creatorPublicKey = creator === selfMid
|
|
136
|
+
? selfKey.publicKey
|
|
137
|
+
: await getPeerPublicKey(ctx, creator, creatorKeyId);
|
|
138
|
+
const aesSecret = computeSharedSecret(selfKey.privateKey, creatorPublicKey);
|
|
139
|
+
const { key, iv } = deriveKeyAndIV(aesSecret);
|
|
140
|
+
const raw = encryptedSharedKey;
|
|
141
|
+
const encrypted = Buffer.isBuffer(raw)
|
|
142
|
+
? raw
|
|
143
|
+
: Buffer.from(raw, typeof raw === 'string' ? 'base64' : undefined);
|
|
144
|
+
const plainText = aesDecrypt(encrypted, key, iv);
|
|
145
|
+
const groupKey = { keyId: groupKeyId, privateKey: plainText };
|
|
146
|
+
// Cache under (chat, groupKeyId) — only ever add, never overwrite a
|
|
147
|
+
// different epoch, so a later rekey cannot strand messages this device can
|
|
148
|
+
// already read.
|
|
149
|
+
const cacheKey = `${chatMid}:${groupKey.keyId}`;
|
|
150
|
+
ctx.groupKeys.set(cacheKey, groupKey);
|
|
151
|
+
const store = ctx.getStore();
|
|
152
|
+
await store?.set?.(`line_e2ee_group_by_mid:${cacheKey}`, JSON.stringify({
|
|
153
|
+
keyId: groupKey.keyId,
|
|
154
|
+
privateKey: groupKey.privateKey.toString('base64'),
|
|
155
|
+
}));
|
|
156
|
+
ctx.logGroupKeyEvent('e2ee.group_key.resolved', {
|
|
157
|
+
chat: chatMid,
|
|
158
|
+
source: resolutionSource,
|
|
159
|
+
receiver_key_id: sharedReceiverKeyId,
|
|
160
|
+
creator,
|
|
161
|
+
creator_key_id: creatorKeyId,
|
|
162
|
+
group_key_id: groupKeyId,
|
|
163
|
+
encrypted_shared_key_bytes: encrypted.length,
|
|
164
|
+
});
|
|
165
|
+
return groupKey;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve and cache the group shared key for a chat.
|
|
169
|
+
*
|
|
170
|
+
* Yomi is read/send-only over group keys: it resolves an EXISTING shared key
|
|
171
|
+
* or returns undefined. It NEVER registers (mints) a group key. Minting is an
|
|
172
|
+
* account-visible write that rotates the group's shared secret for every
|
|
173
|
+
* member; every message encrypted under the previous secret — all of it, for
|
|
174
|
+
* every sender — then fails to decrypt, and members who don't adopt the new
|
|
175
|
+
* secret are stranded too. Group-key lifecycle belongs to the official LINE
|
|
176
|
+
* clients / the primary device, never to Yomi.
|
|
177
|
+
*
|
|
178
|
+
* @param ctx - KeyManager context
|
|
179
|
+
* @param chatMid - Group chat MID
|
|
180
|
+
* @param receiverKeyId - Local receiver key ID
|
|
181
|
+
* @param senderMid - Sender MID
|
|
182
|
+
* @param senderKeyId - Sender key ID
|
|
183
|
+
* @returns Group key or undefined when unavailable
|
|
184
|
+
*/
|
|
185
|
+
export async function getGroupKey(ctx, chatMid, receiverKeyId, senderMid, senderKeyId) {
|
|
186
|
+
if (!receiverKeyId) {
|
|
187
|
+
const cachedLatest = await lookupCachedGroupKey(ctx, chatMid, null);
|
|
188
|
+
if (cachedLatest) {
|
|
189
|
+
return cachedLatest;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const cached = await lookupCachedGroupKey(ctx, chatMid, receiverKeyId);
|
|
193
|
+
if (cached) {
|
|
194
|
+
return cached;
|
|
195
|
+
}
|
|
196
|
+
const resolvedReceiverKeyId = receiverKeyId || 'latest';
|
|
197
|
+
const fetchKey = getGroupKeyFetchKey(chatMid, resolvedReceiverKeyId);
|
|
198
|
+
const inflight = ctx.groupKeyFetches.get(fetchKey);
|
|
199
|
+
if (inflight) {
|
|
200
|
+
ctx.logGroupKeyEvent('e2ee.group_key.fetch_join', {
|
|
201
|
+
chat: chatMid,
|
|
202
|
+
receiver_key_id: receiverKeyId,
|
|
203
|
+
inflight_receiver_key_id: inflight.receiverKeyId,
|
|
204
|
+
});
|
|
205
|
+
return inflight.promise;
|
|
206
|
+
}
|
|
207
|
+
const fetchPromise = (async () => {
|
|
208
|
+
const client = ctx.getClient();
|
|
209
|
+
let shared;
|
|
210
|
+
let resolutionSource = 'server_fetch';
|
|
211
|
+
try {
|
|
212
|
+
shared = await client?.getLastE2EEGroupSharedKey?.(2, chatMid);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const code = typeof error?.data?.code === 'string' ? error.data.code : null;
|
|
216
|
+
const message = error?.message || String(error);
|
|
217
|
+
const shouldFallback = code === 'NOT_FOUND' ||
|
|
218
|
+
message.toLowerCase().includes('no valid group key');
|
|
219
|
+
if (!shouldFallback) {
|
|
220
|
+
ctx.logGroupKeyEvent('e2ee.group_key.fetch_failed', {
|
|
221
|
+
chat: chatMid,
|
|
222
|
+
receiver_key_id: receiverKeyId,
|
|
223
|
+
code,
|
|
224
|
+
error: message,
|
|
225
|
+
});
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
resolutionSource = 'register_fallback';
|
|
229
|
+
}
|
|
230
|
+
if (!shared) {
|
|
231
|
+
// Yomi never mints. LINE has no readable group key for this
|
|
232
|
+
// (receiverKeyId, chat) — fail cleanly rather than register one and
|
|
233
|
+
// rotate the group's secret out from under every other member.
|
|
234
|
+
ctx.logGroupKeyEvent('e2ee.group_key.mint_suppressed', {
|
|
235
|
+
chat: chatMid,
|
|
236
|
+
receiver_key_id: receiverKeyId,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!shared) {
|
|
240
|
+
ctx.logGroupKeyEvent('e2ee.group_key.unavailable', {
|
|
241
|
+
chat: chatMid,
|
|
242
|
+
receiver_key_id: receiverKeyId,
|
|
243
|
+
source: resolutionSource,
|
|
244
|
+
});
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
return deriveAndCacheGroupKey(ctx, chatMid, shared, resolvedReceiverKeyId, senderMid, senderKeyId, resolutionSource);
|
|
248
|
+
})();
|
|
249
|
+
ctx.groupKeyFetches.set(fetchKey, {
|
|
250
|
+
receiverKeyId: resolvedReceiverKeyId,
|
|
251
|
+
promise: fetchPromise,
|
|
252
|
+
});
|
|
253
|
+
try {
|
|
254
|
+
return await fetchPromise;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
ctx.groupKeyFetches.delete(fetchKey);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export { normalizeGroupPublicKeys } from './key-payload.js';
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
3
|
+
import { createCliLogger } from '../../../../util/log.js';
|
|
4
|
+
import { tryDecrypt as tryDecryptFn } from './decrypt.js';
|
|
5
|
+
import { encryptE2EEMessage as encryptE2EEMessageFn } from './encrypt.js';
|
|
6
|
+
import { toChunkKeyId } from './message-crypto.js';
|
|
7
|
+
const e2eeLog = createCliLogger('E2EE');
|
|
8
|
+
/**
|
|
9
|
+
* KeyManager handles E2EE key generation and storage.
|
|
10
|
+
* Implements {@link KeyManagerContext} so it can be passed as `ctx` to module-level functions.
|
|
11
|
+
*/
|
|
12
|
+
export class KeyManager {
|
|
13
|
+
keys = new Map();
|
|
14
|
+
importedKeys = new Map();
|
|
15
|
+
selfKeyByMid = new Map();
|
|
16
|
+
peerPublicKeys = new Map();
|
|
17
|
+
groupKeys = new Map();
|
|
18
|
+
groupKeyFetches = new Map();
|
|
19
|
+
runtimeGetClient;
|
|
20
|
+
runtimeGetStore;
|
|
21
|
+
runtimeGetProfileMid;
|
|
22
|
+
emitWarning;
|
|
23
|
+
/**
|
|
24
|
+
* Generate a new X25519 key pair for E2EE.
|
|
25
|
+
* @returns Generated key pair
|
|
26
|
+
*/
|
|
27
|
+
generateKeyPair() {
|
|
28
|
+
const keyPair = generateKeyPairSync('x25519');
|
|
29
|
+
return {
|
|
30
|
+
publicKey: keyPair.publicKey
|
|
31
|
+
.export({ type: 'spki', format: 'der' })
|
|
32
|
+
.slice(-32),
|
|
33
|
+
privateKey: keyPair.privateKey
|
|
34
|
+
.export({ type: 'pkcs8', format: 'der' })
|
|
35
|
+
.slice(-32),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Store a key pair for a user.
|
|
40
|
+
* @param userId - User identifier
|
|
41
|
+
* @param keyPair - Key pair to store
|
|
42
|
+
*/
|
|
43
|
+
storeKey(userId, keyPair) {
|
|
44
|
+
this.keys.set(userId, keyPair);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get a user's stored key pair.
|
|
48
|
+
* @param userId - User identifier.
|
|
49
|
+
* @returns Stored key pair when present.
|
|
50
|
+
*/
|
|
51
|
+
getKey(userId) {
|
|
52
|
+
return this.keys.get(userId);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Check if a user has a stored key.
|
|
56
|
+
* @param userId - User identifier.
|
|
57
|
+
* @returns True when a key exists for the user.
|
|
58
|
+
*/
|
|
59
|
+
hasKey(userId) {
|
|
60
|
+
return this.keys.has(userId);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Import E2EE keys decoded from the login keychain.
|
|
64
|
+
* Called after successful login with the decrypted key list.
|
|
65
|
+
* @param keys - Array of key objects from decryptKeyChain
|
|
66
|
+
*/
|
|
67
|
+
importKeys(keys) {
|
|
68
|
+
for (const k of keys) {
|
|
69
|
+
const keyId = String(k.keyId);
|
|
70
|
+
const importedKey = {
|
|
71
|
+
keyId,
|
|
72
|
+
version: k.version != null ? String(k.version) : undefined,
|
|
73
|
+
createdTime: k.createdTime != null ? Number(k.createdTime) : null,
|
|
74
|
+
publicKey: Buffer.isBuffer(k.publicKey)
|
|
75
|
+
? k.publicKey
|
|
76
|
+
: Buffer.from(k.publicKey),
|
|
77
|
+
privateKey: Buffer.isBuffer(k.privateKey)
|
|
78
|
+
? k.privateKey
|
|
79
|
+
: Buffer.from(k.privateKey),
|
|
80
|
+
};
|
|
81
|
+
this.importedKeys.set(keyId, importedKey);
|
|
82
|
+
if (k.mid) {
|
|
83
|
+
this.selfKeyByMid.set(String(k.mid), importedKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.resolveLogger().info('e2ee.keys.imported', {
|
|
87
|
+
count: keys.length,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Associate an imported self key with the authenticated user's MID.
|
|
92
|
+
* @param mid - Authenticated LINE MID
|
|
93
|
+
*/
|
|
94
|
+
bindSelfKeysToMid(mid) {
|
|
95
|
+
const latest = [...this.importedKeys.values()].sort((a, b) => (b.createdTime ?? 0) - (a.createdTime ?? 0))[0];
|
|
96
|
+
if (latest) {
|
|
97
|
+
this.selfKeyByMid.set(mid, latest);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Return a self key by stored key ID.
|
|
102
|
+
* @param keyId - Imported self key identifier.
|
|
103
|
+
* @returns Matching imported key when present.
|
|
104
|
+
*/
|
|
105
|
+
getSelfKeyById(keyId) {
|
|
106
|
+
return this.importedKeys.get(String(keyId));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Return the authenticated user's current self key by MID.
|
|
110
|
+
* @param mid - Authenticated LINE MID.
|
|
111
|
+
* @returns Matching imported key when present.
|
|
112
|
+
*/
|
|
113
|
+
getSelfKeyByMid(mid) {
|
|
114
|
+
return this.selfKeyByMid.get(String(mid));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Clear only runtime-discovered E2EE caches while preserving imported self keys.
|
|
118
|
+
*
|
|
119
|
+
* This is used by session-level recovery: peer public keys and group shared
|
|
120
|
+
* keys may have become stale or incomplete, but the authenticated self key
|
|
121
|
+
* material should survive across retries until the user explicitly logs out.
|
|
122
|
+
*/
|
|
123
|
+
resetTransientCaches() {
|
|
124
|
+
this.peerPublicKeys.clear();
|
|
125
|
+
this.groupKeys.clear();
|
|
126
|
+
this.groupKeyFetches.clear();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Bind runtime dependencies required for async E2EE key resolution.
|
|
130
|
+
* @param deps - Runtime dependency providers.
|
|
131
|
+
* @param deps.getClient - Returns the active LINE client.
|
|
132
|
+
* @param deps.getStore - Returns the credential store.
|
|
133
|
+
* @param deps.getProfileMid - Returns the authenticated profile MID.
|
|
134
|
+
* @param deps.emitWarning - Emits a recovery warning to the service.
|
|
135
|
+
*/
|
|
136
|
+
setRuntime(deps) {
|
|
137
|
+
this.runtimeGetClient = deps.getClient;
|
|
138
|
+
this.runtimeGetStore = deps.getStore;
|
|
139
|
+
this.runtimeGetProfileMid = deps.getProfileMid;
|
|
140
|
+
this.emitWarning = deps.emitWarning;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Return the active LINE client when runtime dependencies are bound.
|
|
144
|
+
* @returns Active LINE client.
|
|
145
|
+
*/
|
|
146
|
+
getClient() {
|
|
147
|
+
return this.runtimeGetClient?.();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Return the credential store when runtime dependencies are bound.
|
|
151
|
+
* @returns Credential store.
|
|
152
|
+
*/
|
|
153
|
+
getStore() {
|
|
154
|
+
return this.runtimeGetStore?.();
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Return the authenticated LINE MID when runtime dependencies are bound.
|
|
158
|
+
* @returns Authenticated LINE MID.
|
|
159
|
+
*/
|
|
160
|
+
getProfileMid() {
|
|
161
|
+
return this.runtimeGetProfileMid?.();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Emit a recovery warning if the service is available.
|
|
165
|
+
* @param reason - Warning reason code.
|
|
166
|
+
* @param details - Additional warning metadata.
|
|
167
|
+
*/
|
|
168
|
+
raiseWarning(reason, details = {}) {
|
|
169
|
+
this.emitWarning?.({ active: true, reason, ...details });
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Emit one visible LINE runtime log for group-key diagnostics.
|
|
173
|
+
* @param event - Event name
|
|
174
|
+
* @param context - Structured payload
|
|
175
|
+
*/
|
|
176
|
+
logGroupKeyEvent(event, context = {}) {
|
|
177
|
+
this.resolveLogger().info(event, context);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Emit one warning-level E2EE log through the shared CLI logger.
|
|
181
|
+
*
|
|
182
|
+
* @param event - Event name
|
|
183
|
+
* @param context - Structured payload
|
|
184
|
+
*/
|
|
185
|
+
logE2EEWarning(event, context = {}) {
|
|
186
|
+
this.resolveLogger().warn(event, context);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Resolve the active E2EE logger using the shared logging contract.
|
|
190
|
+
*
|
|
191
|
+
* @returns Startup-aware CLI logger.
|
|
192
|
+
*/
|
|
193
|
+
resolveLogger() {
|
|
194
|
+
const client = this.getClient();
|
|
195
|
+
return client?.startupFlowLogger || client?.logger || e2eeLog;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Attempt to decrypt an E2EE message using stored keys.
|
|
199
|
+
* Returns plaintext on success, or a no-op result for plaintext messages.
|
|
200
|
+
* @param msg - Parsed message object (from parsers.ts)
|
|
201
|
+
* @returns Decryption result with optional plaintext
|
|
202
|
+
*/
|
|
203
|
+
async tryDecrypt(msg) {
|
|
204
|
+
return tryDecryptFn(this, msg);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Prepare an outbound LINE E2EE payload using the same chunk layout as linejs.
|
|
208
|
+
*
|
|
209
|
+
* The returned structure is transport-ready for TalkService sendMessage:
|
|
210
|
+
* `chunks` contains `[salt, ciphertext+tag, nonce, senderKeyIdBytes, receiverKeyIdBytes]`
|
|
211
|
+
* and `contentMetadata` includes the E2EE markers LINE expects for version 2.
|
|
212
|
+
*
|
|
213
|
+
* @param to - Target LINE MID
|
|
214
|
+
* @param data - Text or structured payload to encrypt
|
|
215
|
+
* @param contentType - LINE content type of the payload
|
|
216
|
+
* @returns Message chunks plus metadata required by LINE
|
|
217
|
+
*/
|
|
218
|
+
async encryptE2EEMessage(to, data, contentType = 0) {
|
|
219
|
+
return encryptE2EEMessageFn(this, to, data, contentType);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Public wrapper used by logging/initializer layers to format key IDs from
|
|
223
|
+
* raw message chunks without duplicating chunk parsing logic outside E2EE.
|
|
224
|
+
*
|
|
225
|
+
* @param value - Raw LINE chunk value
|
|
226
|
+
* @returns Parsed decimal key ID or null
|
|
227
|
+
*/
|
|
228
|
+
readChunkKeyId(value) {
|
|
229
|
+
return toChunkKeyId(value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
/**
|
|
3
|
+
* Read one normalized field from a negotiated public-key candidate payload.
|
|
4
|
+
*
|
|
5
|
+
* @param candidate - Raw candidate payload.
|
|
6
|
+
* @param fieldId - Target thrift field id.
|
|
7
|
+
* @returns Raw field value or null.
|
|
8
|
+
*/
|
|
9
|
+
function readCandidateField(candidate, fieldId) {
|
|
10
|
+
if (!candidate) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
if (fieldId === 2) {
|
|
14
|
+
return (candidate[2] ??
|
|
15
|
+
candidate.keyId ??
|
|
16
|
+
candidate.publicKey?.[2] ??
|
|
17
|
+
candidate.publicKey?.keyId ??
|
|
18
|
+
null);
|
|
19
|
+
}
|
|
20
|
+
return (candidate[4] ??
|
|
21
|
+
candidate.keyData ??
|
|
22
|
+
candidate.publicKey?.[4] ??
|
|
23
|
+
candidate.publicKey?.keyData ??
|
|
24
|
+
null);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Select the nested object that actually contains public key fields.
|
|
28
|
+
*
|
|
29
|
+
* @param raw - Raw negotiation response from LINE.
|
|
30
|
+
* @returns Normalized container object.
|
|
31
|
+
*/
|
|
32
|
+
function readPublicKeyContainer(raw) {
|
|
33
|
+
return raw?.[2] || raw?.publicKey || raw?.fields?.[2] || raw || null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read one normalized field from the selected negotiated public-key container.
|
|
37
|
+
*
|
|
38
|
+
* @param publicKey - Selected public-key container.
|
|
39
|
+
* @param raw - Raw negotiation response.
|
|
40
|
+
* @param fieldId - Target thrift field id.
|
|
41
|
+
* @returns Raw field value or null.
|
|
42
|
+
*/
|
|
43
|
+
function readNegotiatedValue(publicKey, raw, fieldId) {
|
|
44
|
+
if (fieldId === 2) {
|
|
45
|
+
return publicKey?.[2] ?? publicKey?.keyId ?? raw?.keyId ?? null;
|
|
46
|
+
}
|
|
47
|
+
return publicKey?.[4] ?? publicKey?.keyData ?? raw?.keyData ?? null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Normalize raw key payloads into Buffer instances.
|
|
51
|
+
*
|
|
52
|
+
* @param keyData - Raw key payload from thrift or object responses.
|
|
53
|
+
* @returns Buffer instance or null when unsupported.
|
|
54
|
+
*/
|
|
55
|
+
export function toKeyBuffer(keyData) {
|
|
56
|
+
if (Buffer.isBuffer(keyData)) {
|
|
57
|
+
return keyData;
|
|
58
|
+
}
|
|
59
|
+
if (typeof keyData === 'string') {
|
|
60
|
+
return Buffer.from(keyData, 'base64');
|
|
61
|
+
}
|
|
62
|
+
if (keyData instanceof Uint8Array) {
|
|
63
|
+
return Buffer.from(keyData);
|
|
64
|
+
}
|
|
65
|
+
if (keyData instanceof ArrayBuffer) {
|
|
66
|
+
return Buffer.from(new Uint8Array(keyData));
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Normalize the getLastE2EEPublicKeys response into a member->public-key map.
|
|
72
|
+
*
|
|
73
|
+
* linejs treats this response as an object keyed by member MID. Depending on
|
|
74
|
+
* our thrift decoder shape it may arrive as a plain object or as nested field
|
|
75
|
+
* tuples, so this helper accepts both and extracts only the fields required to
|
|
76
|
+
* register a new group key.
|
|
77
|
+
*
|
|
78
|
+
* @param raw - Raw getLastE2EEPublicKeys response
|
|
79
|
+
* @returns Map of member MID to key metadata
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeGroupPublicKeys(raw) {
|
|
82
|
+
const result = new Map();
|
|
83
|
+
const entries = raw && typeof raw === 'object' ? Object.entries(raw) : [];
|
|
84
|
+
for (const [mid, value] of entries) {
|
|
85
|
+
const candidate = value;
|
|
86
|
+
const keyId = readCandidateField(candidate, 2);
|
|
87
|
+
const keyData = readCandidateField(candidate, 4);
|
|
88
|
+
if (keyId == null || !keyData) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const normalizedKeyData = toKeyBuffer(keyData);
|
|
92
|
+
if (!normalizedKeyData) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
result.set(String(mid), {
|
|
96
|
+
keyId: String(keyId),
|
|
97
|
+
keyData: normalizedKeyData,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Normalize a negotiateE2EEPublicKey response into the key shape used by the
|
|
104
|
+
* local runtime. LINE returns slightly different nested structs depending on
|
|
105
|
+
* endpoint version and thrift decoder shape; this helper accepts either.
|
|
106
|
+
*
|
|
107
|
+
* @param raw - Raw negotiation response from the LINE Talk service
|
|
108
|
+
* @returns Normalized key payload or null
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeNegotiatedPublicKey(raw) {
|
|
111
|
+
const publicKey = readPublicKeyContainer(raw);
|
|
112
|
+
const keyId = readNegotiatedValue(publicKey, raw, 2);
|
|
113
|
+
const keyData = readNegotiatedValue(publicKey, raw, 4);
|
|
114
|
+
if (keyId == null || keyData == null) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!Buffer.isBuffer(keyData) &&
|
|
118
|
+
typeof keyData !== 'string' &&
|
|
119
|
+
!(keyData instanceof Uint8Array)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
keyId: String(keyId),
|
|
124
|
+
keyData: Buffer.isBuffer(keyData)
|
|
125
|
+
? keyData
|
|
126
|
+
: typeof keyData === 'string'
|
|
127
|
+
? Buffer.from(keyData, 'base64')
|
|
128
|
+
: Buffer.from(keyData),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|