@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,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,6 @@
1
+ /**
2
+ * LINE E2EE - exports
3
+ */
4
+ export { decryptKeyChain } from './bootstrap/keychain.js';
5
+ export { aesDecrypt, generateKeyPair, sha256, xorHalves, } from './crypto/crypto-primitives.js';
6
+ export { KeyManager as E2EEKeyManager } from './keys/key-manager.js';
@@ -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
+ }