@soyeht/soyeht 0.2.1 → 0.2.3
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/http.ts +455 -2
- package/src/index.ts +22 -0
- package/src/pairing.ts +55 -0
- package/src/service.ts +9 -47
- package/src/version.ts +1 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/http.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
-
import { normalizeAccountId } from "./config.js";
|
|
3
|
+
import { normalizeAccountId, resolveSoyehtAccount } from "./config.js";
|
|
4
4
|
import { decryptEnvelopeV2, validateEnvelopeV2, type EnvelopeV2 } from "./envelope-v2.js";
|
|
5
|
-
import { cloneRatchetSession } from "./ratchet.js";
|
|
5
|
+
import { cloneRatchetSession, zeroBuffer, type RatchetState } from "./ratchet.js";
|
|
6
6
|
import type { SecurityV2Deps } from "./service.js";
|
|
7
7
|
import { PLUGIN_VERSION } from "./version.js";
|
|
8
|
+
import {
|
|
9
|
+
base64UrlDecode,
|
|
10
|
+
computeFingerprint,
|
|
11
|
+
generateX25519KeyPair,
|
|
12
|
+
importEd25519PublicKey,
|
|
13
|
+
importX25519PublicKey,
|
|
14
|
+
ed25519Verify,
|
|
15
|
+
isTimestampValid,
|
|
16
|
+
} from "./crypto.js";
|
|
17
|
+
import { buildPairingProofTranscript } from "./pairing.js";
|
|
18
|
+
import {
|
|
19
|
+
buildHandshakeTranscript,
|
|
20
|
+
signHandshakeTranscript,
|
|
21
|
+
verifyHandshakeTranscript,
|
|
22
|
+
performX3DH,
|
|
23
|
+
} from "./x3dh.js";
|
|
24
|
+
import { savePeer, saveSession, deleteSession, type PeerIdentity } from "./identity.js";
|
|
8
25
|
|
|
9
26
|
// ---------------------------------------------------------------------------
|
|
10
27
|
// Helpers
|
|
@@ -341,6 +358,442 @@ export function sseHandler(
|
|
|
341
358
|
};
|
|
342
359
|
}
|
|
343
360
|
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Helpers for HTTP pairing
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
function isWellFormedNonce(nonce: string): boolean {
|
|
366
|
+
try {
|
|
367
|
+
const decoded = base64UrlDecode(nonce);
|
|
368
|
+
return decoded.length >= 16 && decoded.length <= 64;
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): void {
|
|
375
|
+
const existing = v2deps.sessions.get(accountId);
|
|
376
|
+
if (existing) {
|
|
377
|
+
zeroBuffer(existing.rootKey);
|
|
378
|
+
zeroBuffer(existing.sending.chainKey);
|
|
379
|
+
zeroBuffer(existing.receiving.chainKey);
|
|
380
|
+
v2deps.sessions.delete(accountId);
|
|
381
|
+
}
|
|
382
|
+
for (const [key, pending] of v2deps.pendingHandshakes) {
|
|
383
|
+
if (pending.accountId === accountId) {
|
|
384
|
+
v2deps.pendingHandshakes.delete(key);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// GET /soyeht/pairing/info?t=<pairingToken>
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
export function pairingInfoHandler(
|
|
394
|
+
_api: OpenClawPluginApi,
|
|
395
|
+
v2deps: SecurityV2Deps,
|
|
396
|
+
) {
|
|
397
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
398
|
+
if (req.method !== "GET") {
|
|
399
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!v2deps.ready || !v2deps.identity) {
|
|
404
|
+
sendJson(res, 503, { ok: false, error: "service_unavailable" });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
409
|
+
const pairingToken = url.searchParams.get("t") ?? "";
|
|
410
|
+
if (!pairingToken) {
|
|
411
|
+
sendJson(res, 400, { ok: false, error: "missing_pairing_token" });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const { allowed } = v2deps.rateLimiter.check(`pairing:info:${pairingToken}`);
|
|
416
|
+
if (!allowed) {
|
|
417
|
+
sendJson(res, 429, { ok: false, error: "rate_limited" });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const session = v2deps.pairingSessions.get(pairingToken);
|
|
422
|
+
if (!session) {
|
|
423
|
+
sendJson(res, 404, { ok: false, error: "pairing_not_found" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (session.expiresAt <= Date.now()) {
|
|
428
|
+
sendJson(res, 410, { ok: false, error: "pairing_expired" });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
sendJson(res, 200, {
|
|
433
|
+
ok: true,
|
|
434
|
+
accountId: session.accountId,
|
|
435
|
+
pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
|
|
436
|
+
pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
|
|
437
|
+
fingerprint: computeFingerprint(v2deps.identity),
|
|
438
|
+
expiresAt: session.expiresAt,
|
|
439
|
+
allowOverwrite: session.allowOverwrite,
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// POST /soyeht/pairing/pair — register peer + start handshake in one step
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
const PAIRING_HANDSHAKE_TOLERANCE_MS = 120_000; // 2 minutes for HTTP round-trip
|
|
449
|
+
|
|
450
|
+
export function pairingPairHandler(
|
|
451
|
+
api: OpenClawPluginApi,
|
|
452
|
+
v2deps: SecurityV2Deps,
|
|
453
|
+
) {
|
|
454
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
455
|
+
if (req.method !== "POST") {
|
|
456
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!v2deps.ready || !v2deps.identity) {
|
|
461
|
+
sendJson(res, 503, { ok: false, error: "service_unavailable" });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let body: Record<string, unknown>;
|
|
466
|
+
try {
|
|
467
|
+
const raw = await readRawBodyBuffer(req);
|
|
468
|
+
body = JSON.parse(raw.toString("utf8"));
|
|
469
|
+
} catch {
|
|
470
|
+
sendJson(res, 400, { ok: false, error: "invalid_json" });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const pairingToken = body["pairingToken"] as string | undefined;
|
|
475
|
+
const accountId = normalizeAccountId(body["accountId"] as string | undefined);
|
|
476
|
+
const appIdentityKey = body["appIdentityKey"] as string | undefined;
|
|
477
|
+
const appDhKey = body["appDhKey"] as string | undefined;
|
|
478
|
+
const appSignature = body["appSignature"] as string | undefined;
|
|
479
|
+
const appEphemeralKey = body["appEphemeralKey"] as string | undefined;
|
|
480
|
+
const nonce = body["nonce"] as string | undefined;
|
|
481
|
+
const timestamp = body["timestamp"] as number | undefined;
|
|
482
|
+
|
|
483
|
+
if (!pairingToken || !appIdentityKey || !appDhKey || !appSignature ||
|
|
484
|
+
!appEphemeralKey || !nonce || typeof timestamp !== "number") {
|
|
485
|
+
sendJson(res, 400, { ok: false, error: "missing_params" });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const { allowed } = v2deps.rateLimiter.check(`pairing:pair:${accountId}`);
|
|
490
|
+
if (!allowed) {
|
|
491
|
+
sendJson(res, 429, { ok: false, error: "rate_limited" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// --- Validate pairing session ---
|
|
496
|
+
|
|
497
|
+
const pairingSession = v2deps.pairingSessions.get(pairingToken);
|
|
498
|
+
if (!pairingSession) {
|
|
499
|
+
sendJson(res, 404, { ok: false, error: "pairing_not_found" });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (pairingSession.expiresAt <= Date.now()) {
|
|
504
|
+
v2deps.pairingSessions.delete(pairingToken);
|
|
505
|
+
sendJson(res, 410, { ok: false, error: "pairing_expired" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (pairingSession.accountId !== accountId) {
|
|
510
|
+
sendJson(res, 403, { ok: false, error: "account_mismatch" });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// --- Validate keys ---
|
|
515
|
+
|
|
516
|
+
let appIdentityPub;
|
|
517
|
+
try {
|
|
518
|
+
appIdentityPub = importEd25519PublicKey(appIdentityKey);
|
|
519
|
+
} catch {
|
|
520
|
+
sendJson(res, 400, { ok: false, error: "invalid_identity_key" });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
importX25519PublicKey(appDhKey);
|
|
526
|
+
} catch {
|
|
527
|
+
sendJson(res, 400, { ok: false, error: "invalid_dh_key" });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
importX25519PublicKey(appEphemeralKey);
|
|
533
|
+
} catch {
|
|
534
|
+
sendJson(res, 400, { ok: false, error: "invalid_ephemeral_key" });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Verify pairing signature ---
|
|
539
|
+
|
|
540
|
+
const proofTranscript = buildPairingProofTranscript({
|
|
541
|
+
accountId,
|
|
542
|
+
pairingToken,
|
|
543
|
+
expiresAt: pairingSession.expiresAt,
|
|
544
|
+
appIdentityKey,
|
|
545
|
+
appDhKey,
|
|
546
|
+
});
|
|
547
|
+
if (!ed25519Verify(appIdentityPub, proofTranscript, base64UrlDecode(appSignature))) {
|
|
548
|
+
sendJson(res, 403, { ok: false, error: "invalid_signature" });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// --- Validate nonce + timestamp ---
|
|
553
|
+
|
|
554
|
+
if (!isWellFormedNonce(nonce)) {
|
|
555
|
+
sendJson(res, 400, { ok: false, error: "invalid_nonce" });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!isTimestampValid(timestamp, PAIRING_HANDSHAKE_TOLERANCE_MS)) {
|
|
560
|
+
sendJson(res, 400, { ok: false, error: "timestamp_out_of_range" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!v2deps.nonceCache.add(`pairing:http:${accountId}:${nonce}`)) {
|
|
565
|
+
sendJson(res, 409, { ok: false, error: "nonce_reused" });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// --- Check existing peer ---
|
|
570
|
+
|
|
571
|
+
if (v2deps.peers.has(accountId) && !pairingSession.allowOverwrite) {
|
|
572
|
+
sendJson(res, 409, { ok: false, error: "peer_already_paired" });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// --- Register peer (clear old state first) ---
|
|
577
|
+
|
|
578
|
+
clearAccountSessionState(v2deps, accountId);
|
|
579
|
+
if (v2deps.stateDir) {
|
|
580
|
+
await deleteSession(v2deps.stateDir, accountId).catch(() => {});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const peer: PeerIdentity = {
|
|
584
|
+
accountId,
|
|
585
|
+
identityKeyB64: appIdentityKey,
|
|
586
|
+
dhKeyB64: appDhKey,
|
|
587
|
+
};
|
|
588
|
+
v2deps.peers.set(accountId, peer);
|
|
589
|
+
v2deps.pairingSessions.delete(pairingToken);
|
|
590
|
+
|
|
591
|
+
if (v2deps.stateDir) {
|
|
592
|
+
await savePeer(v2deps.stateDir, peer).catch(() => {});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// --- Start handshake ---
|
|
596
|
+
|
|
597
|
+
const cfg = await api.runtime.config.loadConfig();
|
|
598
|
+
const account = resolveSoyehtAccount(cfg, accountId);
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
const challengeExpiresAt = now + PAIRING_HANDSHAKE_TOLERANCE_MS;
|
|
601
|
+
const sessionExpiresAt = now + account.security.sessionMaxAgeMs;
|
|
602
|
+
const pluginEphemeralKey = generateX25519KeyPair();
|
|
603
|
+
|
|
604
|
+
const transcript = buildHandshakeTranscript({
|
|
605
|
+
accountId,
|
|
606
|
+
appEphKeyB64: appEphemeralKey,
|
|
607
|
+
pluginEphKeyB64: pluginEphemeralKey.publicKeyB64,
|
|
608
|
+
nonce,
|
|
609
|
+
timestamp,
|
|
610
|
+
expiresAt: sessionExpiresAt,
|
|
611
|
+
});
|
|
612
|
+
const pluginSignature = signHandshakeTranscript(
|
|
613
|
+
v2deps.identity.signKey.privateKey,
|
|
614
|
+
transcript,
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
const pendingKey = `${accountId}:${nonce}`;
|
|
618
|
+
v2deps.pendingHandshakes.set(pendingKey, {
|
|
619
|
+
key: pendingKey,
|
|
620
|
+
accountId,
|
|
621
|
+
nonce,
|
|
622
|
+
appEphemeralKey,
|
|
623
|
+
pluginEphemeralKey,
|
|
624
|
+
transcript,
|
|
625
|
+
challengeExpiresAt,
|
|
626
|
+
sessionExpiresAt,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
api.logger.info("[soyeht] HTTP pairing + handshake init completed", { accountId });
|
|
630
|
+
|
|
631
|
+
sendJson(res, 200, {
|
|
632
|
+
ok: true,
|
|
633
|
+
accountId,
|
|
634
|
+
pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
|
|
635
|
+
pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
|
|
636
|
+
fingerprint: computeFingerprint(v2deps.identity),
|
|
637
|
+
pluginEphemeralKey: pluginEphemeralKey.publicKeyB64,
|
|
638
|
+
pluginSignature,
|
|
639
|
+
nonce,
|
|
640
|
+
timestamp,
|
|
641
|
+
serverTimestamp: now,
|
|
642
|
+
challengeExpiresAt,
|
|
643
|
+
sessionExpiresAt,
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// POST /soyeht/pairing/finish — complete handshake, derive session, return streamToken
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
export function pairingFinishHandler(
|
|
653
|
+
api: OpenClawPluginApi,
|
|
654
|
+
v2deps: SecurityV2Deps,
|
|
655
|
+
) {
|
|
656
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
657
|
+
if (req.method !== "POST") {
|
|
658
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (!v2deps.ready || !v2deps.identity) {
|
|
663
|
+
sendJson(res, 503, { ok: false, error: "service_unavailable" });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let body: Record<string, unknown>;
|
|
668
|
+
try {
|
|
669
|
+
const raw = await readRawBodyBuffer(req);
|
|
670
|
+
body = JSON.parse(raw.toString("utf8"));
|
|
671
|
+
} catch {
|
|
672
|
+
sendJson(res, 400, { ok: false, error: "invalid_json" });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const accountId = normalizeAccountId(body["accountId"] as string | undefined);
|
|
677
|
+
const nonce = body["nonce"] as string | undefined;
|
|
678
|
+
const appSignature = body["appSignature"] as string | undefined;
|
|
679
|
+
|
|
680
|
+
if (!nonce || !appSignature) {
|
|
681
|
+
sendJson(res, 400, { ok: false, error: "missing_params" });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const { allowed } = v2deps.rateLimiter.check(`pairing:finish:${accountId}`);
|
|
686
|
+
if (!allowed) {
|
|
687
|
+
sendJson(res, 429, { ok: false, error: "rate_limited" });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const pendingKey = `${accountId}:${nonce}`;
|
|
692
|
+
const pending = v2deps.pendingHandshakes.get(pendingKey);
|
|
693
|
+
if (!pending) {
|
|
694
|
+
sendJson(res, 404, { ok: false, error: "handshake_not_found" });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (pending.challengeExpiresAt <= Date.now()) {
|
|
699
|
+
v2deps.pendingHandshakes.delete(pendingKey);
|
|
700
|
+
sendJson(res, 410, { ok: false, error: "handshake_expired" });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const peer = v2deps.peers.get(accountId);
|
|
705
|
+
if (!peer) {
|
|
706
|
+
sendJson(res, 404, { ok: false, error: "peer_not_found" });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
let appIdentityPub;
|
|
711
|
+
try {
|
|
712
|
+
appIdentityPub = importEd25519PublicKey(peer.identityKeyB64);
|
|
713
|
+
} catch {
|
|
714
|
+
sendJson(res, 500, { ok: false, error: "invalid_peer_key" });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!verifyHandshakeTranscript(appIdentityPub, pending.transcript, appSignature)) {
|
|
719
|
+
sendJson(res, 403, { ok: false, error: "invalid_signature" });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
let appEphemeralPub;
|
|
724
|
+
try {
|
|
725
|
+
appEphemeralPub = importX25519PublicKey(pending.appEphemeralKey);
|
|
726
|
+
} catch {
|
|
727
|
+
v2deps.pendingHandshakes.delete(pendingKey);
|
|
728
|
+
sendJson(res, 500, { ok: false, error: "invalid_ephemeral_key" });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let peerStaticDhPub;
|
|
733
|
+
try {
|
|
734
|
+
peerStaticDhPub = importX25519PublicKey(peer.dhKeyB64);
|
|
735
|
+
} catch {
|
|
736
|
+
sendJson(res, 500, { ok: false, error: "invalid_peer_dh_key" });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const x3dhResult = await performX3DH({
|
|
741
|
+
myStaticDhKey: v2deps.identity.dhKey,
|
|
742
|
+
myEphemeralKey: pending.pluginEphemeralKey,
|
|
743
|
+
peerStaticDhPub,
|
|
744
|
+
peerEphemeralPub: appEphemeralPub,
|
|
745
|
+
nonce,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// Clear any existing session for this account
|
|
749
|
+
const existingSession = v2deps.sessions.get(accountId);
|
|
750
|
+
if (existingSession) {
|
|
751
|
+
zeroBuffer(existingSession.rootKey);
|
|
752
|
+
zeroBuffer(existingSession.sending.chainKey);
|
|
753
|
+
zeroBuffer(existingSession.receiving.chainKey);
|
|
754
|
+
v2deps.sessions.delete(accountId);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const session: RatchetState = {
|
|
758
|
+
accountId,
|
|
759
|
+
rootKey: x3dhResult.rootKey,
|
|
760
|
+
sending: { chainKey: x3dhResult.sendChainKey, counter: 0 },
|
|
761
|
+
receiving: { chainKey: x3dhResult.recvChainKey, counter: 0 },
|
|
762
|
+
myCurrentEphDhKey: pending.pluginEphemeralKey,
|
|
763
|
+
peerLastEphDhKeyB64: pending.appEphemeralKey,
|
|
764
|
+
dhRatchetSendCount: 0,
|
|
765
|
+
dhRatchetRecvCount: 0,
|
|
766
|
+
createdAt: Date.now(),
|
|
767
|
+
expiresAt: pending.sessionExpiresAt,
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
v2deps.sessions.set(accountId, session);
|
|
771
|
+
v2deps.pendingHandshakes.delete(pendingKey);
|
|
772
|
+
|
|
773
|
+
if (v2deps.stateDir) {
|
|
774
|
+
await saveSession(v2deps.stateDir, session).catch((err) => {
|
|
775
|
+
api.logger.error("[soyeht] Failed to persist session", { accountId, err });
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Issue stream token for SSE auth
|
|
780
|
+
v2deps.outboundQueue.revokeStreamTokensForAccount(accountId);
|
|
781
|
+
const streamToken = v2deps.outboundQueue.createStreamToken(
|
|
782
|
+
accountId,
|
|
783
|
+
pending.sessionExpiresAt,
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
api.logger.info("[soyeht] HTTP pairing handshake completed", { accountId });
|
|
787
|
+
|
|
788
|
+
sendJson(res, 200, {
|
|
789
|
+
ok: true,
|
|
790
|
+
complete: true,
|
|
791
|
+
sessionExpiresAt: pending.sessionExpiresAt,
|
|
792
|
+
streamToken,
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
344
797
|
// ---------------------------------------------------------------------------
|
|
345
798
|
// POST /soyeht/livekit/token — stub
|
|
346
799
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
livekitTokenHandler,
|
|
16
16
|
inboundHandler,
|
|
17
17
|
sseHandler,
|
|
18
|
+
pairingInfoHandler,
|
|
19
|
+
pairingPairHandler,
|
|
20
|
+
pairingFinishHandler,
|
|
18
21
|
} from "./http.js";
|
|
19
22
|
import {
|
|
20
23
|
createSoyehtService,
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
import {
|
|
24
27
|
handleSecurityIdentity,
|
|
25
28
|
handleSecurityPair,
|
|
29
|
+
handleSecurityPairingInfo,
|
|
26
30
|
handleSecurityPairingStart,
|
|
27
31
|
} from "./pairing.js";
|
|
28
32
|
import { PLUGIN_VERSION } from "./version.js";
|
|
@@ -89,6 +93,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
|
|
|
89
93
|
// Security RPC
|
|
90
94
|
api.registerGatewayMethod("soyeht.security.identity", handleSecurityIdentity(api, v2deps));
|
|
91
95
|
api.registerGatewayMethod("soyeht.security.pairing.start", handleSecurityPairingStart(api, v2deps));
|
|
96
|
+
api.registerGatewayMethod("soyeht.security.pairing.info", handleSecurityPairingInfo(api, v2deps));
|
|
92
97
|
api.registerGatewayMethod("soyeht.security.pair", handleSecurityPair(api, v2deps));
|
|
93
98
|
api.registerGatewayMethod("soyeht.security.handshake.init", handleSecurityHandshake(api, v2deps));
|
|
94
99
|
api.registerGatewayMethod("soyeht.security.handshake.finish", handleSecurityHandshakeFinish(api, v2deps));
|
|
@@ -125,6 +130,23 @@ const soyehtPlugin: OpenClawPluginDefinition = {
|
|
|
125
130
|
handler: sseHandler(api, v2deps),
|
|
126
131
|
});
|
|
127
132
|
|
|
133
|
+
// HTTP pairing routes (app pairs via HTTP, no WebSocket needed)
|
|
134
|
+
api.registerHttpRoute({
|
|
135
|
+
path: "/soyeht/pairing/info",
|
|
136
|
+
auth: "plugin",
|
|
137
|
+
handler: pairingInfoHandler(api, v2deps),
|
|
138
|
+
});
|
|
139
|
+
api.registerHttpRoute({
|
|
140
|
+
path: "/soyeht/pairing/pair",
|
|
141
|
+
auth: "plugin",
|
|
142
|
+
handler: pairingPairHandler(api, v2deps),
|
|
143
|
+
});
|
|
144
|
+
api.registerHttpRoute({
|
|
145
|
+
path: "/soyeht/pairing/finish",
|
|
146
|
+
auth: "plugin",
|
|
147
|
+
handler: pairingFinishHandler(api, v2deps),
|
|
148
|
+
});
|
|
149
|
+
|
|
128
150
|
// Background service (manages state lifecycle)
|
|
129
151
|
api.registerService(createSoyehtService(api, v2deps));
|
|
130
152
|
|
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/service.ts
CHANGED
|
@@ -10,13 +10,12 @@ import {
|
|
|
10
10
|
} from "./security.js";
|
|
11
11
|
import { loadOrGenerateIdentity, loadPeers, loadSessions, saveSession } from "./identity.js";
|
|
12
12
|
import { zeroBuffer } from "./ratchet.js";
|
|
13
|
-
import { base64UrlEncode, computeFingerprint,
|
|
13
|
+
import { base64UrlEncode, computeFingerprint, type X25519KeyPair } from "./crypto.js";
|
|
14
14
|
import type { IdentityBundle, PeerIdentity } from "./identity.js";
|
|
15
15
|
import type { RatchetState } from "./ratchet.js";
|
|
16
16
|
import { createOutboundQueue, type OutboundQueue } from "./outbound-queue.js";
|
|
17
17
|
import { renderQrTerminal } from "./qr.js";
|
|
18
18
|
import { resolveSoyehtAccount } from "./config.js";
|
|
19
|
-
import { buildPairingQrTranscript, buildPairingQrTranscriptV2 } from "./pairing.js";
|
|
20
19
|
|
|
21
20
|
const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
|
|
22
21
|
|
|
@@ -92,51 +91,10 @@ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Pr
|
|
|
92
91
|
const expiresAt = Date.now() + AUTO_PAIRING_TTL_MS;
|
|
93
92
|
const fingerprint = computeFingerprint(identity);
|
|
94
93
|
|
|
95
|
-
// Resolve gatewayUrl from config
|
|
94
|
+
// Resolve gatewayUrl from config
|
|
96
95
|
const cfg = await api.runtime.config.loadConfig();
|
|
97
96
|
const account = resolveSoyehtAccount(cfg, accountId);
|
|
98
|
-
|
|
99
|
-
if (!gatewayUrl) {
|
|
100
|
-
const runtime = api.runtime as Record<string, unknown>;
|
|
101
|
-
if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
|
|
102
|
-
gatewayUrl = runtime["gatewayUrl"];
|
|
103
|
-
} else if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
|
|
104
|
-
gatewayUrl = runtime["baseUrl"];
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const basePayload = {
|
|
109
|
-
accountId,
|
|
110
|
-
pairingToken,
|
|
111
|
-
expiresAt,
|
|
112
|
-
allowOverwrite: false,
|
|
113
|
-
pluginIdentityKey: identity.signKey.publicKeyB64,
|
|
114
|
-
pluginDhKey: identity.dhKey.publicKeyB64,
|
|
115
|
-
fingerprint,
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
let qrPayload: Record<string, unknown>;
|
|
119
|
-
|
|
120
|
-
if (gatewayUrl) {
|
|
121
|
-
const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
|
|
122
|
-
const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
|
|
123
|
-
qrPayload = {
|
|
124
|
-
version: 2,
|
|
125
|
-
type: "soyeht_pairing_qr",
|
|
126
|
-
gatewayUrl,
|
|
127
|
-
...basePayload,
|
|
128
|
-
signature,
|
|
129
|
-
};
|
|
130
|
-
} else {
|
|
131
|
-
const transcript = buildPairingQrTranscript(basePayload);
|
|
132
|
-
const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
|
|
133
|
-
qrPayload = {
|
|
134
|
-
version: 1,
|
|
135
|
-
type: "soyeht_pairing_qr",
|
|
136
|
-
...basePayload,
|
|
137
|
-
signature,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
97
|
+
const gatewayUrl = account.gatewayUrl;
|
|
140
98
|
|
|
141
99
|
v2deps.pairingSessions.set(pairingToken, {
|
|
142
100
|
token: pairingToken,
|
|
@@ -145,11 +103,15 @@ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Pr
|
|
|
145
103
|
allowOverwrite: false,
|
|
146
104
|
});
|
|
147
105
|
|
|
148
|
-
|
|
106
|
+
// Compact QR: soyeht://pair?g=<gatewayUrl>&t=<token>&fp=<fingerprint>
|
|
107
|
+
// App fetches full key material via RPC soyeht.security.pairing.info
|
|
108
|
+
const qrText = `soyeht://pair?g=${encodeURIComponent(gatewayUrl)}&t=${pairingToken}&fp=${fingerprint}`;
|
|
149
109
|
const rendered = renderQrTerminal(qrText);
|
|
150
110
|
|
|
151
111
|
if (rendered) {
|
|
152
|
-
|
|
112
|
+
// Write QR directly to stdout to avoid logger prefixes breaking ANSI escape codes
|
|
113
|
+
process.stdout.write("\n" + rendered + "\n\n");
|
|
114
|
+
api.logger.info(`[soyeht] Scan the QR code above with the Soyeht app to pair`);
|
|
153
115
|
api.logger.info(`[soyeht] Fingerprint: ${fingerprint}`);
|
|
154
116
|
api.logger.info(`[soyeht] QR expires in ${AUTO_PAIRING_TTL_MS / 1000}s — restart plugin to generate a new one`);
|
|
155
117
|
} else {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PLUGIN_VERSION = "0.2.
|
|
1
|
+
export const PLUGIN_VERSION = "0.2.3";
|