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