@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,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
|
+
}
|