@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,191 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { normalizeGroupPublicKeys, normalizeNegotiatedPublicKey, } from './key-payload.js';
3
+ /**
4
+ * Resolve and cache a peer public key by MID and key ID.
5
+ * @param ctx - KeyManager context
6
+ * @param mid - Peer LINE MID
7
+ * @param keyId - Expected peer key ID
8
+ * @returns Peer public key bytes
9
+ */
10
+ export async function getPeerPublicKey(ctx, mid, keyId) {
11
+ const cacheKey = `peer:${keyId}`;
12
+ if (ctx.peerPublicKeys.has(cacheKey)) {
13
+ ctx.logGroupKeyEvent('e2ee.peer_key.cache_hit', {
14
+ mid,
15
+ key_id: keyId,
16
+ source: 'memory',
17
+ });
18
+ return ctx.peerPublicKeys.get(cacheKey);
19
+ }
20
+ const store = ctx.getStore();
21
+ const persisted = await store?.get?.(`line_e2ee_public_by_keyid:${keyId}`);
22
+ if (persisted) {
23
+ const parsed = JSON.parse(persisted);
24
+ const data = Buffer.from(parsed.keyData, 'base64');
25
+ ctx.peerPublicKeys.set(cacheKey, data);
26
+ ctx.logGroupKeyEvent('e2ee.peer_key.cache_hit', {
27
+ mid,
28
+ key_id: keyId,
29
+ source: 'persisted',
30
+ });
31
+ return data;
32
+ }
33
+ const client = ctx.getClient();
34
+ const publicKey = normalizeNegotiatedPublicKey(await client?.negotiateE2EEPublicKey?.(mid));
35
+ const negotiatedKeyId = publicKey?.keyId;
36
+ const keyData = publicKey?.keyData;
37
+ if (!keyData || String(negotiatedKeyId) !== String(keyId)) {
38
+ ctx.logGroupKeyEvent('e2ee.peer_key.fetch_failed', {
39
+ mid,
40
+ key_id: keyId,
41
+ negotiated_key_id: negotiatedKeyId ?? null,
42
+ });
43
+ throw new Error(`Peer E2EE key mismatch for ${mid}: expected=${keyId}, actual=${String(negotiatedKeyId)}`);
44
+ }
45
+ ctx.peerPublicKeys.set(cacheKey, keyData);
46
+ ctx.logGroupKeyEvent('e2ee.peer_key.resolved', {
47
+ mid,
48
+ key_id: keyId,
49
+ source: 'server_fetch',
50
+ });
51
+ await persistPeerPublicKey(ctx, mid, String(keyId), keyData);
52
+ return keyData;
53
+ }
54
+ /**
55
+ * Persist one peer public key by key id and append it to the sender history.
56
+ *
57
+ * @param ctx - KeyManager context
58
+ * @param mid - Sender MID
59
+ * @param keyId - Public key id
60
+ * @param data - 32-byte public key
61
+ */
62
+ export async function persistPeerPublicKey(ctx, mid, keyId, data) {
63
+ const store = ctx.getStore();
64
+ const record = JSON.stringify({
65
+ mid,
66
+ keyId,
67
+ keyData: data.toString('base64'),
68
+ });
69
+ const existing = await store?.get?.(`line_e2ee_public_by_keyid:${keyId}`);
70
+ if (existing !== record) {
71
+ await store?.set?.(`line_e2ee_public_by_keyid:${keyId}`, record);
72
+ }
73
+ const historyKey = `line_e2ee_public_history_by_mid:${mid}`;
74
+ const rawHistory = await store?.get?.(historyKey);
75
+ let history = [];
76
+ if (rawHistory) {
77
+ try {
78
+ const parsed = JSON.parse(rawHistory);
79
+ if (Array.isArray(parsed)) {
80
+ history = parsed.filter(Boolean);
81
+ }
82
+ }
83
+ catch {
84
+ history = [];
85
+ }
86
+ }
87
+ const nextHistory = [
88
+ { mid, keyId, keyData: data.toString('base64') },
89
+ ...history.filter((entry) => String(entry?.keyId) !== keyId),
90
+ ].slice(0, 8);
91
+ await store?.set?.(historyKey, JSON.stringify(nextHistory));
92
+ }
93
+ /**
94
+ * Append the live group-member-map key as the first sender candidate.
95
+ *
96
+ * @param ctx - KeyManager context.
97
+ * @param chatMid - Group chat MID.
98
+ * @param senderMid - Sender MID.
99
+ * @param senderKey - Sender key from LINE group-member map.
100
+ * @param candidates - Mutable candidate collection.
101
+ */
102
+ async function appendLiveSenderCandidate(ctx, chatMid, senderMid, senderKey, candidates) {
103
+ if (!senderKey) {
104
+ return;
105
+ }
106
+ const data = senderKey.keyData;
107
+ ctx.peerPublicKeys.set(`peer:${senderKey.keyId}`, data);
108
+ await persistPeerPublicKey(ctx, senderMid, String(senderKey.keyId), data);
109
+ ctx.logGroupKeyEvent('e2ee.peer_key.resolved', {
110
+ mid: senderMid,
111
+ key_id: senderKey.keyId,
112
+ source: 'group_member_key_map',
113
+ chat: chatMid,
114
+ });
115
+ candidates.push({
116
+ keyId: String(senderKey.keyId),
117
+ keyData: data,
118
+ source: 'group_member_key_map',
119
+ });
120
+ }
121
+ /**
122
+ * Append persisted sender history keys that are not duplicates.
123
+ *
124
+ * @param ctx - KeyManager context.
125
+ * @param senderMid - Sender MID.
126
+ * @param candidates - Mutable candidate collection.
127
+ */
128
+ async function appendHistoricalSenderCandidates(ctx, senderMid, candidates) {
129
+ const store = ctx.getStore();
130
+ const rawHistory = await store?.get?.(`line_e2ee_public_history_by_mid:${senderMid}`);
131
+ const parsed = rawHistory ? JSON.parse(rawHistory) : null;
132
+ const entries = Array.isArray(parsed) ? parsed : [];
133
+ for (const entry of entries) {
134
+ const keyData = entry?.keyData;
135
+ if (typeof keyData !== 'string') {
136
+ continue;
137
+ }
138
+ const buffer = Buffer.from(keyData, 'base64');
139
+ const historyKeyId = String(entry?.keyId || '');
140
+ if (!historyKeyId) {
141
+ continue;
142
+ }
143
+ if (!candidates.some((candidate) => candidate.keyId === historyKeyId || candidate.keyData.equals(buffer))) {
144
+ candidates.push({
145
+ keyId: historyKeyId,
146
+ keyData: buffer,
147
+ source: 'history',
148
+ });
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Resolve sender public key candidates for a group message.
154
+ *
155
+ * Prefers an explicit sender key from the group member key map.
156
+ * Falls back to the persisted sender key history when that field is absent.
157
+ *
158
+ * @param ctx - KeyManager context
159
+ * @param chatMid - Group or room MID
160
+ * @param senderMid - Sender MID
161
+ * @returns Candidate sender public key buffers
162
+ */
163
+ export async function getGroupSenderPublicKeyCandidates(ctx, chatMid, senderMid) {
164
+ ctx.logGroupKeyEvent('e2ee.message.sender_key_id_fallback', {
165
+ chat: chatMid,
166
+ sender: senderMid,
167
+ strategy: 'group_member_key_map_then_history',
168
+ });
169
+ const client = ctx.getClient();
170
+ const keyMap = normalizeGroupPublicKeys(await client?.getLastE2EEPublicKeys?.(chatMid));
171
+ const senderKey = keyMap.get(senderMid);
172
+ const candidates = [];
173
+ await appendLiveSenderCandidate(ctx, chatMid, senderMid, senderKey, candidates);
174
+ try {
175
+ await appendHistoricalSenderCandidates(ctx, senderMid, candidates);
176
+ }
177
+ catch {
178
+ // Ignore malformed history and continue with currently known keys only.
179
+ }
180
+ if (candidates.length === 0) {
181
+ ctx.logGroupKeyEvent('e2ee.message.missing_sender_key_id', {
182
+ chat: chatMid,
183
+ sender: senderMid,
184
+ sender_key_id: null,
185
+ receiver_key_id: null,
186
+ strategy: 'group_member_key_map_miss',
187
+ });
188
+ throw new Error(`Missing group sender key for ${senderMid} in ${chatMid}`);
189
+ }
190
+ return candidates;
191
+ }
@@ -0,0 +1,161 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { decryptKeyChain } from '../index.js';
3
+ /**
4
+ * Attempt to rebuild persisted LINE self keys from the original login bootstrap.
5
+ *
6
+ * When `line_e2ee_keys` is missing but the login-time encrypted keychain and
7
+ * the device secret key are still present in the credential store, we can
8
+ * deterministically reconstruct the same self key material without logging in
9
+ * again. This is strictly a local recovery step: it does not refresh tokens,
10
+ * open new auth sessions, or mutate the remote LINE session.
11
+ *
12
+ * @param service - LineProtocolService-like object
13
+ * @returns Recovered key list, or null when bootstrap material is unavailable
14
+ */
15
+ export async function recoverBootstrapKeys(service) {
16
+ const bootstrapInput = await readBootstrapRecoveryInput(service);
17
+ if (!bootstrapInput) {
18
+ return null;
19
+ }
20
+ const keys = rebuildBootstrapKeys(bootstrapInput);
21
+ if (!keys) {
22
+ return null;
23
+ }
24
+ await persistRecoveredKeys(service, keys, bootstrapInput.mid);
25
+ return keys;
26
+ }
27
+ /**
28
+ * Load and validate the persisted bootstrap inputs required for local E2EE recovery.
29
+ *
30
+ * @param service - LineProtocolService-like object.
31
+ * @returns Decoded bootstrap inputs or null.
32
+ */
33
+ async function readBootstrapRecoveryInput(service) {
34
+ const bootstrapRaw = await service?.credentialStore?.get?.('line_e2ee_bootstrap');
35
+ const bootstrap = parseBootstrapPayload(bootstrapRaw);
36
+ const secretKeyBase64 = await readPersistedSecretKey(service, bootstrap);
37
+ const decodedBuffers = decodeBootstrapBuffers(bootstrap, secretKeyBase64);
38
+ if (!decodedBuffers) {
39
+ return null;
40
+ }
41
+ return {
42
+ ...decodedBuffers,
43
+ mid: await readPersistedMid(service),
44
+ };
45
+ }
46
+ /**
47
+ * Read the local device secret used to decrypt LINE's login keychain.
48
+ *
49
+ * @param service - LineProtocolService-like object.
50
+ * @param bootstrap - Parsed bootstrap payload.
51
+ * @returns Base64 secret key or null.
52
+ */
53
+ async function readPersistedSecretKey(service, bootstrap) {
54
+ if (bootstrap?.secretKey) {
55
+ return bootstrap.secretKey;
56
+ }
57
+ return (await service?.credentialStore?.get?.('line_nacl_secret_key')) || null;
58
+ }
59
+ /**
60
+ * Read the active LINE self MID.
61
+ *
62
+ * @param service - LineProtocolService-like object.
63
+ * @returns Persisted MID or null.
64
+ */
65
+ async function readPersistedMid(service) {
66
+ if (service?.profile?.mid) {
67
+ return service.profile.mid;
68
+ }
69
+ return (await service?.credentialStore?.get?.('line_mid')) || null;
70
+ }
71
+ /**
72
+ * Parse the stored bootstrap JSON payload.
73
+ *
74
+ * @param bootstrapRaw - Raw bootstrap JSON.
75
+ * @returns Parsed payload or null.
76
+ */
77
+ function parseBootstrapPayload(bootstrapRaw) {
78
+ if (!bootstrapRaw) {
79
+ return null;
80
+ }
81
+ try {
82
+ return JSON.parse(bootstrapRaw);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * Decode the persisted bootstrap buffers needed for key recovery.
90
+ *
91
+ * @param bootstrap - Parsed bootstrap payload.
92
+ * @param secretKeyBase64 - Persisted secret key in base64.
93
+ * @returns Decoded buffers or null.
94
+ */
95
+ function decodeBootstrapBuffers(bootstrap, secretKeyBase64) {
96
+ const encryptedKeyChain = decodeBase64Buffer(bootstrap?.encryptedKeyChain);
97
+ const serverPublicKey = decodeBase64Buffer(bootstrap?.serverPublicKey);
98
+ const secretKey = decodeBase64Buffer(secretKeyBase64);
99
+ if (!encryptedKeyChain || !serverPublicKey || !secretKey) {
100
+ return null;
101
+ }
102
+ return {
103
+ encryptedKeyChain,
104
+ serverPublicKey,
105
+ secretKey,
106
+ };
107
+ }
108
+ /**
109
+ * Rebuild persisted self keys from decoded bootstrap inputs.
110
+ *
111
+ * @param input - Decoded bootstrap inputs.
112
+ * @param input.encryptedKeyChain - Encrypted key chain from login bootstrap.
113
+ * @param input.serverPublicKey - Server public key from login bootstrap.
114
+ * @param input.secretKey - Persisted local secret key.
115
+ * @param input.mid - Optional self MID.
116
+ * @returns Recovered key list or null.
117
+ */
118
+ function rebuildBootstrapKeys(input) {
119
+ const keys = decryptKeyChain(input.encryptedKeyChain, input.serverPublicKey, input.secretKey).map((key) => ({
120
+ ...key,
121
+ mid: input.mid,
122
+ }));
123
+ return Array.isArray(keys) && keys.length > 0 ? keys : null;
124
+ }
125
+ /**
126
+ * Persist recovered keys into the in-memory manager and session state.
127
+ *
128
+ * @param service - LineProtocolService-like object.
129
+ * @param keys - Recovered key list.
130
+ * @param mid - Optional self MID.
131
+ */
132
+ async function persistRecoveredKeys(service, keys, mid) {
133
+ service.e2eeManager.importKeys(keys);
134
+ if (mid) {
135
+ service.e2eeManager.bindSelfKeysToMid(mid);
136
+ }
137
+ await service.sessionState.saveE2EEKeys(keys);
138
+ }
139
+ /**
140
+ * Decode a base64-encoded string into a Buffer when possible.
141
+ *
142
+ * @param value - Stored base64 string or Buffer-like value
143
+ * @returns Decoded Buffer or null
144
+ */
145
+ function decodeBase64Buffer(value) {
146
+ if (!value) {
147
+ return null;
148
+ }
149
+ if (Buffer.isBuffer(value)) {
150
+ return value;
151
+ }
152
+ if (typeof value !== 'string' || value.length === 0) {
153
+ return null;
154
+ }
155
+ try {
156
+ return Buffer.from(value, 'base64');
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * LINE E2EE types
3
+ */
4
+ export {};
@@ -0,0 +1,20 @@
1
+ import { requireLineClient } from './client-runtime.js';
2
+ /**
3
+ * Build the LINE group query boundary for a connected runtime.
4
+ *
5
+ * @param getClient - Deferred LINE client accessor
6
+ * @returns Group query methods
7
+ */
8
+ export function createGroupQueryService(getClient) {
9
+ return {
10
+ /**
11
+ * Fetch a LINE group profile.
12
+ *
13
+ * @param groupId - LINE group MID
14
+ * @returns LINE group profile
15
+ */
16
+ async getGroup(groupId) {
17
+ return requireLineClient(getClient).getGroup(groupId);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,78 @@
1
+ import crypto from 'node:crypto';
2
+ import { buildLineVideoChunkHashes } from './media-encrypt.js';
3
+ /**
4
+ * Derive LINE E2EE media keys from decrypted key material.
5
+ *
6
+ * @param keyMaterial - Base64 LINE media key material.
7
+ * @returns Encryption key, MAC key, and CTR nonce.
8
+ */
9
+ async function deriveLineMediaKeyMaterial(keyMaterial) {
10
+ const material = Buffer.from(keyMaterial, 'base64');
11
+ const derived = await new Promise((resolve, reject) => {
12
+ crypto.hkdf('sha256', material, new Uint8Array(0), 'FileEncryption', 76, (error, key) => {
13
+ if (error) {
14
+ reject(error);
15
+ return;
16
+ }
17
+ resolve(Buffer.from(key));
18
+ });
19
+ });
20
+ return {
21
+ encKey: derived.subarray(0, 32),
22
+ macKey: derived.subarray(32, 64),
23
+ nonce: Buffer.concat([derived.subarray(64, 76), Buffer.alloc(4)]),
24
+ };
25
+ }
26
+ /** Thrown when a media object's MAC does not match its ciphertext. */
27
+ export class MediaMacVerificationError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'MediaMacVerificationError';
31
+ }
32
+ }
33
+ /**
34
+ * Decrypt LINE E2EE media bytes, verifying the trailing MAC first.
35
+ *
36
+ * LINE appends a 32-byte MAC, in the clear, after the AES-256-CTR ciphertext.
37
+ * It is keyed with the HKDF's macKey and comes in two constructions, selected
38
+ * by which OBS object is being read — NOT by contentType alone:
39
+ *
40
+ * - whole-file : HMAC-SHA256(macKey, ciphertext)
41
+ * images, audio, files, AND a video's `__ud-preview` poster
42
+ * - chunked : HMAC-SHA256(macKey, ‖ SHA-256(128 KB chunk of ciphertext))
43
+ * a video's main body only
44
+ *
45
+ * Both constructions were confirmed against real received media (image, audio,
46
+ * video body, and that same video's preview) rather than inferred from the send
47
+ * path — the send path could not settle it, because nothing verified these MACs
48
+ * in either direction, so a wrong construction would have gone unnoticed.
49
+ *
50
+ * Verification is not optional hardening. AES-CTR is malleable: without it, any
51
+ * party able to alter the downloaded bytes — starting with LINE's own OBS
52
+ * servers, which is precisely who E2EE exists to defend against — can flip
53
+ * chosen bits of the decrypted plaintext undetected.
54
+ *
55
+ * @param encryptedBytes - Downloaded encrypted media bytes (ciphertext ‖ MAC).
56
+ * @param keyMaterial - Base64 key material from the E2EE data message.
57
+ * @param options - Which MAC construction applies.
58
+ * @param options.chunkHashMac - True only for a video's main body.
59
+ * @returns Decrypted media bytes.
60
+ * @throws MediaMacVerificationError when the MAC does not match.
61
+ */
62
+ export async function decryptLineMediaBytes(encryptedBytes, keyMaterial, options = {}) {
63
+ const keys = await deriveLineMediaKeyMaterial(keyMaterial);
64
+ if (encryptedBytes.length < 32) {
65
+ throw new MediaMacVerificationError(`media object is ${encryptedBytes.length} bytes — too short to carry a 32-byte MAC`);
66
+ }
67
+ const ciphertext = encryptedBytes.subarray(0, encryptedBytes.length - 32);
68
+ const mac = encryptedBytes.subarray(encryptedBytes.length - 32);
69
+ const expected = crypto
70
+ .createHmac('sha256', keys.macKey)
71
+ .update(options.chunkHashMac ? buildLineVideoChunkHashes(ciphertext) : ciphertext)
72
+ .digest();
73
+ if (!crypto.timingSafeEqual(expected, mac)) {
74
+ throw new MediaMacVerificationError(`media MAC mismatch (${options.chunkHashMac ? 'chunked' : 'whole-file'} construction, ${ciphertext.length}B ciphertext) — the object was altered in transit or is not the object this key belongs to`);
75
+ }
76
+ const decipher = crypto.createDecipheriv('aes-256-ctr', keys.encKey, keys.nonce);
77
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
78
+ }
@@ -0,0 +1,97 @@
1
+ import crypto from 'node:crypto';
2
+ /**
3
+ * Derive LINE E2EE media keys from key material, mirroring the derivation
4
+ * in media-decrypt.ts exactly (same HKDF params, same key/nonce layout) so
5
+ * ciphertext produced here is decryptable by decryptLineMediaBytes.
6
+ *
7
+ * @param keyMaterial - Base64 LINE media key material.
8
+ * @returns Encryption key, MAC key, and CTR nonce.
9
+ */
10
+ async function deriveLineMediaKeyMaterial(keyMaterial) {
11
+ const material = Buffer.from(keyMaterial, 'base64');
12
+ const derived = await new Promise((resolve, reject) => {
13
+ crypto.hkdf('sha256', material, new Uint8Array(0), 'FileEncryption', 76, (error, key) => {
14
+ if (error) {
15
+ reject(error);
16
+ return;
17
+ }
18
+ resolve(Buffer.from(key));
19
+ });
20
+ });
21
+ return {
22
+ encKey: derived.subarray(0, 32),
23
+ macKey: derived.subarray(32, 64),
24
+ nonce: Buffer.concat([derived.subarray(64, 76), Buffer.alloc(4)]),
25
+ };
26
+ }
27
+ /**
28
+ * Encrypt LINE E2EE media bytes for outbound upload.
29
+ *
30
+ * Mirrors decryptLineMediaBytes in reverse: AES-256-CTR encrypt with the
31
+ * derived key/nonce, then append an HMAC-SHA256 MAC (computed over the
32
+ * ciphertext only) as the trailing 32 bytes — the exact layout
33
+ * decryptLineMediaBytes expects to strip.
34
+ *
35
+ * @param raw - Plaintext media bytes to encrypt.
36
+ * @param keyMaterial - Base64 key material (caller-generated, 32 random bytes).
37
+ * @returns Ciphertext with the trailing 32-byte MAC appended.
38
+ */
39
+ export async function encryptLineMediaBytes(raw, keyMaterial) {
40
+ const keys = await deriveLineMediaKeyMaterial(keyMaterial);
41
+ const cipher = crypto.createCipheriv('aes-256-ctr', keys.encKey, keys.nonce);
42
+ const ciphertext = Buffer.concat([cipher.update(raw), cipher.final()]);
43
+ const mac = crypto
44
+ .createHmac('sha256', keys.macKey)
45
+ .update(ciphertext)
46
+ .digest();
47
+ return Buffer.concat([ciphertext, mac]);
48
+ }
49
+ /** LINE video E2EE chunk size — the ciphertext is hashed in 128 KB pieces. */
50
+ const LINE_VIDEO_CHUNK_SIZE = 131072;
51
+ /**
52
+ * Concatenate the SHA-256 hash of each 128 KB chunk of a video ciphertext.
53
+ *
54
+ * Video E2EE lets the receiver verify integrity while streaming, so instead of
55
+ * one MAC over the whole ciphertext (image/audio/file), the ciphertext is split
56
+ * into 128 KB chunks, each hashed with SHA-256, and those hashes are what the
57
+ * MAC covers. Pass the ciphertext WITHOUT any trailing MAC — the hashes must
58
+ * match the bytes a receiver actually downloads and re-hashes per chunk.
59
+ *
60
+ * @param ciphertext - AES-256-CTR video ciphertext (no trailing MAC).
61
+ * @returns The N chunk hashes concatenated (N × 32 bytes).
62
+ */
63
+ export function buildLineVideoChunkHashes(ciphertext) {
64
+ const hashes = [];
65
+ for (let offset = 0; offset < ciphertext.length; offset += LINE_VIDEO_CHUNK_SIZE) {
66
+ hashes.push(crypto
67
+ .createHash('sha256')
68
+ .update(ciphertext.subarray(offset, offset + LINE_VIDEO_CHUNK_SIZE))
69
+ .digest());
70
+ }
71
+ return Buffer.concat(hashes);
72
+ }
73
+ /**
74
+ * Encrypt LINE E2EE video bytes for outbound upload.
75
+ *
76
+ * The ciphertext is byte-for-byte identical to {@link encryptLineMediaBytes}
77
+ * (same whole-file AES-256-CTR stream — the whitepaper's per-chunk decryption
78
+ * IV `IV‖(i*8192)` is exactly the same CTR state continued at each 128 KB
79
+ * boundary). Only the MAC differs: it is computed over the concatenation of the
80
+ * per-chunk SHA-256 hashes rather than over the raw ciphertext, then appended
81
+ * as the trailing 32 bytes. The separate chunk-hash manifest object the
82
+ * receiver needs is produced by {@link buildLineVideoChunkHashes}.
83
+ *
84
+ * @param raw - Plaintext video bytes to encrypt.
85
+ * @param keyMaterial - Base64 key material (caller-generated, 32 random bytes).
86
+ * @returns Ciphertext with the trailing 32-byte chunk-hash MAC appended.
87
+ */
88
+ export async function encryptLineVideoBytes(raw, keyMaterial) {
89
+ const keys = await deriveLineMediaKeyMaterial(keyMaterial);
90
+ const cipher = crypto.createCipheriv('aes-256-ctr', keys.encKey, keys.nonce);
91
+ const ciphertext = Buffer.concat([cipher.update(raw), cipher.final()]);
92
+ const mac = crypto
93
+ .createHmac('sha256', keys.macKey)
94
+ .update(buildLineVideoChunkHashes(ciphertext))
95
+ .digest();
96
+ return Buffer.concat([ciphertext, mac]);
97
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * LINE outbound @mention wire format — `contentMetadata.MENTION`.
3
+ *
4
+ * Reverse-engineered from two real inbound group messages captured by the
5
+ * operator (see the task ground truth, not restated here). Established
6
+ * facts, and nothing beyond them:
7
+ *
8
+ * - `contentMetadata.MENTION` is a JSON *string* whose parsed shape is
9
+ * `{ MENTIONEES: [{ M, S, E }, ...] }`.
10
+ * - `M` is the mentioned user's MID; `S`/`E` are the start/end offsets as
11
+ * *strings*, not numbers.
12
+ * - The range is half-open `[S, E)`, measured in UTF-16 code units — i.e.
13
+ * plain JS string indices (`"@鄭富源".length === 4` matched `S:"0", E:"4"`).
14
+ * - Key order inside a mentionee object is not significant.
15
+ * - MENTION rides in `contentMetadata`, which is never part of an E2EE
16
+ * ciphertext payload — it was observed in the clear on a real message.
17
+ *
18
+ * This module only builds and validates that wire shape. It does not
19
+ * resolve display names to MIDs (that guess is out of scope — see
20
+ * `find_contact`/`get_group_members`), and it does not extend the format
21
+ * with anything not listed above.
22
+ */
23
+ /**
24
+ * Validate a set of outbound mentions against the text they annotate.
25
+ * Throws a descriptive `Error` on the first violation found. Every check
26
+ * here exists because a malformed mention does not fail loudly on LINE's
27
+ * side — it silently degrades to a plain, non-notifying `@name` string,
28
+ * which is exactly the failure this module exists to prevent.
29
+ *
30
+ * @param text - The message body the mentions index into.
31
+ * @param mentions - Proposed mentions.
32
+ * @throws Error describing the first invalid mention or overlap found.
33
+ */
34
+ export function validateMentions(text, mentions) {
35
+ const sorted = [...mentions].sort((a, b) => a.start - b.start);
36
+ for (const mention of sorted) {
37
+ const { mid, start, end } = mention;
38
+ if (typeof mid !== 'string' || mid.length === 0) {
39
+ throw new Error(`Mention mid must be a non-empty string (got: ${JSON.stringify(mid)}).`);
40
+ }
41
+ if (!Number.isInteger(start) || !Number.isInteger(end)) {
42
+ throw new Error(`Mention start/end must be integers (got start=${start}, end=${end}, mid=${mid}).`);
43
+ }
44
+ if (!(start >= 0 && start < end)) {
45
+ throw new Error(`Mention range must satisfy 0 <= start < end (got start=${start}, end=${end}, mid=${mid}).`);
46
+ }
47
+ if (end > text.length) {
48
+ throw new Error(`Mention end (${end}) exceeds text length (${text.length}) for mid=${mid}.`);
49
+ }
50
+ // Heuristic guard derived from observed data, not a documented LINE
51
+ // protocol requirement: every real mention we have seen starts at the
52
+ // visible "@" of the "@name" run. A range that does not is almost
53
+ // certainly an off-by-one that would render as a broken/misaligned
54
+ // highlight rather than a real notification.
55
+ if (text[start] !== '@') {
56
+ throw new Error(`Mention range [${start}, ${end}) for mid=${mid} must start at "@" (the "@name" run in text), ` +
57
+ `but text[${start}] is ${JSON.stringify(text[start])}. `);
58
+ }
59
+ }
60
+ for (let i = 1; i < sorted.length; i++) {
61
+ const prev = sorted[i - 1];
62
+ const cur = sorted[i];
63
+ if (cur.start < prev.end) {
64
+ throw new Error(`Mention ranges overlap: [${prev.start}, ${prev.end}) (mid=${prev.mid}) ` +
65
+ `and [${cur.start}, ${cur.end}) (mid=${cur.mid}).`);
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * Build the `contentMetadata.MENTION` wire value for a set of outbound
71
+ * mentions, validating them against `text` first. It is not possible to
72
+ * obtain a built metadata string without passing validation — the two are
73
+ * deliberately not separable at the call site.
74
+ *
75
+ * @param text - The message body the mentions index into.
76
+ * @param mentions - Mentions to encode.
77
+ * @returns The JSON string LINE expects at `contentMetadata.MENTION`.
78
+ * @throws Error - see `validateMentions`.
79
+ */
80
+ export function buildMentionMetadata(text, mentions) {
81
+ validateMentions(text, mentions);
82
+ return JSON.stringify({
83
+ MENTIONEES: mentions.map((m) => ({
84
+ S: String(m.start),
85
+ E: String(m.end),
86
+ M: m.mid,
87
+ })),
88
+ });
89
+ }