@clanker-chain/identity-plugin 0.0.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/dist/index.js +12 -0
- package/dist/src/client.js +165 -0
- package/dist/src/types.js +1 -0
- package/index.ts +15 -0
- package/install.sh +50 -0
- package/openclaw.plugin.json +13 -0
- package/package.json +21 -0
- package/src/client.ts +211 -0
- package/src/openclaw-plugin-sdk-core.d.ts +15 -0
- package/src/skill/SKILL.md +284 -0
- package/src/skill/run.mjs +208 -0
- package/src/types.ts +51 -0
- package/tsconfig.json +13 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
2
|
+
const plugin = {
|
|
3
|
+
id: "clanker-chain-identity",
|
|
4
|
+
name: "Clanker Chain Identity",
|
|
5
|
+
description: "Identity client and identity skill for clanker-chain bots.",
|
|
6
|
+
configSchema: emptyPluginConfigSchema(),
|
|
7
|
+
register(_api) {
|
|
8
|
+
// No channels or HTTP routes yet; the primary value is the identity skill
|
|
9
|
+
// shipped via `skills` in openclaw.plugin.json.
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
export default plugin;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import * as ed25519 from "@noble/ed25519";
|
|
5
|
+
import { SignJWT, importJWK } from "jose";
|
|
6
|
+
const MQTT_TOKEN_AUD = "clanker-mqtt";
|
|
7
|
+
function base64url(buf) {
|
|
8
|
+
return Buffer.from(buf)
|
|
9
|
+
.toString("base64")
|
|
10
|
+
.replace(/\+/g, "-")
|
|
11
|
+
.replace(/\//g, "_")
|
|
12
|
+
.replace(/=+$/, "");
|
|
13
|
+
}
|
|
14
|
+
export class IdentityClient {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.botId = options.botId;
|
|
17
|
+
this.operatorId = options.operatorId;
|
|
18
|
+
this.baseUrl = options.identityServiceUrl ?? process.env.IDENTITY_SERVICE_URL ?? "http://localhost:8080";
|
|
19
|
+
const defaultKeyPath = path.join(os.homedir(), ".openclaw", "keys", `${this.botId}.key`);
|
|
20
|
+
this.keyPath = options.keyPath ?? defaultKeyPath;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ensure a private key exists on disk, returning the 32-byte private key.
|
|
24
|
+
*/
|
|
25
|
+
async loadOrCreatePrivateKey() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await fs.readFile(this.keyPath, "utf8");
|
|
28
|
+
const bytes = Buffer.from(raw.trim(), "base64");
|
|
29
|
+
if (bytes.length !== 32) {
|
|
30
|
+
throw new Error("invalid key length");
|
|
31
|
+
}
|
|
32
|
+
return new Uint8Array(bytes);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await fs.mkdir(path.dirname(this.keyPath), { recursive: true });
|
|
36
|
+
const priv = ed25519.utils.randomPrivateKey();
|
|
37
|
+
const b64 = Buffer.from(priv).toString("base64");
|
|
38
|
+
await fs.writeFile(this.keyPath, `${b64}\n`, { encoding: "utf8", mode: 0o600 });
|
|
39
|
+
return priv;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Derive the base64-encoded public key from the local private key.
|
|
44
|
+
*/
|
|
45
|
+
async getPublicKeyBase64() {
|
|
46
|
+
const priv = await this.loadOrCreatePrivateKey();
|
|
47
|
+
const pub = await ed25519.getPublicKeyAsync(priv);
|
|
48
|
+
return Buffer.from(pub).toString("base64");
|
|
49
|
+
}
|
|
50
|
+
async getJson(pathName) {
|
|
51
|
+
const url = new URL(pathName, this.baseUrl).toString();
|
|
52
|
+
const res = await fetch(url, { method: "GET" });
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(text);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`Unexpected response from identity service: ${text}`);
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const err = parsed;
|
|
63
|
+
throw new Error(err.message || err.error || `HTTP ${res.status}`);
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Verify that the operator and bot exist in the identity service and that
|
|
69
|
+
* this instance's public key is registered on the bot. Does not create
|
|
70
|
+
* operator or bot; they must be minted by the operator first.
|
|
71
|
+
* Safe to call multiple times.
|
|
72
|
+
*/
|
|
73
|
+
async init() {
|
|
74
|
+
try {
|
|
75
|
+
await this.getJson(`/v1/operators/${encodeURIComponent(this.operatorId)}`);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error("Operator not registered. Operator must be minted first (genesis or mint-operator).");
|
|
79
|
+
}
|
|
80
|
+
let bot;
|
|
81
|
+
try {
|
|
82
|
+
bot = await this.getJson(`/v1/bots/${encodeURIComponent(this.botId)}`);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error("Bot not registered or key not found; operator must mint this bot with your public key.");
|
|
86
|
+
}
|
|
87
|
+
const publicKey = await this.getPublicKeyBase64();
|
|
88
|
+
const hasKey = bot.public_keys?.some((k) => k.public_key === publicKey && k.status === "active") ?? false;
|
|
89
|
+
if (!hasKey) {
|
|
90
|
+
throw new Error("Bot not registered or key not found; operator must mint this bot with your public key.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async getBot() {
|
|
94
|
+
return this.getJson(`/v1/bots/${encodeURIComponent(this.botId)}`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Canonical JSON serialization used for signing, following bot-comms.md.
|
|
98
|
+
*/
|
|
99
|
+
static canonicalizeForSignature(msg) {
|
|
100
|
+
const canonicalFields = {
|
|
101
|
+
body: msg.body,
|
|
102
|
+
correlation_id: msg.correlation_id,
|
|
103
|
+
from: msg.from,
|
|
104
|
+
from_id: msg.from_id,
|
|
105
|
+
message_id: msg.message_id,
|
|
106
|
+
operator_id: msg.operator_id,
|
|
107
|
+
subtype: msg.subtype,
|
|
108
|
+
timestamp: msg.timestamp,
|
|
109
|
+
to: msg.to,
|
|
110
|
+
to_id: msg.to_id,
|
|
111
|
+
type: msg.type,
|
|
112
|
+
};
|
|
113
|
+
for (const key of Object.keys(canonicalFields)) {
|
|
114
|
+
if (canonicalFields[key] === undefined || canonicalFields[key] === null) {
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
116
|
+
// Dynamic delete is intentional to keep the canonical JSON minimal.
|
|
117
|
+
// @ts-ignore dynamic delete
|
|
118
|
+
delete canonicalFields[key];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const sortedKeys = Object.keys(canonicalFields).sort();
|
|
122
|
+
return JSON.stringify(canonicalFields, sortedKeys);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Sign a message envelope using the local Ed25519 private key.
|
|
126
|
+
* Returns base64(signature) and signature_scheme.
|
|
127
|
+
*/
|
|
128
|
+
async signMessage(msg) {
|
|
129
|
+
const priv = await this.loadOrCreatePrivateKey();
|
|
130
|
+
const canonical = IdentityClient.canonicalizeForSignature(msg);
|
|
131
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
132
|
+
const sig = await ed25519.signAsync(bytes, priv);
|
|
133
|
+
return {
|
|
134
|
+
signature: Buffer.from(sig).toString("base64"),
|
|
135
|
+
signature_scheme: "ed25519",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Issue a short-lived JWT for MQTT broker authentication (EdDSA / Ed25519).
|
|
140
|
+
* Use as the MQTT CONNECT password with username = bot_id.
|
|
141
|
+
*/
|
|
142
|
+
async issueMqttToken(ttlSec = 300) {
|
|
143
|
+
const priv = await this.loadOrCreatePrivateKey();
|
|
144
|
+
const pub = await ed25519.getPublicKeyAsync(priv);
|
|
145
|
+
const jwk = {
|
|
146
|
+
kty: "OKP",
|
|
147
|
+
crv: "Ed25519",
|
|
148
|
+
d: base64url(priv),
|
|
149
|
+
x: base64url(pub),
|
|
150
|
+
};
|
|
151
|
+
const key = await importJWK(jwk, "EdDSA");
|
|
152
|
+
if (!key) {
|
|
153
|
+
throw new Error("Failed to import key for MQTT token");
|
|
154
|
+
}
|
|
155
|
+
const exp = Math.floor(Date.now() / 1000) + ttlSec;
|
|
156
|
+
const jwt = await new SignJWT({})
|
|
157
|
+
.setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
|
|
158
|
+
.setSubject(this.botId)
|
|
159
|
+
.setAudience(MQTT_TOKEN_AUD)
|
|
160
|
+
.setExpirationTime(exp)
|
|
161
|
+
.sign(key);
|
|
162
|
+
return jwt;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export * from "./types";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OpenClawCorePluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
const plugin = {
|
|
5
|
+
id: "clanker-chain-identity",
|
|
6
|
+
name: "Clanker Chain Identity",
|
|
7
|
+
description: "Identity client and identity skill for clanker-chain bots.",
|
|
8
|
+
configSchema: emptyPluginConfigSchema(),
|
|
9
|
+
register(_api: OpenClawCorePluginApi) {
|
|
10
|
+
// No channels or HTTP routes yet; the primary value is the identity skill
|
|
11
|
+
// shipped via `skills` in openclaw.plugin.json.
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default plugin;
|
package/install.sh
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
PLUGIN_NAME="clanker-chain-identity"
|
|
5
|
+
if [ -n "${OPENCLAW_EXTENSIONS_DIR:-}" ]; then
|
|
6
|
+
TARGET_ROOT="$OPENCLAW_EXTENSIONS_DIR"
|
|
7
|
+
# OpenClaw Docker: app loads from /app/extensions; /extensions would be wrong
|
|
8
|
+
if [ "$TARGET_ROOT" = /extensions ] && [ -d /app/extensions ]; then
|
|
9
|
+
TARGET_ROOT="/app/extensions"
|
|
10
|
+
fi
|
|
11
|
+
elif [ -d /app/extensions ]; then
|
|
12
|
+
TARGET_ROOT="/app/extensions"
|
|
13
|
+
else
|
|
14
|
+
TARGET_ROOT="$HOME/.openclaw/extensions"
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
echo "Installing $PLUGIN_NAME into: $TARGET_ROOT/$PLUGIN_NAME"
|
|
18
|
+
|
|
19
|
+
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
|
|
21
|
+
mkdir -p "$TARGET_ROOT"
|
|
22
|
+
rm -rf "$TARGET_ROOT/$PLUGIN_NAME"
|
|
23
|
+
cp -R "$SRC_DIR" "$TARGET_ROOT/$PLUGIN_NAME"
|
|
24
|
+
|
|
25
|
+
if [ -f "$TARGET_ROOT/$PLUGIN_NAME/package.json" ]; then
|
|
26
|
+
echo "Running npm install --production inside $TARGET_ROOT/$PLUGIN_NAME (if Node is available)..."
|
|
27
|
+
(cd "$TARGET_ROOT/$PLUGIN_NAME" && npm install --production) || echo "npm install failed or Node not available; ensure dependencies are installed if required."
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Expose the plugin's skill as skills/identity so OpenClaw has both the tooling (plugin) and the
|
|
31
|
+
# reference instruction set (SKILL.md in skills/identity), same pattern as Slack.
|
|
32
|
+
SKILLS_DIR="${OPENCLAW_SKILLS_DIR:-}"
|
|
33
|
+
if [ -z "$SKILLS_DIR" ] && [ -d /app/skills ]; then
|
|
34
|
+
SKILLS_DIR="/app/skills"
|
|
35
|
+
fi
|
|
36
|
+
if [ -n "$SKILLS_DIR" ] && [ -d "$TARGET_ROOT/$PLUGIN_NAME/skill" ]; then
|
|
37
|
+
rm -rf "$SKILLS_DIR/identity"
|
|
38
|
+
ln -sf "$TARGET_ROOT/$PLUGIN_NAME/skill" "$SKILLS_DIR/identity"
|
|
39
|
+
echo "Skill symlink: $SKILLS_DIR/identity -> $TARGET_ROOT/$PLUGIN_NAME/skill"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Update OpenClaw workspace lockfile so "docker compose build" with OPENCLAW_EXTENSIONS succeeds
|
|
43
|
+
OPENCLAW_ROOT="${OPENCLAW_ROOT:-$(dirname "$TARGET_ROOT")}"
|
|
44
|
+
if [ -f "$OPENCLAW_ROOT/pnpm-workspace.yaml" ]; then
|
|
45
|
+
echo "Running pnpm install in OpenClaw repo ($OPENCLAW_ROOT) to update lockfile..."
|
|
46
|
+
(cd "$OPENCLAW_ROOT" && npx --yes pnpm install --ignore-scripts) || echo "pnpm install skipped (npx/pnpm not available)."
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
echo "Done. Restart OpenClaw so it picks up the new plugin."
|
|
50
|
+
echo "Plugin path: $TARGET_ROOT/$PLUGIN_NAME"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "clanker-chain-identity",
|
|
3
|
+
"name": "Clanker Chain Identity",
|
|
4
|
+
"description": "Identity client and identity skill for clanker-chain bots.",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {}
|
|
9
|
+
},
|
|
10
|
+
"skills": [
|
|
11
|
+
"src/skill"
|
|
12
|
+
]
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clanker-chain/identity-plugin",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Identity client and identity skill for clanker-chain bots.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": ["./dist/index.js"]
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@noble/ed25519": "^2.3.0",
|
|
15
|
+
"jose": "^5.9.6",
|
|
16
|
+
"typescript": "^5.9.3"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^25.4.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import * as ed25519 from "@noble/ed25519";
|
|
5
|
+
import { SignJWT, importJWK } from "jose";
|
|
6
|
+
import type { BotRecord, IdentityMessageEnvelope, OperatorRecord, PublicKeyRecord } from "./types";
|
|
7
|
+
|
|
8
|
+
const MQTT_TOKEN_AUD = "clanker-mqtt";
|
|
9
|
+
|
|
10
|
+
function base64url(buf: Uint8Array): string {
|
|
11
|
+
return Buffer.from(buf)
|
|
12
|
+
.toString("base64")
|
|
13
|
+
.replace(/\+/g, "-")
|
|
14
|
+
.replace(/\//g, "_")
|
|
15
|
+
.replace(/=+$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IdentityClientOptions {
|
|
19
|
+
botId: string;
|
|
20
|
+
operatorId: string;
|
|
21
|
+
/**
|
|
22
|
+
* Base URL of the identity service, e.g. "http://localhost:8080".
|
|
23
|
+
* Defaults to process.env.IDENTITY_SERVICE_URL or http://localhost:8080.
|
|
24
|
+
*/
|
|
25
|
+
identityServiceUrl?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Path to the private key file. Defaults to ~/.openclaw/keys/{botId}.key
|
|
28
|
+
*/
|
|
29
|
+
keyPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class IdentityClient {
|
|
33
|
+
private readonly botId: string;
|
|
34
|
+
private readonly operatorId: string;
|
|
35
|
+
private readonly baseUrl: string;
|
|
36
|
+
private readonly keyPath: string;
|
|
37
|
+
|
|
38
|
+
constructor(options: IdentityClientOptions) {
|
|
39
|
+
this.botId = options.botId;
|
|
40
|
+
this.operatorId = options.operatorId;
|
|
41
|
+
this.baseUrl = options.identityServiceUrl ?? process.env.IDENTITY_SERVICE_URL ?? "http://localhost:8080";
|
|
42
|
+
const defaultKeyPath = path.join(os.homedir(), ".openclaw", "keys", `${this.botId}.key`);
|
|
43
|
+
this.keyPath = options.keyPath ?? defaultKeyPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ensure a private key exists on disk, returning the 32-byte private key.
|
|
48
|
+
*/
|
|
49
|
+
private async loadOrCreatePrivateKey(): Promise<Uint8Array> {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(this.keyPath, "utf8");
|
|
52
|
+
const bytes = Buffer.from(raw.trim(), "base64");
|
|
53
|
+
if (bytes.length !== 32) {
|
|
54
|
+
throw new Error("invalid key length");
|
|
55
|
+
}
|
|
56
|
+
return new Uint8Array(bytes);
|
|
57
|
+
} catch {
|
|
58
|
+
await fs.mkdir(path.dirname(this.keyPath), { recursive: true });
|
|
59
|
+
const priv = ed25519.utils.randomPrivateKey();
|
|
60
|
+
const b64 = Buffer.from(priv).toString("base64");
|
|
61
|
+
await fs.writeFile(this.keyPath, `${b64}\n`, { encoding: "utf8", mode: 0o600 });
|
|
62
|
+
return priv;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Derive the base64-encoded public key from the local private key.
|
|
68
|
+
*/
|
|
69
|
+
async getPublicKeyBase64(): Promise<string> {
|
|
70
|
+
const priv = await this.loadOrCreatePrivateKey();
|
|
71
|
+
const pub = await ed25519.getPublicKeyAsync(priv);
|
|
72
|
+
return Buffer.from(pub).toString("base64");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async getJson<T>(pathName: string): Promise<T> {
|
|
76
|
+
const url = new URL(pathName, this.baseUrl).toString();
|
|
77
|
+
const res = await fetch(url, { method: "GET" });
|
|
78
|
+
const text = await res.text();
|
|
79
|
+
let parsed: unknown;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(text);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error(`Unexpected response from identity service: ${text}`);
|
|
84
|
+
}
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const err = parsed as { error?: string; message?: string };
|
|
87
|
+
throw new Error(err.message || err.error || `HTTP ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
return parsed as T;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify that the operator and bot exist in the identity service and that
|
|
94
|
+
* this instance's public key is registered on the bot. Does not create
|
|
95
|
+
* operator or bot; they must be minted by the operator first.
|
|
96
|
+
* Safe to call multiple times.
|
|
97
|
+
*/
|
|
98
|
+
async init(): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await this.getJson<OperatorRecord>(
|
|
101
|
+
`/v1/operators/${encodeURIComponent(this.operatorId)}`,
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"Operator not registered. Operator must be minted first (genesis or mint-operator).",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let bot: BotRecord;
|
|
110
|
+
try {
|
|
111
|
+
bot = await this.getJson<BotRecord>(
|
|
112
|
+
`/v1/bots/${encodeURIComponent(this.botId)}`,
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"Bot not registered or key not found; operator must mint this bot with your public key.",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const publicKey = await this.getPublicKeyBase64();
|
|
121
|
+
const hasKey =
|
|
122
|
+
bot.public_keys?.some(
|
|
123
|
+
(k) => k.public_key === publicKey && k.status === "active",
|
|
124
|
+
) ?? false;
|
|
125
|
+
if (!hasKey) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"Bot not registered or key not found; operator must mint this bot with your public key.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getBot(): Promise<BotRecord> {
|
|
133
|
+
return this.getJson<BotRecord>(`/v1/bots/${encodeURIComponent(this.botId)}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Canonical JSON serialization used for signing, following bot-comms.md.
|
|
138
|
+
*/
|
|
139
|
+
private static canonicalizeForSignature(msg: IdentityMessageEnvelope): string {
|
|
140
|
+
const canonicalFields: Record<string, unknown> = {
|
|
141
|
+
body: msg.body,
|
|
142
|
+
correlation_id: msg.correlation_id,
|
|
143
|
+
from: msg.from,
|
|
144
|
+
from_id: msg.from_id,
|
|
145
|
+
message_id: msg.message_id,
|
|
146
|
+
operator_id: msg.operator_id,
|
|
147
|
+
subtype: msg.subtype,
|
|
148
|
+
timestamp: msg.timestamp,
|
|
149
|
+
to: msg.to,
|
|
150
|
+
to_id: msg.to_id,
|
|
151
|
+
type: msg.type,
|
|
152
|
+
};
|
|
153
|
+
for (const key of Object.keys(canonicalFields)) {
|
|
154
|
+
if (canonicalFields[key] === undefined || canonicalFields[key] === null) {
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
156
|
+
// Dynamic delete is intentional to keep the canonical JSON minimal.
|
|
157
|
+
// @ts-ignore dynamic delete
|
|
158
|
+
delete (canonicalFields as Record<string, unknown>)[key];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const sortedKeys = Object.keys(canonicalFields).sort();
|
|
162
|
+
return JSON.stringify(canonicalFields, sortedKeys as (keyof typeof canonicalFields)[]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sign a message envelope using the local Ed25519 private key.
|
|
167
|
+
* Returns base64(signature) and signature_scheme.
|
|
168
|
+
*/
|
|
169
|
+
async signMessage(msg: IdentityMessageEnvelope): Promise<{
|
|
170
|
+
signature: string;
|
|
171
|
+
signature_scheme: "ed25519";
|
|
172
|
+
}> {
|
|
173
|
+
const priv = await this.loadOrCreatePrivateKey();
|
|
174
|
+
const canonical = IdentityClient.canonicalizeForSignature(msg);
|
|
175
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
176
|
+
const sig = await ed25519.signAsync(bytes, priv);
|
|
177
|
+
return {
|
|
178
|
+
signature: Buffer.from(sig).toString("base64"),
|
|
179
|
+
signature_scheme: "ed25519",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Issue a short-lived JWT for MQTT broker authentication (EdDSA / Ed25519).
|
|
185
|
+
* Use as the MQTT CONNECT password with username = bot_id.
|
|
186
|
+
*/
|
|
187
|
+
async issueMqttToken(ttlSec: number = 300): Promise<string> {
|
|
188
|
+
const priv = await this.loadOrCreatePrivateKey();
|
|
189
|
+
const pub = await ed25519.getPublicKeyAsync(priv);
|
|
190
|
+
const jwk = {
|
|
191
|
+
kty: "OKP" as const,
|
|
192
|
+
crv: "Ed25519" as const,
|
|
193
|
+
d: base64url(priv),
|
|
194
|
+
x: base64url(pub),
|
|
195
|
+
};
|
|
196
|
+
const key = await importJWK(jwk, "EdDSA");
|
|
197
|
+
if (!key) {
|
|
198
|
+
throw new Error("Failed to import key for MQTT token");
|
|
199
|
+
}
|
|
200
|
+
const exp = Math.floor(Date.now() / 1000) + ttlSec;
|
|
201
|
+
const jwt = await new SignJWT({})
|
|
202
|
+
.setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
|
|
203
|
+
.setSubject(this.botId)
|
|
204
|
+
.setAudience(MQTT_TOKEN_AUD)
|
|
205
|
+
.setExpirationTime(exp)
|
|
206
|
+
.sign(key);
|
|
207
|
+
return jwt;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export * from "./types";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare module "openclaw/plugin-sdk/core" {
|
|
2
|
+
// This package is built and typechecked outside an OpenClaw checkout.
|
|
3
|
+
// At runtime, OpenClaw provides the real module.
|
|
4
|
+
//
|
|
5
|
+
// We only need minimal declarations for CI/local TypeScript compilation
|
|
6
|
+
// so bundling can succeed even when `openclaw` isn't installed here.
|
|
7
|
+
export type OpenClawCorePluginApi = unknown;
|
|
8
|
+
|
|
9
|
+
export function emptyPluginConfigSchema(): {
|
|
10
|
+
type: "object";
|
|
11
|
+
additionalProperties: false;
|
|
12
|
+
properties: {};
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: identity
|
|
3
|
+
description: Register this bot with the identity service, fetch bot records, and sign coordination messages (Ed25519) for the bot mesh.
|
|
4
|
+
metadata:
|
|
5
|
+
{"openclaw":{"requires":{"env":["IDENTITY_SERVICE_URL"]},"primaryEnv":"IDENTITY_SERVICE_URL"}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Identity skill
|
|
9
|
+
|
|
10
|
+
Use this skill when:
|
|
11
|
+
|
|
12
|
+
- **Bootstrapping or restarting a bot**: verify that the operator and bot exist in the identity service and that this bot’s public key is registered, so other bots can verify its signatures.
|
|
13
|
+
- **Sending a signed message** to another bot over MQTT (or any channel): produce a signed envelope so the recipient can verify authenticity.
|
|
14
|
+
- **Looking up a bot’s record** (e.g. public keys, operator, status) for debugging or coordination.
|
|
15
|
+
|
|
16
|
+
This skill is a thin wrapper around the `identity-node-client` library and expects the underlying clanker-chain identity service to use **proof-based authorization** (Ed25519 signatures). No Bearer admin token is required in the default setup.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
At minimum, you must configure:
|
|
23
|
+
|
|
24
|
+
- **Identity service URL**:
|
|
25
|
+
- Env: `IDENTITY_SERVICE_URL`
|
|
26
|
+
- Example: `http://localhost:8080` or `http://identity-service:8080`
|
|
27
|
+
- **Bot identity**:
|
|
28
|
+
- `bot_id`: canonical bot id, for example: `openclaw.france.prod-1`
|
|
29
|
+
- Local private key path: `~/.openclaw/keys/{bot_id}.key`
|
|
30
|
+
- **Operator identity**:
|
|
31
|
+
- `operator_id`: operator that owns the bot, for example: `org.openclaw.pat`
|
|
32
|
+
|
|
33
|
+
The default key path is derived from `bot_id`:
|
|
34
|
+
|
|
35
|
+
- `~/.openclaw/keys/{bot_id}.key` (e.g. `~/.openclaw/keys/openclaw.france.prod-1.key`)
|
|
36
|
+
|
|
37
|
+
The **mint / registration step is performed by the operator**, not by this skill:
|
|
38
|
+
|
|
39
|
+
1. The operator uses the identity-service CLI to generate a **mint-bot token**:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cd identity-service
|
|
43
|
+
bun run src/cli.ts mint-bot-token openclaw.france.prod-1 org.openclaw.pat "France Bot"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
2. The CLI:
|
|
47
|
+
- Ensures the operator key exists (and creates it if missing).
|
|
48
|
+
- Generates a new bot keypair and writes the private key to `~/.openclaw/keys/openclaw.france.prod-1.key`.
|
|
49
|
+
- Prints a JSON payload that can be POSTed directly to `POST /v1/bots` on the identity service.
|
|
50
|
+
|
|
51
|
+
3. The operator (or deployment pipeline) POSTs that payload once:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl -X POST "$IDENTITY_SERVICE_URL/v1/bots" \
|
|
55
|
+
-H "content-type: application/json" \
|
|
56
|
+
-d '@mint-bot-payload.json'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
4. The private key file is then securely copied to the bot host and kept at `~/.openclaw/keys/openclaw.france.prod-1.key` (mode `0600`).
|
|
60
|
+
|
|
61
|
+
After this one-time registration, the **bot** uses this skill to verify its identity and sign messages; it does not perform registration itself.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Commands
|
|
66
|
+
|
|
67
|
+
Run these from the workspace root. Replace `{baseDir}` with the path to this skill folder (e.g. `skills/identity`).
|
|
68
|
+
|
|
69
|
+
### identity_init — verify bot and key
|
|
70
|
+
|
|
71
|
+
Call once per bot at startup (idempotent). Ensures:
|
|
72
|
+
|
|
73
|
+
- The operator exists in the identity service.
|
|
74
|
+
- The bot exists in the identity service.
|
|
75
|
+
- This bot’s local Ed25519 public key (derived from `~/.openclaw/keys/{bot_id}.key`) is present and active in the bot record.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node {baseDir}/run.mjs init <bot_id> <operator_id>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Example (canonical pairing from this repo):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node skills/identity/run.mjs init openclaw.france.prod-1 org.openclaw.pat
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **bot_id**: Canonical bot ID (e.g. `openclaw.france.prod-1`).
|
|
88
|
+
- **operator_id**: Operator that owns the bot (e.g. `org.openclaw.pat`).
|
|
89
|
+
|
|
90
|
+
Requires `IDENTITY_SERVICE_URL`. The default identity service in this repo does **not** use `IDENTITY_ADMIN_TOKEN`; all writes are authorized by Ed25519 signatures.
|
|
91
|
+
|
|
92
|
+
On **success**, `identity_init` prints JSON to stdout:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"ok": true,
|
|
97
|
+
"bot_id": "openclaw.france.prod-1",
|
|
98
|
+
"operator_id": "org.openclaw.pat",
|
|
99
|
+
"identity_service_url": "http://localhost:8080",
|
|
100
|
+
"public_key": "<base64-bot-public-key>",
|
|
101
|
+
"bot_has_active_key": true,
|
|
102
|
+
"bot": {
|
|
103
|
+
"bot_id": "openclaw.france.prod-1",
|
|
104
|
+
"operator_id": "org.openclaw.pat",
|
|
105
|
+
"public_keys": [
|
|
106
|
+
{
|
|
107
|
+
"key_id": "...",
|
|
108
|
+
"algorithm": "ed25519",
|
|
109
|
+
"public_key": "<base64-bot-public-key>",
|
|
110
|
+
"created": "...",
|
|
111
|
+
"status": "active"
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"status": "active",
|
|
115
|
+
"created": "...",
|
|
116
|
+
"updated": "..."
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
On **failure**, it writes a JSON error to stderr and exits with a non‑zero code:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{ "error": "Operator not registered. Operator must be minted first (genesis or mint-operator)." }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### identity_get_bot — fetch bot record
|
|
128
|
+
|
|
129
|
+
Returns the full bot record from the identity service (public keys, operator_id, status).
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
node {baseDir}/run.mjs get-bot <bot_id>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
node skills/identity/run.mjs get-bot openclaw.tooter.prod-1
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### identity_sign — sign a message envelope
|
|
142
|
+
|
|
143
|
+
Signs a JSON message envelope with this bot’s Ed25519 key. Use the returned `envelope` (with `signature` and `signature_scheme`) when publishing to MQTT or other channels.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
node {baseDir}/run.mjs sign '<envelope_json>'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The envelope must include at least: `from`, `from_id`, `operator_id`, `type`, `timestamp`, `message_id`, `body`. Optional: `to`, `to_id`, `subtype`, `channel`, `correlation_id`.
|
|
150
|
+
|
|
151
|
+
Example (escape the JSON for the shell):
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
node skills/identity/run.mjs sign '{"from":"france-bot","from_id":"openclaw.france.prod-1","operator_id":"org.openclaw.pat","to":"tooter-bot","to_id":"openclaw.tooter.prod-1","type":"coordination","timestamp":"2026-03-09T12:00:00Z","message_id":"msg-1","body":{"action":"ping"}}'
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Output is JSON with `signature`, `signature_scheme`, and `envelope` (the full signed envelope to publish).
|
|
158
|
+
|
|
159
|
+
### identity_verify — detailed registration check
|
|
160
|
+
|
|
161
|
+
Runs a non‑throwing health check against the identity service for a given bot/operator pair.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
node {baseDir}/run.mjs verify <bot_id> <operator_id>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
node skills/identity/run.mjs verify openclaw.france.prod-1 org.openclaw.pat
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
On **success**, it prints a summary JSON object and exits with code 0:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"ok": true,
|
|
178
|
+
"bot_id": "openclaw.france.prod-1",
|
|
179
|
+
"operator_id": "org.openclaw.pat",
|
|
180
|
+
"identity_service_url": "http://localhost:8080",
|
|
181
|
+
"operator": { "exists": true },
|
|
182
|
+
"bot": { "exists": true },
|
|
183
|
+
"key": {
|
|
184
|
+
"matches": true,
|
|
185
|
+
"public_key": "<base64-bot-public-key>"
|
|
186
|
+
},
|
|
187
|
+
"error": null
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
If something is wrong (operator missing, bot missing, or key mismatch), it still exits with code 0 but sets `ok: false` and populates `error`:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"ok": false,
|
|
196
|
+
"bot_id": "openclaw.france.prod-1",
|
|
197
|
+
"operator_id": "org.openclaw.pat",
|
|
198
|
+
"identity_service_url": "http://localhost:8080",
|
|
199
|
+
"operator": { "exists": true },
|
|
200
|
+
"bot": { "exists": true },
|
|
201
|
+
"key": {
|
|
202
|
+
"matches": false,
|
|
203
|
+
"public_key": "<base64-bot-public-key>"
|
|
204
|
+
},
|
|
205
|
+
"error": "bot exists but does not have an active public key matching the local key"
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This makes `identity_verify` a good choice for periodic health checks or diagnostics, while `identity_init` is best for strict startup gating.
|
|
210
|
+
|
|
211
|
+
### identity_issue_mqtt_token — issue MQTT broker auth token
|
|
212
|
+
|
|
213
|
+
Issues a short-lived JWT signed with this bot’s Ed25519 key for MQTT broker authentication. Use the output as the MQTT CONNECT password with username = `bot_id`.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
node {baseDir}/run.mjs issue-mqtt-token <bot_id> <operator_id> [ttl_sec]
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **bot_id**: Canonical bot ID (e.g. `openclaw.france.prod-1`).
|
|
220
|
+
- **operator_id**: Operator that owns the bot (e.g. `org.openclaw.pat`).
|
|
221
|
+
- **ttl_sec** (optional): Token lifetime in seconds (default 300).
|
|
222
|
+
|
|
223
|
+
Output is the raw JWT string on stdout. Use it as the password when connecting to the MQTT broker with username = `bot_id`.
|
|
224
|
+
|
|
225
|
+
## Environment
|
|
226
|
+
|
|
227
|
+
- **IDENTITY_SERVICE_URL** (required): Base URL of the identity service (e.g. `http://localhost:8080`).
|
|
228
|
+
|
|
229
|
+
Keys are stored under `~/.openclaw/keys/<bot_id>.key`. Do not share or commit this file.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Packaging and deployment (OpenClaw skills and plugins)
|
|
234
|
+
|
|
235
|
+
This skill is designed to be **small and thin** on the bot. The heavy lifting (ledger, HTTP service, operator CLI) stays on the operator’s machine; bots only need:
|
|
236
|
+
|
|
237
|
+
- A private key file under `~/.openclaw/keys/<bot_id>.key`.
|
|
238
|
+
- Network access to the identity service (`IDENTITY_SERVICE_URL`).
|
|
239
|
+
- A thin identity client library (for example, the `@clanker-chain/identity-plugin` package) that this skill can import.
|
|
240
|
+
|
|
241
|
+
### Skills vs plugins
|
|
242
|
+
|
|
243
|
+
Per the [OpenClaw Skills docs](https://www.learnclawdbot.org/docs/tools/skills):
|
|
244
|
+
|
|
245
|
+
- A **skill** is a directory with a `SKILL.md` manifest that describes tools/commands.
|
|
246
|
+
- Skills can be:
|
|
247
|
+
- Workspace skills (e.g. `skills/identity` in this repo).
|
|
248
|
+
- Plugin‑provided skills, listed in a plugin’s `openclaw.plugin.json`.
|
|
249
|
+
|
|
250
|
+
Per the [OpenClaw plugin docs](https://docs.openclaw.ai/plugin):
|
|
251
|
+
|
|
252
|
+
- Every plugin has an `openclaw.plugin.json` with an `id`, `name`, `description`, and `configSchema`.
|
|
253
|
+
- Plugins can ship their own skills by listing skill directories (paths relative to the plugin root) in the manifest.
|
|
254
|
+
|
|
255
|
+
This repo provides the **skill manifest and CLI wrapper** (`run.mjs`); you typically package the identity client code itself as a plugin/extension in your OpenClaw environment and point this skill at it.
|
|
256
|
+
|
|
257
|
+
### Typical packaging flow
|
|
258
|
+
|
|
259
|
+
1. Install the Clanker Chain identity plugin: `openclaw plugins install @clanker-chain/identity-plugin`, or in your OpenClaw plugin or bot image source repo, add the plugin (see `identity/SERVICE.md` for example manifests).
|
|
260
|
+
2. In your Dockerfile for bot images (on the Ubuntu host), ensure the identity plugin is available so that:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import { IdentityClient } from "@clanker-chain/identity-plugin";
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
resolves inside `run.mjs` (e.g. by installing `@clanker-chain/identity-plugin` in the skill or app).
|
|
267
|
+
3. Copy only `SKILL.md` (and optionally `run.mjs`) into `skills/identity/` in the image, or point the plugin’s `skills` array at your workspace skill directory.
|
|
268
|
+
|
|
269
|
+
With this setup, bots do not need any of the identity service or ledger code; they only need:
|
|
270
|
+
|
|
271
|
+
- The **extension** providing the identity client library.
|
|
272
|
+
- This **skill** describing how to call it.
|
|
273
|
+
- Their own private key and the identity service URL.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Error handling
|
|
278
|
+
|
|
279
|
+
Common failure modes and what they mean:
|
|
280
|
+
|
|
281
|
+
- **Service unreachable / connection error**: treat as infrastructure outage; retry with backoff, and surface that the identity service is offline.
|
|
282
|
+
- **\"Operator not registered\"** from `identity_init`: the operator has not been minted into the ledger; run genesis or `mint-operator` first.
|
|
283
|
+
- **\"Bot not registered or key not found\"** from `identity_init`: the bot has not been minted with this public key; rerun the operator-side mint-bot-token flow and POST to `/v1/bots`.
|
|
284
|
+
- **Missing key file**: if `~/.openclaw/keys/{bot_id}.key` does not exist or is unreadable, the bot cannot sign; fix key provisioning rather than silently generating a new key.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runner for the identity skill. Invoke from OpenClaw via exec.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node run.mjs init <bot_id> <operator_id>
|
|
7
|
+
* node run.mjs verify <bot_id> <operator_id>
|
|
8
|
+
* node run.mjs get-bot <bot_id>
|
|
9
|
+
* node run.mjs sign '<envelope_json>'
|
|
10
|
+
* node run.mjs issue-mqtt-token <bot_id> <operator_id> [ttl_sec]
|
|
11
|
+
*
|
|
12
|
+
* Env: IDENTITY_SERVICE_URL.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { IdentityClient } from "@clanker-chain/identity-plugin";
|
|
16
|
+
|
|
17
|
+
const [,, cmd, ...args] = process.argv;
|
|
18
|
+
|
|
19
|
+
function usage() {
|
|
20
|
+
console.error(`Usage:
|
|
21
|
+
node run.mjs init <bot_id> <operator_id>
|
|
22
|
+
node run.mjs verify <bot_id> <operator_id>
|
|
23
|
+
node run.mjs get-bot <bot_id>
|
|
24
|
+
node run.mjs sign '<envelope_json>'
|
|
25
|
+
node run.mjs issue-mqtt-token <bot_id> <operator_id> [ttl_sec]
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
if (!cmd) {
|
|
31
|
+
usage();
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
if (cmd === "init") {
|
|
37
|
+
const [botId, operatorId] = args;
|
|
38
|
+
if (!botId || !operatorId) {
|
|
39
|
+
console.error("identity init requires bot_id and operator_id");
|
|
40
|
+
usage();
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const client = new IdentityClient({ botId, operatorId });
|
|
44
|
+
const identityServiceUrl = process.env.IDENTITY_SERVICE_URL ?? "http://localhost:8080";
|
|
45
|
+
|
|
46
|
+
const publicKey = await client.getPublicKeyBase64();
|
|
47
|
+
await client.init();
|
|
48
|
+
const bot = await client.getBot();
|
|
49
|
+
const hasActiveKey =
|
|
50
|
+
Array.isArray(bot.public_keys) &&
|
|
51
|
+
bot.public_keys.some((k) => k.public_key === publicKey && k.status === "active");
|
|
52
|
+
|
|
53
|
+
console.log(
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
ok: true,
|
|
56
|
+
bot_id: botId,
|
|
57
|
+
operator_id: operatorId,
|
|
58
|
+
identity_service_url: identityServiceUrl,
|
|
59
|
+
public_key: publicKey,
|
|
60
|
+
bot_has_active_key: hasActiveKey,
|
|
61
|
+
bot,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cmd === "verify") {
|
|
68
|
+
const [botId, operatorId] = args;
|
|
69
|
+
if (!botId || !operatorId) {
|
|
70
|
+
console.error("identity verify requires bot_id and operator_id");
|
|
71
|
+
usage();
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const identityServiceUrl = process.env.IDENTITY_SERVICE_URL ?? "http://localhost:8080";
|
|
76
|
+
const client = new IdentityClient({ botId, operatorId });
|
|
77
|
+
|
|
78
|
+
const result = {
|
|
79
|
+
ok: false,
|
|
80
|
+
bot_id: botId,
|
|
81
|
+
operator_id: operatorId,
|
|
82
|
+
identity_service_url: identityServiceUrl,
|
|
83
|
+
operator: { exists: false },
|
|
84
|
+
bot: { exists: false },
|
|
85
|
+
key: { matches: false, public_key: undefined },
|
|
86
|
+
error: undefined,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const publicKey = await client.getPublicKeyBase64();
|
|
91
|
+
result.key.public_key = publicKey;
|
|
92
|
+
|
|
93
|
+
const opRes = await fetch(
|
|
94
|
+
`${identityServiceUrl}/v1/operators/${encodeURIComponent(operatorId)}`,
|
|
95
|
+
);
|
|
96
|
+
if (!opRes.ok) {
|
|
97
|
+
const text = await opRes.text();
|
|
98
|
+
result.error =
|
|
99
|
+
text || `operator lookup failed with status ${opRes.status}`;
|
|
100
|
+
console.log(JSON.stringify(result));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
result.operator.exists = true;
|
|
104
|
+
|
|
105
|
+
const botRes = await fetch(
|
|
106
|
+
`${identityServiceUrl}/v1/bots/${encodeURIComponent(botId)}`,
|
|
107
|
+
);
|
|
108
|
+
if (!botRes.ok) {
|
|
109
|
+
const text = await botRes.text();
|
|
110
|
+
result.error = text || `bot lookup failed with status ${botRes.status}`;
|
|
111
|
+
console.log(JSON.stringify(result));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const bot = await botRes.json();
|
|
115
|
+
result.bot.exists = true;
|
|
116
|
+
|
|
117
|
+
const hasKey =
|
|
118
|
+
Array.isArray(bot.public_keys) &&
|
|
119
|
+
bot.public_keys.some(
|
|
120
|
+
(k) =>
|
|
121
|
+
k.public_key === publicKey && k.status === "active",
|
|
122
|
+
);
|
|
123
|
+
result.key.matches = hasKey;
|
|
124
|
+
|
|
125
|
+
if (!hasKey) {
|
|
126
|
+
result.error =
|
|
127
|
+
"bot exists but does not have an active public key matching the local key";
|
|
128
|
+
console.log(JSON.stringify(result));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
result.ok = true;
|
|
133
|
+
console.log(JSON.stringify(result));
|
|
134
|
+
return;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
result.error = (err && err.message) || String(err);
|
|
137
|
+
console.log(JSON.stringify(result));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (cmd === "get-bot") {
|
|
143
|
+
const [botId] = args;
|
|
144
|
+
if (!botId) {
|
|
145
|
+
console.error("identity get-bot requires bot_id");
|
|
146
|
+
usage();
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
const client = new IdentityClient({ botId, operatorId: "" });
|
|
150
|
+
const bot = await client.getBot();
|
|
151
|
+
console.log(JSON.stringify(bot));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (cmd === "sign") {
|
|
156
|
+
const [envelopeJson] = args;
|
|
157
|
+
if (!envelopeJson) {
|
|
158
|
+
console.error("identity sign requires envelope JSON string");
|
|
159
|
+
usage();
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
const envelope = JSON.parse(envelopeJson);
|
|
163
|
+
const botId = envelope.from_id;
|
|
164
|
+
const operatorId = envelope.operator_id;
|
|
165
|
+
if (!botId || !operatorId) {
|
|
166
|
+
console.error("envelope must include from_id and operator_id");
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
const client = new IdentityClient({ botId, operatorId });
|
|
170
|
+
const { signature, signature_scheme } = await client.signMessage(envelope);
|
|
171
|
+
console.log(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
signature,
|
|
174
|
+
signature_scheme,
|
|
175
|
+
envelope: { ...envelope, signature, signature_scheme },
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (cmd === "issue-mqtt-token") {
|
|
182
|
+
const [botId, operatorId, ttlSecStr] = args;
|
|
183
|
+
if (!botId || !operatorId) {
|
|
184
|
+
console.error("identity issue-mqtt-token requires bot_id and operator_id");
|
|
185
|
+
usage();
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
const ttlSec = ttlSecStr ? parseInt(ttlSecStr, 10) : 300;
|
|
189
|
+
if (Number.isNaN(ttlSec) || ttlSec < 1) {
|
|
190
|
+
console.error("ttl_sec must be a positive number");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const client = new IdentityClient({ botId, operatorId });
|
|
194
|
+
const token = await client.issueMqttToken(ttlSec);
|
|
195
|
+
console.log(token);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.error("Unknown command:", cmd);
|
|
200
|
+
usage();
|
|
201
|
+
process.exit(1);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error(JSON.stringify({ error: (err && err.message) || String(err) }));
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
main();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type PublicKeyStatus = "active" | "revoked";
|
|
2
|
+
|
|
3
|
+
export interface PublicKeyRecord {
|
|
4
|
+
key_id: string;
|
|
5
|
+
algorithm: string;
|
|
6
|
+
public_key: string;
|
|
7
|
+
created: string;
|
|
8
|
+
status: PublicKeyStatus;
|
|
9
|
+
metadata?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OperatorRecord {
|
|
13
|
+
operator_id: string;
|
|
14
|
+
display_name?: string;
|
|
15
|
+
public_keys?: PublicKeyRecord[];
|
|
16
|
+
status: "active" | "suspended" | "retired";
|
|
17
|
+
created: string;
|
|
18
|
+
updated: string;
|
|
19
|
+
metadata?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BotRecord {
|
|
23
|
+
bot_id: string;
|
|
24
|
+
display_name?: string;
|
|
25
|
+
operator_id: string;
|
|
26
|
+
aliases?: string[];
|
|
27
|
+
public_keys?: PublicKeyRecord[];
|
|
28
|
+
status: "active" | "suspended" | "retired";
|
|
29
|
+
created: string;
|
|
30
|
+
updated: string;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IdentityMessageEnvelope {
|
|
35
|
+
from: string;
|
|
36
|
+
from_id: string;
|
|
37
|
+
operator_id: string;
|
|
38
|
+
to?: string;
|
|
39
|
+
to_id?: string;
|
|
40
|
+
type: string;
|
|
41
|
+
subtype?: string;
|
|
42
|
+
channel?: string;
|
|
43
|
+
timestamp: string;
|
|
44
|
+
message_id: string;
|
|
45
|
+
correlation_id?: string;
|
|
46
|
+
privacy?: "default" | "private" | "encrypted";
|
|
47
|
+
encrypted?: boolean;
|
|
48
|
+
encryption_scheme?: string | null;
|
|
49
|
+
identity_token?: string | null;
|
|
50
|
+
body: unknown;
|
|
51
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*", "index.ts"]
|
|
13
|
+
}
|