@soyeht/soyeht 0.2.0 → 0.2.2
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/pairing.ts +55 -0
- package/src/qr.ts +448 -0
- package/src/service.ts +67 -1
- package/src/version.ts +1 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
handleSecurityIdentity,
|
|
25
25
|
handleSecurityPair,
|
|
26
|
+
handleSecurityPairingInfo,
|
|
26
27
|
handleSecurityPairingStart,
|
|
27
28
|
} from "./pairing.js";
|
|
28
29
|
import { PLUGIN_VERSION } from "./version.js";
|
|
@@ -89,6 +90,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
|
|
|
89
90
|
// Security RPC
|
|
90
91
|
api.registerGatewayMethod("soyeht.security.identity", handleSecurityIdentity(api, v2deps));
|
|
91
92
|
api.registerGatewayMethod("soyeht.security.pairing.start", handleSecurityPairingStart(api, v2deps));
|
|
93
|
+
api.registerGatewayMethod("soyeht.security.pairing.info", handleSecurityPairingInfo(api, v2deps));
|
|
92
94
|
api.registerGatewayMethod("soyeht.security.pair", handleSecurityPair(api, v2deps));
|
|
93
95
|
api.registerGatewayMethod("soyeht.security.handshake.init", handleSecurityHandshake(api, v2deps));
|
|
94
96
|
api.registerGatewayMethod("soyeht.security.handshake.finish", handleSecurityHandshakeFinish(api, v2deps));
|
package/src/pairing.ts
CHANGED
|
@@ -136,6 +136,61 @@ function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): st
|
|
|
136
136
|
return "";
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// soyeht.security.pairing.info — return session data for a given pairing token
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export function handleSecurityPairingInfo(
|
|
144
|
+
_api: OpenClawPluginApi,
|
|
145
|
+
v2deps: SecurityV2Deps,
|
|
146
|
+
): GatewayRequestHandler {
|
|
147
|
+
return async ({ params, respond }) => {
|
|
148
|
+
if (!v2deps.ready) {
|
|
149
|
+
respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!v2deps.identity) {
|
|
153
|
+
respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pairingToken = params["pairingToken"] as string | undefined;
|
|
158
|
+
if (!pairingToken) {
|
|
159
|
+
respond(false, undefined, {
|
|
160
|
+
code: "INVALID_PARAMS",
|
|
161
|
+
message: "Missing required param: pairingToken",
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const session = v2deps.pairingSessions.get(pairingToken);
|
|
167
|
+
if (!session) {
|
|
168
|
+
respond(false, undefined, {
|
|
169
|
+
code: "PAIRING_REQUIRED",
|
|
170
|
+
message: "No active pairing session. Scan a fresh QR code first.",
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (session.expiresAt <= Date.now()) {
|
|
176
|
+
respond(false, undefined, {
|
|
177
|
+
code: "PAIRING_EXPIRED",
|
|
178
|
+
message: "Pairing QR expired. Scan a fresh QR code first.",
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
respond(true, {
|
|
184
|
+
accountId: session.accountId,
|
|
185
|
+
expiresAt: session.expiresAt,
|
|
186
|
+
allowOverwrite: session.allowOverwrite,
|
|
187
|
+
pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
|
|
188
|
+
pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
|
|
189
|
+
fingerprint: computeFingerprint(v2deps.identity),
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
139
194
|
// ---------------------------------------------------------------------------
|
|
140
195
|
// soyeht.security.identity — expose plugin public keys
|
|
141
196
|
// ---------------------------------------------------------------------------
|
package/src/qr.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Minimal QR Code encoder — byte mode, EC level M, versions 1-20
|
|
3
|
+
// Zero external dependencies. Used for terminal rendering on service start.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
// Galois Field GF(2^8) with primitive polynomial 0x11d
|
|
7
|
+
const EXP = new Uint8Array(256);
|
|
8
|
+
const LOG = new Uint8Array(256);
|
|
9
|
+
(function initGF() {
|
|
10
|
+
let v = 1;
|
|
11
|
+
for (let i = 0; i < 255; i++) {
|
|
12
|
+
EXP[i] = v;
|
|
13
|
+
LOG[v] = i;
|
|
14
|
+
v <<= 1;
|
|
15
|
+
if (v >= 256) v ^= 0x11d;
|
|
16
|
+
}
|
|
17
|
+
EXP[255] = EXP[0];
|
|
18
|
+
})();
|
|
19
|
+
|
|
20
|
+
function gfMul(a: number, b: number): number {
|
|
21
|
+
if (a === 0 || b === 0) return 0;
|
|
22
|
+
return EXP[(LOG[a] + LOG[b]) % 255];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rsGenPoly(n: number): number[] {
|
|
26
|
+
let g = [1];
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
const ng = new Array(g.length + 1).fill(0);
|
|
29
|
+
for (let j = 0; j < g.length; j++) {
|
|
30
|
+
ng[j] ^= g[j];
|
|
31
|
+
ng[j + 1] ^= gfMul(g[j], EXP[i]);
|
|
32
|
+
}
|
|
33
|
+
g = ng;
|
|
34
|
+
}
|
|
35
|
+
return g;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rsEncode(data: number[], ecLen: number): number[] {
|
|
39
|
+
const gen = rsGenPoly(ecLen);
|
|
40
|
+
const msg = new Array(data.length + ecLen).fill(0);
|
|
41
|
+
for (let i = 0; i < data.length; i++) msg[i] = data[i];
|
|
42
|
+
for (let i = 0; i < data.length; i++) {
|
|
43
|
+
const coef = msg[i];
|
|
44
|
+
if (coef !== 0) {
|
|
45
|
+
for (let j = 0; j < gen.length; j++) {
|
|
46
|
+
msg[i + j] ^= gfMul(gen[j], coef);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return msg.slice(data.length);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Version table: [totalCodewords, ecPerBlock, blocks1, dataPerBlock1, blocks2, dataPerBlock2]
|
|
54
|
+
const VERSIONS: (number[] | null)[] = [
|
|
55
|
+
null,
|
|
56
|
+
[26, 10, 1, 16, 0, 0],
|
|
57
|
+
[44, 16, 1, 28, 0, 0],
|
|
58
|
+
[70, 26, 1, 44, 0, 0],
|
|
59
|
+
[100, 18, 2, 32, 0, 0],
|
|
60
|
+
[134, 24, 2, 43, 0, 0],
|
|
61
|
+
[172, 16, 4, 27, 0, 0],
|
|
62
|
+
[196, 18, 4, 31, 0, 0],
|
|
63
|
+
[242, 22, 4, 38, 0, 0],
|
|
64
|
+
[292, 22, 2, 36, 2, 37],
|
|
65
|
+
[346, 26, 4, 43, 1, 44],
|
|
66
|
+
[404, 30, 1, 50, 4, 51],
|
|
67
|
+
[466, 22, 6, 36, 2, 37],
|
|
68
|
+
[532, 22, 8, 37, 1, 38],
|
|
69
|
+
[581, 24, 4, 40, 5, 41],
|
|
70
|
+
[655, 24, 5, 41, 5, 42],
|
|
71
|
+
[733, 28, 7, 45, 3, 46],
|
|
72
|
+
[815, 28, 10, 46, 1, 47],
|
|
73
|
+
[901, 26, 9, 43, 4, 44],
|
|
74
|
+
[991, 26, 3, 44, 11, 45],
|
|
75
|
+
[1085, 28, 3, 41, 13, 42],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const DATA_CAP = VERSIONS.map((v) => {
|
|
79
|
+
if (!v) return 0;
|
|
80
|
+
return v[2] * v[3] + (v[4] ? v[4] * v[5] : 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function chooseVersion(dataLen: number): number {
|
|
84
|
+
for (let v = 1; v <= 20; v++) {
|
|
85
|
+
if (DATA_CAP[v] >= dataLen) return v;
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function encodeToBytes(str: string): number[] {
|
|
91
|
+
return Array.from(Buffer.from(str, "utf8"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildBitstream(dataBytes: number[], version: number): number[] {
|
|
95
|
+
const bits: number[] = [];
|
|
96
|
+
function push(val: number, len: number) {
|
|
97
|
+
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
|
|
98
|
+
}
|
|
99
|
+
push(4, 4); // byte mode
|
|
100
|
+
const ccBits = version <= 9 ? 8 : 16;
|
|
101
|
+
push(dataBytes.length, ccBits);
|
|
102
|
+
for (const b of dataBytes) push(b, 8);
|
|
103
|
+
const totalBits = DATA_CAP[version] * 8;
|
|
104
|
+
push(0, Math.min(4, totalBits - bits.length));
|
|
105
|
+
while (bits.length % 8 !== 0) bits.push(0);
|
|
106
|
+
const padBytes = [0xec, 0x11];
|
|
107
|
+
let pi = 0;
|
|
108
|
+
while (bits.length < totalBits) {
|
|
109
|
+
push(padBytes[pi], 8);
|
|
110
|
+
pi = (pi + 1) % 2;
|
|
111
|
+
}
|
|
112
|
+
const out: number[] = [];
|
|
113
|
+
for (let i = 0; i < bits.length; i += 8) {
|
|
114
|
+
let b = 0;
|
|
115
|
+
for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
|
|
116
|
+
out.push(b);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function interleaveBlocks(data: number[], version: number): number[] {
|
|
122
|
+
const vInfo = VERSIONS[version]!;
|
|
123
|
+
const ecPerBlock = vInfo[1];
|
|
124
|
+
const blocks1 = vInfo[2], dPerBlock1 = vInfo[3];
|
|
125
|
+
const blocks2 = vInfo[4] || 0, dPerBlock2 = vInfo[5] || 0;
|
|
126
|
+
const dataBlocks: number[][] = [];
|
|
127
|
+
const ecBlocks: number[][] = [];
|
|
128
|
+
let offset = 0;
|
|
129
|
+
for (let i = 0; i < blocks1; i++) {
|
|
130
|
+
const block = data.slice(offset, offset + dPerBlock1);
|
|
131
|
+
dataBlocks.push(block);
|
|
132
|
+
ecBlocks.push(rsEncode(block, ecPerBlock));
|
|
133
|
+
offset += dPerBlock1;
|
|
134
|
+
}
|
|
135
|
+
for (let i = 0; i < blocks2; i++) {
|
|
136
|
+
const block = data.slice(offset, offset + dPerBlock2);
|
|
137
|
+
dataBlocks.push(block);
|
|
138
|
+
ecBlocks.push(rsEncode(block, ecPerBlock));
|
|
139
|
+
offset += dPerBlock2;
|
|
140
|
+
}
|
|
141
|
+
const result: number[] = [];
|
|
142
|
+
const maxDataLen = Math.max(dPerBlock1, dPerBlock2 || 0);
|
|
143
|
+
for (let i = 0; i < maxDataLen; i++) {
|
|
144
|
+
for (const block of dataBlocks) {
|
|
145
|
+
if (i < block.length) result.push(block[i]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < ecPerBlock; i++) {
|
|
149
|
+
for (const block of ecBlocks) {
|
|
150
|
+
if (i < block.length) result.push(block[i]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SIZE_FOR_VERSION = (v: number) => 17 + v * 4;
|
|
157
|
+
|
|
158
|
+
function createMatrix(version: number): number[][] {
|
|
159
|
+
const size = SIZE_FOR_VERSION(version);
|
|
160
|
+
return Array.from({ length: size }, () => new Array(size).fill(-1));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function setMod(m: number[][], r: number, c: number, val: number) {
|
|
164
|
+
if (r >= 0 && r < m.length && c >= 0 && c < m.length) m[r][c] = val;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function placeFinderPattern(m: number[][], row: number, col: number) {
|
|
168
|
+
for (let r = -1; r <= 7; r++) {
|
|
169
|
+
for (let c = -1; c <= 7; c++) {
|
|
170
|
+
let val: number;
|
|
171
|
+
if (r === -1 || r === 7 || c === -1 || c === 7) val = 0;
|
|
172
|
+
else if (r === 0 || r === 6 || c === 0 || c === 6) val = 1;
|
|
173
|
+
else if (r >= 2 && r <= 4 && c >= 2 && c <= 4) val = 1;
|
|
174
|
+
else val = 0;
|
|
175
|
+
setMod(m, row + r, col + c, val);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ALIGN_POS: (number[] | null)[] = [
|
|
181
|
+
null, [],
|
|
182
|
+
[6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
|
|
183
|
+
[6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
|
|
184
|
+
[6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
|
|
185
|
+
[6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78],
|
|
186
|
+
[6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
function placeAlignmentPatterns(m: number[][], version: number) {
|
|
190
|
+
if (version < 2) return;
|
|
191
|
+
const pos = ALIGN_POS[version]!;
|
|
192
|
+
for (const r of pos) {
|
|
193
|
+
for (const c of pos) {
|
|
194
|
+
if (m[r][c] !== -1) continue;
|
|
195
|
+
for (let dr = -2; dr <= 2; dr++) {
|
|
196
|
+
for (let dc = -2; dc <= 2; dc++) {
|
|
197
|
+
const val = (Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0;
|
|
198
|
+
setMod(m, r + dr, c + dc, val);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function placeTimingPatterns(m: number[][]) {
|
|
206
|
+
const size = m.length;
|
|
207
|
+
for (let i = 8; i < size - 8; i++) {
|
|
208
|
+
if (m[6][i] === -1) m[6][i] = (i % 2 === 0) ? 1 : 0;
|
|
209
|
+
if (m[i][6] === -1) m[i][6] = (i % 2 === 0) ? 1 : 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function reserveFormatBits(m: number[][]) {
|
|
214
|
+
const size = m.length;
|
|
215
|
+
for (let i = 0; i <= 8; i++) {
|
|
216
|
+
if (m[8][i] === -1) m[8][i] = 0;
|
|
217
|
+
if (m[i][8] === -1) m[i][8] = 0;
|
|
218
|
+
}
|
|
219
|
+
for (let i = 0; i <= 7; i++) {
|
|
220
|
+
if (m[8][size - 1 - i] === -1) m[8][size - 1 - i] = 0;
|
|
221
|
+
}
|
|
222
|
+
for (let i = 0; i <= 7; i++) {
|
|
223
|
+
if (m[size - 1 - i][8] === -1) m[size - 1 - i][8] = 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function reserveVersionBits(m: number[][], version: number) {
|
|
228
|
+
if (version < 7) return;
|
|
229
|
+
const size = m.length;
|
|
230
|
+
for (let i = 0; i < 6; i++) {
|
|
231
|
+
for (let j = 0; j < 3; j++) {
|
|
232
|
+
if (m[i][size - 11 + j] === -1) m[i][size - 11 + j] = 0;
|
|
233
|
+
if (m[size - 11 + j][i] === -1) m[size - 11 + j][i] = 0;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function placeDataBits(m: number[][], dataBits: number[]) {
|
|
239
|
+
const size = m.length;
|
|
240
|
+
let bitIdx = 0;
|
|
241
|
+
let upward = true;
|
|
242
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
243
|
+
if (right === 6) right = 5;
|
|
244
|
+
for (let cnt = 0; cnt < size; cnt++) {
|
|
245
|
+
const row = upward ? (size - 1 - cnt) : cnt;
|
|
246
|
+
for (let dx = 0; dx <= 1; dx++) {
|
|
247
|
+
const col = right - dx;
|
|
248
|
+
if (col < 0 || col >= size) continue;
|
|
249
|
+
if (m[row][col] !== -1) continue;
|
|
250
|
+
m[row][col] = (bitIdx < dataBits.length) ? dataBits[bitIdx] : 0;
|
|
251
|
+
bitIdx++;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
upward = !upward;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const MASK_FNS: ((r: number, c: number) => boolean)[] = [
|
|
259
|
+
(r, c) => (r + c) % 2 === 0,
|
|
260
|
+
(r) => r % 2 === 0,
|
|
261
|
+
(_, c) => c % 3 === 0,
|
|
262
|
+
(r, c) => (r + c) % 3 === 0,
|
|
263
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
|
264
|
+
(r, c) => (r * c) % 2 + (r * c) % 3 === 0,
|
|
265
|
+
(r, c) => ((r * c) % 2 + (r * c) % 3) % 2 === 0,
|
|
266
|
+
(r, c) => ((r + c) % 2 + (r * c) % 3) % 2 === 0,
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
function applyMask(m: number[][], reserved: number[][], maskIdx: number) {
|
|
270
|
+
const fn = MASK_FNS[maskIdx];
|
|
271
|
+
const size = m.length;
|
|
272
|
+
for (let r = 0; r < size; r++) {
|
|
273
|
+
for (let c = 0; c < size; c++) {
|
|
274
|
+
if (reserved[r][c] === -1 && fn(r, c)) m[r][c] ^= 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scorePenalty(m: number[][]): number {
|
|
280
|
+
const size = m.length;
|
|
281
|
+
let score = 0;
|
|
282
|
+
for (let r = 0; r < size; r++) {
|
|
283
|
+
let run = 1;
|
|
284
|
+
for (let c = 1; c < size; c++) {
|
|
285
|
+
if (m[r][c] === m[r][c - 1]) run++;
|
|
286
|
+
else { if (run >= 5) score += run - 2; run = 1; }
|
|
287
|
+
}
|
|
288
|
+
if (run >= 5) score += run - 2;
|
|
289
|
+
}
|
|
290
|
+
for (let c = 0; c < size; c++) {
|
|
291
|
+
let run = 1;
|
|
292
|
+
for (let r = 1; r < size; r++) {
|
|
293
|
+
if (m[r][c] === m[r - 1][c]) run++;
|
|
294
|
+
else { if (run >= 5) score += run - 2; run = 1; }
|
|
295
|
+
}
|
|
296
|
+
if (run >= 5) score += run - 2;
|
|
297
|
+
}
|
|
298
|
+
for (let r = 0; r < size - 1; r++) {
|
|
299
|
+
for (let c = 0; c < size - 1; c++) {
|
|
300
|
+
const v = m[r][c];
|
|
301
|
+
if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return score;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// EC level M format info table (pre-computed with BCH)
|
|
308
|
+
const FORMAT_BITS: number[] = [
|
|
309
|
+
0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0,
|
|
310
|
+
0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976,
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
function placeFormatBits(m: number[][], maskIdx: number) {
|
|
314
|
+
const formatInfo = FORMAT_BITS[maskIdx]; // EC level M = index 0..7
|
|
315
|
+
const size = m.length;
|
|
316
|
+
const bits: number[] = [];
|
|
317
|
+
for (let i = 14; i >= 0; i--) bits.push((formatInfo >> i) & 1);
|
|
318
|
+
const pos1: [number, number][] = [
|
|
319
|
+
[0, 8], [1, 8], [2, 8], [3, 8], [4, 8], [5, 8], [7, 8], [8, 8],
|
|
320
|
+
[8, 7], [8, 5], [8, 4], [8, 3], [8, 2], [8, 1], [8, 0],
|
|
321
|
+
];
|
|
322
|
+
for (let i = 0; i < 15; i++) m[pos1[i][0]][pos1[i][1]] = bits[i];
|
|
323
|
+
for (let i = 0; i < 7; i++) m[size - 1 - i][8] = bits[i];
|
|
324
|
+
for (let i = 7; i < 15; i++) m[8][size - 15 + i] = bits[i];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const VERSION_INFO: number[] = [
|
|
328
|
+
0, 0, 0, 0, 0, 0, 0,
|
|
329
|
+
0x07c94, 0x085bc, 0x09a99, 0x0a4d3, 0x0bbf6, 0x0c762, 0x0d847,
|
|
330
|
+
0x0e60d, 0x0f928, 0x10b78, 0x1145d, 0x12a17, 0x13532, 0x149a6,
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
function placeVersionBits(m: number[][], version: number) {
|
|
334
|
+
if (version < 7) return;
|
|
335
|
+
const info = VERSION_INFO[version];
|
|
336
|
+
const size = m.length;
|
|
337
|
+
for (let i = 0; i < 18; i++) {
|
|
338
|
+
const bit = (info >> i) & 1;
|
|
339
|
+
const r = Math.floor(i / 3);
|
|
340
|
+
const c = size - 11 + (i % 3);
|
|
341
|
+
m[r][c] = bit;
|
|
342
|
+
m[c][r] = bit;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Public API
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
export type QRResult = {
|
|
351
|
+
matrix: number[][];
|
|
352
|
+
size: number;
|
|
353
|
+
version: number;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
export function generateQR(text: string): QRResult | null {
|
|
357
|
+
const dataBytes = encodeToBytes(text);
|
|
358
|
+
let overhead = Math.ceil((4 + 8) / 8);
|
|
359
|
+
let version = chooseVersion(dataBytes.length + overhead);
|
|
360
|
+
if (version === -1) {
|
|
361
|
+
overhead = Math.ceil((4 + 16) / 8);
|
|
362
|
+
version = chooseVersion(dataBytes.length + overhead);
|
|
363
|
+
}
|
|
364
|
+
if (version === -1) return null;
|
|
365
|
+
|
|
366
|
+
const ccBits = version <= 9 ? 8 : 16;
|
|
367
|
+
const needed = Math.ceil((4 + ccBits + dataBytes.length * 8) / 8);
|
|
368
|
+
if (needed > DATA_CAP[version]) {
|
|
369
|
+
version++;
|
|
370
|
+
if (version > 20) return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const bitstream = buildBitstream(dataBytes, version);
|
|
374
|
+
const codewords = interleaveBlocks(bitstream, version);
|
|
375
|
+
const dataBits: number[] = [];
|
|
376
|
+
for (const cw of codewords) {
|
|
377
|
+
for (let j = 7; j >= 0; j--) dataBits.push((cw >> j) & 1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const m = createMatrix(version);
|
|
381
|
+
placeFinderPattern(m, 0, 0);
|
|
382
|
+
placeFinderPattern(m, 0, m.length - 7);
|
|
383
|
+
placeFinderPattern(m, m.length - 7, 0);
|
|
384
|
+
placeAlignmentPatterns(m, version);
|
|
385
|
+
placeTimingPatterns(m);
|
|
386
|
+
m[(4 * version) + 9][8] = 1; // dark module
|
|
387
|
+
reserveFormatBits(m);
|
|
388
|
+
reserveVersionBits(m, version);
|
|
389
|
+
|
|
390
|
+
const reserved = m.map((row) => row.slice());
|
|
391
|
+
placeDataBits(m, dataBits);
|
|
392
|
+
|
|
393
|
+
let bestMask = 0;
|
|
394
|
+
let bestScore = Infinity;
|
|
395
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
396
|
+
const trial = m.map((row) => row.slice());
|
|
397
|
+
applyMask(trial, reserved, mask);
|
|
398
|
+
placeFormatBits(trial, mask);
|
|
399
|
+
placeVersionBits(trial, version);
|
|
400
|
+
const s = scorePenalty(trial);
|
|
401
|
+
if (s < bestScore) { bestScore = s; bestMask = mask; }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
applyMask(m, reserved, bestMask);
|
|
405
|
+
placeFormatBits(m, bestMask);
|
|
406
|
+
placeVersionBits(m, version);
|
|
407
|
+
|
|
408
|
+
return { matrix: m, size: m.length, version };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Terminal renderer — ANSI colors, half-block characters
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
export function renderQrTerminal(text: string): string | null {
|
|
416
|
+
const qr = generateQR(text);
|
|
417
|
+
if (!qr) return null;
|
|
418
|
+
|
|
419
|
+
const { matrix, size } = qr;
|
|
420
|
+
const quiet = 2;
|
|
421
|
+
const total = size + quiet * 2;
|
|
422
|
+
|
|
423
|
+
function mod(r: number, c: number): number {
|
|
424
|
+
if (r < quiet || r >= size + quiet || c < quiet || c >= size + quiet) return 0;
|
|
425
|
+
return matrix[r - quiet][c - quiet];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ▄ (lower half block): foreground = lower half, background = upper half
|
|
429
|
+
function cell(top: number, bottom: number): string {
|
|
430
|
+
if (top === 1 && bottom === 1) return "\x1b[40m \x1b[0m"; // both black
|
|
431
|
+
if (top === 0 && bottom === 0) return "\x1b[47m \x1b[0m"; // both white
|
|
432
|
+
if (top === 1 && bottom === 0) return "\x1b[37;40m\u2584\x1b[0m"; // top black, bottom white
|
|
433
|
+
return "\x1b[30;47m\u2584\x1b[0m"; // top white, bottom black
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const lines: string[] = [];
|
|
437
|
+
for (let r = 0; r < total; r += 2) {
|
|
438
|
+
let line = "";
|
|
439
|
+
for (let c = 0; c < total; c++) {
|
|
440
|
+
const top = mod(r, c);
|
|
441
|
+
const bottom = (r + 1 < total) ? mod(r + 1, c) : 0;
|
|
442
|
+
line += cell(top, bottom);
|
|
443
|
+
}
|
|
444
|
+
lines.push(line);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return lines.join("\n");
|
|
448
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { readFile, writeFile, rm } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
|
@@ -9,10 +10,11 @@ import {
|
|
|
9
10
|
} from "./security.js";
|
|
10
11
|
import { loadOrGenerateIdentity, loadPeers, loadSessions, saveSession } from "./identity.js";
|
|
11
12
|
import { zeroBuffer } from "./ratchet.js";
|
|
12
|
-
import { computeFingerprint, type X25519KeyPair } from "./crypto.js";
|
|
13
|
+
import { base64UrlEncode, computeFingerprint, type X25519KeyPair } from "./crypto.js";
|
|
13
14
|
import type { IdentityBundle, PeerIdentity } from "./identity.js";
|
|
14
15
|
import type { RatchetState } from "./ratchet.js";
|
|
15
16
|
import { createOutboundQueue, type OutboundQueue } from "./outbound-queue.js";
|
|
17
|
+
import { renderQrTerminal } from "./qr.js";
|
|
16
18
|
|
|
17
19
|
const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
|
|
18
20
|
|
|
@@ -65,6 +67,61 @@ export function createSecurityV2Deps(): SecurityV2Deps {
|
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Auto-pairing QR display (terminal)
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const AUTO_PAIRING_TTL_MS = 90_000;
|
|
75
|
+
|
|
76
|
+
async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Promise<void> {
|
|
77
|
+
const identity = v2deps.identity;
|
|
78
|
+
if (!identity) return;
|
|
79
|
+
|
|
80
|
+
const accountId = "default";
|
|
81
|
+
|
|
82
|
+
// Clear any stale pairing sessions for this account
|
|
83
|
+
for (const [token, session] of v2deps.pairingSessions) {
|
|
84
|
+
if (session.accountId === accountId) {
|
|
85
|
+
v2deps.pairingSessions.delete(token);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const pairingToken = base64UrlEncode(randomBytes(32));
|
|
90
|
+
const expiresAt = Date.now() + AUTO_PAIRING_TTL_MS;
|
|
91
|
+
const fingerprint = computeFingerprint(identity);
|
|
92
|
+
|
|
93
|
+
// Resolve gatewayUrl from config or runtime
|
|
94
|
+
let gatewayUrl = "";
|
|
95
|
+
const runtime = api.runtime as Record<string, unknown>;
|
|
96
|
+
if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
|
|
97
|
+
gatewayUrl = runtime["gatewayUrl"];
|
|
98
|
+
} else if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
|
|
99
|
+
gatewayUrl = runtime["baseUrl"];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
v2deps.pairingSessions.set(pairingToken, {
|
|
103
|
+
token: pairingToken,
|
|
104
|
+
accountId,
|
|
105
|
+
expiresAt,
|
|
106
|
+
allowOverwrite: false,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Compact QR: soyeht://pair?g=<gatewayUrl>&t=<token>&fp=<fingerprint>
|
|
110
|
+
// App fetches full key material via RPC soyeht.security.pairing.info
|
|
111
|
+
const qrText = `soyeht://pair?g=${encodeURIComponent(gatewayUrl)}&t=${pairingToken}&fp=${fingerprint}`;
|
|
112
|
+
const rendered = renderQrTerminal(qrText);
|
|
113
|
+
|
|
114
|
+
if (rendered) {
|
|
115
|
+
// Write QR directly to stdout to avoid logger prefixes breaking ANSI escape codes
|
|
116
|
+
process.stdout.write("\n" + rendered + "\n\n");
|
|
117
|
+
api.logger.info(`[soyeht] Scan the QR code above with the Soyeht app to pair`);
|
|
118
|
+
api.logger.info(`[soyeht] Fingerprint: ${fingerprint}`);
|
|
119
|
+
api.logger.info(`[soyeht] QR expires in ${AUTO_PAIRING_TTL_MS / 1000}s — restart plugin to generate a new one`);
|
|
120
|
+
} else {
|
|
121
|
+
api.logger.warn("[soyeht] QR code too large for terminal rendering. Use RPC soyeht.security.pairing.start instead.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
// ---------------------------------------------------------------------------
|
|
69
126
|
// Service
|
|
70
127
|
// ---------------------------------------------------------------------------
|
|
@@ -110,6 +167,15 @@ export function createSoyehtService(
|
|
|
110
167
|
}
|
|
111
168
|
if (v2deps) v2deps.ready = true;
|
|
112
169
|
|
|
170
|
+
// Auto-generate and display pairing QR if no peers are paired
|
|
171
|
+
if (v2deps?.identity && v2deps.peers.size === 0) {
|
|
172
|
+
try {
|
|
173
|
+
await showPairingQr(api, v2deps);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
api.logger.error("[soyeht] Failed to auto-generate pairing QR", { err });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
113
179
|
api.logger.info("[soyeht] Service started");
|
|
114
180
|
|
|
115
181
|
heartbeatTimer = setInterval(async () => {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PLUGIN_VERSION = "0.2.
|
|
1
|
+
export const PLUGIN_VERSION = "0.2.2";
|