@@ -91,13 +91,179 @@ const MAX_ATTACHMENTS = Number(process.env.FEISHU_BRIDGE_MAX_ATTACHMENTS ?? 4);
9191
9292const SELFTEST = process . argv . includes ( '--selftest' ) || process . env . FEISHU_BRIDGE_SELFTEST === '1' ;
9393const 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
97104function 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 - f 0 - 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+
101267function 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
432601const 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