mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 11:24:47 +00:00
fix: preserve shared macOS and CLI device identities
Fixes #76815. - Teach the Swift macOS identity store to load TypeScript PEM identity files without regenerating device IDs. - Teach the TypeScript identity store to migrate legacy Swift raw-key identities to PEM after validating key material. - Preserve recognized invalid identity files instead of clobbering them, preventing repeated pairing churn while retaining diagnostic evidence. - Align the macOS wizard CLI with the generated protocol model. Reported by @aboundTechOlogy. Thanks @BunsDev.
This commit is contained in:
@@ -235,6 +235,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/CLI: handle resumed CLI JSONL output and bound supervisor output buffering so resumed runs stay readable without letting noisy child output grow unbounded.
|
||||
- Codex app-server: honor per-call `timeoutMs`, configured `image_generate` timeouts, and media image-understanding timeouts for dynamic tool calls, capped at 600000 ms, so slow image generation and image analysis no longer fail at the 30s bridge default. Fixes #79810. Thanks @omarshahine.
|
||||
- Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys.
|
||||
- macOS/device pairing: let the native app read CLI PEM device identities and let the TypeScript loader migrate legacy Swift raw-key identities without generating a new device id, preventing repeated pairing prompts when `OPENCLAW_STATE_DIR` is shared. Fixes #76815. Thanks @BunsDev.
|
||||
- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys.
|
||||
- Telegram: persist reply-chain message cache records as a compact append log instead of rewriting the full cache on every inbound message, reducing large-group turn latency.
|
||||
- Telegram/CLI-backend: mirror outbound replies to the session transcript so CLI-backend agent responses create `.jsonl` session files, preventing `sessionId=unknown` on subsequent runs. Fixes #75991.
|
||||
|
||||
@@ -207,7 +207,7 @@ actor GatewayWizardClient {
|
||||
let frame = try decodeFrame(message)
|
||||
if case let .res(res) = frame, res.id == id {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
|
||||
let msg = res.error?.message ?? "gateway error"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
return res
|
||||
@@ -308,7 +308,7 @@ actor GatewayWizardClient {
|
||||
let frameResponse = try decodeFrame(message)
|
||||
if case let .res(res) = frameResponse, res.id == reqId {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
let msg = res.error?.message ?? "gateway connect failed"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||
@@ -375,7 +375,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
return
|
||||
}
|
||||
|
||||
if let step = decodeWizardStep(nextResult.step) {
|
||||
if let step = nextResult.step {
|
||||
let answer = try promptAnswer(for: step)
|
||||
var answerPayload: [String: ProtoAnyCodable] = [
|
||||
"stepId": ProtoAnyCodable(step.id),
|
||||
|
||||
@@ -38,22 +38,68 @@ enum DeviceIdentityPaths {
|
||||
|
||||
public enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
private static let ed25519SPKIPrefix = Data([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
|
||||
0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
private static let ed25519PKCS8PrivatePrefix = Data([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
||||
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
])
|
||||
|
||||
public static func loadOrCreate() -> DeviceIdentity {
|
||||
let url = self.fileURL()
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||
!decoded.deviceId.isEmpty,
|
||||
!decoded.publicKey.isEmpty,
|
||||
!decoded.privateKey.isEmpty
|
||||
{
|
||||
return decoded
|
||||
self.loadOrCreate(fileURL: self.fileURL())
|
||||
}
|
||||
|
||||
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
|
||||
if let data = try? Data(contentsOf: url) {
|
||||
switch self.decodeStoredIdentity(data) {
|
||||
case .identity(let decoded):
|
||||
return decoded
|
||||
case .recognizedInvalid:
|
||||
return self.generate()
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
let identity = self.generate()
|
||||
self.save(identity)
|
||||
self.save(identity, to: url)
|
||||
return identity
|
||||
}
|
||||
|
||||
private enum DecodeResult {
|
||||
case identity(DeviceIdentity)
|
||||
case recognizedInvalid
|
||||
case unknown
|
||||
}
|
||||
|
||||
private static func decodeStoredIdentity(_ data: Data) -> DecodeResult {
|
||||
let decoder = JSONDecoder()
|
||||
if let decoded = try? decoder.decode(DeviceIdentity.self, from: data) {
|
||||
guard let identity = self.normalizedRawIdentity(decoded) else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
return .identity(identity)
|
||||
}
|
||||
|
||||
if let decoded = try? decoder.decode(PemDeviceIdentity.self, from: data) {
|
||||
guard decoded.version == 1,
|
||||
let publicKeyData = self.rawPublicKey(fromPEM: decoded.publicKeyPem),
|
||||
let privateKeyData = self.rawPrivateKey(fromPEM: decoded.privateKeyPem),
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
return .identity(DeviceIdentity(
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: decoded.createdAtMs))
|
||||
}
|
||||
|
||||
return self.hasRecognizedIdentityShape(data) ? .recognizedInvalid : .unknown
|
||||
}
|
||||
|
||||
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||
do {
|
||||
@@ -70,7 +116,7 @@ public enum DeviceIdentityStore {
|
||||
let publicKey = privateKey.publicKey
|
||||
let publicKeyData = publicKey.rawRepresentation
|
||||
let privateKeyData = privateKey.rawRepresentation
|
||||
let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||
let deviceId = self.deviceId(publicKeyData: publicKeyData)
|
||||
return DeviceIdentity(
|
||||
deviceId: deviceId,
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
@@ -91,8 +137,69 @@ public enum DeviceIdentityStore {
|
||||
return self.base64UrlEncode(data)
|
||||
}
|
||||
|
||||
private static func save(_ identity: DeviceIdentity) {
|
||||
let url = self.fileURL()
|
||||
private static func normalizedRawIdentity(_ identity: DeviceIdentity) -> DeviceIdentity? {
|
||||
guard !identity.deviceId.isEmpty,
|
||||
let publicKeyData = Data(base64Encoded: identity.publicKey),
|
||||
let privateKeyData = Data(base64Encoded: identity.privateKey)
|
||||
else { return nil }
|
||||
|
||||
guard publicKeyData.count == 32 && privateKeyData.count == 32,
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else { return nil }
|
||||
return DeviceIdentity(
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKey: identity.publicKey,
|
||||
privateKey: identity.privateKey,
|
||||
createdAtMs: identity.createdAtMs)
|
||||
}
|
||||
|
||||
private static func rawPublicKey(fromPEM pem: String) -> Data? {
|
||||
guard let der = self.derData(fromPEM: pem),
|
||||
der.count == self.ed25519SPKIPrefix.count + 32,
|
||||
der.prefix(self.ed25519SPKIPrefix.count) == self.ed25519SPKIPrefix
|
||||
else { return nil }
|
||||
return der.suffix(32)
|
||||
}
|
||||
|
||||
private static func rawPrivateKey(fromPEM pem: String) -> Data? {
|
||||
guard let der = self.derData(fromPEM: pem),
|
||||
der.count == self.ed25519PKCS8PrivatePrefix.count + 32,
|
||||
der.prefix(self.ed25519PKCS8PrivatePrefix.count) == self.ed25519PKCS8PrivatePrefix
|
||||
else { return nil }
|
||||
return der.suffix(32)
|
||||
}
|
||||
|
||||
private static func keyPairMatches(publicKeyData: Data, privateKeyData: Data) -> Bool {
|
||||
guard let privateKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return privateKey.publicKey.rawRepresentation == publicKeyData
|
||||
}
|
||||
|
||||
private static func derData(fromPEM pem: String) -> Data? {
|
||||
let body = pem
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.filter { !$0.hasPrefix("-----") }
|
||||
.joined()
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
|
||||
private static func hasRecognizedIdentityShape(_ data: Data) -> Bool {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return false
|
||||
}
|
||||
return object.keys.contains("publicKeyPem")
|
||||
|| object.keys.contains("privateKeyPem")
|
||||
|| object.keys.contains("publicKey")
|
||||
|| object.keys.contains("privateKey")
|
||||
}
|
||||
|
||||
private static func deviceId(publicKeyData: Data) -> String {
|
||||
SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func save(_ identity: DeviceIdentity, to url: URL) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
@@ -111,3 +218,11 @@ public enum DeviceIdentityStore {
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PemDeviceIdentity: Codable {
|
||||
var version: Int
|
||||
var deviceId: String
|
||||
var publicKeyPem: String
|
||||
var privateKeyPem: String
|
||||
var createdAtMs: Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceIdentityStoreTests {
|
||||
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
|
||||
func loadsTypeScriptPEMIdentitySchema() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let identityURL = tempDir
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device.json", isDirectory: false)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: identityURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let stored = try Self.identityJSON(
|
||||
publicKeyPem: Self.pem(
|
||||
label: "PUBLIC KEY",
|
||||
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
|
||||
privateKeyPem: Self.pem(
|
||||
label: "PRIVATE KEY",
|
||||
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
|
||||
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let before = try String(contentsOf: identityURL, encoding: .utf8)
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||
|
||||
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
|
||||
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
|
||||
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
|
||||
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
|
||||
let signatureData = try #require(Self.base64UrlDecode(signature))
|
||||
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
|
||||
#expect(publicKey.isValidSignature(signatureData, for: Data("hello".utf8)))
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
}
|
||||
|
||||
@Test("does not overwrite a recognized invalid TypeScript identity schema")
|
||||
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let identityURL = tempDir
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device.json", isDirectory: false)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: identityURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let stored = """
|
||||
{
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
"publicKeyPem": "not-a-valid-public-key",
|
||||
"privateKeyPem": "not-a-valid-private-key",
|
||||
"createdAtMs": 1700000000000
|
||||
}
|
||||
"""
|
||||
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let before = try String(contentsOf: identityURL, encoding: .utf8)
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||
|
||||
#expect(identity.deviceId != "stale-device-id")
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
}
|
||||
|
||||
private static func base64UrlDecode(_ value: String) -> Data? {
|
||||
let normalized = value
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padded = normalized + String(repeating: "=", count: (4 - normalized.count % 4) % 4)
|
||||
return Data(base64Encoded: padded)
|
||||
}
|
||||
|
||||
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> String {
|
||||
let object: [String: Any] = [
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
"publicKeyPem": publicKeyPem,
|
||||
"privateKeyPem": privateKeyPem,
|
||||
"createdAtMs": 1_700_000_000_000,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
return String(decoding: data, as: UTF8.self) + "\n"
|
||||
}
|
||||
|
||||
private static func pem(label: String, body: String) -> String {
|
||||
"-----BEGIN \(label)-----\n\(body)\n-----END \(label)-----\n"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
verifyDeviceSignature,
|
||||
} from "./device-identity.js";
|
||||
|
||||
const SWIFT_RAW_DEVICE_ID = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c";
|
||||
const SWIFT_RAW_PUBLIC_KEY = "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=";
|
||||
const SWIFT_RAW_PRIVATE_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; // pragma: allowlist secret
|
||||
const MISMATCHED_SWIFT_RAW_PRIVATE_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; // pragma: allowlist secret
|
||||
|
||||
async function withIdentity(
|
||||
run: (identity: ReturnType<typeof loadOrCreateDeviceIdentity>) => void,
|
||||
) {
|
||||
@@ -52,6 +57,110 @@ describe("device identity crypto helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads Swift raw-key identity files without generating a new device id", async () => {
|
||||
await withTempDir("openclaw-device-identity-swift-", async (dir) => {
|
||||
const identityPath = path.join(dir, "identity", "device.json");
|
||||
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
identityPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
deviceId: SWIFT_RAW_DEVICE_ID,
|
||||
publicKey: SWIFT_RAW_PUBLIC_KEY,
|
||||
privateKey: SWIFT_RAW_PRIVATE_KEY,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const readonly = loadDeviceIdentityIfPresent(identityPath);
|
||||
const loaded = loadOrCreateDeviceIdentity(identityPath);
|
||||
const stored = JSON.parse(fs.readFileSync(identityPath, "utf8")) as Record<string, unknown>;
|
||||
|
||||
expect(readonly?.deviceId).toBe(SWIFT_RAW_DEVICE_ID);
|
||||
expect(loaded.deviceId).toBe(SWIFT_RAW_DEVICE_ID);
|
||||
expect(publicKeyRawBase64UrlFromPem(loaded.publicKeyPem)).toBe(
|
||||
"A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg",
|
||||
);
|
||||
expect(
|
||||
verifyDeviceSignature(
|
||||
loaded.publicKeyPem,
|
||||
"hello",
|
||||
signDevicePayload(loaded.privateKeyPem, "hello"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(stored).toMatchObject({
|
||||
version: 1,
|
||||
deviceId: SWIFT_RAW_DEVICE_ID,
|
||||
publicKeyPem: expect.stringContaining("BEGIN PUBLIC KEY"),
|
||||
privateKeyPem: expect.stringContaining("BEGIN PRIVATE KEY"),
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
});
|
||||
expect(stored).not.toHaveProperty("publicKey");
|
||||
expect(stored).not.toHaveProperty("privateKey");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite recognized invalid identity files", async () => {
|
||||
await withTempDir("openclaw-device-identity-invalid-", async (dir) => {
|
||||
const identityPath = path.join(dir, "identity", "device.json");
|
||||
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
identityPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
deviceId: "stale-device-id",
|
||||
publicKeyPem: "not-a-valid-public-key",
|
||||
privateKeyPem: "not-a-valid-private-key", // pragma: allowlist secret
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const before = fs.readFileSync(identityPath, "utf8");
|
||||
|
||||
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
|
||||
const loaded = loadOrCreateDeviceIdentity(identityPath);
|
||||
|
||||
expect(loaded.deviceId).not.toBe("stale-device-id");
|
||||
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate Swift raw-key identity files with mismatched key material", async () => {
|
||||
await withTempDir("openclaw-device-identity-swift-invalid-", async (dir) => {
|
||||
const identityPath = path.join(dir, "identity", "device.json");
|
||||
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
identityPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
deviceId: SWIFT_RAW_DEVICE_ID,
|
||||
publicKey: SWIFT_RAW_PUBLIC_KEY,
|
||||
privateKey: MISMATCHED_SWIFT_RAW_PRIVATE_KEY,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const before = fs.readFileSync(identityPath, "utf8");
|
||||
|
||||
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
|
||||
const loaded = loadOrCreateDeviceIdentity(identityPath);
|
||||
|
||||
expect(loaded.deviceId).not.toBe(SWIFT_RAW_DEVICE_ID);
|
||||
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the same canonical raw key and device id from pem and encoded public keys", async () => {
|
||||
await withIdentity((identity) => {
|
||||
const publicKeyRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { privateFileStoreSync } from "./private-file-store.js";
|
||||
@@ -17,11 +18,19 @@ type StoredIdentity = {
|
||||
createdAtMs: number;
|
||||
};
|
||||
|
||||
type StoredSwiftIdentity = {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
createdAtMs: number;
|
||||
};
|
||||
|
||||
function resolveDefaultIdentityPath(): string {
|
||||
return path.join(resolveStateDir(), "identity", "device.json");
|
||||
}
|
||||
|
||||
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const ED25519_PKCS8_PRIVATE_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
||||
function base64UrlEncode(buf: Buffer): string {
|
||||
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
||||
@@ -33,6 +42,23 @@ function base64UrlDecode(input: string): Buffer {
|
||||
return Buffer.from(padded, "base64");
|
||||
}
|
||||
|
||||
function pemEncode(label: "PUBLIC KEY" | "PRIVATE KEY", der: Buffer): string {
|
||||
const body =
|
||||
der
|
||||
.toString("base64")
|
||||
.match(/.{1,64}/g)
|
||||
?.join("\n") ?? "";
|
||||
return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
function publicKeyPemFromRaw(publicKeyRaw: Buffer): string {
|
||||
return pemEncode("PUBLIC KEY", Buffer.concat([ED25519_SPKI_PREFIX, publicKeyRaw]));
|
||||
}
|
||||
|
||||
function privateKeyPemFromRaw(privateKeyRaw: Buffer): string {
|
||||
return pemEncode("PRIVATE KEY", Buffer.concat([ED25519_PKCS8_PRIVATE_PREFIX, privateKeyRaw]));
|
||||
}
|
||||
|
||||
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
||||
const key = crypto.createPublicKey(publicKeyPem);
|
||||
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
|
||||
@@ -50,6 +76,24 @@ function fingerprintPublicKey(publicKeyPem: string): string {
|
||||
return crypto.createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
function tryFingerprintPublicKey(publicKeyPem: string): string | null {
|
||||
try {
|
||||
return fingerprintPublicKey(publicKeyPem);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function keyPairMatches(publicKeyPem: string, privateKeyPem: string): boolean {
|
||||
try {
|
||||
const payload = Buffer.from("openclaw-device-identity-self-check", "utf8");
|
||||
const signature = crypto.sign(null, payload, crypto.createPrivateKey(privateKeyPem));
|
||||
return crypto.verify(null, payload, crypto.createPublicKey(publicKeyPem), signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function generateIdentity(): DeviceIdentity {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
@@ -58,42 +102,146 @@ function generateIdentity(): DeviceIdentity {
|
||||
return { deviceId, publicKeyPem, privateKeyPem };
|
||||
}
|
||||
|
||||
type NormalizedStoredIdentity =
|
||||
| {
|
||||
kind: "identity";
|
||||
identity: DeviceIdentity;
|
||||
stored?: StoredIdentity;
|
||||
validForReadOnly: boolean;
|
||||
}
|
||||
| { kind: "recognized-invalid" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object";
|
||||
}
|
||||
|
||||
function hasRecognizedIdentityShape(parsed: unknown): boolean {
|
||||
return (
|
||||
isRecord(parsed) &&
|
||||
("publicKeyPem" in parsed ||
|
||||
"privateKeyPem" in parsed ||
|
||||
"publicKey" in parsed ||
|
||||
"privateKey" in parsed)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStoredIdentity(parsed: unknown): NormalizedStoredIdentity | null {
|
||||
if (
|
||||
isRecord(parsed) &&
|
||||
"version" in parsed &&
|
||||
parsed.version === 1 &&
|
||||
"deviceId" in parsed &&
|
||||
typeof parsed.deviceId === "string" &&
|
||||
"publicKeyPem" in parsed &&
|
||||
typeof parsed.publicKeyPem === "string" &&
|
||||
"privateKeyPem" in parsed &&
|
||||
typeof parsed.privateKeyPem === "string"
|
||||
) {
|
||||
const stored = parsed as StoredIdentity;
|
||||
const derivedId = tryFingerprintPublicKey(stored.publicKeyPem);
|
||||
if (!derivedId || !keyPairMatches(stored.publicKeyPem, stored.privateKeyPem)) {
|
||||
return { kind: "recognized-invalid" };
|
||||
}
|
||||
const identity = {
|
||||
deviceId: derivedId,
|
||||
publicKeyPem: stored.publicKeyPem,
|
||||
privateKeyPem: stored.privateKeyPem,
|
||||
};
|
||||
return derivedId === stored.deviceId
|
||||
? { kind: "identity", identity, validForReadOnly: true }
|
||||
: {
|
||||
kind: "identity",
|
||||
identity,
|
||||
validForReadOnly: false,
|
||||
stored: {
|
||||
...stored,
|
||||
deviceId: derivedId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isRecord(parsed) &&
|
||||
!("version" in parsed) &&
|
||||
"deviceId" in parsed &&
|
||||
typeof parsed.deviceId === "string" &&
|
||||
"publicKey" in parsed &&
|
||||
typeof parsed.publicKey === "string" &&
|
||||
"privateKey" in parsed &&
|
||||
typeof parsed.privateKey === "string"
|
||||
) {
|
||||
const stored = parsed as StoredSwiftIdentity;
|
||||
const publicKeyRaw = base64UrlDecode(stored.publicKey);
|
||||
const privateKeyRaw = base64UrlDecode(stored.privateKey);
|
||||
if (publicKeyRaw.length !== 32 || privateKeyRaw.length !== 32) {
|
||||
return { kind: "recognized-invalid" };
|
||||
}
|
||||
const publicKeyPem = publicKeyPemFromRaw(publicKeyRaw);
|
||||
const privateKeyPem = privateKeyPemFromRaw(privateKeyRaw);
|
||||
if (!keyPairMatches(publicKeyPem, privateKeyPem)) {
|
||||
return { kind: "recognized-invalid" };
|
||||
}
|
||||
const derivedId = fingerprintPublicKey(publicKeyPem);
|
||||
const validForReadOnly = derivedId === stored.deviceId;
|
||||
const migrated: StoredIdentity = {
|
||||
version: 1,
|
||||
deviceId: derivedId,
|
||||
publicKeyPem,
|
||||
privateKeyPem,
|
||||
createdAtMs:
|
||||
typeof stored.createdAtMs === "number" && Number.isFinite(stored.createdAtMs)
|
||||
? stored.createdAtMs
|
||||
: Date.now(),
|
||||
};
|
||||
return {
|
||||
kind: "identity",
|
||||
identity: {
|
||||
deviceId: derivedId,
|
||||
publicKeyPem,
|
||||
privateKeyPem,
|
||||
},
|
||||
validForReadOnly,
|
||||
stored: migrated,
|
||||
};
|
||||
}
|
||||
|
||||
return hasRecognizedIdentityShape(parsed) ? { kind: "recognized-invalid" } : null;
|
||||
}
|
||||
|
||||
function identityFileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOrCreateDeviceIdentity(
|
||||
filePath: string = resolveDefaultIdentityPath(),
|
||||
): DeviceIdentity {
|
||||
try {
|
||||
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists<StoredIdentity>(
|
||||
path.basename(filePath),
|
||||
);
|
||||
if (
|
||||
parsed?.version === 1 &&
|
||||
typeof parsed.deviceId === "string" &&
|
||||
typeof parsed.publicKeyPem === "string" &&
|
||||
typeof parsed.privateKeyPem === "string"
|
||||
) {
|
||||
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
||||
if (derivedId && derivedId !== parsed.deviceId) {
|
||||
const updated: StoredIdentity = {
|
||||
...parsed,
|
||||
deviceId: derivedId,
|
||||
};
|
||||
privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), updated, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
return {
|
||||
deviceId: derivedId,
|
||||
publicKeyPem: parsed.publicKeyPem,
|
||||
privateKeyPem: parsed.privateKeyPem,
|
||||
};
|
||||
const store = privateFileStoreSync(path.dirname(filePath));
|
||||
const parsed = store.readJsonIfExists(path.basename(filePath));
|
||||
const normalized = normalizeStoredIdentity(parsed);
|
||||
if (normalized?.kind === "identity") {
|
||||
if (normalized.stored) {
|
||||
try {
|
||||
store.writeJson(path.basename(filePath), normalized.stored, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
} catch {
|
||||
// Keep using recognized OpenClaw key material even if best-effort normalization fails.
|
||||
}
|
||||
}
|
||||
return {
|
||||
deviceId: parsed.deviceId,
|
||||
publicKeyPem: parsed.publicKeyPem,
|
||||
privateKeyPem: parsed.privateKeyPem,
|
||||
};
|
||||
return normalized.identity;
|
||||
}
|
||||
if (normalized?.kind === "recognized-invalid") {
|
||||
return generateIdentity();
|
||||
}
|
||||
} catch {
|
||||
// fall through to regenerate
|
||||
if (identityFileExists(filePath)) {
|
||||
return generateIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
const identity = generateIdentity();
|
||||
@@ -114,27 +262,14 @@ export function loadDeviceIdentityIfPresent(
|
||||
filePath: string = resolveDefaultIdentityPath(),
|
||||
): DeviceIdentity | null {
|
||||
try {
|
||||
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists<StoredIdentity>(
|
||||
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists(
|
||||
path.basename(filePath),
|
||||
);
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.version !== 1 ||
|
||||
typeof parsed.deviceId !== "string" ||
|
||||
typeof parsed.publicKeyPem !== "string" ||
|
||||
typeof parsed.privateKeyPem !== "string"
|
||||
) {
|
||||
const normalized = normalizeStoredIdentity(parsed);
|
||||
if (normalized?.kind !== "identity" || !normalized.validForReadOnly) {
|
||||
return null;
|
||||
}
|
||||
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
||||
if (!derivedId || derivedId !== parsed.deviceId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
deviceId: parsed.deviceId,
|
||||
publicKeyPem: parsed.publicKeyPem,
|
||||
privateKeyPem: parsed.privateKeyPem,
|
||||
};
|
||||
return normalized.identity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user