@soyeht/soyeht 0.1.1
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/README.md +73 -0
- package/docs/PROTOCOL.md +388 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +49 -0
- package/src/channel.ts +157 -0
- package/src/config.ts +203 -0
- package/src/crypto.ts +227 -0
- package/src/envelope-v2.ts +201 -0
- package/src/http.ts +175 -0
- package/src/identity.ts +157 -0
- package/src/index.ts +120 -0
- package/src/media.ts +100 -0
- package/src/openclaw-plugin-sdk.d.ts +209 -0
- package/src/outbound.ts +198 -0
- package/src/pairing.ts +324 -0
- package/src/ratchet.ts +262 -0
- package/src/rpc.ts +503 -0
- package/src/security.ts +158 -0
- package/src/service.ts +177 -0
- package/src/types.ts +213 -0
- package/src/version.ts +1 -0
- package/src/x3dh.ts +105 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// Inlined from openclaw/plugin-sdk to avoid transitive dependency resolution at runtime.
|
|
4
|
+
function listConfiguredAccountIds(params: {
|
|
5
|
+
accounts: Record<string, unknown> | undefined;
|
|
6
|
+
normalizeAccountId: (accountId: string) => string;
|
|
7
|
+
}): string[] {
|
|
8
|
+
if (!params.accounts) return [];
|
|
9
|
+
const ids = new Set<string>();
|
|
10
|
+
for (const key of Object.keys(params.accounts)) {
|
|
11
|
+
if (!key) continue;
|
|
12
|
+
ids.add(params.normalizeAccountId(key));
|
|
13
|
+
}
|
|
14
|
+
return [...ids];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Resolved account type — all fields have concrete defaults
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export type ResolvedSoyehtAccount = {
|
|
22
|
+
accountId: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
backendBaseUrl: string;
|
|
25
|
+
pluginAuthToken: string;
|
|
26
|
+
allowProactive: boolean;
|
|
27
|
+
audio: {
|
|
28
|
+
transcribeInbound: boolean;
|
|
29
|
+
ttsOutbound: boolean;
|
|
30
|
+
};
|
|
31
|
+
files: {
|
|
32
|
+
acceptInbound: boolean;
|
|
33
|
+
maxBytes: number;
|
|
34
|
+
};
|
|
35
|
+
security: {
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
timestampToleranceMs: number;
|
|
38
|
+
dhRatchetIntervalMessages: number;
|
|
39
|
+
dhRatchetIntervalMs: number;
|
|
40
|
+
sessionMaxAgeMs: number;
|
|
41
|
+
rateLimit: {
|
|
42
|
+
maxRequests: number;
|
|
43
|
+
windowMs: number;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Constants
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const CONFIG_ROOT = "channels.soyeht" as const;
|
|
53
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
54
|
+
const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function readConfigSection(cfg: OpenClawConfig): Record<string, unknown> | undefined {
|
|
61
|
+
const raw = cfg as Record<string, unknown>;
|
|
62
|
+
const channels = raw["channels"] as Record<string, unknown> | undefined;
|
|
63
|
+
return channels?.["soyeht"] as Record<string, unknown> | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readAccountsSection(
|
|
67
|
+
cfg: OpenClawConfig,
|
|
68
|
+
): Record<string, unknown> | undefined {
|
|
69
|
+
const section = readConfigSection(cfg);
|
|
70
|
+
return section?.["accounts"] as Record<string, unknown> | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readAccountConfig(
|
|
74
|
+
cfg: OpenClawConfig,
|
|
75
|
+
accountId: string,
|
|
76
|
+
): Record<string, unknown> {
|
|
77
|
+
const accounts = readAccountsSection(cfg);
|
|
78
|
+
if (!accounts) return {};
|
|
79
|
+
const entry = accounts[accountId];
|
|
80
|
+
return entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// enforceHttps — reject non-https backend URLs (allow http only for localhost)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
function enforceHttps(url: string): string {
|
|
88
|
+
if (!url) return url;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(url);
|
|
91
|
+
if (parsed.protocol === "https:") return url;
|
|
92
|
+
// Allow http for localhost/127.x development
|
|
93
|
+
if (parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname.startsWith("127."))) {
|
|
94
|
+
return url;
|
|
95
|
+
}
|
|
96
|
+
// Reject non-https for remote hosts — return empty to mark as unconfigured
|
|
97
|
+
return "";
|
|
98
|
+
} catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// normalizeAccountId
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export function normalizeAccountId(id?: string | null): string {
|
|
108
|
+
const trimmed = (id ?? "").trim().toLowerCase();
|
|
109
|
+
return trimmed || DEFAULT_ACCOUNT_ID;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// resolveSoyehtAccount
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export function resolveSoyehtAccount(
|
|
117
|
+
cfg: OpenClawConfig,
|
|
118
|
+
accountId?: string | null,
|
|
119
|
+
): ResolvedSoyehtAccount {
|
|
120
|
+
const normalizedId = normalizeAccountId(accountId);
|
|
121
|
+
const raw = readAccountConfig(cfg, normalizedId);
|
|
122
|
+
const audioRaw = (raw["audio"] as Record<string, unknown>) ?? {};
|
|
123
|
+
const filesRaw = (raw["files"] as Record<string, unknown>) ?? {};
|
|
124
|
+
const securityRaw = (raw["security"] as Record<string, unknown>) ?? {};
|
|
125
|
+
const rateLimitRaw = (securityRaw["rateLimit"] as Record<string, unknown>) ?? {};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
accountId: normalizedId,
|
|
129
|
+
enabled: typeof raw["enabled"] === "boolean" ? raw["enabled"] : true,
|
|
130
|
+
backendBaseUrl:
|
|
131
|
+
typeof raw["backendBaseUrl"] === "string"
|
|
132
|
+
? enforceHttps(raw["backendBaseUrl"])
|
|
133
|
+
: "",
|
|
134
|
+
pluginAuthToken:
|
|
135
|
+
typeof raw["pluginAuthToken"] === "string" ? raw["pluginAuthToken"] : "",
|
|
136
|
+
allowProactive:
|
|
137
|
+
typeof raw["allowProactive"] === "boolean" ? raw["allowProactive"] : false,
|
|
138
|
+
audio: {
|
|
139
|
+
transcribeInbound:
|
|
140
|
+
typeof audioRaw["transcribeInbound"] === "boolean"
|
|
141
|
+
? audioRaw["transcribeInbound"]
|
|
142
|
+
: true,
|
|
143
|
+
ttsOutbound:
|
|
144
|
+
typeof audioRaw["ttsOutbound"] === "boolean"
|
|
145
|
+
? audioRaw["ttsOutbound"]
|
|
146
|
+
: false,
|
|
147
|
+
},
|
|
148
|
+
files: {
|
|
149
|
+
acceptInbound:
|
|
150
|
+
typeof filesRaw["acceptInbound"] === "boolean"
|
|
151
|
+
? filesRaw["acceptInbound"]
|
|
152
|
+
: true,
|
|
153
|
+
maxBytes:
|
|
154
|
+
typeof filesRaw["maxBytes"] === "number"
|
|
155
|
+
? filesRaw["maxBytes"]
|
|
156
|
+
: DEFAULT_MAX_FILE_BYTES,
|
|
157
|
+
},
|
|
158
|
+
security: {
|
|
159
|
+
enabled:
|
|
160
|
+
typeof securityRaw["enabled"] === "boolean"
|
|
161
|
+
? securityRaw["enabled"]
|
|
162
|
+
: false,
|
|
163
|
+
timestampToleranceMs:
|
|
164
|
+
typeof securityRaw["timestampToleranceMs"] === "number"
|
|
165
|
+
? securityRaw["timestampToleranceMs"]
|
|
166
|
+
: 300_000,
|
|
167
|
+
dhRatchetIntervalMessages:
|
|
168
|
+
typeof securityRaw["dhRatchetIntervalMessages"] === "number"
|
|
169
|
+
? securityRaw["dhRatchetIntervalMessages"]
|
|
170
|
+
: 50,
|
|
171
|
+
dhRatchetIntervalMs:
|
|
172
|
+
typeof securityRaw["dhRatchetIntervalMs"] === "number"
|
|
173
|
+
? securityRaw["dhRatchetIntervalMs"]
|
|
174
|
+
: 3_600_000,
|
|
175
|
+
sessionMaxAgeMs:
|
|
176
|
+
typeof securityRaw["sessionMaxAgeMs"] === "number"
|
|
177
|
+
? securityRaw["sessionMaxAgeMs"]
|
|
178
|
+
: 86_400_000,
|
|
179
|
+
rateLimit: {
|
|
180
|
+
maxRequests:
|
|
181
|
+
typeof rateLimitRaw["maxRequests"] === "number"
|
|
182
|
+
? rateLimitRaw["maxRequests"]
|
|
183
|
+
: 60,
|
|
184
|
+
windowMs:
|
|
185
|
+
typeof rateLimitRaw["windowMs"] === "number"
|
|
186
|
+
? rateLimitRaw["windowMs"]
|
|
187
|
+
: 60_000,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// listSoyehtAccountIds
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export function listSoyehtAccountIds(cfg: OpenClawConfig): string[] {
|
|
198
|
+
const accounts = readAccountsSection(cfg);
|
|
199
|
+
return listConfiguredAccountIds({
|
|
200
|
+
accounts: accounts as Record<string, unknown> | undefined,
|
|
201
|
+
normalizeAccountId,
|
|
202
|
+
});
|
|
203
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateKeyPairSync,
|
|
3
|
+
createPublicKey,
|
|
4
|
+
createPrivateKey,
|
|
5
|
+
diffieHellman,
|
|
6
|
+
hkdf,
|
|
7
|
+
hkdfSync,
|
|
8
|
+
createHash,
|
|
9
|
+
createCipheriv,
|
|
10
|
+
createDecipheriv,
|
|
11
|
+
randomBytes,
|
|
12
|
+
sign,
|
|
13
|
+
verify,
|
|
14
|
+
type KeyObject,
|
|
15
|
+
} from "node:crypto";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Base64url encoding
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export function base64UrlEncode(buf: Buffer): string {
|
|
22
|
+
return buf.toString("base64url");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function base64UrlDecode(str: string): Buffer {
|
|
26
|
+
return Buffer.from(str, "base64url");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// X25519 key pairs
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type X25519KeyPair = {
|
|
34
|
+
publicKey: KeyObject;
|
|
35
|
+
privateKey: KeyObject;
|
|
36
|
+
publicKeyRaw: Buffer;
|
|
37
|
+
publicKeyB64: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function generateX25519KeyPair(): X25519KeyPair {
|
|
41
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
42
|
+
const publicKeyRaw = publicKey.export({ type: "spki", format: "der" }).subarray(-32);
|
|
43
|
+
return {
|
|
44
|
+
publicKey,
|
|
45
|
+
privateKey,
|
|
46
|
+
publicKeyRaw,
|
|
47
|
+
publicKeyB64: base64UrlEncode(publicKeyRaw),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function importX25519PublicKey(rawB64: string): KeyObject {
|
|
52
|
+
const raw = base64UrlDecode(rawB64);
|
|
53
|
+
if (raw.length !== 32) {
|
|
54
|
+
throw new Error(`Invalid X25519 public key length: ${raw.length}`);
|
|
55
|
+
}
|
|
56
|
+
// X25519 SPKI DER prefix (12 bytes) + 32 bytes raw key
|
|
57
|
+
const spkiPrefix = Buffer.from("302a300506032b656e032100", "hex");
|
|
58
|
+
const spki = Buffer.concat([spkiPrefix, raw]);
|
|
59
|
+
return createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// ECDH shared secret
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function computeSharedSecret(privateKey: KeyObject, peerPublicKey: KeyObject): Buffer {
|
|
67
|
+
return diffieHellman({ privateKey, publicKey: peerPublicKey });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Nonce generation
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export function generateNonce(): Buffer {
|
|
75
|
+
return randomBytes(16);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function generateNonceB64(): string {
|
|
79
|
+
return base64UrlEncode(generateNonce());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// AES-256-GCM encrypt / decrypt
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
export type AesGcmEncryptParams = {
|
|
87
|
+
key: Buffer;
|
|
88
|
+
plaintext: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type AesGcmEncryptResult = {
|
|
92
|
+
ciphertext: string; // base64url
|
|
93
|
+
iv: string; // base64url (12 bytes)
|
|
94
|
+
tag: string; // base64url (16 bytes auth tag)
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function aesGcmEncrypt(params: AesGcmEncryptParams): AesGcmEncryptResult {
|
|
98
|
+
const iv = randomBytes(12);
|
|
99
|
+
const cipher = createCipheriv("aes-256-gcm", params.key, iv);
|
|
100
|
+
const encrypted = Buffer.concat([
|
|
101
|
+
cipher.update(params.plaintext, "utf8"),
|
|
102
|
+
cipher.final(),
|
|
103
|
+
]);
|
|
104
|
+
const tag = cipher.getAuthTag();
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ciphertext: base64UrlEncode(encrypted),
|
|
108
|
+
iv: base64UrlEncode(iv),
|
|
109
|
+
tag: base64UrlEncode(tag),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type AesGcmDecryptParams = {
|
|
114
|
+
key: Buffer;
|
|
115
|
+
ciphertext: string; // base64url
|
|
116
|
+
iv: string; // base64url
|
|
117
|
+
tag: string; // base64url
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function aesGcmDecrypt(params: AesGcmDecryptParams): string {
|
|
121
|
+
const decipher = createDecipheriv(
|
|
122
|
+
"aes-256-gcm",
|
|
123
|
+
params.key,
|
|
124
|
+
base64UrlDecode(params.iv),
|
|
125
|
+
);
|
|
126
|
+
decipher.setAuthTag(base64UrlDecode(params.tag));
|
|
127
|
+
const decrypted = Buffer.concat([
|
|
128
|
+
decipher.update(base64UrlDecode(params.ciphertext)),
|
|
129
|
+
decipher.final(),
|
|
130
|
+
]);
|
|
131
|
+
return decrypted.toString("utf8");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Ed25519 key pairs
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
139
|
+
|
|
140
|
+
export type Ed25519KeyPair = {
|
|
141
|
+
publicKey: KeyObject;
|
|
142
|
+
privateKey: KeyObject;
|
|
143
|
+
publicKeyB64: string; // raw 32-byte pub, base64url
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function ed25519GenerateKeyPair(): Ed25519KeyPair {
|
|
147
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
148
|
+
const pubRaw = Buffer.from(publicKey.export({ type: "spki", format: "der" })).subarray(-32);
|
|
149
|
+
return { publicKey, privateKey, publicKeyB64: base64UrlEncode(pubRaw) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function ed25519Sign(privateKey: KeyObject, data: Buffer): Buffer {
|
|
153
|
+
return Buffer.from(sign(null, data, privateKey));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function ed25519Verify(publicKey: KeyObject, data: Buffer, sig: Buffer): boolean {
|
|
157
|
+
try {
|
|
158
|
+
return verify(null, data, publicKey, sig);
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function importEd25519PublicKey(b64: string): KeyObject {
|
|
165
|
+
const raw = base64UrlDecode(b64);
|
|
166
|
+
if (raw.length !== 32) throw new Error(`Invalid Ed25519 public key length: ${raw.length}`);
|
|
167
|
+
const spki = Buffer.concat([ED25519_SPKI_PREFIX, raw]);
|
|
168
|
+
return createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function exportPublicKeyRaw(key: KeyObject): Buffer {
|
|
172
|
+
return Buffer.from(key.export({ type: "spki", format: "der" })).subarray(-32);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function exportPrivateKeyPkcs8(key: KeyObject): Buffer {
|
|
176
|
+
return Buffer.from(key.export({ type: "pkcs8", format: "der" }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function importPrivateKeyPkcs8(der: Buffer): KeyObject {
|
|
180
|
+
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Async HKDF-SHA256 (used for X3DH root derivation with nonce as salt)
|
|
184
|
+
export function hkdfDerive(
|
|
185
|
+
ikm: Buffer,
|
|
186
|
+
salt: Buffer,
|
|
187
|
+
info: string,
|
|
188
|
+
length: number,
|
|
189
|
+
): Promise<Buffer> {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
hkdf("sha256", ikm, salt, Buffer.from(info, "utf8"), length, (err, derived) => {
|
|
192
|
+
if (err) reject(err);
|
|
193
|
+
else resolve(Buffer.from(derived));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Sync HKDF-SHA256 (used for KDF chain advancement — called per-message)
|
|
199
|
+
export function hkdfDeriveSync(ikm: Buffer, salt: Buffer, info: string, length: number): Buffer {
|
|
200
|
+
return Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(info, "utf8"), length));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Timestamp validation
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export function isTimestampValid(timestamp: number, toleranceMs = 300_000): boolean {
|
|
208
|
+
const diff = Math.abs(Date.now() - timestamp);
|
|
209
|
+
return diff <= toleranceMs;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Fingerprint — SHA-256 of identity keys, truncated to 16 bytes hex (32 chars)
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export function computeFingerprint(identity: {
|
|
217
|
+
signKey: { publicKeyB64: string };
|
|
218
|
+
dhKey: { publicKeyB64: string };
|
|
219
|
+
}): string {
|
|
220
|
+
const signRaw = base64UrlDecode(identity.signKey.publicKeyB64);
|
|
221
|
+
const dhRaw = base64UrlDecode(identity.dhKey.publicKeyB64);
|
|
222
|
+
const hash = createHash("sha256")
|
|
223
|
+
.update(signRaw)
|
|
224
|
+
.update(dhRaw)
|
|
225
|
+
.digest();
|
|
226
|
+
return hash.subarray(0, 16).toString("hex");
|
|
227
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
base64UrlEncode,
|
|
4
|
+
base64UrlDecode,
|
|
5
|
+
importX25519PublicKey,
|
|
6
|
+
} from "./crypto.js";
|
|
7
|
+
import {
|
|
8
|
+
advanceChain,
|
|
9
|
+
needsDhRatchet,
|
|
10
|
+
applySendDhRatchet,
|
|
11
|
+
executeDhRatchet,
|
|
12
|
+
type RatchetState,
|
|
13
|
+
type DhRatchetConfig,
|
|
14
|
+
} from "./ratchet.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Wire format
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export type EnvelopeV2 = {
|
|
21
|
+
v: 2;
|
|
22
|
+
accountId: string;
|
|
23
|
+
direction: "plugin_to_app" | "app_to_plugin";
|
|
24
|
+
counter: number;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
dhRatchetKey?: string; // X25519 pub raw b64url, only when DH ratchet emitted
|
|
27
|
+
ciphertext: string; // base64url AES-256-GCM ciphertext
|
|
28
|
+
iv: string; // base64url 12 bytes
|
|
29
|
+
tag: string; // base64url 16 bytes auth tag
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// AAD construction
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function buildAad(env: Pick<EnvelopeV2, "v" | "accountId" | "direction" | "counter" | "timestamp">): Buffer {
|
|
37
|
+
return Buffer.from(
|
|
38
|
+
`${env.v}|${env.accountId}|${env.direction}|${env.counter}|${env.timestamp}`,
|
|
39
|
+
"utf8",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Encrypt
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export type EncryptParams = {
|
|
48
|
+
session: RatchetState;
|
|
49
|
+
accountId: string;
|
|
50
|
+
plaintext: string;
|
|
51
|
+
dhRatchetCfg: DhRatchetConfig;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type EncryptResult = {
|
|
55
|
+
envelope: EnvelopeV2;
|
|
56
|
+
updatedSession: RatchetState;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function encryptEnvelopeV2(params: EncryptParams): EncryptResult {
|
|
60
|
+
let session = params.session;
|
|
61
|
+
let dhRatchetKey: string | undefined;
|
|
62
|
+
|
|
63
|
+
// DH ratchet step (if needed)
|
|
64
|
+
if (needsDhRatchet(session, params.dhRatchetCfg)) {
|
|
65
|
+
const { updatedSession, newEphPubB64 } = applySendDhRatchet(session);
|
|
66
|
+
session = updatedSession;
|
|
67
|
+
dhRatchetKey = newEphPubB64;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Advance KDF chain to get message key
|
|
71
|
+
const { messageKey, next: nextSending } = advanceChain(session.sending);
|
|
72
|
+
const counter = nextSending.counter - 1; // counter of THIS message
|
|
73
|
+
const timestamp = Date.now();
|
|
74
|
+
|
|
75
|
+
// Build partial envelope (without ciphertext) for AAD
|
|
76
|
+
const partial = {
|
|
77
|
+
v: 2 as const,
|
|
78
|
+
accountId: params.accountId,
|
|
79
|
+
direction: "plugin_to_app" as const,
|
|
80
|
+
counter,
|
|
81
|
+
timestamp,
|
|
82
|
+
};
|
|
83
|
+
const aad = buildAad(partial);
|
|
84
|
+
|
|
85
|
+
// AES-256-GCM encrypt
|
|
86
|
+
const iv = randomBytes(12);
|
|
87
|
+
const cipher = createCipheriv("aes-256-gcm", messageKey, iv);
|
|
88
|
+
cipher.setAAD(aad);
|
|
89
|
+
const cipherBuf = Buffer.concat([
|
|
90
|
+
cipher.update(params.plaintext, "utf8"),
|
|
91
|
+
cipher.final(),
|
|
92
|
+
]);
|
|
93
|
+
const tag = cipher.getAuthTag();
|
|
94
|
+
|
|
95
|
+
// Zero message key
|
|
96
|
+
messageKey.fill(0);
|
|
97
|
+
|
|
98
|
+
const updatedSession: RatchetState = {
|
|
99
|
+
...session,
|
|
100
|
+
sending: nextSending,
|
|
101
|
+
dhRatchetSendCount: session.dhRatchetSendCount + 1,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const envelope: EnvelopeV2 = {
|
|
105
|
+
v: 2,
|
|
106
|
+
accountId: params.accountId,
|
|
107
|
+
direction: "plugin_to_app",
|
|
108
|
+
counter,
|
|
109
|
+
timestamp,
|
|
110
|
+
...(dhRatchetKey ? { dhRatchetKey } : {}),
|
|
111
|
+
ciphertext: base64UrlEncode(cipherBuf),
|
|
112
|
+
iv: base64UrlEncode(iv),
|
|
113
|
+
tag: base64UrlEncode(tag),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return { envelope, updatedSession };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Validate inbound envelope (before decryption)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export type ValidationResult = { valid: true } | { valid: false; error: string };
|
|
124
|
+
|
|
125
|
+
// Counter validation uses strict monotonic ordering (no window).
|
|
126
|
+
// This is intentional: allowing out-of-order counters would require
|
|
127
|
+
// tracking a window of seen counters and complicates replay protection.
|
|
128
|
+
export function validateEnvelopeV2(
|
|
129
|
+
envelope: EnvelopeV2,
|
|
130
|
+
session: RatchetState,
|
|
131
|
+
): ValidationResult {
|
|
132
|
+
if (envelope.v !== 2) return { valid: false, error: "version_mismatch" };
|
|
133
|
+
if (envelope.direction !== "app_to_plugin") return { valid: false, error: "direction_mismatch" };
|
|
134
|
+
if (envelope.counter < session.receiving.counter) {
|
|
135
|
+
return { valid: false, error: "counter_mismatch" };
|
|
136
|
+
}
|
|
137
|
+
return { valid: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Decrypt
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export type DecryptParams = {
|
|
145
|
+
session: RatchetState;
|
|
146
|
+
envelope: EnvelopeV2;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export type DecryptResult = {
|
|
150
|
+
plaintext: string;
|
|
151
|
+
updatedSession: RatchetState;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function decryptEnvelopeV2(params: DecryptParams): DecryptResult {
|
|
155
|
+
let { session } = params;
|
|
156
|
+
const { envelope } = params;
|
|
157
|
+
|
|
158
|
+
// DH ratchet step (if sender included new ephemeral)
|
|
159
|
+
if (envelope.dhRatchetKey) {
|
|
160
|
+
const peerNewEphPub = importX25519PublicKey(envelope.dhRatchetKey);
|
|
161
|
+
const { newSession } = executeDhRatchet(session, peerNewEphPub);
|
|
162
|
+
session = newSession;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Validate counter (strict monotonic after potential ratchet)
|
|
166
|
+
if (envelope.counter < session.receiving.counter) {
|
|
167
|
+
throw new Error("counter_mismatch");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Advance receiving chain
|
|
171
|
+
const { messageKey, next: nextReceiving } = advanceChain(session.receiving);
|
|
172
|
+
|
|
173
|
+
const aad = buildAad(envelope);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const decipher = createDecipheriv(
|
|
177
|
+
"aes-256-gcm",
|
|
178
|
+
messageKey,
|
|
179
|
+
base64UrlDecode(envelope.iv),
|
|
180
|
+
);
|
|
181
|
+
decipher.setAuthTag(base64UrlDecode(envelope.tag));
|
|
182
|
+
decipher.setAAD(aad);
|
|
183
|
+
const plainBuf = Buffer.concat([
|
|
184
|
+
decipher.update(base64UrlDecode(envelope.ciphertext)),
|
|
185
|
+
decipher.final(),
|
|
186
|
+
]);
|
|
187
|
+
const plaintext = plainBuf.toString("utf8");
|
|
188
|
+
messageKey.fill(0);
|
|
189
|
+
|
|
190
|
+
const updatedSession: RatchetState = {
|
|
191
|
+
...session,
|
|
192
|
+
receiving: nextReceiving,
|
|
193
|
+
dhRatchetRecvCount: session.dhRatchetRecvCount + 1,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return { plaintext, updatedSession };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
messageKey.fill(0);
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|