Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/core/identity-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions src/core/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -312,6 +319,7 @@ export function generateIdentity(): PeerIdentity {
privateKey: keyPair.privateKey,
certificate,
fingerprint: getCertificateFingerprint(certificate),
deviceId: deriveDeviceId(keyPair.privateKey),
};
}

Expand Down Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions src/test/identity-device-id.test.ts
Original file line number Diff line number Diff line change
@@ -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"),
);
});