@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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +81 -0
  3. package/PRIVACY.md +39 -0
  4. package/README.md +659 -0
  5. package/dist/auth/credential-store.js +395 -0
  6. package/dist/auth/keychain.js +256 -0
  7. package/dist/cli/login.js +142 -0
  8. package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
  9. package/dist/line/auth/protocol/index.js +3 -0
  10. package/dist/line/auth/protocol/login-metadata.js +159 -0
  11. package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
  12. package/dist/line/auth/pwless/index.js +163 -0
  13. package/dist/line/auth/pwless/steps.js +213 -0
  14. package/dist/line/client/auth-service/client.js +240 -0
  15. package/dist/line/client/e2ee-service/client.js +69 -0
  16. package/dist/line/client/e2ee-service/requests.js +92 -0
  17. package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
  18. package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
  19. package/dist/line/client/index.js +213 -0
  20. package/dist/line/client/obs-media-client.js +140 -0
  21. package/dist/line/client/parsers.js +69 -0
  22. package/dist/line/client/shop-service/client.js +63 -0
  23. package/dist/line/client/sticker-meta.js +46 -0
  24. package/dist/line/client/sync-service/client.js +317 -0
  25. package/dist/line/client/talk-service/chat-query.js +122 -0
  26. package/dist/line/client/talk-service/command-client.js +242 -0
  27. package/dist/line/client/talk-service/contact-query.js +77 -0
  28. package/dist/line/client/talk-service/message-box-query.js +137 -0
  29. package/dist/line/client/talk-service/message-id-query.js +241 -0
  30. package/dist/line/client/talk-service/query-client.js +219 -0
  31. package/dist/line/client/talk-service/requests.js +383 -0
  32. package/dist/line/client/transport.js +138 -0
  33. package/dist/line/core/auth-session-logout.js +55 -0
  34. package/dist/line/core/auth-session-runtime.js +231 -0
  35. package/dist/line/core/auth-session-service.js +85 -0
  36. package/dist/line/core/chat-runtime-service.js +343 -0
  37. package/dist/line/core/client-runtime.js +17 -0
  38. package/dist/line/core/config.js +67 -0
  39. package/dist/line/core/constants.js +56 -0
  40. package/dist/line/core/contact-query-service.js +20 -0
  41. package/dist/line/core/directory-runtime-service.js +83 -0
  42. package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
  43. package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
  44. package/dist/line/core/e2ee/index.js +6 -0
  45. package/dist/line/core/e2ee/keys/decrypt.js +110 -0
  46. package/dist/line/core/e2ee/keys/encrypt.js +117 -0
  47. package/dist/line/core/e2ee/keys/group-key.js +260 -0
  48. package/dist/line/core/e2ee/keys/key-manager.js +231 -0
  49. package/dist/line/core/e2ee/keys/key-payload.js +130 -0
  50. package/dist/line/core/e2ee/keys/key-types.js +1 -0
  51. package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
  52. package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
  53. package/dist/line/core/e2ee/keys/peer-key.js +191 -0
  54. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
  55. package/dist/line/core/e2ee/types.js +4 -0
  56. package/dist/line/core/group-query-service.js +20 -0
  57. package/dist/line/core/media-decrypt.js +78 -0
  58. package/dist/line/core/media-encrypt.js +97 -0
  59. package/dist/line/core/mention.js +89 -0
  60. package/dist/line/core/message-command-service.js +454 -0
  61. package/dist/line/core/message-query-service.js +161 -0
  62. package/dist/line/core/service.js +134 -0
  63. package/dist/line/core/session-state/index.js +243 -0
  64. package/dist/line/core/session-state/persist.js +192 -0
  65. package/dist/line/core/store.js +8 -0
  66. package/dist/line/core/thrift/binary/binary-reader.js +151 -0
  67. package/dist/line/core/thrift/binary/binary-writer.js +157 -0
  68. package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
  69. package/dist/line/core/thrift/compact/compact-reader.js +180 -0
  70. package/dist/line/core/thrift/compact/compact-writer.js +161 -0
  71. package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
  72. package/dist/line/core/thrift/fields/builders.js +124 -0
  73. package/dist/line/core/thrift/index.js +12 -0
  74. package/dist/line/core/thrift/message/decoder.js +55 -0
  75. package/dist/line/core/thrift/message/encoder.js +25 -0
  76. package/dist/line/core/thrift/primitives/varint.js +64 -0
  77. package/dist/line/core/thrift/struct/reader.js +172 -0
  78. package/dist/line/core/thrift/struct/writer.js +189 -0
  79. package/dist/line/core/thrift/types.js +23 -0
  80. package/dist/line/core/video-thumbnail.js +104 -0
  81. package/dist/mcp/handlers/contacts.js +166 -0
  82. package/dist/mcp/handlers/conversations.js +193 -0
  83. package/dist/mcp/handlers/groups.js +123 -0
  84. package/dist/mcp/handlers/index.js +15 -0
  85. package/dist/mcp/handlers/login.js +473 -0
  86. package/dist/mcp/handlers/media.js +69 -0
  87. package/dist/mcp/handlers/message-actions.js +62 -0
  88. package/dist/mcp/handlers/scope.js +120 -0
  89. package/dist/mcp/handlers/search.js +196 -0
  90. package/dist/mcp/handlers/send.js +523 -0
  91. package/dist/mcp/handlers/shared.js +84 -0
  92. package/dist/mcp/handlers/stickers.js +87 -0
  93. package/dist/mcp/media.js +228 -0
  94. package/dist/mcp/names.js +133 -0
  95. package/dist/mcp/phi-guard.js +105 -0
  96. package/dist/mcp/policy.js +34 -0
  97. package/dist/mcp/server.js +301 -0
  98. package/dist/mcp/tools.js +761 -0
  99. package/dist/mcp/ui/capability.js +26 -0
  100. package/dist/mcp/ui/login-app-protocol.js +118 -0
  101. package/dist/mcp/ui/login-app-styles.js +142 -0
  102. package/dist/mcp/ui/login-app-view.js +361 -0
  103. package/dist/mcp/ui/login-app.js +72 -0
  104. package/dist/mcp/ui/login-structured-content.js +1 -0
  105. package/dist/mcp/ui/resource.js +35 -0
  106. package/dist/mcp/ui/tools-with-ui.js +40 -0
  107. package/dist/search/bigram.js +86 -0
  108. package/dist/search/capture.js +140 -0
  109. package/dist/search/collector.js +176 -0
  110. package/dist/search/default-embedder.js +101 -0
  111. package/dist/search/embedder.js +13 -0
  112. package/dist/search/scope.js +142 -0
  113. package/dist/search/sqlite.js +141 -0
  114. package/dist/search/store.js +469 -0
  115. package/dist/util/data-dir.js +55 -0
  116. package/dist/util/log.js +86 -0
  117. package/dist/version.js +7 -0
  118. package/package.json +63 -0
  119. package/run.mjs +71 -0
