@graygate/bot-sdk 0.1.0
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/README.md +131 -0
- package/dist/api.d.ts +31 -0
- package/dist/api.js +105 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +288 -0
- package/dist/signature.d.ts +18 -0
- package/dist/signature.js +47 -0
- package/dist/state.d.ts +21 -0
- package/dist/state.js +85 -0
- package/dist/wire/body.d.ts +124 -0
- package/dist/wire/body.js +134 -0
- package/dist/wire/constants.d.ts +28 -0
- package/dist/wire/constants.js +31 -0
- package/dist/wire/crypto.d.ts +40 -0
- package/dist/wire/crypto.js +125 -0
- package/dist/wire/events.d.ts +75 -0
- package/dist/wire/events.js +28 -0
- package/dist/wire/index.d.ts +9 -0
- package/dist/wire/index.js +25 -0
- package/dist/wire/webhook.d.ts +569 -0
- package/dist/wire/webhook.js +33 -0
- package/package.json +55 -0
- package/src/api.ts +111 -0
- package/src/index.ts +369 -0
- package/src/signature.test.ts +37 -0
- package/src/signature.ts +51 -0
- package/src/state.ts +104 -0
- package/src/wire/body.ts +140 -0
- package/src/wire/constants.ts +35 -0
- package/src/wire/crypto.ts +132 -0
- package/src/wire/events.ts +29 -0
- package/src/wire/index.ts +9 -0
- package/src/wire/webhook.ts +38 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bot runtime's state directory.
|
|
6
|
+
*
|
|
7
|
+
* It holds **the private key PEM and the room keys, in the clear**. Neither the server nor the
|
|
8
|
+
* phone of the person who created the bot has a copy, so this directory is the only one: lose it
|
|
9
|
+
* and the past messages of those rooms never open again (there is no room-key backup, by design),
|
|
10
|
+
* and new messages stay closed until the next rotation. **Guarding and backing it up is the
|
|
11
|
+
* operator's job.**
|
|
12
|
+
*
|
|
13
|
+
* The bot token is not written here. It arrives through the environment and stays in memory.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface StateShape {
|
|
17
|
+
/** room id → key version → room key */
|
|
18
|
+
roomKeys: Record<string, Record<string, string>>;
|
|
19
|
+
/** Ids already handled. Delivery is at-least-once, so the same item can arrive twice. */
|
|
20
|
+
seen: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SEEN_MAX = 5000;
|
|
24
|
+
|
|
25
|
+
export class BotState {
|
|
26
|
+
private readonly keyPath: string;
|
|
27
|
+
private readonly statePath: string;
|
|
28
|
+
private state: StateShape = { roomKeys: {}, seen: [] };
|
|
29
|
+
private seenSet = new Set<string>();
|
|
30
|
+
|
|
31
|
+
constructor(private readonly dir: string) {
|
|
32
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
33
|
+
this.keyPath = join(dir, 'private-key.pem');
|
|
34
|
+
this.statePath = join(dir, 'state.json');
|
|
35
|
+
try {
|
|
36
|
+
this.state = JSON.parse(readFileSync(this.statePath, 'utf8')) as StateShape;
|
|
37
|
+
this.state.roomKeys ??= {};
|
|
38
|
+
this.state.seen ??= [];
|
|
39
|
+
this.seenSet = new Set(this.state.seen);
|
|
40
|
+
} catch {
|
|
41
|
+
// First run — start empty.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
readPrivateKey(): string | null {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(this.keyPath, 'utf8');
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
writePrivateKey(pem: string): void {
|
|
54
|
+
writeAtomic(this.keyPath, pem);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
roomKey(roomId: string, keyVersion: number): string | null {
|
|
58
|
+
return this.state.roomKeys[roomId]?.[String(keyVersion)] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The newest key version and its key — what sending uses. */
|
|
62
|
+
latestRoomKey(roomId: string): { keyVersion: number; roomKey: string } | null {
|
|
63
|
+
const versions = this.state.roomKeys[roomId];
|
|
64
|
+
if (!versions) return null;
|
|
65
|
+
let best = 0;
|
|
66
|
+
for (const v of Object.keys(versions)) best = Math.max(best, Number(v));
|
|
67
|
+
const roomKey = versions[String(best)];
|
|
68
|
+
return best > 0 && roomKey ? { keyVersion: best, roomKey } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
putRoomKey(roomId: string, keyVersion: number, roomKey: string): void {
|
|
72
|
+
(this.state.roomKeys[roomId] ??= {})[String(keyVersion)] = roomKey;
|
|
73
|
+
this.persist();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
forgetRoom(roomId: string): void {
|
|
77
|
+
delete this.state.roomKeys[roomId];
|
|
78
|
+
this.persist();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether this item was already handled; a first sighting is recorded and returns false. */
|
|
82
|
+
markSeen(id: string): boolean {
|
|
83
|
+
if (this.seenSet.has(id)) return true;
|
|
84
|
+
this.seenSet.add(id);
|
|
85
|
+
this.state.seen.push(id);
|
|
86
|
+
if (this.state.seen.length > SEEN_MAX) {
|
|
87
|
+
const dropped = this.state.seen.splice(0, this.state.seen.length - SEEN_MAX);
|
|
88
|
+
for (const old of dropped) this.seenSet.delete(old);
|
|
89
|
+
}
|
|
90
|
+
this.persist();
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private persist(): void {
|
|
95
|
+
writeAtomic(this.statePath, JSON.stringify(this.state));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Write to a temporary file, then rename — dying mid-write cannot leave half a state file. */
|
|
100
|
+
function writeAtomic(path: string, contents: string): void {
|
|
101
|
+
const tmp = `${path}.tmp`;
|
|
102
|
+
writeFileSync(tmp, contents, { mode: 0o600 });
|
|
103
|
+
renameSync(tmp, path);
|
|
104
|
+
}
|
package/src/wire/body.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from protocol/src/body.ts in the graygate monorepo — do not edit by hand.
|
|
3
|
+
* Only the declarations the bot SDK needs are here; the rest of that module stays private.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MEDIA_MAX_BYTES, TEXT_CIPHERTEXT_MAX } from './constants';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Channel post or comment — **no `parentId` means a post, a `parentId` means a first-level comment
|
|
11
|
+
* on that post.**
|
|
12
|
+
*
|
|
13
|
+
* The parent reference sits inside the ciphertext so the server never learns the shape of a
|
|
14
|
+
* conversation. The price is that the server cannot enforce who may post; that is enforced twice on
|
|
15
|
+
* the client instead, once by hiding the composer and once by checking on receipt.
|
|
16
|
+
*
|
|
17
|
+
* Outside a channel the field is ignored — out of spec, absorbed harmlessly. Replies to replies do
|
|
18
|
+
* not exist: if the parent is already a comment, the receiving client refuses it.
|
|
19
|
+
*/
|
|
20
|
+
const parentId = z.string().uuid().optional();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The message being replied to — **the id alone, never a copy of what it said.**
|
|
24
|
+
*
|
|
25
|
+
* Leaving out the quoted snapshot is the whole design of the feature:
|
|
26
|
+
*
|
|
27
|
+
* 1. A device without the original **has no business learning it.** Someone who joined late, whose
|
|
28
|
+
* copy expired under auto-delete, or who deleted it locally only needs to know "this is a
|
|
29
|
+
* reply". They had no path to the original in the first place.
|
|
30
|
+
* 2. A snapshot would be **a copy that outlives both auto-delete and local deletion**: a sentence
|
|
31
|
+
* that should have expired lives on inside somebody else's reply, out of reach of the deletion
|
|
32
|
+
* that was supposed to remove it.
|
|
33
|
+
*
|
|
34
|
+
* So the quote card is drawn **only when the receiving device finds the original in its own
|
|
35
|
+
* storage.** Otherwise the message just shows as a reply. It is a separate field from `parentId`
|
|
36
|
+
* because it means something else: `parentId` is the post/comment hierarchy of a channel, this is a
|
|
37
|
+
* reference between messages at the same level.
|
|
38
|
+
*
|
|
39
|
+
* A client too old to know this field has it stripped by the schema and renders the text alone.
|
|
40
|
+
*/
|
|
41
|
+
const replyTo = z.string().uuid().optional();
|
|
42
|
+
|
|
43
|
+
export const messageBodySchema = z.discriminatedUnion('t', [
|
|
44
|
+
z.object({
|
|
45
|
+
t: z.literal('text'),
|
|
46
|
+
text: z.string().min(1).max(8000),
|
|
47
|
+
parentId,
|
|
48
|
+
replyTo,
|
|
49
|
+
}),
|
|
50
|
+
z.object({
|
|
51
|
+
t: z.literal('media'),
|
|
52
|
+
fileName: z.string().min(1).max(255),
|
|
53
|
+
mime: z.string().min(1).max(100),
|
|
54
|
+
size: z.number().int().positive().max(MEDIA_MAX_BYTES),
|
|
55
|
+
/**
|
|
56
|
+
* Text sent along with the attachment, up to the same 8,000 characters as a text message.
|
|
57
|
+
*
|
|
58
|
+
* It rides in the same message rather than a second one because in a channel one message is one
|
|
59
|
+
* post: split them and a picture and its caption become **two separate posts** (and the caption
|
|
60
|
+
* cannot be a comment either — the parent id only exists once the first one is stored).
|
|
61
|
+
*
|
|
62
|
+
* Nothing changes for the server; this is inside the ciphertext. A client too old to know the
|
|
63
|
+
* field has it stripped by the schema and renders the attachment alone.
|
|
64
|
+
*/
|
|
65
|
+
caption: z.string().min(1).max(8000).optional(),
|
|
66
|
+
/**
|
|
67
|
+
* Ties together several pictures chosen at once. Messages sharing a `groupId` are drawn as one
|
|
68
|
+
* bubble, and tapping opens them in a swipeable viewer — but each picture is still **its own
|
|
69
|
+
* message**, with its own key, upload and acknowledgement. The server sees none of this. A
|
|
70
|
+
* client too old to know the field has it stripped and draws the pictures one by one, which
|
|
71
|
+
* loses the grouping and nothing else. `groupCount` is how many the sender sent, so the
|
|
72
|
+
* receiver can tell that some have not arrived yet.
|
|
73
|
+
*/
|
|
74
|
+
groupId: z.string().uuid().optional(),
|
|
75
|
+
groupCount: z.number().int().min(2).max(50).optional(),
|
|
76
|
+
parentId,
|
|
77
|
+
replyTo,
|
|
78
|
+
}),
|
|
79
|
+
/**
|
|
80
|
+
* A shared location: **two numbers and nothing else.** No map image, no street address, no place
|
|
81
|
+
* name — a map tile is an outbound request, and an address or place name means handing the
|
|
82
|
+
* coordinates to a geocoding service first.
|
|
83
|
+
*
|
|
84
|
+
* The receiver draws a card from these numbers and only opens the system map app after the person
|
|
85
|
+
* confirms, building the URL itself from the validated numbers. A string supplied by the sender
|
|
86
|
+
* is never opened.
|
|
87
|
+
*
|
|
88
|
+
* `accuracy` is the radius in metres, when known. A client too old to know this type fails the
|
|
89
|
+
* discriminated union and leaves an "integrity check failed" line, which is better than
|
|
90
|
+
* disguising the message as text — that would put coordinates in a body where they render as a
|
|
91
|
+
* tappable link.
|
|
92
|
+
*/
|
|
93
|
+
z.object({
|
|
94
|
+
t: z.literal('location'),
|
|
95
|
+
lat: z.number().min(-90).max(90),
|
|
96
|
+
lng: z.number().min(-180).max(180),
|
|
97
|
+
accuracy: z.number().nonnegative().max(100_000).optional(),
|
|
98
|
+
parentId,
|
|
99
|
+
replyTo,
|
|
100
|
+
}),
|
|
101
|
+
// System events sent by clients — this path is for things the server must not learn.
|
|
102
|
+
z.object({
|
|
103
|
+
t: z.literal('sys.screenshot'),
|
|
104
|
+
}),
|
|
105
|
+
/**
|
|
106
|
+
* Pins a channel post. With a `messageId` the post is pinned; **without one the pin is cleared.**
|
|
107
|
+
*
|
|
108
|
+
* The pin is not server state for the same reason posting rights are not: the server cannot tell
|
|
109
|
+
* which ciphertext is a post, so it could not act on "pin that one" without being told, in the
|
|
110
|
+
* clear, which message belongs to which room. So a pin travels exactly like a post — one message
|
|
111
|
+
* encrypted with the room key — and every device records it locally.
|
|
112
|
+
*
|
|
113
|
+
* Authority works the same way. The control is shown only to the admin, and a receiving client
|
|
114
|
+
* applies the pin only when the sender created the room. Outside a channel it is ignored.
|
|
115
|
+
*
|
|
116
|
+
* **A channel has one pin.** Pinning again replaces it, ordered by the message's `createdAt` so
|
|
117
|
+
* that an old event arriving late cannot overwrite a newer pin.
|
|
118
|
+
*
|
|
119
|
+
* A client too old to know this type fails the discriminated union and leaves an "integrity check
|
|
120
|
+
* failed" line.
|
|
121
|
+
*/
|
|
122
|
+
z.object({
|
|
123
|
+
t: z.literal('sys.pin'),
|
|
124
|
+
messageId: z.string().uuid().optional(),
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
export type MessageBody = z.infer<typeof messageBodySchema>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* How an encrypted message travels — the same shape the crypto layer produces. The size limits here
|
|
132
|
+
* are the ones the server enforces.
|
|
133
|
+
*/
|
|
134
|
+
export const encryptedPayloadSchema = z.object({
|
|
135
|
+
ciphertext: z.string().min(1).max(TEXT_CIPHERTEXT_MAX),
|
|
136
|
+
iv: z.string().min(1).max(64), // 16-byte AES-CBC IV, base64 = 24 chars
|
|
137
|
+
mac: z.string().min(1).max(128), // HMAC-SHA256 in hex = 64 chars
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export type EncryptedPayload = z.infer<typeof encryptedPayloadSchema>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from protocol/src/constants.ts in the graygate monorepo — do not edit by hand.
|
|
3
|
+
* Only the declarations the bot SDK needs are here; the rest of that module stays private.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Largest ciphertext a text message may carry (bytes, after base64 encoding). */
|
|
7
|
+
export const TEXT_CIPHERTEXT_MAX = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Largest media file (bytes). Uploaded straight to object storage, never through the server. */
|
|
10
|
+
export const MEDIA_MAX_BYTES = 50 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Longest comment on a channel post (a post itself may run to 8,000 characters — see body.ts).
|
|
14
|
+
* **Only the sending client enforces this**: the limit lives inside the ciphertext, where the
|
|
15
|
+
* server cannot see it.
|
|
16
|
+
*/
|
|
17
|
+
export const COMMENT_TEXT_MAX = 2000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The client header (`x-graygate-client`) a bot runtime sends — **not a secret.**
|
|
21
|
+
*
|
|
22
|
+
* The edge in front of the server keeps out traffic that never came from a graygate client at all;
|
|
23
|
+
* it is not a security boundary, so bots get their own value alongside the app's and both are
|
|
24
|
+
* accepted. The edge worker and the server hold copies of both.
|
|
25
|
+
*/
|
|
26
|
+
export const BOT_CLIENT_TOKEN = 'Qb7dK2mXhs1TfLpA9wEzR4vNc0uGyJ6i';
|
|
27
|
+
|
|
28
|
+
/** Bot access-token lifetime (minutes), same as a person's. No refresh token: the bot token is the long-lived credential. */
|
|
29
|
+
export const BOT_ACCESS_TOKEN_TTL_MIN = 15;
|
|
30
|
+
|
|
31
|
+
/** Most items one webhook request may carry. */
|
|
32
|
+
export const BOT_WEBHOOK_BATCH_MAX = 100;
|
|
33
|
+
|
|
34
|
+
/** How far a signature timestamp may be from now (seconds). Anything further is treated as a replay and dropped. */
|
|
35
|
+
export const BOT_WEBHOOK_SIGNATURE_TOLERANCE_S = 300;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from crypto/src/index.ts in the graygate monorepo — do not edit by hand.
|
|
3
|
+
* Only the declarations the bot SDK needs are here; the rest of that module stays private.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import CryptoJS from 'crypto-js';
|
|
7
|
+
import forge from 'node-forge';
|
|
8
|
+
import { CryptoUtils } from 'graygate-cypher/dist/crypto';
|
|
9
|
+
|
|
10
|
+
export class IntegrityError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('message integrity check failed (HMAC mismatch)');
|
|
13
|
+
this.name = 'IntegrityError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DecryptionError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super('decryption failed');
|
|
20
|
+
this.name = 'DecryptionError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Domain separation — one password derives keys that are independent of each other. */
|
|
25
|
+
const CTX = {
|
|
26
|
+
roomKey: 'graygate:v1:roomkey',
|
|
27
|
+
verifier: 'graygate:v1:verifier',
|
|
28
|
+
hmac: 'graygate:v1:hmac',
|
|
29
|
+
devicePin: 'graygate:v1:devicepin',
|
|
30
|
+
/** Recovery phrase to the verifier sent to the server. */
|
|
31
|
+
recovery: 'graygate:v1:recovery',
|
|
32
|
+
/** Recovery phrase to the verifier kept on this device. **Must differ** from the server's. */
|
|
33
|
+
recoveryLocal: 'graygate:v1:recoverylocal',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export interface EncryptedMessage {
|
|
37
|
+
ciphertext: string;
|
|
38
|
+
iv: string;
|
|
39
|
+
mac: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RsaKeyPair {
|
|
43
|
+
publicKeyPem: string;
|
|
44
|
+
privateKeyPem: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generates a user's RSA key pair — at signup, and again when someone moves to a new device. It is
|
|
49
|
+
* pure JavaScript, so on a phone it can take tens of seconds.
|
|
50
|
+
*
|
|
51
|
+
* **Why the stepwise generator:** the callback form of `generateKeyPair` holds the JavaScript thread
|
|
52
|
+
* for the whole computation when there is no worker to run it on. The progress line set just before
|
|
53
|
+
* it ("generating keys") never reaches the screen, and the person watches the *previous* step's text
|
|
54
|
+
* sit there for half a minute. Stepping the generator in slices and yielding to the event loop in
|
|
55
|
+
* between keeps the progress honest — a slow security step should be **shown**, not hidden.
|
|
56
|
+
*/
|
|
57
|
+
export function generateRsaKeyPair(bits = 2048): Promise<RsaKeyPair> {
|
|
58
|
+
// The stepwise API exists at runtime but is missing from the type definitions, hence the cast.
|
|
59
|
+
const rsa = forge.pki.rsa as unknown as {
|
|
60
|
+
createKeyPairGenerationState(bits: number, e: number): { keys: forge.pki.KeyPair | null };
|
|
61
|
+
stepKeyPairGenerationState(state: unknown, ms: number): boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const state = rsa.createKeyPairGenerationState(bits, 0x10001);
|
|
66
|
+
const run = (): void => {
|
|
67
|
+
try {
|
|
68
|
+
// 100ms of work at a time, then yield — enough that frames still get drawn.
|
|
69
|
+
if (!rsa.stepKeyPairGenerationState(state, 100)) {
|
|
70
|
+
setTimeout(run, 0);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const keys = state.keys!;
|
|
74
|
+
resolve({
|
|
75
|
+
publicKeyPem: forge.pki.publicKeyToPem(keys.publicKey),
|
|
76
|
+
privateKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
run();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function deriveMacKey(roomKey: string): Promise<string> {
|
|
87
|
+
return CryptoUtils.deriveFileKey(roomKey, CTX.hmac);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Encrypts a message body (any JSON-serialisable value) with the room key and signs it. */
|
|
91
|
+
export async function encryptMessage(roomKey: string, body: unknown): Promise<EncryptedMessage> {
|
|
92
|
+
const { encryptedData, iv } = await CryptoUtils.encrypt(JSON.stringify(body), roomKey);
|
|
93
|
+
const macKey = await deriveMacKey(roomKey);
|
|
94
|
+
const mac = CryptoJS.HmacSHA256(`${encryptedData}.${iv}`, macKey).toString(CryptoJS.enc.Hex);
|
|
95
|
+
return { ciphertext: encryptedData, iv, mac };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Verifies the HMAC, then decrypts. A mismatch throws `IntegrityError` and decryption is never
|
|
100
|
+
* attempted — this is the first stage of the content-validation pipeline, and it runs before any
|
|
101
|
+
* received bytes reach a parser.
|
|
102
|
+
*/
|
|
103
|
+
export async function decryptMessage(roomKey: string, msg: EncryptedMessage): Promise<unknown> {
|
|
104
|
+
const macKey = await deriveMacKey(roomKey);
|
|
105
|
+
const expected = CryptoJS.HmacSHA256(`${msg.ciphertext}.${msg.iv}`, macKey).toString(CryptoJS.enc.Hex);
|
|
106
|
+
if (!constantTimeEqual(expected, msg.mac)) throw new IntegrityError();
|
|
107
|
+
|
|
108
|
+
let plain: string;
|
|
109
|
+
try {
|
|
110
|
+
plain = await CryptoUtils.decrypt(msg.ciphertext, roomKey, msg.iv);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new DecryptionError();
|
|
113
|
+
}
|
|
114
|
+
if (!plain) throw new DecryptionError();
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(plain);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new DecryptionError();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Opens an envelope with this device's private key (PEM) and returns the room key. */
|
|
123
|
+
export function unwrapRoomKey(wrapped: string, privateKeyPem: string): Promise<string> {
|
|
124
|
+
return CryptoUtils.decryptFileKey(wrapped, new TextEncoder().encode(privateKeyPem));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
128
|
+
if (a.length !== b.length) return false;
|
|
129
|
+
let diff = 0;
|
|
130
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
131
|
+
return diff === 0;
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from protocol/src/ws.ts in the graygate monorepo — do not edit by hand.
|
|
3
|
+
* Only the declarations the bot SDK needs are here; the rest of that module stays private.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { encryptedPayloadSchema } from './body';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
const uuid = z.string().uuid();
|
|
10
|
+
|
|
11
|
+
export const msgNewPayload = z.object({
|
|
12
|
+
id: uuid,
|
|
13
|
+
roomId: uuid,
|
|
14
|
+
senderId: uuid,
|
|
15
|
+
keyVersion: z.number().int().positive(),
|
|
16
|
+
kind: z.enum(['text', 'media']),
|
|
17
|
+
ciphertext: encryptedPayloadSchema,
|
|
18
|
+
mediaId: uuid.optional(),
|
|
19
|
+
createdAt: z.string().datetime(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const envelopeNewPayload = z.object({
|
|
23
|
+
id: uuid,
|
|
24
|
+
roomId: uuid,
|
|
25
|
+
keyVersion: z.number().int().positive(),
|
|
26
|
+
purpose: z.enum(['invite', 'rotation']),
|
|
27
|
+
ciphertext: z.string().min(1), // RSA-OAEP base64
|
|
28
|
+
createdBy: uuid,
|
|
29
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format the bot speaks — message bodies, webhook items, and the crypto around them.
|
|
3
|
+
* Generated from the graygate monorepo (see scripts/export-public.mjs there); do not edit.
|
|
4
|
+
*/
|
|
5
|
+
export * from './constants';
|
|
6
|
+
export * from './body';
|
|
7
|
+
export * from './events';
|
|
8
|
+
export * from './webhook';
|
|
9
|
+
export * from './crypto';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from protocol/src/rest.ts in the graygate monorepo — do not edit by hand.
|
|
3
|
+
* Only the declarations the bot SDK needs are here; the rest of that module stays private.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BOT_WEBHOOK_BATCH_MAX } from './constants';
|
|
7
|
+
import { envelopeNewPayload, msgNewPayload } from './events';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
const uuid = z.string().uuid();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The webhook body a bot receives. Each item has **the same shape** as the payload the WebSocket
|
|
14
|
+
* pushes to a phone.
|
|
15
|
+
*
|
|
16
|
+
* What leaves is ciphertext, so a webhook delivered to the wrong address leaks the metadata around
|
|
17
|
+
* a message — room id, sender id, timestamp — and not what was said.
|
|
18
|
+
*/
|
|
19
|
+
export const botWebhookItem = z.discriminatedUnion('type', [
|
|
20
|
+
z.object({ type: z.literal('msg.new'), payload: msgNewPayload }),
|
|
21
|
+
z.object({ type: z.literal('envelope.new'), payload: envelopeNewPayload }),
|
|
22
|
+
z.object({ type: z.literal('member.joined'), payload: z.object({ roomId: uuid, userId: uuid }) }),
|
|
23
|
+
z.object({ type: z.literal('member.left'), payload: z.object({ roomId: uuid, userId: uuid }) }),
|
|
24
|
+
z.object({
|
|
25
|
+
type: z.literal('member.key_changed'),
|
|
26
|
+
payload: z.object({ roomId: uuid, userId: uuid, keyUpdatedAt: z.string().datetime() }),
|
|
27
|
+
}),
|
|
28
|
+
z.object({ type: z.literal('room.deleted'), payload: z.object({ roomId: uuid }) }),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export type BotWebhookItem = z.infer<typeof botWebhookItem>;
|
|
32
|
+
|
|
33
|
+
export const botWebhookBody = z.object({
|
|
34
|
+
botId: uuid,
|
|
35
|
+
items: z.array(botWebhookItem).max(BOT_WEBHOOK_BATCH_MAX),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type BotWebhookBody = z.infer<typeof botWebhookBody>;
|