diff --git a/CHANGELOG.md b/CHANGELOG.md index 2056dad61f3..c076b1d14dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index dbf1828b4aa..ec110ead8d9 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -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), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift index 6b8945f9df4..539d8c39fed 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -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 +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift new file mode 100644 index 00000000000..2e6b178b484 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -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" + } +} diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts index a7c72a99542..7c99a94c8e7 100644 --- a/src/infra/device-identity.test.ts +++ b/src/infra/device-identity.test.ts @@ -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) => 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; + + 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); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 4953578f428..cdf352061f6 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -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 { + 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( - 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( + 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; }