@@ -0,0 +1,146 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import crypto from 'node:crypto';
3
+ import { aesDecrypt, computeSharedSecret, deriveGCMKey, deriveMessageKeyAndIV, } from '../crypto/crypto-primitives.js';
4
+ /**
5
+ * Decrypt a V1 (AES-256-CBC) E2EE payload.
6
+ * @param shared - ECDH shared secret
7
+ * @param salt - Message salt
8
+ * @param ciphertext - Encrypted payload
9
+ * @returns Decrypted UTF-8 string
10
+ */
11
+ export function decryptV1(shared, salt, ciphertext) {
12
+ const { key, iv } = deriveMessageKeyAndIV(shared, salt);
13
+ return aesDecrypt(ciphertext, key, iv).toString('utf8');
14
+ }
15
+ /**
16
+ * Decrypt a V2 (AES-256-GCM) E2EE payload.
17
+ * Auth tag is the last 16 bytes of the ciphertext buffer.
18
+ * @param shared - ECDH shared secret
19
+ * @param salt - Message salt
20
+ * @param ciphertext - Encrypted payload with appended auth tag
21
+ * @param nonce - GCM nonce/sign chunk from the LINE message
22
+ * @param aad - Additional authenticated data derived from message metadata
23
+ * @returns Decrypted UTF-8 string
24
+ */
25
+ export function decryptV2(shared, salt, ciphertext, nonce, aad) {
26
+ const gcmKey = deriveGCMKey(shared, salt);
27
+ const tagLength = 16;
28
+ const encData = ciphertext.subarray(0, ciphertext.length - tagLength);
29
+ const authTag = ciphertext.subarray(ciphertext.length - tagLength);
30
+ const decipher = crypto.createDecipheriv('aes-256-gcm', gcmKey, nonce);
31
+ decipher.setAuthTag(authTag);
32
+ decipher.setAAD(aad);
33
+ return Buffer.concat([decipher.update(encData), decipher.final()]).toString('utf8');
34
+ }
35
+ /**
36
+ * Try AES-GCM decrypt against multiple sender public keys until one validates.
37
+ *
38
+ * @param privateKey - Group private key
39
+ * @param candidates - Candidate sender public keys
40
+ * @param salt - Message salt
41
+ * @param ciphertext - Ciphertext with auth tag
42
+ * @param nonce - GCM nonce
43
+ * @param buildAAD - Builds candidate-specific additional authenticated data
44
+ * @returns Raw plaintext string
45
+ */
46
+ export function decryptV2WithCandidates(privateKey, candidates, salt, ciphertext, nonce, buildAAD) {
47
+ let lastError = null;
48
+ for (let index = 0; index < candidates.length; index += 1) {
49
+ try {
50
+ const shared = computeSharedSecret(privateKey, candidates[index].keyData);
51
+ return decryptV2(shared, salt, ciphertext, nonce, buildAAD(candidates[index]));
52
+ }
53
+ catch (error) {
54
+ lastError = error;
55
+ }
56
+ }
57
+ throw (lastError || new Error('No sender key candidate could decrypt the message'));
58
+ }
59
+ /**
60
+ * Extract the user-visible text payload from a decrypted LINE message body.
61
+ *
62
+ * linejs decrypts E2EE text messages into JSON objects such as
63
+ * `{ "text": "hello" }`. Callers expect `tryDecrypt()` to return plain text
64
+ * directly, so this helper unwraps that structure while preserving
65
+ * non-JSON payloads as-is.
66
+ *
67
+ * @param raw - Raw UTF-8 plaintext returned by the crypto layer
68
+ * @returns User-visible text value or the original payload string
69
+ */
70
+ export function extractTextPayload(raw) {
71
+ try {
72
+ const parsed = JSON.parse(raw);
73
+ if (parsed &&
74
+ typeof parsed === 'object' &&
75
+ typeof parsed.text === 'string') {
76
+ return parsed.text;
77
+ }
78
+ }
79
+ catch {
80
+ // Non-JSON payloads are valid for older/atypical message bodies.
81
+ }
82
+ return raw;
83
+ }
84
+ /**
85
+ * Generate the GCM AAD used by LINE E2EE v2.
86
+ * Matches linejs ordering: to, from, senderKeyId, receiverKeyId, specVersion, contentType.
87
+ *
88
+ * @param to - Message target MID
89
+ * @param from - Message sender MID
90
+ * @param senderKeyId - Sender key ID
91
+ * @param receiverKeyId - Receiver key ID
92
+ * @param specVersion - E2EE version
93
+ * @param contentType - LINE content type
94
+ * @returns Concatenated AAD buffer
95
+ */
96
+ export function generateAAD(to, from, senderKeyId, receiverKeyId, specVersion, contentType) {
97
+ return Buffer.concat([
98
+ Buffer.from(to),
99
+ Buffer.from(from),
100
+ getIntBytes(senderKeyId),
101
+ getIntBytes(receiverKeyId),
102
+ getIntBytes(specVersion),
103
+ getIntBytes(contentType),
104
+ ]);
105
+ }
106
+ /**
107
+ * Encode a signed 32-bit integer using the same big-endian byte order as linejs.
108
+ * @param value - Integer value
109
+ * @returns 4-byte buffer
110
+ */
111
+ export function getIntBytes(value) {
112
+ const buffer = new ArrayBuffer(4);
113
+ const view = new DataView(buffer);
114
+ view.setInt32(0, value);
115
+ return Buffer.from(new Uint8Array(buffer));
116
+ }
117
+ /**
118
+ * Convert a raw LINE chunk into a decimal key ID string.
119
+ * @param value - Raw chunk value
120
+ * @returns Parsed key ID string or null when the chunk is not a valid integer
121
+ */
122
+ export function toChunkKeyId(value) {
123
+ if (value == null) {
124
+ return null;
125
+ }
126
+ if (typeof value === 'number') {
127
+ return value > 0 ? String(value) : null;
128
+ }
129
+ const buf = Buffer.isBuffer(value)
130
+ ? value
131
+ : value instanceof Uint8Array
132
+ ? Buffer.from(value)
133
+ : Array.isArray(value)
134
+ ? Buffer.from(value)
135
+ : typeof value === 'string'
136
+ ? Buffer.from(value, 'utf8')
137
+ : null;
138
+ if (!buf || buf.length === 0 || buf.length > 6) {
139
+ return null;
140
+ }
141
+ let parsed = 0;
142
+ for (const byte of buf.values()) {
143
+ parsed = (parsed << 8) | byte;
144
+ }
145
+ return parsed > 0 ? String(parsed) : null;
146
+ }
@@ -0,0 +1,390 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { computeSharedSecret } from '../crypto/crypto-primitives.js';
3
+ import { getGroupKey } from './group-key.js';
4
+ import { decryptV1, decryptV2, decryptV2WithCandidates, extractTextPayload, generateAAD, toChunkKeyId, } from './message-crypto.js';
5
+ import { getGroupSenderPublicKeyCandidates, getPeerPublicKey, } from './peer-key.js';
6
+ /**
7
+ * Emit one structured log when the decrypt path still lacks sender/self keys.
8
+ *
9
+ * @param ctx - KeyManager context.
10
+ * @param msg - Raw LINE message.
11
+ * @param chatMid - Group chat MID when applicable.
12
+ * @param senderKeyId - Parsed sender key id.
13
+ * @param receiverKeyId - Parsed receiver/group key id.
14
+ * @param isUserChat - Whether the message came from a 1:1 chat.
15
+ * @param isSelf - Whether the sender is the authenticated user.
16
+ */
17
+ function logMissingDecryptKeys(ctx, msg, chatMid, senderKeyId, receiverKeyId, isUserChat, isSelf) {
18
+ ctx.logGroupKeyEvent('e2ee.message.decrypt_missing_keys', {
19
+ chat: chatMid,
20
+ message_id: msg.id || null,
21
+ sender: msg.from || null,
22
+ sender_key_id: senderKeyId,
23
+ receiver_key_id: receiverKeyId,
24
+ to_type: msg.toType ?? null,
25
+ is_user_chat: isUserChat,
26
+ is_self: isSelf,
27
+ });
28
+ }
29
+ /**
30
+ * Check whether the resolved decrypt material is sufficient to proceed.
31
+ *
32
+ * @param resolved - Resolved decrypt keys.
33
+ * @returns True when a direct sender key or fallback candidates exist.
34
+ */
35
+ function hasDecryptMaterial(resolved) {
36
+ return (Boolean(resolved?.publicKey) ||
37
+ (resolved?.candidatePublicKeys?.length ?? 0) > 0);
38
+ }
39
+ /**
40
+ * Parse E2EE chunk data from a raw message object.
41
+ *
42
+ * @param ctx - KeyManager context
43
+ * @param msg - Raw message object
44
+ * @returns Parsed E2EE chunk data, or null if the message is not E2EE
45
+ */
46
+ export function parseE2EEChunks(ctx, msg) {
47
+ const chunks = msg.chunks;
48
+ if (!chunks || !Array.isArray(chunks) || chunks.length < 5) {
49
+ return null;
50
+ }
51
+ const toType = Number(msg.toType);
52
+ const isUserChat = toType === 0;
53
+ return {
54
+ salt: Buffer.from(chunks[0]),
55
+ ciphertext: Buffer.from(chunks[1]),
56
+ sign: Buffer.from(chunks[2]),
57
+ version: String(msg.contentMetadata?.e2eeVersion || '2'),
58
+ senderKeyId: toChunkKeyId(chunks[3]),
59
+ receiverKeyId: toChunkKeyId(chunks[4]),
60
+ toType,
61
+ isUserChat,
62
+ chatMid: !isUserChat && typeof msg.to === 'string' ? msg.to : null,
63
+ isSelf: Boolean(msg.from && ctx.getProfileMid() === msg.from),
64
+ };
65
+ }
66
+ /**
67
+ * Read one field from an E2EEMessageInfo response across decoded shapes.
68
+ *
69
+ * @param info - Decoded E2EEMessageInfo payload.
70
+ * @param namedKey - Object key used by typed decoders.
71
+ * @param tupleIndex - Numeric field id used by thrift tuple decoders.
72
+ * @returns Field value when available.
73
+ */
74
+ function readMessageInfoField(info, namedKey, tupleIndex) {
75
+ return (info?.[namedKey] ?? info?.[tupleIndex] ?? info?.fields?.[tupleIndex] ?? null);
76
+ }
77
+ /**
78
+ * Check whether a user-chat E2EE payload needs message-info enrichment.
79
+ *
80
+ * @param msg - Raw LINE message.
81
+ * @param chunks - Parsed chunk metadata.
82
+ * @returns Whether the envelope is missing the peer key id.
83
+ */
84
+ function shouldFetchMessageInfoEnvelope(msg, chunks) {
85
+ if (!chunks.isUserChat ||
86
+ chunks.isSelf ||
87
+ chunks.senderKeyId ||
88
+ !chunks.receiverKeyId) {
89
+ return false;
90
+ }
91
+ return Boolean(msg?.id && msg?.from);
92
+ }
93
+ /**
94
+ * Attach message-info enrichment diagnostics to a message clone.
95
+ *
96
+ * @param msg - Raw LINE message.
97
+ * @param envelopeInfo - Diagnostic payload.
98
+ * @returns Message carrying the enrichment diagnostics.
99
+ */
100
+ function withEnvelopeInfo(msg, envelopeInfo) {
101
+ return {
102
+ ...msg,
103
+ e2eeEnvelopeInfo: envelopeInfo,
104
+ };
105
+ }
106
+ /**
107
+ * Fetch a complete E2EE message envelope when history payload omitted key ids.
108
+ *
109
+ * @param ctx - KeyManager context.
110
+ * @param msg - Raw LINE message.
111
+ * @param chunks - Parsed chunk metadata.
112
+ * @returns Message with enriched envelope when LINE provides it.
113
+ */
114
+ async function enrichMissingUserEnvelope(ctx, msg, chunks) {
115
+ if (!shouldFetchMessageInfoEnvelope(msg, chunks)) {
116
+ return { chunks, msg };
117
+ }
118
+ const getMessageInfo = ctx.getClient()?.getE2EEMessageInfo;
119
+ if (typeof getMessageInfo !== 'function') {
120
+ return {
121
+ chunks,
122
+ msg: withEnvelopeInfo(msg, {
123
+ attempted: false,
124
+ reason: 'client_method_unavailable',
125
+ }),
126
+ };
127
+ }
128
+ let info = null;
129
+ try {
130
+ info = await getMessageInfo(msg.from, msg.id, Number(chunks.receiverKeyId));
131
+ }
132
+ catch (error) {
133
+ return {
134
+ chunks,
135
+ msg: withEnvelopeInfo(msg, {
136
+ attempted: true,
137
+ error: error instanceof Error ? error.message : String(error),
138
+ reason: 'message_info_fetch_failed',
139
+ }),
140
+ };
141
+ }
142
+ const infoChunks = readMessageInfoField(info, 'chunks', 3);
143
+ if (!Array.isArray(infoChunks) || infoChunks.length < 5) {
144
+ return {
145
+ chunks,
146
+ msg: withEnvelopeInfo(msg, {
147
+ attempted: true,
148
+ reason: 'message_info_missing_chunks',
149
+ responseType: info == null ? 'null' : typeof info,
150
+ }),
151
+ };
152
+ }
153
+ const enrichedMsg = {
154
+ ...msg,
155
+ chunks: infoChunks,
156
+ contentMetadata: readMessageInfoField(info, 'contentMetadata', 2) || msg.contentMetadata,
157
+ contentType: readMessageInfoField(info, 'contentType', 1) ?? msg.contentType,
158
+ e2eeEnvelopeInfo: {
159
+ attempted: true,
160
+ chunkCount: infoChunks.length,
161
+ reason: 'message_info_enriched',
162
+ },
163
+ };
164
+ const enrichedChunks = parseE2EEChunks(ctx, enrichedMsg);
165
+ return enrichedChunks
166
+ ? { chunks: enrichedChunks, msg: enrichedMsg }
167
+ : { chunks, msg };
168
+ }
169
+ /**
170
+ * Resolve the self key that matches the E2EE envelope.
171
+ *
172
+ * @param ctx - KeyManager context.
173
+ * @param selfMid - Current LINE profile MID.
174
+ * @param chunks - Parsed E2EE chunk data.
175
+ * @returns Matching imported self key.
176
+ */
177
+ function resolveSelfKey(ctx, selfMid, chunks) {
178
+ const envelopeSelfKeyId = chunks.isSelf
179
+ ? chunks.senderKeyId
180
+ : chunks.receiverKeyId;
181
+ if (envelopeSelfKeyId) {
182
+ return ctx.getSelfKeyById(envelopeSelfKeyId) || ctx.getSelfKeyByMid(selfMid);
183
+ }
184
+ return ctx.getSelfKeyByMid(selfMid);
185
+ }
186
+ /**
187
+ * Resolve group sender public key for a non-self group message.
188
+ *
189
+ * @param ctx - KeyManager context
190
+ * @param msg - Raw message object
191
+ * @param chatMid - Group chat MID
192
+ * @param senderKeyId - Sender key ID
193
+ * @returns Public key or candidate list, or null when missing
194
+ */
195
+ async function resolveGroupSenderKey(ctx, msg, chatMid, senderKeyId) {
196
+ if (!msg.from) {
197
+ ctx.logGroupKeyEvent('e2ee.message.missing_sender_key_id', {
198
+ chat: chatMid,
199
+ sender: null,
200
+ sender_key_id: senderKeyId,
201
+ receiver_key_id: null,
202
+ });
203
+ return null;
204
+ }
205
+ if (senderKeyId) {
206
+ return {
207
+ publicKey: await getPeerPublicKey(ctx, msg.from, senderKeyId),
208
+ candidatePublicKeys: null,
209
+ };
210
+ }
211
+ const candidatePublicKeys = chatMid
212
+ ? await getGroupSenderPublicKeyCandidates(ctx, chatMid, msg.from)
213
+ : [];
214
+ return { candidatePublicKeys, publicKey: undefined };
215
+ }
216
+ /**
217
+ * Resolve keys for decryption based on chat type and message metadata.
218
+ *
219
+ * @param ctx - KeyManager context
220
+ * @param msg - Raw message object
221
+ * @param selfKey - Authenticated user's self key
222
+ * @param isUserChat - True for 1:1 chats
223
+ * @param isSelf - True when the message sender is the authenticated user
224
+ * @param chatMid - Group chat MID (null for 1:1)
225
+ * @param senderKeyId - Sender key ID from message chunks
226
+ * @param receiverKeyId - Receiver key ID from message chunks
227
+ * @returns Resolved keys or null when decryption cannot proceed
228
+ */
229
+ async function resolveDecryptKeys(ctx, msg, selfKey, isUserChat, isSelf, chatMid, senderKeyId, receiverKeyId) {
230
+ if (isUserChat) {
231
+ const peerMid = isSelf ? msg.to : msg.from;
232
+ const peerKeyId = isSelf ? receiverKeyId : senderKeyId;
233
+ if (!peerMid || !peerKeyId) {
234
+ return null;
235
+ }
236
+ return {
237
+ privateKey: selfKey.privateKey,
238
+ publicKey: await getPeerPublicKey(ctx, peerMid, peerKeyId),
239
+ candidatePublicKeys: null,
240
+ resolvedReceiverKeyId: receiverKeyId,
241
+ };
242
+ }
243
+ const groupKey = chatMid
244
+ ? await getGroupKey(ctx, chatMid, receiverKeyId, msg.from, senderKeyId)
245
+ : undefined;
246
+ if (!groupKey) {
247
+ return {
248
+ privateKey: selfKey.privateKey,
249
+ publicKey: undefined,
250
+ candidatePublicKeys: null,
251
+ resolvedReceiverKeyId: receiverKeyId,
252
+ };
253
+ }
254
+ ctx.logGroupKeyEvent('e2ee.message.decrypt_context', {
255
+ chat: chatMid,
256
+ message_id: msg.id || null,
257
+ sender: msg.from || null,
258
+ sender_key_id: senderKeyId,
259
+ message_group_key_id: receiverKeyId,
260
+ resolved_group_key_id: groupKey.keyId,
261
+ is_self: isSelf,
262
+ });
263
+ if (isSelf) {
264
+ return {
265
+ privateKey: groupKey.privateKey,
266
+ publicKey: Buffer.from(selfKey.publicKey),
267
+ candidatePublicKeys: null,
268
+ resolvedReceiverKeyId: groupKey.keyId,
269
+ };
270
+ }
271
+ const senderKeys = await resolveGroupSenderKey(ctx, msg, chatMid, senderKeyId);
272
+ if (!senderKeys) {
273
+ return null;
274
+ }
275
+ return {
276
+ privateKey: groupKey.privateKey,
277
+ publicKey: senderKeys.publicKey,
278
+ candidatePublicKeys: senderKeys.candidatePublicKeys ?? null,
279
+ resolvedReceiverKeyId: groupKey.keyId,
280
+ };
281
+ }
282
+ /**
283
+ * Decrypt the ciphertext using the resolved keys and return plaintext.
284
+ *
285
+ * @param msg - Raw message object
286
+ * @param chunks - Parsed E2EE chunk data
287
+ * @param privateKey - Decryption private key
288
+ * @param publicKey - Peer public key
289
+ * @param candidatePublicKeys - Candidate public keys for group sender fallback
290
+ * @param resolvedReceiverKeyId - Effective receiver/group key id used for AAD
291
+ * @returns Decrypted plaintext string
292
+ */
293
+ export function performDecrypt(msg, chunks, privateKey, publicKey, candidatePublicKeys, resolvedReceiverKeyId) {
294
+ const { salt, ciphertext, sign, version, senderKeyId, receiverKeyId } = chunks;
295
+ // `version` is envelope metadata (contentMetadata.e2eeVersion), i.e. supplied
296
+ // by whoever hands us the message. It selects the cipher, so it must be an
297
+ // exact allow-list: this used to read `version !== '2'`, which sent '3', '',
298
+ // and any junk value down the V1 path — silently choosing the ONE cipher here
299
+ // that authenticates nothing. Unknown versions now fail instead of
300
+ // downgrading.
301
+ if (version === '1') {
302
+ return extractTextPayload(decryptV1(computeSharedSecret(privateKey, publicKey), salt, ciphertext));
303
+ }
304
+ if (version !== '2') {
305
+ throw new Error(`Unsupported E2EE version ${JSON.stringify(version)} — refusing to guess a cipher`);
306
+ }
307
+ const effectiveReceiverKeyId = Number(resolvedReceiverKeyId || receiverKeyId || 0);
308
+ const buildAAD = (effectiveSenderKeyId) => generateAAD(String(msg.to || ''), String(msg.from || ''), Number(effectiveSenderKeyId || senderKeyId || 0), effectiveReceiverKeyId, Number(version || 2), Number(msg.contentType || 0));
309
+ if (candidatePublicKeys && candidatePublicKeys.length > 0) {
310
+ return extractTextPayload(decryptV2WithCandidates(privateKey, candidatePublicKeys, salt, ciphertext, sign, (candidate) => buildAAD(candidate.keyId)));
311
+ }
312
+ return extractTextPayload(decryptV2(computeSharedSecret(privateKey, publicKey), salt, ciphertext, sign, buildAAD(senderKeyId)));
313
+ }
314
+ /**
315
+ * Build a stable failed decrypt result when the message still lacks keys.
316
+ *
317
+ * @param ctx - KeyManager context.
318
+ * @param msg - Raw LINE message.
319
+ * @param chunks - Parsed E2EE chunks.
320
+ * @returns Stable failed decrypt result.
321
+ */
322
+ function buildMissingDecryptResult(ctx, msg, chunks) {
323
+ const { isUserChat, isSelf, chatMid, senderKeyId, receiverKeyId, version } = chunks;
324
+ console.warn(`[E2EE] No stored key: version=${version}, senderKeyId=${senderKeyId}, receiverKeyId=${receiverKeyId}, toType=${String(msg.toType)}`);
325
+ logMissingDecryptKeys(ctx, msg, chatMid, senderKeyId, receiverKeyId, isUserChat, isSelf);
326
+ return {
327
+ decrypted: false,
328
+ envelopeInfo: msg.e2eeEnvelopeInfo || null,
329
+ isSelf,
330
+ isUserChat,
331
+ reason: 'missing_decrypt_material',
332
+ receiverKeyId,
333
+ senderKeyId,
334
+ toType: chunks.toType,
335
+ };
336
+ }
337
+ /**
338
+ * Execute the E2EE decryption pipeline — parse chunks, resolve keys, decrypt.
339
+ * Separated from tryDecrypt to keep the outer try-catch minimal.
340
+ *
341
+ * @param ctx - KeyManager context
342
+ * @param msg - Parsed message object
343
+ * @returns Decryption result with optional plaintext
344
+ */
345
+ export async function tryDecryptInner(ctx, msg) {
346
+ const parsedChunks = parseE2EEChunks(ctx, msg);
347
+ if (!parsedChunks) {
348
+ return { decrypted: false, reason: 'missing_chunks' };
349
+ }
350
+ const prepared = await enrichMissingUserEnvelope(ctx, msg, parsedChunks);
351
+ const { chunks } = prepared;
352
+ const decryptMsg = prepared.msg;
353
+ const selfMid = ctx.getProfileMid();
354
+ if (!selfMid) {
355
+ return {
356
+ decrypted: false,
357
+ isSelf: chunks.isSelf,
358
+ isUserChat: chunks.isUserChat,
359
+ reason: 'missing_profile_mid',
360
+ receiverKeyId: chunks.receiverKeyId,
361
+ senderKeyId: chunks.senderKeyId,
362
+ toType: chunks.toType,
363
+ };
364
+ }
365
+ const selfKey = resolveSelfKey(ctx, selfMid, chunks);
366
+ if (!selfKey) {
367
+ ctx.raiseWarning('missing_self_key', { profileMid: selfMid });
368
+ return {
369
+ decrypted: false,
370
+ isSelf: chunks.isSelf,
371
+ isUserChat: chunks.isUserChat,
372
+ reason: 'missing_self_key',
373
+ receiverKeyId: chunks.receiverKeyId,
374
+ senderKeyId: chunks.senderKeyId,
375
+ toType: chunks.toType,
376
+ };
377
+ }
378
+ const { isUserChat, isSelf, chatMid, senderKeyId, receiverKeyId } = chunks;
379
+ const resolved = await resolveDecryptKeys(ctx, decryptMsg, selfKey, isUserChat, isSelf, chatMid, senderKeyId, receiverKeyId);
380
+ if (!hasDecryptMaterial(resolved)) {
381
+ return buildMissingDecryptResult(ctx, decryptMsg, chunks);
382
+ }
383
+ return {
384
+ decrypted: true,
385
+ // Only V2 (AES-256-GCM) authenticates. V1 plaintext is decrypted but
386
+ // unverified — see DecryptResult.integrityVerified.
387
+ integrityVerified: chunks.version === '2',
388
+ text: performDecrypt(decryptMsg, chunks, resolved.privateKey, resolved.publicKey, resolved?.candidatePublicKeys ?? null, resolved?.resolvedReceiverKeyId ?? receiverKeyId),
389
+ };
390
+ }