diff --git a/package.json b/package.json index 57a8efc..c0423bb 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "generate:server-json": "tsx scripts/sync-release-metadata.ts", "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts", "lint": "eslint .", - "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/become-coordinator-actual-port.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js dist/test/handshake.test.js dist/test/approval.integration.test.js dist/test/listener-policy.integration.test.js dist/test/mesh-smoke.integration.test.js", + "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/identity-device-id.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/become-coordinator-actual-port.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js dist/test/handshake.test.js dist/test/approval.integration.test.js dist/test/listener-policy.integration.test.js dist/test/mesh-smoke.integration.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", "test:federation": "node dist/test/federation.integration.test.js", diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index 6614e7f..4a518de 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -12,6 +12,7 @@ import * as path from "node:path"; import { generateIdentity, getCertificateFingerprint, + deriveDeviceId, CERTIFICATE_VALIDITY_MS, } from "./identity.js"; import type { PeerIdentity } from "./identity.js"; @@ -145,6 +146,7 @@ function loadStoredIdentity(identityFile: string): PeerIdentity | undefined { privateKey: parsed.privateKey, certificate: parsed.certificate, fingerprint: getCertificateFingerprint(parsed.certificate), + deviceId: deriveDeviceId(parsed.privateKey), }; } catch (err) { console.error( diff --git a/src/core/identity.ts b/src/core/identity.ts index 8b2b27e..3cde880 100644 --- a/src/core/identity.ts +++ b/src/core/identity.ts @@ -19,6 +19,9 @@ import { randomBytes, } from "node:crypto"; +/** The uncompressed SEC1 point tag byte (RFC 5480 §2.2): 0x04 marks what follows as raw X||Y, never compressed or hybrid encoding. */ +const UNCOMPRESSED_POINT_TAG = 0x04; + // ─── Types ────────────────────────────────────────────────────────────────── /** Self-signed certificate validity. Exported so identity persistence can derive its renewal margin. */ @@ -31,6 +34,10 @@ export interface PeerIdentity { certificate: string; /** SHA-256 fingerprint of the certificate (DER), hex-encoded with colons. */ fingerprint: string; + /** + * SHA-256 of the raw, uncompressed SEC1 public-key point -- wire-mesh's own device-id derivation (never the certificate's DER encoding, which embeds a serial number and validity window that change on every reissue even for an identical key; this is the exact instability fingerprint already has). Additive: nothing in this codebase reads it yet. `fingerprint` remains what MeshStore.peerId is set to until the substrate migration cuts over to this field instead. + */ + deviceId: Uint8Array; } // ─── ASN.1 DER helpers ───────────────────────────────────────────────────── @@ -312,6 +319,7 @@ export function generateIdentity(): PeerIdentity { privateKey: keyPair.privateKey, certificate, fingerprint: getCertificateFingerprint(certificate), + deviceId: deriveDeviceId(keyPair.privateKey), }; } @@ -345,6 +353,21 @@ function pemToDer(pem: string): Buffer { return Buffer.from(b64, "base64"); } +/** Derives wire-mesh's own device-id (SHA-256 of the raw, uncompressed SEC1 public-key point) from a PEM-encoded EC private key. */ +export function deriveDeviceId(privateKeyPem: string): Uint8Array { + const publicKey = createPublicKey(privateKeyPem); + const jwk = publicKey.export({ format: "jwk" }); + if (jwk.x === undefined || jwk.y === undefined) { + throw new Error("expected an EC JWK with x/y coordinates"); + } + const rawPublicKey = Buffer.concat([ + Buffer.from([UNCOMPRESSED_POINT_TAG]), + Buffer.from(jwk.x, "base64url"), + Buffer.from(jwk.y, "base64url"), + ]); + return createHash("sha256").update(rawPublicKey).digest(); +} + /** Encode DER bytes as a PEM certificate string. */ function derToCertificatePem(der: Buffer): string { const b64 = der.toString("base64").match(/.{1,64}/g); diff --git a/src/test/identity-device-id.test.ts b/src/test/identity-device-id.test.ts new file mode 100644 index 0000000..26c4f4a --- /dev/null +++ b/src/test/identity-device-id.test.ts @@ -0,0 +1,43 @@ +/** + * The device-id wire-mesh's own migration will key peer identity on is SHA-256 of the raw public-key bytes -- never the certificate's own DER encoding, which embeds a serial number and validity window that change on every reissue even for an identical key (the same bug this project's certificate-fingerprint peerId already has, and the reason the migration plan treats it as provably unstable). This is purely additive: nothing yet reads identity.deviceId, so this only proves the value itself is correct, not that anything consumes it. + */ + +import { createHash, createPublicKey } from "node:crypto"; +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { generateIdentity } from "../core/identity.js"; + +/** Re-derives the raw uncompressed SEC1 point independently of identity.ts's own implementation, via the JWK x/y coordinates every Node public KeyObject can export -- an oracle the test checks identity.ts's own derivation against, not a copy of it. */ +function rawPublicKeyFromCertificate(certificatePem: string): Buffer { + const publicKey = createPublicKey(certificatePem); + const jwk = publicKey.export({ format: "jwk" }); + if (jwk.x === undefined || jwk.y === undefined) { + throw new Error("expected an EC JWK with x/y coordinates"); + } + const UNCOMPRESSED_POINT_TAG = 0x04; + return Buffer.concat([ + Buffer.from([UNCOMPRESSED_POINT_TAG]), + Buffer.from(jwk.x, "base64url"), + Buffer.from(jwk.y, "base64url"), + ]); +} + +const SHA256_BYTE_LENGTH = 32; + +void test("deviceId is SHA-256 of the raw public key, independent of the certificate", () => { + const identity = generateIdentity(); + assert.equal(identity.deviceId.length, SHA256_BYTE_LENGTH); + + const rawPublicKey = rawPublicKeyFromCertificate(identity.certificate); + const expected = createHash("sha256").update(rawPublicKey).digest(); + assert.equal(Buffer.from(identity.deviceId).equals(expected), true); +}); + +void test("deviceId differs between two freshly generated identities", () => { + const a = generateIdentity(); + const b = generateIdentity(); + assert.notEqual( + Buffer.from(a.deviceId).toString("hex"), + Buffer.from(b.deviceId).toString("hex"), + ); +});