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:
Val Alexander
2026-05-09 23:32:33 -05:00
committed by GitHub
parent 4c1e6ba2f0
commit dafbdb6f20
6 changed files with 516 additions and 61 deletions

View File

@@ -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.

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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);

View File

@@ -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;
}