@xmtp/convos-cli 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 +572 -0
- package/bin/dev.js +4 -0
- package/bin/run.js +4 -0
- package/dist/baseCommand.d.ts +46 -0
- package/dist/baseCommand.js +171 -0
- package/dist/commands/agent/serve.d.ts +67 -0
- package/dist/commands/agent/serve.js +662 -0
- package/dist/commands/conversation/add-members.d.ts +19 -0
- package/dist/commands/conversation/add-members.js +39 -0
- package/dist/commands/conversation/consent-state.d.ts +18 -0
- package/dist/commands/conversation/consent-state.js +24 -0
- package/dist/commands/conversation/download-attachment.d.ts +28 -0
- package/dist/commands/conversation/download-attachment.js +164 -0
- package/dist/commands/conversation/explode.d.ts +24 -0
- package/dist/commands/conversation/explode.js +156 -0
- package/dist/commands/conversation/info.d.ts +22 -0
- package/dist/commands/conversation/info.js +79 -0
- package/dist/commands/conversation/invite.d.ts +26 -0
- package/dist/commands/conversation/invite.js +137 -0
- package/dist/commands/conversation/lock.d.ts +24 -0
- package/dist/commands/conversation/lock.js +98 -0
- package/dist/commands/conversation/members.d.ts +22 -0
- package/dist/commands/conversation/members.js +39 -0
- package/dist/commands/conversation/messages.d.ts +31 -0
- package/dist/commands/conversation/messages.js +141 -0
- package/dist/commands/conversation/permissions.d.ts +18 -0
- package/dist/commands/conversation/permissions.js +33 -0
- package/dist/commands/conversation/profiles.d.ts +22 -0
- package/dist/commands/conversation/profiles.js +80 -0
- package/dist/commands/conversation/remove-members.d.ts +19 -0
- package/dist/commands/conversation/remove-members.js +36 -0
- package/dist/commands/conversation/send-attachment.d.ts +30 -0
- package/dist/commands/conversation/send-attachment.js +187 -0
- package/dist/commands/conversation/send-reaction.d.ts +21 -0
- package/dist/commands/conversation/send-reaction.js +38 -0
- package/dist/commands/conversation/send-remote-attachment.d.ts +30 -0
- package/dist/commands/conversation/send-remote-attachment.js +96 -0
- package/dist/commands/conversation/send-reply.d.ts +32 -0
- package/dist/commands/conversation/send-reply.js +170 -0
- package/dist/commands/conversation/send-text.d.ts +24 -0
- package/dist/commands/conversation/send-text.js +64 -0
- package/dist/commands/conversation/stream.d.ts +24 -0
- package/dist/commands/conversation/stream.js +81 -0
- package/dist/commands/conversation/sync.d.ts +18 -0
- package/dist/commands/conversation/sync.js +25 -0
- package/dist/commands/conversation/update-consent.d.ts +19 -0
- package/dist/commands/conversation/update-consent.js +35 -0
- package/dist/commands/conversation/update-description.d.ts +19 -0
- package/dist/commands/conversation/update-description.js +28 -0
- package/dist/commands/conversation/update-name.d.ts +19 -0
- package/dist/commands/conversation/update-name.js +29 -0
- package/dist/commands/conversation/update-profile.d.ts +24 -0
- package/dist/commands/conversation/update-profile.js +97 -0
- package/dist/commands/conversations/create.d.ts +26 -0
- package/dist/commands/conversations/create.js +165 -0
- package/dist/commands/conversations/join.d.ts +27 -0
- package/dist/commands/conversations/join.js +232 -0
- package/dist/commands/conversations/list.d.ts +20 -0
- package/dist/commands/conversations/list.js +109 -0
- package/dist/commands/conversations/process-join-requests.d.ts +26 -0
- package/dist/commands/conversations/process-join-requests.js +261 -0
- package/dist/commands/conversations/sync.d.ts +19 -0
- package/dist/commands/conversations/sync.js +50 -0
- package/dist/commands/identity/create.d.ts +21 -0
- package/dist/commands/identity/create.js +56 -0
- package/dist/commands/identity/info.d.ts +22 -0
- package/dist/commands/identity/info.js +63 -0
- package/dist/commands/identity/list.d.ts +19 -0
- package/dist/commands/identity/list.js +59 -0
- package/dist/commands/identity/remove.d.ts +23 -0
- package/dist/commands/identity/remove.js +51 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +91 -0
- package/dist/commands/reset.d.ts +17 -0
- package/dist/commands/reset.js +93 -0
- package/dist/help.d.ts +4 -0
- package/dist/help.js +31 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +15 -0
- package/dist/utils/client.d.ts +8 -0
- package/dist/utils/client.js +58 -0
- package/dist/utils/config.d.ts +15 -0
- package/dist/utils/config.js +1 -0
- package/dist/utils/identities.d.ts +49 -0
- package/dist/utils/identities.js +92 -0
- package/dist/utils/invite.d.ts +70 -0
- package/dist/utils/invite.js +339 -0
- package/dist/utils/metadata.d.ts +39 -0
- package/dist/utils/metadata.js +180 -0
- package/dist/utils/mime.d.ts +2 -0
- package/dist/utils/mime.js +42 -0
- package/dist/utils/random.d.ts +5 -0
- package/dist/utils/random.js +19 -0
- package/dist/utils/upload.d.ts +14 -0
- package/dist/utils/upload.js +51 -0
- package/dist/utils/xmtp.d.ts +45 -0
- package/dist/utils/xmtp.js +298 -0
- package/oclif.manifest.json +5562 -0
- package/package.json +124 -0
- package/skills/convos-cli/SKILL.md +588 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export declare function encryptConversationToken(conversationId: string, creatorInboxId: string, privateKeyBytes: Uint8Array): Buffer;
|
|
2
|
+
export declare function decryptConversationToken(tokenBytes: Buffer, creatorInboxId: string, privateKeyBytes: Uint8Array): string;
|
|
3
|
+
export interface InviteOptions {
|
|
4
|
+
name?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
imageUrl?: string;
|
|
7
|
+
expiresAt?: Date;
|
|
8
|
+
expiresAfterUse?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generate an invite slug for a conversation (ADR 001).
|
|
12
|
+
*
|
|
13
|
+
* 1. Encrypt conversation ID with ChaCha20-Poly1305
|
|
14
|
+
* 2. Build InvitePayload protobuf
|
|
15
|
+
* 3. Sign with secp256k1 ECDSA (recoverable)
|
|
16
|
+
* 4. Wrap in SignedInvite, compress, base64url encode
|
|
17
|
+
*/
|
|
18
|
+
export declare function createInviteSlug(conversationId: string, creatorInboxId: string, inviteTag: string, walletPrivateKey: string, options?: InviteOptions): Promise<string>;
|
|
19
|
+
export interface ParsedInvite {
|
|
20
|
+
tag: string;
|
|
21
|
+
creatorInboxId: string;
|
|
22
|
+
conversationToken: Buffer;
|
|
23
|
+
name?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
imageUrl?: string;
|
|
26
|
+
expiresAt?: Date;
|
|
27
|
+
conversationExpiresAt?: Date;
|
|
28
|
+
expiresAfterUse: boolean;
|
|
29
|
+
/** Raw serialized payload bytes (for re-encoding) */
|
|
30
|
+
payloadBytes: Uint8Array;
|
|
31
|
+
/** 65-byte recoverable signature */
|
|
32
|
+
signature: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse an invite from a slug or URL.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseInvite(inviteInput: string): ParsedInvite;
|
|
38
|
+
/**
|
|
39
|
+
* Verify the invite has a valid structure and a recoverable ECDSA signature.
|
|
40
|
+
*
|
|
41
|
+
* This performs:
|
|
42
|
+
* 1. Structural validation (all required fields present)
|
|
43
|
+
* 2. Cryptographic signature verification (can recover a public key from the
|
|
44
|
+
* secp256k1 ECDSA signature over SHA256(payloadBytes))
|
|
45
|
+
*
|
|
46
|
+
* Note: this does not verify the recovered public key matches a specific
|
|
47
|
+
* creator — that requires additional context. Use `verifyInviteSignature()`
|
|
48
|
+
* on the creator side to match against a known wallet key.
|
|
49
|
+
*/
|
|
50
|
+
export declare function verifyInvite(invite: ParsedInvite): Promise<boolean>;
|
|
51
|
+
/**
|
|
52
|
+
* Recover the signer's public key from an invite's signature.
|
|
53
|
+
*
|
|
54
|
+
* Uses secp256k1 ECDSA recovery on SHA256(payloadBytes).
|
|
55
|
+
* Returns the uncompressed public key (0x04... prefix, 65 bytes).
|
|
56
|
+
*/
|
|
57
|
+
export declare function recoverInvitePublicKey(invite: ParsedInvite): Promise<`0x${string}`>;
|
|
58
|
+
/**
|
|
59
|
+
* Verify that an invite was signed by a specific wallet private key.
|
|
60
|
+
*
|
|
61
|
+
* Used on the creator side (process-join-requests) to confirm the invite
|
|
62
|
+
* was genuinely created by this identity's wallet. Recovers the signer's
|
|
63
|
+
* public key from the signature and compares it to the public key derived
|
|
64
|
+
* from the given private key.
|
|
65
|
+
*/
|
|
66
|
+
export declare function verifyInviteSignature(invite: ParsedInvite, walletPrivateKey: string): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Re-encode a parsed invite back to a slug (for sending as DM join request).
|
|
69
|
+
*/
|
|
70
|
+
export declare function inviteToSlug(invite: ParsedInvite): string;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, createCipheriv, createDecipheriv, } from "node:crypto";
|
|
2
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
3
|
+
import protobuf from "protobufjs";
|
|
4
|
+
import { hexToBytes as viemHexToBytes, recoverPublicKey } from "viem";
|
|
5
|
+
import { sign as viemSign, privateKeyToAccount } from "viem/accounts";
|
|
6
|
+
// ─── Protobuf Schemas (matching convos-ios invite.proto) ───
|
|
7
|
+
const root = new protobuf.Root();
|
|
8
|
+
root.add(new protobuf.Type("InvitePayload")
|
|
9
|
+
.add(new protobuf.Field("conversationToken", 1, "bytes"))
|
|
10
|
+
.add(new protobuf.Field("creatorInboxId", 2, "bytes"))
|
|
11
|
+
.add(new protobuf.Field("tag", 3, "string"))
|
|
12
|
+
.add(new protobuf.Field("name", 4, "string", "optional"))
|
|
13
|
+
.add(new protobuf.Field("description_p", 5, "string", "optional"))
|
|
14
|
+
.add(new protobuf.Field("imageURL", 6, "string", "optional"))
|
|
15
|
+
.add(new protobuf.Field("conversationExpiresAtUnix", 7, "sfixed64", "optional"))
|
|
16
|
+
.add(new protobuf.Field("expiresAtUnix", 8, "sfixed64", "optional"))
|
|
17
|
+
.add(new protobuf.Field("expiresAfterUse", 9, "bool")));
|
|
18
|
+
root.add(new protobuf.Type("SignedInvite")
|
|
19
|
+
.add(new protobuf.Field("payload", 1, "bytes"))
|
|
20
|
+
.add(new protobuf.Field("signature", 2, "bytes")));
|
|
21
|
+
const InvitePayload = root.lookupType("InvitePayload");
|
|
22
|
+
const SignedInviteType = root.lookupType("SignedInvite");
|
|
23
|
+
// ─── HKDF-SHA256 ───
|
|
24
|
+
function hkdfSha256(ikm, salt, info, length) {
|
|
25
|
+
const prk = createHmac("sha256", salt).update(ikm).digest();
|
|
26
|
+
let t = Buffer.alloc(0);
|
|
27
|
+
let okm = Buffer.alloc(0);
|
|
28
|
+
for (let i = 1; okm.length < length; i++) {
|
|
29
|
+
t = createHmac("sha256", prk)
|
|
30
|
+
.update(Buffer.concat([t, info, Buffer.from([i])]))
|
|
31
|
+
.digest();
|
|
32
|
+
okm = Buffer.concat([okm, t]);
|
|
33
|
+
}
|
|
34
|
+
return okm.subarray(0, length);
|
|
35
|
+
}
|
|
36
|
+
// ─── ChaCha20-Poly1305 Conversation Token (matching InviteConversationToken.swift) ───
|
|
37
|
+
const FORMAT_VERSION = 1;
|
|
38
|
+
const HKDF_SALT = Buffer.from("ConvosInviteV1", "utf-8");
|
|
39
|
+
function deriveTokenKey(privateKeyBytes, inboxId) {
|
|
40
|
+
const info = Buffer.from(`inbox:${inboxId}`, "utf-8");
|
|
41
|
+
return hkdfSha256(privateKeyBytes, HKDF_SALT, info, 32);
|
|
42
|
+
}
|
|
43
|
+
function packConversationId(conversationId) {
|
|
44
|
+
const uuidMatch = conversationId.match(/^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i);
|
|
45
|
+
if (uuidMatch) {
|
|
46
|
+
const hex = uuidMatch.slice(1).join("");
|
|
47
|
+
return Buffer.concat([Buffer.from([0x01]), Buffer.from(hex, "hex")]);
|
|
48
|
+
}
|
|
49
|
+
const strBytes = Buffer.from(conversationId, "utf-8");
|
|
50
|
+
if (strBytes.length <= 255) {
|
|
51
|
+
return Buffer.concat([Buffer.from([0x02, strBytes.length]), strBytes]);
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat([
|
|
54
|
+
Buffer.from([0x02, 0x00, (strBytes.length >> 8) & 0xff, strBytes.length & 0xff]),
|
|
55
|
+
strBytes,
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
function unpackConversationId(data) {
|
|
59
|
+
const tag = data[0];
|
|
60
|
+
if (tag === 0x01) {
|
|
61
|
+
const hex = data.subarray(1, 17).toString("hex");
|
|
62
|
+
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join("-");
|
|
63
|
+
}
|
|
64
|
+
if (tag === 0x02) {
|
|
65
|
+
let offset = 1;
|
|
66
|
+
let length = data[offset];
|
|
67
|
+
offset++;
|
|
68
|
+
if (length === 0) {
|
|
69
|
+
length = (data[offset] << 8) | data[offset + 1];
|
|
70
|
+
offset += 2;
|
|
71
|
+
}
|
|
72
|
+
return data.subarray(offset, offset + length).toString("utf-8");
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Unknown conversation ID tag: ${tag}`);
|
|
75
|
+
}
|
|
76
|
+
export function encryptConversationToken(conversationId, creatorInboxId, privateKeyBytes) {
|
|
77
|
+
const key = deriveTokenKey(privateKeyBytes, creatorInboxId);
|
|
78
|
+
const plaintext = packConversationId(conversationId);
|
|
79
|
+
const nonce = randomBytes(12);
|
|
80
|
+
const aad = Buffer.from(creatorInboxId, "utf-8");
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
const cipher = createCipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
|
|
83
|
+
cipher.setAAD(aad);
|
|
84
|
+
const ciphertext = cipher.update(plaintext);
|
|
85
|
+
cipher.final();
|
|
86
|
+
const authTag = cipher.getAuthTag();
|
|
87
|
+
return Buffer.concat([Buffer.from([FORMAT_VERSION]), nonce, ciphertext, authTag]);
|
|
88
|
+
}
|
|
89
|
+
export function decryptConversationToken(tokenBytes, creatorInboxId, privateKeyBytes) {
|
|
90
|
+
if (tokenBytes[0] !== FORMAT_VERSION) {
|
|
91
|
+
throw new Error(`Unsupported token version: ${tokenBytes[0]}`);
|
|
92
|
+
}
|
|
93
|
+
const nonce = tokenBytes.subarray(1, 13);
|
|
94
|
+
const authTag = tokenBytes.subarray(tokenBytes.length - 16);
|
|
95
|
+
const ciphertext = tokenBytes.subarray(13, tokenBytes.length - 16);
|
|
96
|
+
const key = deriveTokenKey(privateKeyBytes, creatorInboxId);
|
|
97
|
+
const aad = Buffer.from(creatorInboxId, "utf-8");
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
|
|
100
|
+
decipher.setAAD(aad);
|
|
101
|
+
decipher.setAuthTag(authTag);
|
|
102
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
103
|
+
return unpackConversationId(plaintext);
|
|
104
|
+
}
|
|
105
|
+
// ─── secp256k1 ECDSA with recovery (using viem) ───
|
|
106
|
+
function sha256(data) {
|
|
107
|
+
return createHash("sha256").update(data).digest();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Sign a message hash with secp256k1 and produce a 65-byte recoverable signature
|
|
111
|
+
* (64 bytes compact + 1 byte recovery ID), matching the iOS implementation.
|
|
112
|
+
*
|
|
113
|
+
* Uses viem's sign() which wraps @noble/curves and returns {r, s, yParity}.
|
|
114
|
+
*/
|
|
115
|
+
async function signWithRecovery(messageHash, privateKeyHex) {
|
|
116
|
+
const hashHex = `0x${messageHash.toString("hex")}`;
|
|
117
|
+
const keyHex = privateKeyHex.startsWith("0x") ? privateKeyHex : `0x${privateKeyHex}`;
|
|
118
|
+
const sig = await viemSign({
|
|
119
|
+
hash: hashHex,
|
|
120
|
+
privateKey: keyHex,
|
|
121
|
+
});
|
|
122
|
+
// Convert r, s (hex strings) to 32-byte buffers
|
|
123
|
+
const rBytes = Buffer.from(viemHexToBytes(sig.r));
|
|
124
|
+
const sBytes = Buffer.from(viemHexToBytes(sig.s));
|
|
125
|
+
// Recovery ID is yParity (0 or 1)
|
|
126
|
+
const recoveryId = sig.yParity;
|
|
127
|
+
const result = Buffer.alloc(65);
|
|
128
|
+
rBytes.copy(result, 32 - rBytes.length); // pad r to 32 bytes
|
|
129
|
+
sBytes.copy(result, 64 - sBytes.length); // pad s to 32 bytes
|
|
130
|
+
result[64] = recoveryId ?? 0;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
// ─── Base64URL ───
|
|
134
|
+
function base64urlEncode(data) {
|
|
135
|
+
return data.toString("base64url");
|
|
136
|
+
}
|
|
137
|
+
function base64urlDecode(str) {
|
|
138
|
+
return Buffer.from(str, "base64url");
|
|
139
|
+
}
|
|
140
|
+
// ─── Compression ───
|
|
141
|
+
// Format must match iOS: [marker: 1 byte][original_size: 4 bytes big-endian][zlib data]
|
|
142
|
+
const COMPRESSION_MARKER = 0x1f;
|
|
143
|
+
function compressIfSmaller(data) {
|
|
144
|
+
if (data.length <= 100)
|
|
145
|
+
return data;
|
|
146
|
+
const compressed = deflateSync(data);
|
|
147
|
+
// 1 byte marker + 4 bytes size + compressed data
|
|
148
|
+
if (compressed.length + 5 < data.length) {
|
|
149
|
+
const sizeBytes = Buffer.alloc(4);
|
|
150
|
+
sizeBytes.writeUInt32BE(data.length);
|
|
151
|
+
return Buffer.concat([Buffer.from([COMPRESSION_MARKER]), sizeBytes, compressed]);
|
|
152
|
+
}
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
function decompressIfNeeded(data) {
|
|
156
|
+
if (data[0] === COMPRESSION_MARKER) {
|
|
157
|
+
// iOS format: [marker][4-byte size BE][zlib data]
|
|
158
|
+
return Buffer.from(inflateSync(data.subarray(5)));
|
|
159
|
+
}
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
// ─── iMessage compatibility ───
|
|
163
|
+
function insertSeparators(str, sep, every) {
|
|
164
|
+
if (str.length <= every)
|
|
165
|
+
return str;
|
|
166
|
+
const parts = [];
|
|
167
|
+
for (let i = 0; i < str.length; i += every) {
|
|
168
|
+
parts.push(str.slice(i, i + every));
|
|
169
|
+
}
|
|
170
|
+
return parts.join(sep);
|
|
171
|
+
}
|
|
172
|
+
function removeSeparators(str) {
|
|
173
|
+
return str.replace(/\*/g, "");
|
|
174
|
+
}
|
|
175
|
+
// ─── Hex helpers ───
|
|
176
|
+
function hexToBytes(hex) {
|
|
177
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
178
|
+
return Buffer.from(clean, "hex");
|
|
179
|
+
}
|
|
180
|
+
function bytesToHex(bytes) {
|
|
181
|
+
return Buffer.from(bytes).toString("hex");
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Generate an invite slug for a conversation (ADR 001).
|
|
185
|
+
*
|
|
186
|
+
* 1. Encrypt conversation ID with ChaCha20-Poly1305
|
|
187
|
+
* 2. Build InvitePayload protobuf
|
|
188
|
+
* 3. Sign with secp256k1 ECDSA (recoverable)
|
|
189
|
+
* 4. Wrap in SignedInvite, compress, base64url encode
|
|
190
|
+
*/
|
|
191
|
+
export async function createInviteSlug(conversationId, creatorInboxId, inviteTag, walletPrivateKey, options) {
|
|
192
|
+
const privateKeyBytes = hexToBytes(walletPrivateKey);
|
|
193
|
+
const conversationToken = encryptConversationToken(conversationId, creatorInboxId, privateKeyBytes);
|
|
194
|
+
const creatorInboxIdBytes = hexToBytes(creatorInboxId);
|
|
195
|
+
const payloadObj = {
|
|
196
|
+
conversationToken,
|
|
197
|
+
creatorInboxId: creatorInboxIdBytes,
|
|
198
|
+
tag: inviteTag,
|
|
199
|
+
expiresAfterUse: options?.expiresAfterUse ?? false,
|
|
200
|
+
};
|
|
201
|
+
if (options?.name)
|
|
202
|
+
payloadObj.name = options.name;
|
|
203
|
+
if (options?.description)
|
|
204
|
+
payloadObj.description_p = options.description;
|
|
205
|
+
if (options?.imageUrl)
|
|
206
|
+
payloadObj.imageURL = options.imageUrl;
|
|
207
|
+
if (options?.expiresAt) {
|
|
208
|
+
payloadObj.expiresAtUnix = Math.floor(options.expiresAt.getTime() / 1000);
|
|
209
|
+
}
|
|
210
|
+
const errMsg = InvitePayload.verify(payloadObj);
|
|
211
|
+
if (errMsg)
|
|
212
|
+
throw new Error(`Invalid payload: ${errMsg}`);
|
|
213
|
+
const payloadBytes = Buffer.from(InvitePayload.encode(InvitePayload.create(payloadObj)).finish());
|
|
214
|
+
// Sign: SHA256(payloadBytes) → secp256k1 ECDSA recoverable
|
|
215
|
+
const messageHash = sha256(payloadBytes);
|
|
216
|
+
const signature = await signWithRecovery(messageHash, walletPrivateKey);
|
|
217
|
+
const signedInviteBytes = Buffer.from(SignedInviteType.encode(SignedInviteType.create({ payload: payloadBytes, signature })).finish());
|
|
218
|
+
const compressed = compressIfSmaller(signedInviteBytes);
|
|
219
|
+
return insertSeparators(base64urlEncode(compressed), "*", 300);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Parse an invite from a slug or URL.
|
|
223
|
+
*/
|
|
224
|
+
export function parseInvite(inviteInput) {
|
|
225
|
+
let slug = inviteInput.trim();
|
|
226
|
+
// Extract from URL if it looks like one
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(slug);
|
|
229
|
+
const iParam = url.searchParams.get("i");
|
|
230
|
+
if (iParam)
|
|
231
|
+
slug = iParam;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Not a URL
|
|
235
|
+
}
|
|
236
|
+
slug = removeSeparators(slug);
|
|
237
|
+
const data = base64urlDecode(slug);
|
|
238
|
+
const decompressed = decompressIfNeeded(data);
|
|
239
|
+
const signedInvite = SignedInviteType.decode(decompressed);
|
|
240
|
+
const payload = InvitePayload.decode(signedInvite.payload);
|
|
241
|
+
const toNum = (v) => {
|
|
242
|
+
if (v == null)
|
|
243
|
+
return undefined;
|
|
244
|
+
return typeof v === "number" ? v : v.toNumber();
|
|
245
|
+
};
|
|
246
|
+
const expiresAtUnix = toNum(payload.expiresAtUnix);
|
|
247
|
+
const convExpiresAtUnix = toNum(payload.conversationExpiresAtUnix);
|
|
248
|
+
return {
|
|
249
|
+
tag: payload.tag,
|
|
250
|
+
creatorInboxId: bytesToHex(payload.creatorInboxId),
|
|
251
|
+
conversationToken: Buffer.from(payload.conversationToken),
|
|
252
|
+
name: payload.name || undefined,
|
|
253
|
+
description: payload.description_p || undefined,
|
|
254
|
+
imageUrl: payload.imageURL || undefined,
|
|
255
|
+
expiresAt: expiresAtUnix ? new Date(expiresAtUnix * 1000) : undefined,
|
|
256
|
+
conversationExpiresAt: convExpiresAtUnix ? new Date(convExpiresAtUnix * 1000) : undefined,
|
|
257
|
+
expiresAfterUse: payload.expiresAfterUse ?? false,
|
|
258
|
+
payloadBytes: signedInvite.payload,
|
|
259
|
+
signature: signedInvite.signature,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Verify the invite has a valid structure and a recoverable ECDSA signature.
|
|
264
|
+
*
|
|
265
|
+
* This performs:
|
|
266
|
+
* 1. Structural validation (all required fields present)
|
|
267
|
+
* 2. Cryptographic signature verification (can recover a public key from the
|
|
268
|
+
* secp256k1 ECDSA signature over SHA256(payloadBytes))
|
|
269
|
+
*
|
|
270
|
+
* Note: this does not verify the recovered public key matches a specific
|
|
271
|
+
* creator — that requires additional context. Use `verifyInviteSignature()`
|
|
272
|
+
* on the creator side to match against a known wallet key.
|
|
273
|
+
*/
|
|
274
|
+
export async function verifyInvite(invite) {
|
|
275
|
+
try {
|
|
276
|
+
// Structural validation
|
|
277
|
+
if (!invite.tag || invite.tag.length === 0)
|
|
278
|
+
return false;
|
|
279
|
+
if (!invite.creatorInboxId || invite.creatorInboxId.length === 0)
|
|
280
|
+
return false;
|
|
281
|
+
if (!invite.conversationToken || invite.conversationToken.length === 0)
|
|
282
|
+
return false;
|
|
283
|
+
if (!invite.signature || invite.signature.length !== 65)
|
|
284
|
+
return false;
|
|
285
|
+
if (!invite.payloadBytes || invite.payloadBytes.length === 0)
|
|
286
|
+
return false;
|
|
287
|
+
// Cryptographic verification: recover public key from signature
|
|
288
|
+
await recoverInvitePublicKey(invite);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Recover the signer's public key from an invite's signature.
|
|
297
|
+
*
|
|
298
|
+
* Uses secp256k1 ECDSA recovery on SHA256(payloadBytes).
|
|
299
|
+
* Returns the uncompressed public key (0x04... prefix, 65 bytes).
|
|
300
|
+
*/
|
|
301
|
+
export async function recoverInvitePublicKey(invite) {
|
|
302
|
+
const messageHash = `0x${sha256(Buffer.from(invite.payloadBytes)).toString("hex")}`;
|
|
303
|
+
// Reconstruct signature as 65-byte hex: r (32) + s (32) + v (1)
|
|
304
|
+
const r = Buffer.from(invite.signature.slice(0, 32)).toString("hex");
|
|
305
|
+
const s = Buffer.from(invite.signature.slice(32, 64)).toString("hex");
|
|
306
|
+
const v = invite.signature[64]; // recovery ID (0 or 1)
|
|
307
|
+
const signatureHex = `0x${r}${s}${v === 1 ? "01" : "00"}`;
|
|
308
|
+
return recoverPublicKey({ hash: messageHash, signature: signatureHex });
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Verify that an invite was signed by a specific wallet private key.
|
|
312
|
+
*
|
|
313
|
+
* Used on the creator side (process-join-requests) to confirm the invite
|
|
314
|
+
* was genuinely created by this identity's wallet. Recovers the signer's
|
|
315
|
+
* public key from the signature and compares it to the public key derived
|
|
316
|
+
* from the given private key.
|
|
317
|
+
*/
|
|
318
|
+
export async function verifyInviteSignature(invite, walletPrivateKey) {
|
|
319
|
+
try {
|
|
320
|
+
const keyHex = (walletPrivateKey.startsWith("0x") ? walletPrivateKey : `0x${walletPrivateKey}`);
|
|
321
|
+
const account = privateKeyToAccount(keyHex);
|
|
322
|
+
const recoveredPubKey = await recoverInvitePublicKey(invite);
|
|
323
|
+
return recoveredPubKey.toLowerCase() === account.publicKey.toLowerCase();
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Re-encode a parsed invite back to a slug (for sending as DM join request).
|
|
331
|
+
*/
|
|
332
|
+
export function inviteToSlug(invite) {
|
|
333
|
+
const signedInviteBytes = Buffer.from(SignedInviteType.encode(SignedInviteType.create({
|
|
334
|
+
payload: Buffer.from(invite.payloadBytes),
|
|
335
|
+
signature: Buffer.from(invite.signature),
|
|
336
|
+
})).finish());
|
|
337
|
+
const compressed = compressIfSmaller(signedInviteBytes);
|
|
338
|
+
return insertSeparators(base64urlEncode(compressed), "*", 300);
|
|
339
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationCustomMetadata — protobuf schema matching convos-ios
|
|
3
|
+
* Stored in XMTP group's appData field (max 8KB).
|
|
4
|
+
*
|
|
5
|
+
* Encoding: protobuf → optional DEFLATE compress → base64url
|
|
6
|
+
*/
|
|
7
|
+
export interface ConversationProfile {
|
|
8
|
+
inboxId: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
image?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ConversationCustomMetadata {
|
|
13
|
+
tag: string;
|
|
14
|
+
profiles: ConversationProfile[];
|
|
15
|
+
expiresAtUnix?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse appData string into ConversationCustomMetadata.
|
|
19
|
+
* Returns empty metadata if appData is empty or invalid.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseAppData(appData: string): ConversationCustomMetadata;
|
|
22
|
+
/**
|
|
23
|
+
* Serialize ConversationCustomMetadata to an appData string.
|
|
24
|
+
* Throws if result exceeds 8KB.
|
|
25
|
+
*/
|
|
26
|
+
export declare function serializeAppData(metadata: ConversationCustomMetadata): string;
|
|
27
|
+
/**
|
|
28
|
+
* Upsert a profile in the metadata. If a profile with the same inboxId exists,
|
|
29
|
+
* it is updated. Otherwise, a new profile is added.
|
|
30
|
+
*/
|
|
31
|
+
export declare function upsertProfile(metadata: ConversationCustomMetadata, profile: ConversationProfile): ConversationCustomMetadata;
|
|
32
|
+
/**
|
|
33
|
+
* Remove a profile from the metadata by inbox ID.
|
|
34
|
+
*/
|
|
35
|
+
export declare function removeProfile(metadata: ConversationCustomMetadata, inboxId: string): ConversationCustomMetadata;
|
|
36
|
+
/**
|
|
37
|
+
* Get a profile by inbox ID.
|
|
38
|
+
*/
|
|
39
|
+
export declare function getProfile(metadata: ConversationCustomMetadata, inboxId: string): ConversationProfile | undefined;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationCustomMetadata — protobuf schema matching convos-ios
|
|
3
|
+
* Stored in XMTP group's appData field (max 8KB).
|
|
4
|
+
*
|
|
5
|
+
* Encoding: protobuf → optional DEFLATE compress → base64url
|
|
6
|
+
*/
|
|
7
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
8
|
+
import protobuf from "protobufjs";
|
|
9
|
+
// ─── Protobuf Schema ───
|
|
10
|
+
const root = new protobuf.Root();
|
|
11
|
+
const EncryptedImageRefType = new protobuf.Type("EncryptedImageRef")
|
|
12
|
+
.add(new protobuf.Field("url", 1, "string"))
|
|
13
|
+
.add(new protobuf.Field("salt", 2, "bytes"))
|
|
14
|
+
.add(new protobuf.Field("nonce", 3, "bytes"));
|
|
15
|
+
const ConversationProfileType = new protobuf.Type("ConversationProfile")
|
|
16
|
+
.add(new protobuf.Field("inboxId", 1, "bytes"))
|
|
17
|
+
.add(new protobuf.Field("name", 2, "string", "optional"))
|
|
18
|
+
.add(new protobuf.Field("image", 3, "string", "optional"))
|
|
19
|
+
.add(new protobuf.Field("encryptedImage", 4, "EncryptedImageRef", "optional"));
|
|
20
|
+
const ConversationCustomMetadataType = new protobuf.Type("ConversationCustomMetadata")
|
|
21
|
+
.add(new protobuf.Field("tag", 1, "string"))
|
|
22
|
+
.add(new protobuf.Field("profiles", 2, "ConversationProfile", "repeated"))
|
|
23
|
+
.add(new protobuf.Field("expiresAtUnix", 3, "sfixed64", "optional"))
|
|
24
|
+
.add(new protobuf.Field("imageEncryptionKey", 4, "bytes", "optional"))
|
|
25
|
+
.add(new protobuf.Field("encryptedGroupImage", 5, "EncryptedImageRef", "optional"));
|
|
26
|
+
root.add(EncryptedImageRefType);
|
|
27
|
+
root.add(ConversationProfileType);
|
|
28
|
+
root.add(ConversationCustomMetadataType);
|
|
29
|
+
// ─── Compression (matching iOS: DEFLATE if >100 bytes) ───
|
|
30
|
+
const COMPRESSION_MARKER = 0x1f;
|
|
31
|
+
const MAX_DECOMPRESSED_SIZE = 10 * 1024 * 1024; // 10MB
|
|
32
|
+
// Format must match iOS: [marker: 1 byte][original_size: 4 bytes big-endian][zlib data]
|
|
33
|
+
function compressIfSmaller(data) {
|
|
34
|
+
if (data.length <= 100)
|
|
35
|
+
return data;
|
|
36
|
+
const compressed = deflateSync(data);
|
|
37
|
+
// 1 byte marker + 4 bytes size + compressed data
|
|
38
|
+
if (compressed.length + 5 < data.length) {
|
|
39
|
+
const sizeBytes = Buffer.alloc(4);
|
|
40
|
+
sizeBytes.writeUInt32BE(data.length);
|
|
41
|
+
return Buffer.concat([Buffer.from([COMPRESSION_MARKER]), sizeBytes, compressed]);
|
|
42
|
+
}
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
function decompressIfNeeded(data) {
|
|
46
|
+
if (data.length === 0)
|
|
47
|
+
return data;
|
|
48
|
+
if (data[0] === COMPRESSION_MARKER) {
|
|
49
|
+
// iOS format: [marker][4-byte size BE][zlib data]
|
|
50
|
+
const decompressed = Buffer.from(inflateSync(data.subarray(5)));
|
|
51
|
+
if (decompressed.length > MAX_DECOMPRESSED_SIZE) {
|
|
52
|
+
throw new Error("Decompressed metadata exceeds size limit");
|
|
53
|
+
}
|
|
54
|
+
return decompressed;
|
|
55
|
+
}
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
// ─── Hex <-> Bytes ───
|
|
59
|
+
function hexToBytes(hex) {
|
|
60
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
61
|
+
return Buffer.from(clean, "hex");
|
|
62
|
+
}
|
|
63
|
+
function bytesToHex(bytes) {
|
|
64
|
+
return Buffer.from(bytes).toString("hex");
|
|
65
|
+
}
|
|
66
|
+
// ─── Base64URL ───
|
|
67
|
+
function base64urlEncode(data) {
|
|
68
|
+
return data.toString("base64url");
|
|
69
|
+
}
|
|
70
|
+
function base64urlDecode(str) {
|
|
71
|
+
return Buffer.from(str, "base64url");
|
|
72
|
+
}
|
|
73
|
+
// ─── Public API ───
|
|
74
|
+
const APP_DATA_LIMIT = 8 * 1024; // 8KB
|
|
75
|
+
/**
|
|
76
|
+
* Parse appData string into ConversationCustomMetadata.
|
|
77
|
+
* Returns empty metadata if appData is empty or invalid.
|
|
78
|
+
*/
|
|
79
|
+
export function parseAppData(appData) {
|
|
80
|
+
if (!appData || appData.length === 0) {
|
|
81
|
+
return { tag: "", profiles: [] };
|
|
82
|
+
}
|
|
83
|
+
// Try legacy JSON format first (from early invite tag storage)
|
|
84
|
+
if (appData.startsWith("{")) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(appData);
|
|
87
|
+
return {
|
|
88
|
+
tag: parsed.tag || "",
|
|
89
|
+
profiles: [],
|
|
90
|
+
expiresAtUnix: parsed.expiresAtUnix,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Not valid JSON, try protobuf
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Try base64url → decompress → protobuf
|
|
98
|
+
try {
|
|
99
|
+
const rawBytes = base64urlDecode(appData);
|
|
100
|
+
const decompressed = decompressIfNeeded(rawBytes);
|
|
101
|
+
const msg = ConversationCustomMetadataType.decode(decompressed);
|
|
102
|
+
const toNum = (v) => {
|
|
103
|
+
if (v == null)
|
|
104
|
+
return undefined;
|
|
105
|
+
const n = typeof v === "number" ? v : v.toNumber();
|
|
106
|
+
// Treat 0 as unset — protobuf sfixed64 defaults to 0 when the field
|
|
107
|
+
// is absent, and epoch 0 (1970-01-01) is never a valid expiration.
|
|
108
|
+
// Without this, re-serializing would write expiresAtUnix=0 which iOS
|
|
109
|
+
// interprets as "expired in 1970" and hides the conversation.
|
|
110
|
+
return n === 0 ? undefined : n;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
tag: msg.tag || "",
|
|
114
|
+
profiles: (msg.profiles || []).map((p) => ({
|
|
115
|
+
inboxId: bytesToHex(p.inboxId),
|
|
116
|
+
name: p.name || undefined,
|
|
117
|
+
image: p.image || undefined,
|
|
118
|
+
})),
|
|
119
|
+
expiresAtUnix: toNum(msg.expiresAtUnix),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { tag: "", profiles: [] };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Serialize ConversationCustomMetadata to an appData string.
|
|
128
|
+
* Throws if result exceeds 8KB.
|
|
129
|
+
*/
|
|
130
|
+
export function serializeAppData(metadata) {
|
|
131
|
+
const obj = {
|
|
132
|
+
tag: metadata.tag,
|
|
133
|
+
profiles: metadata.profiles.map((p) => ({
|
|
134
|
+
inboxId: hexToBytes(p.inboxId),
|
|
135
|
+
name: p.name,
|
|
136
|
+
image: p.image,
|
|
137
|
+
})),
|
|
138
|
+
expiresAtUnix: metadata.expiresAtUnix,
|
|
139
|
+
};
|
|
140
|
+
const errMsg = ConversationCustomMetadataType.verify(obj);
|
|
141
|
+
if (errMsg)
|
|
142
|
+
throw new Error(`Invalid metadata: ${errMsg}`);
|
|
143
|
+
const bytes = Buffer.from(ConversationCustomMetadataType.encode(ConversationCustomMetadataType.create(obj)).finish());
|
|
144
|
+
const compressed = compressIfSmaller(bytes);
|
|
145
|
+
const encoded = base64urlEncode(compressed);
|
|
146
|
+
if (Buffer.byteLength(encoded, "utf-8") > APP_DATA_LIMIT) {
|
|
147
|
+
throw new Error(`Metadata exceeds ${APP_DATA_LIMIT} byte limit (${Buffer.byteLength(encoded, "utf-8")} bytes)`);
|
|
148
|
+
}
|
|
149
|
+
return encoded;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Upsert a profile in the metadata. If a profile with the same inboxId exists,
|
|
153
|
+
* it is updated. Otherwise, a new profile is added.
|
|
154
|
+
*/
|
|
155
|
+
export function upsertProfile(metadata, profile) {
|
|
156
|
+
const existing = metadata.profiles.findIndex((p) => p.inboxId.toLowerCase() === profile.inboxId.toLowerCase());
|
|
157
|
+
const profiles = [...metadata.profiles];
|
|
158
|
+
if (existing >= 0) {
|
|
159
|
+
profiles[existing] = { ...profiles[existing], ...profile };
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
profiles.push(profile);
|
|
163
|
+
}
|
|
164
|
+
return { ...metadata, profiles };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Remove a profile from the metadata by inbox ID.
|
|
168
|
+
*/
|
|
169
|
+
export function removeProfile(metadata, inboxId) {
|
|
170
|
+
return {
|
|
171
|
+
...metadata,
|
|
172
|
+
profiles: metadata.profiles.filter((p) => p.inboxId.toLowerCase() !== inboxId.toLowerCase()),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Get a profile by inbox ID.
|
|
177
|
+
*/
|
|
178
|
+
export function getProfile(metadata, inboxId) {
|
|
179
|
+
return metadata.profiles.find((p) => p.inboxId.toLowerCase() === inboxId.toLowerCase());
|
|
180
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
const MIME_TYPES = {
|
|
3
|
+
".jpg": "image/jpeg",
|
|
4
|
+
".jpeg": "image/jpeg",
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".gif": "image/gif",
|
|
7
|
+
".webp": "image/webp",
|
|
8
|
+
".svg": "image/svg+xml",
|
|
9
|
+
".bmp": "image/bmp",
|
|
10
|
+
".ico": "image/x-icon",
|
|
11
|
+
".tif": "image/tiff",
|
|
12
|
+
".tiff": "image/tiff",
|
|
13
|
+
".heic": "image/heic",
|
|
14
|
+
".heif": "image/heif",
|
|
15
|
+
".avif": "image/avif",
|
|
16
|
+
".mp4": "video/mp4",
|
|
17
|
+
".mov": "video/quicktime",
|
|
18
|
+
".avi": "video/x-msvideo",
|
|
19
|
+
".webm": "video/webm",
|
|
20
|
+
".mp3": "audio/mpeg",
|
|
21
|
+
".wav": "audio/wav",
|
|
22
|
+
".ogg": "audio/ogg",
|
|
23
|
+
".pdf": "application/pdf",
|
|
24
|
+
".txt": "text/plain",
|
|
25
|
+
".json": "application/json",
|
|
26
|
+
".xml": "application/xml",
|
|
27
|
+
".zip": "application/zip",
|
|
28
|
+
".gz": "application/gzip",
|
|
29
|
+
};
|
|
30
|
+
const EXT_BY_MIME = {};
|
|
31
|
+
for (const [ext, mime] of Object.entries(MIME_TYPES)) {
|
|
32
|
+
if (!EXT_BY_MIME[mime]) {
|
|
33
|
+
EXT_BY_MIME[mime] = ext;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function getMimeType(filePath) {
|
|
37
|
+
const ext = extname(filePath).toLowerCase();
|
|
38
|
+
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
39
|
+
}
|
|
40
|
+
export function getExtension(mimeType) {
|
|
41
|
+
return EXT_BY_MIME[mimeType] ?? ".bin";
|
|
42
|
+
}
|