@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Desktop client configuration.
|
|
3
|
+
* Shared across LINE protocol clients.
|
|
4
|
+
*
|
|
5
|
+
* Protocol version derived from installed LINE.app via Info.plist.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
const LINE_INFO_PLIST_PATH = '/Applications/LINE.app/Contents/Info.plist';
|
|
11
|
+
/**
|
|
12
|
+
* Read a plist key from LINE.app, with fallback.
|
|
13
|
+
*
|
|
14
|
+
* @param key - The plist key to read
|
|
15
|
+
* @param fallback - Default value if read fails
|
|
16
|
+
* @returns The plist value or fallback
|
|
17
|
+
*/
|
|
18
|
+
function plist(key, fallback) {
|
|
19
|
+
if (!fs.existsSync(LINE_INFO_PLIST_PATH)) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return execSync(`defaults read ${LINE_INFO_PLIST_PATH} ${key} 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* macOS product version (e.g. "15.3"), NOT Darwin kernel version.
|
|
31
|
+
*
|
|
32
|
+
* @returns The macOS version string
|
|
33
|
+
*/
|
|
34
|
+
function macosVersion() {
|
|
35
|
+
try {
|
|
36
|
+
return execSync('sw_vers -productVersion', {
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
timeout: 3000,
|
|
39
|
+
}).trim();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return '15.0';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const LINE_SHORT = plist('CFBundleShortVersionString', '26.0.2');
|
|
46
|
+
const LINE_BUILD = plist('CFBundleVersion', '3828');
|
|
47
|
+
const LINE_VERSION = `${LINE_SHORT}.${LINE_BUILD}`;
|
|
48
|
+
const MACOS_VER = macosVersion();
|
|
49
|
+
export const LINE_APP_CONFIG = {
|
|
50
|
+
host: 'ga2.line.naver.jp',
|
|
51
|
+
// Service paths
|
|
52
|
+
talkPath: '/S4',
|
|
53
|
+
syncPath: '/SYNC4',
|
|
54
|
+
authPath: '/AS4',
|
|
55
|
+
revokePath: '/RS4',
|
|
56
|
+
tokenRefreshPath: '/EXT/auth/tokenrefresh/v1',
|
|
57
|
+
qrPath: '/acct/lgn/sq/v1',
|
|
58
|
+
qrLongPollPath: '/acct/lp/lgn/sq/v1',
|
|
59
|
+
pwlessPath: '/acct/lgn/secpwless/v1',
|
|
60
|
+
pwlessLongPollPath: '/acct/lp/lgn/secpwless/v1',
|
|
61
|
+
// Client identification — matches real LINE desktop format
|
|
62
|
+
lineApp: `DESKTOPMAC\t${LINE_VERSION}\tMAC\t${MACOS_VER}`,
|
|
63
|
+
userAgent: `Line/${LINE_VERSION}`,
|
|
64
|
+
// Device info shown on phone during secondary-device approval.
|
|
65
|
+
systemName: os.hostname() || 'yomi',
|
|
66
|
+
modelName: 'yomi',
|
|
67
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE protocol constants — operation types and content types.
|
|
3
|
+
*/
|
|
4
|
+
export const OP_TYPE = {
|
|
5
|
+
END_OF_OPERATION: 0,
|
|
6
|
+
UPDATE_PROFILE: 1,
|
|
7
|
+
NOTIFIED_UPDATE_PROFILE: 2,
|
|
8
|
+
RECEIVE_MESSAGE: 26,
|
|
9
|
+
SEND_MESSAGE: 25,
|
|
10
|
+
RECEIVE_MESSAGE_RECEIPT: 28,
|
|
11
|
+
SEND_MESSAGE_RECEIPT: 29,
|
|
12
|
+
NOTIFIED_READ_MESSAGE: 55,
|
|
13
|
+
NOTIFIED_UNREAD_COUNT: 56,
|
|
14
|
+
NOTIFIED_DESTROY_MESSAGE: 61,
|
|
15
|
+
NOTIFIED_TYPING: 90,
|
|
16
|
+
ADD_CONTACT: 15,
|
|
17
|
+
BLOCK_CONTACT: 16,
|
|
18
|
+
UNBLOCK_CONTACT: 17,
|
|
19
|
+
UPDATE_GROUP: 9,
|
|
20
|
+
NOTIFIED_UPDATE_GROUP: 10,
|
|
21
|
+
INVITE_INTO_GROUP: 11,
|
|
22
|
+
ACCEPT_GROUP_INVITATION: 13,
|
|
23
|
+
LEAVE_GROUP: 31,
|
|
24
|
+
NOTIFIED_INVITE_INTO_GROUP: 32,
|
|
25
|
+
NOTIFIED_ACCEPT_GROUP_INVITATION: 34,
|
|
26
|
+
NOTIFIED_LEAVE_GROUP: 35,
|
|
27
|
+
KICKOUT_FROM_GROUP: 36,
|
|
28
|
+
};
|
|
29
|
+
// Values mirror LINE's Thrift `ContentType` enum exactly. Do not renumber to
|
|
30
|
+
// match intuition: a wrong value silently misroutes a message type (e.g. FILE
|
|
31
|
+
// was 19 = MUSIC, so file attachments were never recognized as downloadable).
|
|
32
|
+
export const CONTENT_TYPE = {
|
|
33
|
+
NONE: 0,
|
|
34
|
+
IMAGE: 1,
|
|
35
|
+
VIDEO: 2,
|
|
36
|
+
AUDIO: 3,
|
|
37
|
+
HTML: 4,
|
|
38
|
+
PDF: 5,
|
|
39
|
+
CALL: 6,
|
|
40
|
+
STICKER: 7,
|
|
41
|
+
PRESENCE: 8,
|
|
42
|
+
GIFT: 9,
|
|
43
|
+
GROUPBOARD: 10,
|
|
44
|
+
APPLINK: 11,
|
|
45
|
+
LINK: 12,
|
|
46
|
+
CONTACT: 13,
|
|
47
|
+
FILE: 14,
|
|
48
|
+
LOCATION: 15,
|
|
49
|
+
POSTNOTIFICATION: 16,
|
|
50
|
+
RICH: 17,
|
|
51
|
+
CHATEVENT: 18,
|
|
52
|
+
MUSIC: 19,
|
|
53
|
+
PAYMENT: 20,
|
|
54
|
+
EXTIMAGE: 21,
|
|
55
|
+
FLEX: 22,
|
|
56
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { requireLineClient } from './client-runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build the LINE contact query boundary for a connected runtime.
|
|
4
|
+
*
|
|
5
|
+
* @param getClient - Deferred LINE client accessor
|
|
6
|
+
* @returns Contact query methods
|
|
7
|
+
*/
|
|
8
|
+
export function createContactQueryService(getClient) {
|
|
9
|
+
return {
|
|
10
|
+
/**
|
|
11
|
+
* Fetch a LINE contact profile.
|
|
12
|
+
*
|
|
13
|
+
* @param mid - LINE contact MID
|
|
14
|
+
* @returns LINE contact profile
|
|
15
|
+
*/
|
|
16
|
+
async getContact(mid) {
|
|
17
|
+
return requireLineClient(getClient).getContact(mid);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE core directory/runtime capability.
|
|
3
|
+
*/
|
|
4
|
+
import { fetchStickerPackageMeta } from '../client/sticker-meta.js';
|
|
5
|
+
import { createContactQueryService } from './contact-query-service.js';
|
|
6
|
+
import { createGroupQueryService } from './group-query-service.js';
|
|
7
|
+
/**
|
|
8
|
+
* Create the directory runtime capability bound to one LINE protocol service.
|
|
9
|
+
*
|
|
10
|
+
* @param service - Mutable LINE protocol service runtime.
|
|
11
|
+
* @returns Directory and lightweight runtime methods.
|
|
12
|
+
*/
|
|
13
|
+
export function createDirectoryRuntimeService(service) {
|
|
14
|
+
return {
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a display name for one MID from the in-memory cache.
|
|
17
|
+
*
|
|
18
|
+
* @param mid - The user or group MID.
|
|
19
|
+
* @returns Cached name or the MID itself.
|
|
20
|
+
*/
|
|
21
|
+
resolveName(mid) {
|
|
22
|
+
return service.nameCache.get(mid) || mid;
|
|
23
|
+
},
|
|
24
|
+
/**
|
|
25
|
+
* Fetch a LINE contact profile through the explicit contact-query boundary.
|
|
26
|
+
*
|
|
27
|
+
* @param mid - LINE contact MID.
|
|
28
|
+
* @returns LINE contact profile.
|
|
29
|
+
*/
|
|
30
|
+
async getContact(mid) {
|
|
31
|
+
return createContactQueryService(() => service.client).getContact(mid);
|
|
32
|
+
},
|
|
33
|
+
/**
|
|
34
|
+
* Fetch a LINE group profile through the explicit group-query boundary.
|
|
35
|
+
*
|
|
36
|
+
* @param groupId - LINE group MID.
|
|
37
|
+
* @returns LINE group profile.
|
|
38
|
+
*/
|
|
39
|
+
async getGroup(groupId) {
|
|
40
|
+
return createGroupQueryService(() => service.client).getGroup(groupId);
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* List the sticker packages the account owns (and can therefore send).
|
|
44
|
+
*
|
|
45
|
+
* @param language - Locale for package titles (default 'en').
|
|
46
|
+
* @returns Owned sticker packages: { packageId, title, version }.
|
|
47
|
+
*/
|
|
48
|
+
async listStickerPackages(language = 'en') {
|
|
49
|
+
return service.client.getOwnedStickerPackages(language);
|
|
50
|
+
},
|
|
51
|
+
/**
|
|
52
|
+
* Search the account's OWNED sticker packages by title substring, and
|
|
53
|
+
* expand each match into its sendable individual sticker ids (via the
|
|
54
|
+
* public sticker CDN) so send_sticker can be called directly.
|
|
55
|
+
*
|
|
56
|
+
* @param query - Case-insensitive substring to match against package titles.
|
|
57
|
+
* @param language - Locale for package titles (default 'en').
|
|
58
|
+
* @param limit - Max matching packages to expand (default 8).
|
|
59
|
+
* @returns Matching packages with their sticker ids.
|
|
60
|
+
*/
|
|
61
|
+
async searchStickerPackages(query, language = 'en', limit = 8) {
|
|
62
|
+
const owned = await service.client.getOwnedStickerPackages(language);
|
|
63
|
+
const needle = (query ?? '').trim().toLowerCase();
|
|
64
|
+
const matches = needle
|
|
65
|
+
? owned.filter((p) => String(p.title ?? '')
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.includes(needle))
|
|
68
|
+
: owned;
|
|
69
|
+
const top = matches.slice(0, limit);
|
|
70
|
+
const packages = await Promise.all(top.map(async (p) => {
|
|
71
|
+
const meta = await fetchStickerPackageMeta(p.packageId).catch(() => null);
|
|
72
|
+
return {
|
|
73
|
+
packageId: p.packageId,
|
|
74
|
+
version: p.version,
|
|
75
|
+
title: p.title,
|
|
76
|
+
titles: meta?.title ?? {},
|
|
77
|
+
stickerIds: meta?.stickerIds ?? [],
|
|
78
|
+
};
|
|
79
|
+
}));
|
|
80
|
+
return { total: matches.length, packages };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE E2EE login bootstrap keychain decryption.
|
|
3
|
+
*/
|
|
4
|
+
import { Buffer } from 'node:buffer';
|
|
5
|
+
import * as crypto from 'node:crypto';
|
|
6
|
+
import { readStructFields } from '../../thrift/index.js';
|
|
7
|
+
import { computeSharedSecret, deriveKeyAndIV, } from '../crypto/crypto-primitives.js';
|
|
8
|
+
/**
|
|
9
|
+
* Decrypt the E2EE key chain from login response.
|
|
10
|
+
*
|
|
11
|
+
* @param encryptedKeyChain - From login metaData.encryptedKeyChain (base64 decoded)
|
|
12
|
+
* @param serverPublicKey - From login metaData.publicKey (base64 decoded, 32 bytes)
|
|
13
|
+
* @param ourPrivateKey - Our NaCl 32-byte private key (secretKey from login)
|
|
14
|
+
* @returns Parsed E2EE keys
|
|
15
|
+
*/
|
|
16
|
+
export function decryptKeyChain(encryptedKeyChain, serverPublicKey, ourPrivateKey) {
|
|
17
|
+
const shared = computeSharedSecret(ourPrivateKey, serverPublicKey);
|
|
18
|
+
const { key, iv } = deriveKeyAndIV(shared);
|
|
19
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
20
|
+
decipher.setAutoPadding(false);
|
|
21
|
+
const decrypted = Buffer.concat([
|
|
22
|
+
decipher.update(encryptedKeyChain),
|
|
23
|
+
decipher.final(),
|
|
24
|
+
]);
|
|
25
|
+
const parsed = readStructFields(decrypted);
|
|
26
|
+
const keyList = parsed[1];
|
|
27
|
+
if (!Array.isArray(keyList)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return keyList.map((keyStruct) => ({
|
|
31
|
+
version: keyStruct[1],
|
|
32
|
+
keyId: keyStruct[2],
|
|
33
|
+
publicKey: Buffer.isBuffer(keyStruct[4])
|
|
34
|
+
? keyStruct[4]
|
|
35
|
+
: Buffer.from(keyStruct[4]),
|
|
36
|
+
privateKey: Buffer.isBuffer(keyStruct[5])
|
|
37
|
+
? keyStruct[5]
|
|
38
|
+
: Buffer.from(keyStruct[5]),
|
|
39
|
+
createdTime: keyStruct[6] ? Number(keyStruct[6]) : null,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto Primitives for E2EE
|
|
3
|
+
*/
|
|
4
|
+
import { Buffer } from 'node:buffer';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { sharedKey as curve25519SharedKey } from 'curve25519-js';
|
|
7
|
+
/**
|
|
8
|
+
* Compute SHA-256 hash of input data
|
|
9
|
+
* @param args - Data to hash (strings or Uint8Array)
|
|
10
|
+
* @returns Hash digest as Buffer
|
|
11
|
+
*/
|
|
12
|
+
export function sha256(...args) {
|
|
13
|
+
const h = crypto.createHash('sha256');
|
|
14
|
+
for (const a of args) {
|
|
15
|
+
const buf = Buffer.from(a);
|
|
16
|
+
h.update(buf);
|
|
17
|
+
}
|
|
18
|
+
return h.digest();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* XOR the two halves of a buffer together
|
|
22
|
+
* @param buf - Buffer to XOR
|
|
23
|
+
* @returns Resulting buffer with half the length
|
|
24
|
+
*/
|
|
25
|
+
export function xorHalves(buf) {
|
|
26
|
+
const half = Math.floor(buf.length / 2);
|
|
27
|
+
const out = Buffer.alloc(half);
|
|
28
|
+
for (let i = 0; i < half; i++) {
|
|
29
|
+
out[i] = buf[i] ^ buf[half + i];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Decrypt data using AES-256-CBC
|
|
35
|
+
* @param data - Encrypted data
|
|
36
|
+
* @param key - Decryption key
|
|
37
|
+
* @param iv - Initialization vector
|
|
38
|
+
* @returns Decrypted data
|
|
39
|
+
*/
|
|
40
|
+
export function aesDecrypt(data, key, iv) {
|
|
41
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
42
|
+
return Buffer.concat([decipher.update(data), decipher.final()]);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Generate X25519 key pair for E2EE
|
|
46
|
+
* @returns Key pair object
|
|
47
|
+
*/
|
|
48
|
+
export function generateKeyPair() {
|
|
49
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
50
|
+
const pub = publicKey.export({ type: 'spki', format: 'der' }).slice(-32);
|
|
51
|
+
const priv = privateKey.export({ type: 'pkcs8', format: 'der' }).slice(-32);
|
|
52
|
+
return {
|
|
53
|
+
publicKey: pub,
|
|
54
|
+
privateKey: priv,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Compute X25519 shared secret from raw 32-byte keys.
|
|
59
|
+
*
|
|
60
|
+
* @param privateKey - 32-byte private key
|
|
61
|
+
* @param publicKey - 32-byte public key
|
|
62
|
+
* @returns 32-byte shared secret
|
|
63
|
+
*/
|
|
64
|
+
export function computeSharedSecret(privateKey, publicKey) {
|
|
65
|
+
return Buffer.from(curve25519SharedKey(Uint8Array.from(privateKey), Uint8Array.from(publicKey)));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Derive AES-256 key and 16-byte IV from shared secret.
|
|
69
|
+
* @param sharedSecret - 32-byte ECDH shared secret
|
|
70
|
+
* @returns Derived key and IV
|
|
71
|
+
*/
|
|
72
|
+
export function deriveKeyAndIV(sharedSecret) {
|
|
73
|
+
return {
|
|
74
|
+
key: sha256(sharedSecret, 'Key'),
|
|
75
|
+
iv: xorHalves(sha256(sharedSecret, 'IV')),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Derive message-specific key and IV (for V1 messages with salt).
|
|
80
|
+
* @param sharedSecret - 32-byte ECDH shared secret
|
|
81
|
+
* @param salt - Message salt
|
|
82
|
+
* @returns Object containing derived key and IV
|
|
83
|
+
*/
|
|
84
|
+
export function deriveMessageKeyAndIV(sharedSecret, salt) {
|
|
85
|
+
return {
|
|
86
|
+
key: sha256(sharedSecret, salt, 'Key'),
|
|
87
|
+
iv: xorHalves(sha256(sharedSecret, salt, 'IV')),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Derive GCM key for V2 messages.
|
|
92
|
+
* @param sharedSecret - 32-byte ECDH shared secret
|
|
93
|
+
* @param salt - Message salt
|
|
94
|
+
* @returns Derived 32-byte key
|
|
95
|
+
*/
|
|
96
|
+
export function deriveGCMKey(sharedSecret, salt) {
|
|
97
|
+
return sha256(sharedSecret, salt, 'Key');
|
|
98
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { tryDecryptInner } from './message-decrypt.js';
|
|
2
|
+
const PEER_KEY_MISMATCH_PATTERN = /Peer E2EE key mismatch/i;
|
|
3
|
+
const MISSING_GROUP_SENDER_KEY_PATTERN = /Missing group sender key/i;
|
|
4
|
+
const GCM_AUTH_FAILURE_PATTERN = /unable to authenticate data|Unsupported state or unable to authenticate/i;
|
|
5
|
+
export { decryptV1, decryptV2, decryptV2WithCandidates, extractTextPayload, generateAAD, getIntBytes, toChunkKeyId, } from './message-crypto.js';
|
|
6
|
+
/**
|
|
7
|
+
* Build one structured decrypt-failure log payload from a raw LINE message.
|
|
8
|
+
*
|
|
9
|
+
* @param ctx - KeyManager context.
|
|
10
|
+
* @param msg - Raw LINE message object.
|
|
11
|
+
* @param error - Thrown decryption error.
|
|
12
|
+
* @returns Structured log context.
|
|
13
|
+
*/
|
|
14
|
+
function buildDecryptFailureContext(ctx, msg, error) {
|
|
15
|
+
const chatMid = resolveDecryptFailureChatMid(msg);
|
|
16
|
+
return {
|
|
17
|
+
chat: chatMid,
|
|
18
|
+
message_id: msg?.id ?? null,
|
|
19
|
+
sender: msg?.from ?? null,
|
|
20
|
+
to: msg?.to ?? null,
|
|
21
|
+
sender_key_id: readDecryptFailureChunkKeyId(ctx, msg, 3),
|
|
22
|
+
receiver_key_id: readDecryptFailureChunkKeyId(ctx, msg, 4),
|
|
23
|
+
content_type: msg?.contentType ?? null,
|
|
24
|
+
e2ee_version: msg?.contentMetadata?.e2eeVersion ?? null,
|
|
25
|
+
error: error.message,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Convert one thrown decrypt error into a stable reason code.
|
|
30
|
+
*
|
|
31
|
+
* @param error - Thrown decrypt error.
|
|
32
|
+
* @returns Diagnostic reason code.
|
|
33
|
+
*/
|
|
34
|
+
function resolveDecryptFailureReason(error) {
|
|
35
|
+
if (PEER_KEY_MISMATCH_PATTERN.test(error.message)) {
|
|
36
|
+
return 'peer_key_mismatch';
|
|
37
|
+
}
|
|
38
|
+
if (MISSING_GROUP_SENDER_KEY_PATTERN.test(error.message)) {
|
|
39
|
+
return 'missing_group_sender_key';
|
|
40
|
+
}
|
|
41
|
+
if (GCM_AUTH_FAILURE_PATTERN.test(error.message)) {
|
|
42
|
+
return 'gcm_auth_failed';
|
|
43
|
+
}
|
|
44
|
+
return 'decrypt_failed';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the group chat MID for one failed decrypt attempt.
|
|
48
|
+
*
|
|
49
|
+
* @param msg - Raw LINE message object.
|
|
50
|
+
* @returns Group chat MID or null.
|
|
51
|
+
*/
|
|
52
|
+
function resolveDecryptFailureChatMid(msg) {
|
|
53
|
+
const isGroup = Number(msg?.toType) !== 0;
|
|
54
|
+
return isGroup && typeof msg?.to === 'string' ? msg.to : null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read one parsed LINE E2EE chunk key id from a failed decrypt payload.
|
|
58
|
+
*
|
|
59
|
+
* @param ctx - KeyManager context.
|
|
60
|
+
* @param msg - Raw LINE message object.
|
|
61
|
+
* @param index - Chunk index.
|
|
62
|
+
* @returns Parsed chunk key id or null.
|
|
63
|
+
*/
|
|
64
|
+
function readDecryptFailureChunkKeyId(ctx, msg, index) {
|
|
65
|
+
if (typeof ctx.readChunkKeyId !== 'function') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return ctx.readChunkKeyId(msg?.chunks?.[index]);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Handle one E2EE decrypt failure without escalating to auth/session state.
|
|
72
|
+
*
|
|
73
|
+
* @param ctx - KeyManager context.
|
|
74
|
+
* @param msg - Raw LINE message object.
|
|
75
|
+
* @param error - Thrown decryption error.
|
|
76
|
+
* @returns Stable failed decrypt result.
|
|
77
|
+
*/
|
|
78
|
+
async function handleDecryptFailure(ctx, msg, error) {
|
|
79
|
+
// Do NOT invalidate the group key on failure. Keys are cached per epoch
|
|
80
|
+
// (chat:groupKeyId) and are never the wrong secret for their id; a group
|
|
81
|
+
// decrypt failure means the message's epoch is one LINE can no longer hand
|
|
82
|
+
// us (it only returns the latest), which a refetch cannot fix. Dropping the
|
|
83
|
+
// key here would only evict epochs this device can still read and churn the
|
|
84
|
+
// credential store.
|
|
85
|
+
ctx.logGroupKeyEvent?.('e2ee.message.decrypt_failed', buildDecryptFailureContext(ctx, msg, error));
|
|
86
|
+
console.warn('[E2EE] Decryption failed:', error.message);
|
|
87
|
+
return {
|
|
88
|
+
decrypted: false,
|
|
89
|
+
error: error.message,
|
|
90
|
+
reason: resolveDecryptFailureReason(error),
|
|
91
|
+
receiverKeyId: readDecryptFailureChunkKeyId(ctx, msg, 4),
|
|
92
|
+
senderKeyId: readDecryptFailureChunkKeyId(ctx, msg, 3),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Attempt to decrypt an E2EE message using stored keys.
|
|
97
|
+
* Returns plaintext on success, or a no-op result for plaintext messages.
|
|
98
|
+
*
|
|
99
|
+
* @param ctx - KeyManager context
|
|
100
|
+
* @param msg - Parsed message object (from parsers.ts)
|
|
101
|
+
* @returns Decryption result with optional plaintext
|
|
102
|
+
*/
|
|
103
|
+
export async function tryDecrypt(ctx, msg) {
|
|
104
|
+
try {
|
|
105
|
+
return await tryDecryptInner(ctx, msg);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return handleDecryptFailure(ctx, msg, err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { computeSharedSecret, deriveGCMKey, } from '../crypto/crypto-primitives.js';
|
|
4
|
+
import { getGroupKey } from './group-key.js';
|
|
5
|
+
import { normalizeNegotiatedPublicKey } from './key-payload.js';
|
|
6
|
+
import { generateAAD, getIntBytes } from './message-crypto.js';
|
|
7
|
+
/**
|
|
8
|
+
* Infer the LINE MID type from its leading prefix.
|
|
9
|
+
* `u` = user, `c` = group, `r` = room.
|
|
10
|
+
*
|
|
11
|
+
* @param mid - LINE MID
|
|
12
|
+
* @returns Numeric MID type compatible with Talk/E2EE logic
|
|
13
|
+
*/
|
|
14
|
+
function getMidType(mid) {
|
|
15
|
+
if (typeof mid !== 'string' || mid.length === 0) {
|
|
16
|
+
return -1;
|
|
17
|
+
}
|
|
18
|
+
if (mid.startsWith('u')) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
if (mid.startsWith('c')) {
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (mid.startsWith('r')) {
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
return -1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt an outbound LINE E2EE v2 payload and append the GCM auth tag.
|
|
31
|
+
*
|
|
32
|
+
* @param data - Serialized message payload
|
|
33
|
+
* @param gcmKey - Derived AES-GCM key
|
|
34
|
+
* @param nonce - Random 12-byte GCM nonce
|
|
35
|
+
* @param aad - Additional authenticated data
|
|
36
|
+
* @returns Ciphertext buffer with trailing auth tag
|
|
37
|
+
*/
|
|
38
|
+
function encryptE2EEMessageV2(data, gcmKey, nonce, aad) {
|
|
39
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', gcmKey, nonce);
|
|
40
|
+
cipher.setAAD(aad);
|
|
41
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
42
|
+
return Buffer.concat([encrypted, cipher.getAuthTag()]);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Prepare an outbound LINE E2EE payload using the same chunk layout as linejs.
|
|
46
|
+
*
|
|
47
|
+
* The returned structure is transport-ready for TalkService sendMessage:
|
|
48
|
+
* `chunks` contains `[salt, ciphertext+tag, nonce, senderKeyIdBytes, receiverKeyIdBytes]`
|
|
49
|
+
* and `contentMetadata` includes the E2EE markers LINE expects for version 2.
|
|
50
|
+
*
|
|
51
|
+
* @param ctx - KeyManager context
|
|
52
|
+
* @param to - Target LINE MID
|
|
53
|
+
* @param data - Text or structured payload to encrypt
|
|
54
|
+
* @param contentType - LINE content type of the payload
|
|
55
|
+
* @returns Message chunks plus metadata required by LINE
|
|
56
|
+
*/
|
|
57
|
+
export async function encryptE2EEMessage(ctx, to, data, contentType = 0) {
|
|
58
|
+
const selfMid = ctx.getProfileMid();
|
|
59
|
+
if (!selfMid) {
|
|
60
|
+
throw new Error('Cannot encrypt E2EE message without an authenticated LINE profile');
|
|
61
|
+
}
|
|
62
|
+
const selfKey = ctx.getSelfKeyByMid(selfMid);
|
|
63
|
+
if (!selfKey) {
|
|
64
|
+
throw new Error(`Missing self E2EE key for mid=${selfMid}`);
|
|
65
|
+
}
|
|
66
|
+
const toType = getMidType(to);
|
|
67
|
+
if (![0, 1, 2].includes(toType)) {
|
|
68
|
+
throw new Error(`Invalid LINE target MID for E2EE: ${to}`);
|
|
69
|
+
}
|
|
70
|
+
const senderKeyId = Number(selfKey.keyId);
|
|
71
|
+
let receiverKeyId;
|
|
72
|
+
let sharedSecret;
|
|
73
|
+
if (toType === 0) {
|
|
74
|
+
const publicKey = normalizeNegotiatedPublicKey(await ctx.getClient()?.negotiateE2EEPublicKey?.(to));
|
|
75
|
+
if (!publicKey) {
|
|
76
|
+
throw new Error(`Failed to negotiate peer E2EE public key for ${to}`);
|
|
77
|
+
}
|
|
78
|
+
receiverKeyId = Number(publicKey.keyId);
|
|
79
|
+
sharedSecret = computeSharedSecret(selfKey.privateKey, publicKey.keyData);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Never mint: resolve an EXISTING group key or fail. Registering a new
|
|
83
|
+
// group key is an account-visible write that rotates the group's shared
|
|
84
|
+
// secret for every member and strands all history under the previous
|
|
85
|
+
// secret — Yomi must never do that (see getGroupKey).
|
|
86
|
+
const groupKey = await getGroupKey(ctx, to, null, selfMid, selfKey.keyId);
|
|
87
|
+
if (!groupKey) {
|
|
88
|
+
throw new Error(`No existing LINE group E2EE key for ${to}; Yomi will not register one (that would rotate the group key for every member). Let the official LINE client establish the group's E2EE key first.`);
|
|
89
|
+
}
|
|
90
|
+
receiverKeyId = Number(groupKey.keyId);
|
|
91
|
+
sharedSecret = computeSharedSecret(groupKey.privateKey, selfKey.publicKey);
|
|
92
|
+
}
|
|
93
|
+
const specVersion = 2;
|
|
94
|
+
const salt = crypto.randomBytes(16);
|
|
95
|
+
const nonce = crypto.randomBytes(12);
|
|
96
|
+
const gcmKey = deriveGCMKey(sharedSecret, salt);
|
|
97
|
+
const aad = generateAAD(to, selfMid, senderKeyId, receiverKeyId, specVersion, contentType);
|
|
98
|
+
const serialized = typeof data === 'string'
|
|
99
|
+
? Buffer.from(JSON.stringify({ text: data }))
|
|
100
|
+
: Buffer.from(JSON.stringify(contentType === 15 ? { location: data } : data));
|
|
101
|
+
const ciphertext = encryptE2EEMessageV2(serialized, gcmKey, nonce, aad);
|
|
102
|
+
return {
|
|
103
|
+
chunks: [
|
|
104
|
+
salt,
|
|
105
|
+
ciphertext,
|
|
106
|
+
nonce,
|
|
107
|
+
getIntBytes(senderKeyId),
|
|
108
|
+
getIntBytes(receiverKeyId),
|
|
109
|
+
],
|
|
110
|
+
contentType,
|
|
111
|
+
contentMetadata: {
|
|
112
|
+
e2eeVersion: '2',
|
|
113
|
+
contentType: String(contentType),
|
|
114
|
+
e2eeMark: '2',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|