Skip to content

Commit 944a106

Browse files
authored
Merge pull request #16 from AlexAnys/feat/device-identity
feat: Device Identity 认证支持(修复 OpenClaw 2026.2.x scope 问题)
2 parents dd65e23 + 6a00615 commit 944a106

2 files changed

Lines changed: 313 additions & 17 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ chmod 600 ~/.clawdbot/secrets/feishu_app_secret
199199
FEISHU_APP_ID=cli_xxxxxxxxx node bridge.mjs
200200
```
201201

202+
该桥接兼容 Clawdbot / OpenClaw Gateway。
203+
202204
在飞书里给机器人发一条消息,看到回复就说明成功了 🎉
203205

204206
### 第五步:设置开机自启(可选但推荐)
@@ -308,6 +310,21 @@ tail -n 200 ~/.clawdbot/logs/feishu-bridge.err.log
308310

309311
> 如果你希望自动解析 PDF/Word/Excel 并把内容转成文本喂给 AI,需要额外扩展(欢迎提 issue)。
310312
313+
### 5) 升级到 OpenClaw 2026.2.x 后报 "missing scope: operator.write"
314+
315+
这是因为新版 OpenClaw 引入了 Device Identity 认证机制。**解决方法:升级桥接到最新版本。**
316+
317+
```bash
318+
cd /path/to/feishu-openclaw
319+
git pull
320+
npm install
321+
# 重启服务
322+
launchctl unload ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist
323+
launchctl load ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist
324+
```
325+
326+
升级后桥接会自动生成设备密钥并完成认证,无需额外操作。
327+
311328
## 常见歧义与配置说明
312329

313330
### 1) Clawdbot 配置路径

bridge.mjs

Lines changed: 296 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,179 @@ const MAX_ATTACHMENTS = Number(process.env.FEISHU_BRIDGE_MAX_ATTACHMENTS ?? 4);
9191

9292
const SELFTEST = process.argv.includes('--selftest') || process.env.FEISHU_BRIDGE_SELFTEST === '1';
9393
const DEBUG = process.env.FEISHU_BRIDGE_DEBUG === '1';
94+
const BRIDGE_VERSION = readBridgeVersion();
95+
const DEVICE_IDENTITY_PATH = resolveDeviceIdentityPath(process.env.FEISHU_BRIDGE_DEVICE_IDENTITY_PATH);
96+
97+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
98+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
99+
100+
let DEVICE_IDENTITY = null;
94101

95102
// ─── Helpers ─────────────────────────────────────────────────────
96103

97104
function resolvePath(p) {
98105
return String(p || '').replace(/^~/, os.homedir());
99106
}
100107

108+
function toBase64Url(buffer) {
109+
return Buffer.from(buffer).toString('base64url');
110+
}
111+
112+
function fromBase64Url(str) {
113+
return Buffer.from(str, 'base64url');
114+
}
115+
116+
function readBridgeVersion() {
117+
try {
118+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'package.json');
119+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
120+
return String(pkg?.version || '0.0.0');
121+
} catch {
122+
return '0.0.0';
123+
}
124+
}
125+
126+
function resolveDeviceIdentityPath(explicitPath) {
127+
if (explicitPath) return resolvePath(explicitPath);
128+
const openclawDir = resolvePath('~/.openclaw');
129+
if (fs.existsSync(openclawDir)) {
130+
return path.join(openclawDir, 'feishu-bridge-device.json');
131+
}
132+
return resolvePath('~/.clawdbot/feishu-bridge-device.json');
133+
}
134+
135+
function buildEd25519PublicKey(rawPublicKey) {
136+
if (!Buffer.isBuffer(rawPublicKey) || rawPublicKey.length !== 32) {
137+
throw new Error('Invalid Ed25519 public key length');
138+
}
139+
const der = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]);
140+
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
141+
}
142+
143+
function buildEd25519PrivateKey(rawPrivateKey) {
144+
if (!Buffer.isBuffer(rawPrivateKey) || rawPrivateKey.length !== 32) {
145+
throw new Error('Invalid Ed25519 private key length');
146+
}
147+
const der = Buffer.concat([ED25519_PKCS8_PREFIX, rawPrivateKey]);
148+
return crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
149+
}
150+
151+
function writeDeviceIdentityFile(filePath, record) {
152+
const resolved = resolvePath(filePath);
153+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
154+
fs.writeFileSync(resolved, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
155+
try {
156+
fs.chmodSync(resolved, 0o600);
157+
} catch {
158+
// ignore permission errors on non-POSIX fs
159+
}
160+
}
161+
162+
function validateAndHydrateDeviceIdentity(record, filePath) {
163+
if (!record || typeof record !== 'object') throw new Error('Device identity must be an object');
164+
165+
const deviceId = String(record.deviceId || '').trim();
166+
const publicKey = String(record.publicKey || '').trim();
167+
const privateKey = String(record.privateKey || '').trim();
168+
const createdAtMs = Number(record.createdAtMs || 0);
169+
170+
if (record.version !== 1) throw new Error('Unsupported device identity version');
171+
if (!/^[a-f0-9]{64}$/i.test(deviceId)) throw new Error('Invalid deviceId');
172+
173+
const pubRaw = fromBase64Url(publicKey);
174+
const privRaw = fromBase64Url(privateKey);
175+
if (pubRaw.length !== 32) throw new Error('Invalid public key bytes');
176+
if (privRaw.length !== 32) throw new Error('Invalid private key bytes');
177+
178+
const derivedDeviceId = crypto.createHash('sha256').update(pubRaw).digest('hex');
179+
if (derivedDeviceId !== deviceId.toLowerCase()) throw new Error('deviceId mismatch');
180+
181+
return {
182+
version: 1,
183+
deviceId: derivedDeviceId,
184+
publicKey,
185+
privateKey,
186+
createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : Date.now(),
187+
deviceToken: typeof record.deviceToken === 'string' ? record.deviceToken : undefined,
188+
filePath: resolvePath(filePath),
189+
publicKeyObject: buildEd25519PublicKey(pubRaw),
190+
privateKeyObject: buildEd25519PrivateKey(privRaw),
191+
};
192+
}
193+
194+
function createDeviceIdentityRecord() {
195+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
196+
const pubRaw = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
197+
const privRaw = privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-32);
198+
const deviceId = crypto.createHash('sha256').update(pubRaw).digest('hex');
199+
200+
return {
201+
version: 1,
202+
deviceId,
203+
publicKey: toBase64Url(pubRaw),
204+
privateKey: toBase64Url(privRaw),
205+
createdAtMs: Date.now(),
206+
};
207+
}
208+
209+
async function loadOrCreateDeviceIdentity(filePath = DEVICE_IDENTITY_PATH) {
210+
const resolved = resolvePath(filePath);
211+
212+
if (fs.existsSync(resolved)) {
213+
try {
214+
const raw = fs.readFileSync(resolved, 'utf8');
215+
const parsed = JSON.parse(raw);
216+
const identity = validateAndHydrateDeviceIdentity(parsed, resolved);
217+
try {
218+
fs.chmodSync(resolved, 0o600);
219+
} catch {
220+
// ignore
221+
}
222+
return identity;
223+
} catch (e) {
224+
console.error(`[WARN] Device identity file invalid, regenerating: ${e?.message || String(e)}`);
225+
}
226+
}
227+
228+
const record = createDeviceIdentityRecord();
229+
writeDeviceIdentityFile(resolved, record);
230+
return validateAndHydrateDeviceIdentity(record, resolved);
231+
}
232+
233+
function persistDeviceToken(identity, deviceToken) {
234+
const token = String(deviceToken || '').trim();
235+
if (!token || !identity) return identity;
236+
if (identity.deviceToken === token) return identity;
237+
238+
const record = {
239+
version: 1,
240+
deviceId: identity.deviceId,
241+
publicKey: identity.publicKey,
242+
privateKey: identity.privateKey,
243+
createdAtMs: identity.createdAtMs,
244+
deviceToken: token,
245+
};
246+
247+
writeDeviceIdentityFile(identity.filePath, record);
248+
return { ...identity, deviceToken: token };
249+
}
250+
251+
/**
252+
* Build the device auth payload string that the Gateway expects.
253+
* Format: version|deviceId|clientId|clientMode|role|scopes|signedAtMs|token[|nonce]
254+
*/
255+
function buildDeviceAuthPayload({ deviceId, clientId, clientMode, role, scopes, signedAtMs, token, nonce }) {
256+
const version = nonce ? 'v2' : 'v1';
257+
const base = [version, deviceId, clientId, clientMode, role, scopes.join(','), String(signedAtMs), token || ''];
258+
if (version === 'v2') base.push(nonce || '');
259+
return base.join('|');
260+
}
261+
262+
function signDevicePayload(identity, payload) {
263+
const signature = crypto.sign(null, Buffer.from(payload, 'utf8'), identity.privateKeyObject);
264+
return toBase64Url(signature);
265+
}
266+
101267
function mustRead(filePath, label) {
102268
const resolved = resolvePath(filePath);
103269
if (!fs.existsSync(resolved)) {
@@ -427,6 +593,9 @@ if (!GATEWAY_TOKEN) {
427593
process.exit(1);
428594
}
429595

596+
DEVICE_IDENTITY = await loadOrCreateDeviceIdentity(DEVICE_IDENTITY_PATH);
597+
console.log(`[OK] Device identity: ${DEVICE_IDENTITY.deviceId.slice(0, 8)}...`);
598+
430599
// ─── Feishu SDK setup ────────────────────────────────────────────
431600

432601
const sdkConfig = {
@@ -463,13 +632,84 @@ async function askClawdbot({ text, sessionKey, attachments = [] }) {
463632
let runId = null;
464633
let buf = '';
465634
let mediaUrls = [];
635+
let connectSent = false;
636+
let connectFallbackTimer = null;
466637

467638
const close = () => {
468639
try {
469640
ws.close();
470641
} catch {}
471642
};
472643

644+
const sendConnect = (challengeNonce = null) => {
645+
if (connectSent) return;
646+
connectSent = true;
647+
648+
if (connectFallbackTimer) {
649+
clearTimeout(connectFallbackTimer);
650+
connectFallbackTimer = null;
651+
}
652+
653+
const params = {
654+
minProtocol: 3,
655+
maxProtocol: 3,
656+
client: { id: 'gateway-client', version: BRIDGE_VERSION, platform: 'macos', mode: 'backend' },
657+
role: 'operator',
658+
scopes: ['operator.read', 'operator.write'],
659+
auth: { token: GATEWAY_TOKEN },
660+
locale: 'zh-CN',
661+
userAgent: 'feishu-openclaw-bridge',
662+
};
663+
664+
if (DEVICE_IDENTITY) {
665+
try {
666+
const signedAt = Date.now();
667+
const payload = buildDeviceAuthPayload({
668+
deviceId: DEVICE_IDENTITY.deviceId,
669+
clientId: params.client.id,
670+
clientMode: params.client.mode,
671+
role: params.role,
672+
scopes: params.scopes,
673+
signedAtMs: signedAt,
674+
token: GATEWAY_TOKEN,
675+
nonce: challengeNonce || undefined,
676+
});
677+
const signature = signDevicePayload(DEVICE_IDENTITY, payload);
678+
params.device = {
679+
id: DEVICE_IDENTITY.deviceId,
680+
publicKey: DEVICE_IDENTITY.publicKey,
681+
signature,
682+
signedAt,
683+
...(challengeNonce ? { nonce: challengeNonce } : {}),
684+
};
685+
} catch (e) {
686+
console.error(`[WARN] Device auth sign failed, falling back without device block: ${e?.message || String(e)}`);
687+
}
688+
}
689+
690+
ws.send(
691+
JSON.stringify({
692+
type: 'req',
693+
id: 'connect',
694+
method: 'connect',
695+
params,
696+
}),
697+
);
698+
};
699+
700+
ws.on('open', () => {
701+
// Backward compatibility: older gateways may not send connect.challenge.
702+
connectFallbackTimer = setTimeout(() => sendConnect(null), 300);
703+
if (typeof connectFallbackTimer.unref === 'function') connectFallbackTimer.unref();
704+
});
705+
706+
ws.on('close', () => {
707+
if (connectFallbackTimer) {
708+
clearTimeout(connectFallbackTimer);
709+
connectFallbackTimer = null;
710+
}
711+
});
712+
473713
ws.on('error', (e) => {
474714
close();
475715
reject(e);
@@ -484,23 +724,8 @@ async function askClawdbot({ text, sessionKey, attachments = [] }) {
484724
}
485725

486726
if (msg.type === 'event' && msg.event === 'connect.challenge') {
487-
ws.send(
488-
JSON.stringify({
489-
type: 'req',
490-
id: 'connect',
491-
method: 'connect',
492-
params: {
493-
minProtocol: 3,
494-
maxProtocol: 3,
495-
client: { id: 'gateway-client', version: '0.3.0', platform: 'macos', mode: 'backend' },
496-
role: 'operator',
497-
scopes: ['operator.read', 'operator.write'],
498-
auth: { token: GATEWAY_TOKEN },
499-
locale: 'zh-CN',
500-
userAgent: 'feishu-clawdbot-bridge',
501-
},
502-
}),
503-
);
727+
const nonce = msg.payload?.nonce;
728+
sendConnect(typeof nonce === 'string' ? nonce : null);
504729
return;
505730
}
506731

@@ -511,6 +736,15 @@ async function askClawdbot({ text, sessionKey, attachments = [] }) {
511736
return;
512737
}
513738

739+
const deviceToken = msg.payload?.auth?.deviceToken;
740+
if (typeof deviceToken === 'string' && deviceToken.trim()) {
741+
try {
742+
DEVICE_IDENTITY = persistDeviceToken(DEVICE_IDENTITY, deviceToken);
743+
} catch (e) {
744+
console.error(`[WARN] Failed to persist device token: ${e?.message || String(e)}`);
745+
}
746+
}
747+
514748
const params = {
515749
message: text,
516750
agentId: CLAWDBOT_AGENT_ID,
@@ -1346,5 +1580,50 @@ async function runSelfTest() {
13461580
const r = parseMediaLines('hello\nMEDIA: /tmp/a.mp4\nworld');
13471581
ok('MEDIA parsed', r.mediaUrls.length === 1 && r.text.includes('hello') && r.text.includes('world'));
13481582

1583+
// 4) Device identity generation + persistence + signing
1584+
const testDevicePath = path.join(os.tmpdir(), `feishu_bridge_test_device_${Date.now()}.json`);
1585+
const testIdentity = await loadOrCreateDeviceIdentity(testDevicePath);
1586+
ok('device identity generated', Boolean(testIdentity.deviceId) && testIdentity.deviceId.length === 64);
1587+
ok('device identity has keys', Boolean(testIdentity.publicKey) && Boolean(testIdentity.privateKey));
1588+
1589+
// Verify payload signing round-trip
1590+
const testPayload = buildDeviceAuthPayload({
1591+
deviceId: testIdentity.deviceId,
1592+
clientId: 'test-client',
1593+
clientMode: 'backend',
1594+
role: 'operator',
1595+
scopes: ['operator.read', 'operator.write'],
1596+
signedAtMs: Date.now(),
1597+
token: 'test-token',
1598+
nonce: 'test-nonce',
1599+
});
1600+
ok('payload format v2', testPayload.startsWith('v2|') && testPayload.includes('test-nonce'));
1601+
const testSig = signDevicePayload(testIdentity, testPayload);
1602+
ok('signature is base64url', typeof testSig === 'string' && testSig.length > 0 && !testSig.includes('+'));
1603+
1604+
// Verify signature with public key
1605+
const pubKeyObj = testIdentity.publicKeyObject;
1606+
const sigBuf = fromBase64Url(testSig);
1607+
const verified = crypto.verify(null, Buffer.from(testPayload, 'utf8'), pubKeyObj, sigBuf);
1608+
ok('signature verifies', verified === true);
1609+
1610+
// v1 payload (no nonce)
1611+
const testPayloadV1 = buildDeviceAuthPayload({
1612+
deviceId: testIdentity.deviceId,
1613+
clientId: 'test-client',
1614+
clientMode: 'backend',
1615+
role: 'operator',
1616+
scopes: ['operator.read', 'operator.write'],
1617+
signedAtMs: Date.now(),
1618+
token: 'test-token',
1619+
});
1620+
ok('payload format v1', testPayloadV1.startsWith('v1|') && !testPayloadV1.includes('test-nonce'));
1621+
1622+
try {
1623+
fs.unlinkSync(testDevicePath);
1624+
} catch {
1625+
// ignore
1626+
}
1627+
13491628
console.log('[OK] Selftests finished');
13501629
}

0 commit comments

Comments
 (0)