@stage-labs/metro 0.1.0-beta.97 → 0.1.0-beta.99
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +5 -0
- package/runtime/node_modules/@metro-labs/core/src/station-names.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +35 -6
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +16 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/body.ts +24 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +5 -23
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/threema-callback.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +64 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +14 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/threema-callbacks.ts +32 -0
- package/runtime/node_modules/@metro-labs/threema/package.json +26 -0
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +100 -0
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +219 -0
- package/runtime/node_modules/@metro-labs/threema/src/api.ts +130 -0
- package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +132 -0
- package/runtime/node_modules/@metro-labs/threema/src/format.ts +123 -0
- package/runtime/node_modules/@metro-labs/threema/src/ids.ts +21 -0
- package/runtime/node_modules/@metro-labs/threema/src/index.ts +29 -0
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -0
- package/runtime/node_modules/@metro-labs/threema/src/verify.ts +117 -0
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +3 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readJson } from '@metro-labs/core/secure-fs';
|
|
2
|
+
import { accountFilePath } from './materialize.js';
|
|
3
|
+
|
|
4
|
+
export interface ThreemaCallback {
|
|
5
|
+
id: string;
|
|
6
|
+
callbackId: string;
|
|
7
|
+
callbackToken: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const str = (v: unknown): string | undefined =>
|
|
11
|
+
typeof v === 'string' && v !== '' ? v : undefined;
|
|
12
|
+
|
|
13
|
+
function toCallback(row: unknown): ThreemaCallback | null {
|
|
14
|
+
if (typeof row !== 'object' || row === null) return null;
|
|
15
|
+
const rec = row as Record<string, unknown>;
|
|
16
|
+
const id = str(rec.id);
|
|
17
|
+
const callbackId = str(rec.callbackId);
|
|
18
|
+
const callbackToken = str(rec.callbackToken);
|
|
19
|
+
if (id === undefined || callbackId === undefined || callbackToken === undefined) return null;
|
|
20
|
+
return { id, callbackId, callbackToken };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listThreemaCallbacks(): ThreemaCallback[] {
|
|
24
|
+
const raw = readJson<unknown[]>(accountFilePath('threema'), [], {
|
|
25
|
+
warn: 'threema-accounts.json: malformed, ignoring',
|
|
26
|
+
});
|
|
27
|
+
if (!Array.isArray(raw)) return [];
|
|
28
|
+
return raw.map(toCallback).filter((c): c is ThreemaCallback => c !== null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const findThreemaCallback = (callbackId: string): ThreemaCallback | undefined =>
|
|
32
|
+
listThreemaCallbacks().find((c) => c.callbackId === callbackId);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@metro-labs/threema",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/station.ts",
|
|
8
|
+
"./train": "./src/index.ts",
|
|
9
|
+
"./verify": "./src/verify.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "tsc --noEmit && bun test test/"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@metro-labs/core": "workspace:*",
|
|
17
|
+
"tweetnacl": "^1.0.3"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@stage-labs/config": "0.1.0-beta.2",
|
|
21
|
+
"eslint": "^10.3.0",
|
|
22
|
+
"knip": "^6.15.0",
|
|
23
|
+
"madge": "^8.0.0",
|
|
24
|
+
"typescript": "^5"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
makeAccountStore,
|
|
5
|
+
resolveAccountId,
|
|
6
|
+
type Die,
|
|
7
|
+
} from '@metro-labs/core/stations/account-store';
|
|
8
|
+
import { Line } from '@metro-labs/core/lines';
|
|
9
|
+
import { fetchPublicKey } from './api.js';
|
|
10
|
+
import { hexToBytes, keyPairFrom, type KeyPair } from './crypto.js';
|
|
11
|
+
import { isGatewayId, normalizeThreemaId, parsePrivateKey } from './ids.js';
|
|
12
|
+
|
|
13
|
+
const ACCOUNTS_FILE =
|
|
14
|
+
process.env.THREEMA_ACCOUNTS_FILE ??
|
|
15
|
+
join(homedir(), '.metro', 'threema-accounts.json');
|
|
16
|
+
|
|
17
|
+
export interface AccountConfig {
|
|
18
|
+
id: string;
|
|
19
|
+
gatewayId: string;
|
|
20
|
+
secret: string;
|
|
21
|
+
privateKey: string;
|
|
22
|
+
callbackId?: string;
|
|
23
|
+
callbackToken?: string;
|
|
24
|
+
owner?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkAccount(a: AccountConfig, die: Die): void {
|
|
28
|
+
if (!a.id) die('account missing id');
|
|
29
|
+
if (typeof a.gatewayId !== 'string' || !isGatewayId(a.gatewayId))
|
|
30
|
+
die(`account '${a.id}' has no Gateway ID`);
|
|
31
|
+
if (typeof a.secret !== 'string' || a.secret === '')
|
|
32
|
+
die(`account '${a.id}' missing secret`);
|
|
33
|
+
if (typeof a.privateKey !== 'string' || parsePrivateKey(a.privateKey) === null)
|
|
34
|
+
die(`account '${a.id}' has no usable private key`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const { loadAccounts } = makeAccountStore<AccountConfig>({
|
|
38
|
+
prefix: 'threema',
|
|
39
|
+
file: ACCOUNTS_FILE,
|
|
40
|
+
validate(raw, die) {
|
|
41
|
+
const seenId = new Set<string>();
|
|
42
|
+
const seenGateway = new Set<string>();
|
|
43
|
+
for (const a of raw) {
|
|
44
|
+
checkAccount(a, die);
|
|
45
|
+
if (seenId.has(a.id)) die(`duplicate account id '${a.id}'`);
|
|
46
|
+
if (seenGateway.has(a.gatewayId))
|
|
47
|
+
die(`account '${a.id}' reuses the Gateway ID of another account`);
|
|
48
|
+
seenId.add(a.id);
|
|
49
|
+
seenGateway.add(a.gatewayId);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface Account {
|
|
55
|
+
cfg: AccountConfig;
|
|
56
|
+
keys: KeyPair;
|
|
57
|
+
publicKeys: Map<string, Uint8Array>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const accounts = new Map<string, Account>();
|
|
61
|
+
|
|
62
|
+
export function bootAccount(cfg: AccountConfig): Account {
|
|
63
|
+
const key = parsePrivateKey(cfg.privateKey);
|
|
64
|
+
if (key === null) throw new Error(`account '${cfg.id}' has no usable private key`);
|
|
65
|
+
return { cfg, keys: keyPairFrom(key), publicKeys: new Map() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function accountFor(id: string): Account {
|
|
69
|
+
const acct = accounts.get(id);
|
|
70
|
+
if (!acct)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`unknown account '${id}' (have: ${[...accounts.keys()].join(', ')})`,
|
|
73
|
+
);
|
|
74
|
+
return acct;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function targetOf(
|
|
78
|
+
line: string,
|
|
79
|
+
account?: string,
|
|
80
|
+
): { acct: Account; to: string } {
|
|
81
|
+
const parsed = Line.parseThreema(line);
|
|
82
|
+
if (parsed === null) throw new Error(`not a threema line: ${line}`);
|
|
83
|
+
const id = resolveAccountId(
|
|
84
|
+
accounts,
|
|
85
|
+
{ account, line },
|
|
86
|
+
(l) => Line.parseThreema(l)?.accountId,
|
|
87
|
+
);
|
|
88
|
+
return { acct: accountFor(id), to: normalizeThreemaId(parsed.resource) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function publicKeyFor(
|
|
92
|
+
acct: Account,
|
|
93
|
+
threemaId: string,
|
|
94
|
+
): Promise<Uint8Array> {
|
|
95
|
+
const cached = acct.publicKeys.get(threemaId);
|
|
96
|
+
if (cached) return cached;
|
|
97
|
+
const key = hexToBytes(await fetchPublicKey(acct.cfg, threemaId), 'public key');
|
|
98
|
+
acct.publicKeys.set(threemaId, key);
|
|
99
|
+
return key;
|
|
100
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
2
|
+
import {
|
|
3
|
+
makeStation,
|
|
4
|
+
respond,
|
|
5
|
+
type CallMsg,
|
|
6
|
+
} from '@metro-labs/core/stations/station-runtime';
|
|
7
|
+
import type { Normalized } from '@metro-labs/core/stations/messaging-normalize';
|
|
8
|
+
import { accountFor, accounts, publicKeyFor, targetOf, type Account } from './accounts.js';
|
|
9
|
+
import { sendE2E } from './api.js';
|
|
10
|
+
import {
|
|
11
|
+
bytesToHex,
|
|
12
|
+
decode,
|
|
13
|
+
encodeText,
|
|
14
|
+
hexToBytes,
|
|
15
|
+
macMatches,
|
|
16
|
+
open,
|
|
17
|
+
quoted,
|
|
18
|
+
seal,
|
|
19
|
+
type Decoded,
|
|
20
|
+
} from './crypto.js';
|
|
21
|
+
import {
|
|
22
|
+
emitInbound,
|
|
23
|
+
emitOutbound,
|
|
24
|
+
receiptEnvelope,
|
|
25
|
+
textEnvelope,
|
|
26
|
+
type InboundMeta,
|
|
27
|
+
} from './format.js';
|
|
28
|
+
import { isThreemaId, MESSAGE_ID_RE, normalizeThreemaId } from './ids.js';
|
|
29
|
+
|
|
30
|
+
export type { CallMsg };
|
|
31
|
+
|
|
32
|
+
export const MAX_TEXT_BYTES = 3500;
|
|
33
|
+
const SENT_MAX = 2000;
|
|
34
|
+
|
|
35
|
+
type Args = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
const sentIds = new Set<string>();
|
|
38
|
+
|
|
39
|
+
function noteSent(messageId: string): void {
|
|
40
|
+
sentIds.add(messageId);
|
|
41
|
+
while (sentIds.size > SENT_MAX) {
|
|
42
|
+
const oldest = sentIds.values().next();
|
|
43
|
+
if (oldest.done) break;
|
|
44
|
+
sentIds.delete(oldest.value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sentByUs = (messageId: string): boolean => sentIds.has(messageId);
|
|
49
|
+
|
|
50
|
+
function listAccounts(id: string): void {
|
|
51
|
+
const list = [...accounts.values()].map((a) => ({
|
|
52
|
+
id: a.cfg.id,
|
|
53
|
+
handle: a.cfg.gatewayId,
|
|
54
|
+
url: null,
|
|
55
|
+
owner: a.cfg.owner ?? null,
|
|
56
|
+
gatewayId: a.cfg.gatewayId,
|
|
57
|
+
...(a.cfg.callbackId && a.cfg.callbackToken
|
|
58
|
+
? { callbackId: a.cfg.callbackId, callbackToken: a.cfg.callbackToken }
|
|
59
|
+
: {}),
|
|
60
|
+
}));
|
|
61
|
+
respond(id, { result: { accounts: list } });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SendArgs {
|
|
65
|
+
line: string;
|
|
66
|
+
text?: unknown;
|
|
67
|
+
replyTo?: unknown;
|
|
68
|
+
account?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireText(a: SendArgs): string {
|
|
72
|
+
const text = typeof a.text === 'string' ? a.text : '';
|
|
73
|
+
if (text === '')
|
|
74
|
+
throw new TrainError(
|
|
75
|
+
'threema_text_required',
|
|
76
|
+
'threema carries text only; give some text to send',
|
|
77
|
+
{ retryable: false },
|
|
78
|
+
);
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function replyTargetOf(a: SendArgs): string | undefined {
|
|
83
|
+
if (a.replyTo === undefined || a.replyTo === null || a.replyTo === '') return undefined;
|
|
84
|
+
const target = typeof a.replyTo === 'string' ? a.replyTo.toLowerCase() : '';
|
|
85
|
+
if (!MESSAGE_ID_RE.test(target))
|
|
86
|
+
throw new TrainError(
|
|
87
|
+
'threema_bad_reply_target',
|
|
88
|
+
'replyTo must be a Threema message id, 16 hex characters',
|
|
89
|
+
{ retryable: false },
|
|
90
|
+
);
|
|
91
|
+
return target;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function send(id: string, args: Args): Promise<void> {
|
|
95
|
+
const a = args as unknown as SendArgs;
|
|
96
|
+
const text = requireText(a);
|
|
97
|
+
const replyTo = replyTargetOf(a);
|
|
98
|
+
const { acct, to } = targetOf(a.line, a.account);
|
|
99
|
+
const plain = encodeText(replyTo === undefined ? text : quoted(replyTo, text));
|
|
100
|
+
if (plain.length - 1 > MAX_TEXT_BYTES)
|
|
101
|
+
throw new TrainError(
|
|
102
|
+
'threema_message_too_long',
|
|
103
|
+
`Threema carries at most ${MAX_TEXT_BYTES} bytes of text per message; split it up`,
|
|
104
|
+
{ retryable: false },
|
|
105
|
+
);
|
|
106
|
+
const { nonce, box } = seal(plain, await publicKeyFor(acct, to), acct.keys);
|
|
107
|
+
const messageId = await sendE2E(acct.cfg, to, bytesToHex(nonce), bytesToHex(box));
|
|
108
|
+
noteSent(messageId);
|
|
109
|
+
emitOutbound(acct.cfg.id, a.line, messageId, text, replyTo);
|
|
110
|
+
respond(id, { result: { messageId, account: acct.cfg.id } });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface CallbackArgs {
|
|
114
|
+
account: string;
|
|
115
|
+
from: string;
|
|
116
|
+
to: string;
|
|
117
|
+
messageId: string;
|
|
118
|
+
date: string;
|
|
119
|
+
nonce: string;
|
|
120
|
+
box: string;
|
|
121
|
+
mac: string;
|
|
122
|
+
nickname?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
126
|
+
|
|
127
|
+
function parseCallback(args: Args): CallbackArgs {
|
|
128
|
+
const fields = {
|
|
129
|
+
account: str(args.account),
|
|
130
|
+
from: str(args.from),
|
|
131
|
+
to: str(args.to),
|
|
132
|
+
messageId: str(args.messageId),
|
|
133
|
+
date: str(args.date),
|
|
134
|
+
nonce: str(args.nonce),
|
|
135
|
+
box: str(args.box),
|
|
136
|
+
mac: str(args.mac),
|
|
137
|
+
};
|
|
138
|
+
for (const [key, value] of Object.entries(fields))
|
|
139
|
+
if (value === '')
|
|
140
|
+
throw new TrainError('threema_bad_callback', `callback is missing ${key}`, {
|
|
141
|
+
retryable: false,
|
|
142
|
+
});
|
|
143
|
+
const nickname = str(args.nickname);
|
|
144
|
+
return nickname === '' ? fields : { ...fields, nickname };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function deliver(acct: Account, m: InboundMeta, decoded: Decoded): string {
|
|
148
|
+
const owner = acct.cfg.owner;
|
|
149
|
+
if (decoded.kind === 'text') {
|
|
150
|
+
emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, decoded.text, sentByUs));
|
|
151
|
+
return 'text';
|
|
152
|
+
}
|
|
153
|
+
if (decoded.kind === 'receipt') {
|
|
154
|
+
let reactions = 0;
|
|
155
|
+
for (const target of decoded.messageIds) {
|
|
156
|
+
const env = receiptEnvelope(acct.cfg.id, m, decoded.status, target);
|
|
157
|
+
if (env === null) continue;
|
|
158
|
+
emitInbound(acct.cfg.id, owner, env);
|
|
159
|
+
reactions += 1;
|
|
160
|
+
}
|
|
161
|
+
return reactions > 0 ? 'reaction' : `receipt:${decoded.status}`;
|
|
162
|
+
}
|
|
163
|
+
if (decoded.kind === 'typing') return 'typing';
|
|
164
|
+
const type = `0x${decoded.type.toString(16)}`;
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
`threema[${acct.cfg.id}]: ignored a message of type ${type} from ${m.from}\n`,
|
|
167
|
+
);
|
|
168
|
+
return `ignored:${type}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function callback(id: string, args: Args): Promise<void> {
|
|
172
|
+
const cb = parseCallback(args);
|
|
173
|
+
const acct = accountFor(cb.account);
|
|
174
|
+
if (!macMatches(acct.cfg.secret, cb, cb.mac))
|
|
175
|
+
throw new TrainError(
|
|
176
|
+
'threema_bad_mac',
|
|
177
|
+
'the callback MAC does not match this account API secret',
|
|
178
|
+
{ retryable: false },
|
|
179
|
+
);
|
|
180
|
+
const from = normalizeThreemaId(cb.from);
|
|
181
|
+
if (!isThreemaId(from))
|
|
182
|
+
throw new TrainError('threema_bad_callback', `'${cb.from}' is not a Threema ID`, {
|
|
183
|
+
retryable: false,
|
|
184
|
+
});
|
|
185
|
+
if (normalizeThreemaId(cb.to) !== acct.cfg.gatewayId)
|
|
186
|
+
throw new TrainError(
|
|
187
|
+
'threema_wrong_recipient',
|
|
188
|
+
`callback addressed to ${cb.to}, not to ${acct.cfg.gatewayId}`,
|
|
189
|
+
{ retryable: false },
|
|
190
|
+
);
|
|
191
|
+
const plain = open(
|
|
192
|
+
hexToBytes(cb.box, 'box'),
|
|
193
|
+
hexToBytes(cb.nonce, 'nonce'),
|
|
194
|
+
await publicKeyFor(acct, from),
|
|
195
|
+
acct.keys,
|
|
196
|
+
);
|
|
197
|
+
if (plain === null)
|
|
198
|
+
throw new TrainError(
|
|
199
|
+
'threema_undecryptable',
|
|
200
|
+
`could not decrypt a message from ${from}; the private key may not match the Gateway ID`,
|
|
201
|
+
{ retryable: false },
|
|
202
|
+
);
|
|
203
|
+
const meta: InboundMeta = { ...cb, from, messageId: cb.messageId.toLowerCase() };
|
|
204
|
+
respond(id, { result: { ok: true, kind: deliver(acct, meta, decode(plain)) } });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function normalizeThreema(action: string, env: Args): Normalized {
|
|
208
|
+
if (action === 'reply')
|
|
209
|
+
return {
|
|
210
|
+
action: 'send',
|
|
211
|
+
args: { line: env.line, text: env.text, replyTo: env.replyTo, account: env.account },
|
|
212
|
+
};
|
|
213
|
+
return { action, args: env };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const handleCall = makeStation({
|
|
217
|
+
handlers: { accounts: listAccounts, send, callback },
|
|
218
|
+
normalize: normalizeThreema,
|
|
219
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
2
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
3
|
+
import { GATEWAY_API, MESSAGE_ID_RE } from './ids.js';
|
|
4
|
+
|
|
5
|
+
const TIMEOUT_MS = 15_000;
|
|
6
|
+
const PUBLIC_KEY_RE = /^[0-9a-f]{64}$/;
|
|
7
|
+
|
|
8
|
+
export interface GatewayCreds {
|
|
9
|
+
gatewayId: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function call(
|
|
14
|
+
path: string,
|
|
15
|
+
init: RequestInit,
|
|
16
|
+
label: string,
|
|
17
|
+
): Promise<Response> {
|
|
18
|
+
try {
|
|
19
|
+
return await fetch(`${GATEWAY_API}${path}`, {
|
|
20
|
+
...init,
|
|
21
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
22
|
+
});
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new TrainError(
|
|
25
|
+
'threema_unreachable',
|
|
26
|
+
`${label}: could not reach the Threema Gateway (${errMsg(err)})`,
|
|
27
|
+
{ retryable: true },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const authQuery = (c: GatewayCreds): string =>
|
|
33
|
+
new URLSearchParams({ from: c.gatewayId, secret: c.secret }).toString();
|
|
34
|
+
|
|
35
|
+
function refusal(status: number, what: string): TrainError {
|
|
36
|
+
if (status === 401)
|
|
37
|
+
return new TrainError(
|
|
38
|
+
'threema_unauthorized',
|
|
39
|
+
'the Threema Gateway rejected this account credentials; check the Gateway ID and API secret',
|
|
40
|
+
{ retryable: false },
|
|
41
|
+
);
|
|
42
|
+
if (status === 402)
|
|
43
|
+
return new TrainError(
|
|
44
|
+
'threema_no_credits',
|
|
45
|
+
'this Threema Gateway ID has no credits left; top it up at gateway.threema.ch',
|
|
46
|
+
{ retryable: false },
|
|
47
|
+
);
|
|
48
|
+
if (status === 404)
|
|
49
|
+
return new TrainError('threema_unknown_id', `Threema does not know the ID ${what}`, {
|
|
50
|
+
retryable: false,
|
|
51
|
+
});
|
|
52
|
+
if (status === 413)
|
|
53
|
+
return new TrainError(
|
|
54
|
+
'threema_message_too_long',
|
|
55
|
+
'Threema refused the message as too long',
|
|
56
|
+
{ retryable: false },
|
|
57
|
+
);
|
|
58
|
+
if (status === 400)
|
|
59
|
+
return new TrainError('threema_bad_recipient', `Threema refused the recipient ${what}`, {
|
|
60
|
+
retryable: false,
|
|
61
|
+
});
|
|
62
|
+
return new TrainError(
|
|
63
|
+
'threema_gateway_error',
|
|
64
|
+
`the Threema Gateway answered ${status}`,
|
|
65
|
+
{ retryable: status >= 500 },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function fetchPublicKey(
|
|
70
|
+
c: GatewayCreds,
|
|
71
|
+
threemaId: string,
|
|
72
|
+
): Promise<string> {
|
|
73
|
+
const res = await call(
|
|
74
|
+
`/pubkeys/${encodeURIComponent(threemaId)}?${authQuery(c)}`,
|
|
75
|
+
{},
|
|
76
|
+
'pubkeys',
|
|
77
|
+
);
|
|
78
|
+
if (!res.ok) throw refusal(res.status, threemaId);
|
|
79
|
+
const hex = (await res.text()).trim().toLowerCase();
|
|
80
|
+
if (!PUBLIC_KEY_RE.test(hex))
|
|
81
|
+
throw new TrainError(
|
|
82
|
+
'threema_gateway_error',
|
|
83
|
+
`pubkeys: the Threema Gateway gave an unexpected answer for ${threemaId}`,
|
|
84
|
+
);
|
|
85
|
+
return hex;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function fetchCredits(c: GatewayCreds): Promise<number> {
|
|
89
|
+
const res = await call(`/credits?${authQuery(c)}`, {}, 'credits');
|
|
90
|
+
if (!res.ok) throw refusal(res.status, c.gatewayId);
|
|
91
|
+
const credits = Number((await res.text()).trim());
|
|
92
|
+
if (!Number.isInteger(credits))
|
|
93
|
+
throw new TrainError(
|
|
94
|
+
'threema_gateway_error',
|
|
95
|
+
'credits: the Threema Gateway gave an unexpected answer',
|
|
96
|
+
);
|
|
97
|
+
return credits;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function sendE2E(
|
|
101
|
+
c: GatewayCreds,
|
|
102
|
+
to: string,
|
|
103
|
+
nonceHex: string,
|
|
104
|
+
boxHex: string,
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
const body = new URLSearchParams({
|
|
107
|
+
from: c.gatewayId,
|
|
108
|
+
to,
|
|
109
|
+
nonce: nonceHex,
|
|
110
|
+
box: boxHex,
|
|
111
|
+
secret: c.secret,
|
|
112
|
+
});
|
|
113
|
+
const res = await call(
|
|
114
|
+
'/send_e2e',
|
|
115
|
+
{
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
118
|
+
body: body.toString(),
|
|
119
|
+
},
|
|
120
|
+
'send_e2e',
|
|
121
|
+
);
|
|
122
|
+
if (!res.ok) throw refusal(res.status, to);
|
|
123
|
+
const messageId = (await res.text()).trim().toLowerCase();
|
|
124
|
+
if (!MESSAGE_ID_RE.test(messageId))
|
|
125
|
+
throw new TrainError(
|
|
126
|
+
'threema_gateway_error',
|
|
127
|
+
'send_e2e: the Threema Gateway answered without a message id',
|
|
128
|
+
);
|
|
129
|
+
return messageId;
|
|
130
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHmac, randomInt, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
|
|
4
|
+
export const MSG_TEXT = 0x01;
|
|
5
|
+
export const MSG_DELIVERY_RECEIPT = 0x80;
|
|
6
|
+
export const MSG_TYPING = 0x90;
|
|
7
|
+
|
|
8
|
+
export const RECEIPT_ACK = 0x03;
|
|
9
|
+
export const RECEIPT_DECLINE = 0x04;
|
|
10
|
+
|
|
11
|
+
const MESSAGE_ID_BYTES = 8;
|
|
12
|
+
const HEX_RE = /^(?:[0-9a-fA-F]{2})*$/;
|
|
13
|
+
|
|
14
|
+
export interface KeyPair {
|
|
15
|
+
publicKey: Uint8Array;
|
|
16
|
+
secretKey: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hexToBytes(hex: string, label: string): Uint8Array {
|
|
20
|
+
if (!HEX_RE.test(hex)) throw new Error(`${label} is not hex`);
|
|
21
|
+
return new Uint8Array(Buffer.from(hex, 'hex'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const bytesToHex = (bytes: Uint8Array): string =>
|
|
25
|
+
Buffer.from(bytes).toString('hex');
|
|
26
|
+
|
|
27
|
+
export const keyPairFrom = (privateKeyHex: string): KeyPair =>
|
|
28
|
+
nacl.box.keyPair.fromSecretKey(hexToBytes(privateKeyHex, 'private key'));
|
|
29
|
+
|
|
30
|
+
function pad(plain: Uint8Array): Uint8Array {
|
|
31
|
+
const n = randomInt(1, 256);
|
|
32
|
+
const out = new Uint8Array(plain.length + n);
|
|
33
|
+
out.set(plain);
|
|
34
|
+
out.fill(n, plain.length);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unpad(data: Uint8Array): Uint8Array | null {
|
|
39
|
+
const n = data[data.length - 1];
|
|
40
|
+
if (n === undefined || n < 1 || n > data.length) return null;
|
|
41
|
+
return data.subarray(0, data.length - n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function seal(
|
|
45
|
+
plain: Uint8Array,
|
|
46
|
+
theirPublicKey: Uint8Array,
|
|
47
|
+
mine: KeyPair,
|
|
48
|
+
): { nonce: Uint8Array; box: Uint8Array } {
|
|
49
|
+
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
|
50
|
+
return {
|
|
51
|
+
nonce,
|
|
52
|
+
box: nacl.box(pad(plain), nonce, theirPublicKey, mine.secretKey),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function open(
|
|
57
|
+
box: Uint8Array,
|
|
58
|
+
nonce: Uint8Array,
|
|
59
|
+
theirPublicKey: Uint8Array,
|
|
60
|
+
mine: KeyPair,
|
|
61
|
+
): Uint8Array | null {
|
|
62
|
+
const padded = nacl.box.open(box, nonce, theirPublicKey, mine.secretKey);
|
|
63
|
+
return padded === null ? null : unpad(padded);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function encodeText(text: string): Uint8Array {
|
|
67
|
+
const utf8 = Buffer.from(text, 'utf8');
|
|
68
|
+
const out = new Uint8Array(1 + utf8.length);
|
|
69
|
+
out[0] = MSG_TEXT;
|
|
70
|
+
out.set(utf8, 1);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Decoded =
|
|
75
|
+
| { kind: 'text'; text: string }
|
|
76
|
+
| { kind: 'receipt'; status: number; messageIds: string[] }
|
|
77
|
+
| { kind: 'typing' }
|
|
78
|
+
| { kind: 'other'; type: number };
|
|
79
|
+
|
|
80
|
+
function receiptIds(body: Uint8Array): string[] {
|
|
81
|
+
const ids: string[] = [];
|
|
82
|
+
for (let at = 1; at + MESSAGE_ID_BYTES <= body.length; at += MESSAGE_ID_BYTES)
|
|
83
|
+
ids.push(bytesToHex(body.subarray(at, at + MESSAGE_ID_BYTES)));
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function decode(plain: Uint8Array): Decoded {
|
|
88
|
+
const type = plain[0];
|
|
89
|
+
const body = plain.subarray(1);
|
|
90
|
+
if (type === MSG_TEXT)
|
|
91
|
+
return { kind: 'text', text: Buffer.from(body).toString('utf8') };
|
|
92
|
+
if (type === MSG_DELIVERY_RECEIPT)
|
|
93
|
+
return { kind: 'receipt', status: body[0] ?? 0, messageIds: receiptIds(body) };
|
|
94
|
+
if (type === MSG_TYPING) return { kind: 'typing' };
|
|
95
|
+
return { kind: 'other', type: type ?? -1 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CallbackFields {
|
|
99
|
+
from: string;
|
|
100
|
+
to: string;
|
|
101
|
+
messageId: string;
|
|
102
|
+
date: string;
|
|
103
|
+
nonce: string;
|
|
104
|
+
box: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const callbackMac = (secret: string, f: CallbackFields): string =>
|
|
108
|
+
createHmac('sha256', secret)
|
|
109
|
+
.update(f.from + f.to + f.messageId + f.date + f.nonce + f.box)
|
|
110
|
+
.digest('hex');
|
|
111
|
+
|
|
112
|
+
export function macMatches(
|
|
113
|
+
secret: string,
|
|
114
|
+
fields: CallbackFields,
|
|
115
|
+
mac: string,
|
|
116
|
+
): boolean {
|
|
117
|
+
const want = Buffer.from(callbackMac(secret, fields), 'hex');
|
|
118
|
+
const got = Buffer.from(mac.toLowerCase(), 'hex');
|
|
119
|
+
return want.length === got.length && timingSafeEqual(want, got);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const QUOTE_RE = /^> quote #([0-9a-f]{16})\n\n([\s\S]*)$/;
|
|
123
|
+
|
|
124
|
+
export const quoted = (replyTo: string, text: string): string =>
|
|
125
|
+
`> quote #${replyTo}\n\n${text}`;
|
|
126
|
+
|
|
127
|
+
export function unquote(text: string): { replyTo?: string; text: string } {
|
|
128
|
+
const m = QUOTE_RE.exec(text);
|
|
129
|
+
const replyTo = m?.[1];
|
|
130
|
+
const body = m?.[2];
|
|
131
|
+
return replyTo === undefined || body === undefined ? { text } : { replyTo, text: body };
|
|
132
|
+
}
|