///
// NOTE: We can't use crypto.subtle (non-extractable CryptoKeys) for our keys, because subtle is asynchronous, and everything here (key generation, cert creation, signing) must be available synchronously. The only real benefit of subtle is that a cross-site scripting attack can't exfiltrate the key. Which, while nice, is of minor benefit, as the cross-site script can already do quite a bit of damage anyway with access. And if that happens, the first thing the user is probably going to do is reset all their credentials, which solves the case of the key being exfiltrated anyway (by telling the server to stop trusting all identities).
// NOTE: We are just not going to support HTTPS browser certs. Our code is purely for identification, and so it only supports ED25519.
// https://www.rfc-editor.org/rfc/rfc5280#page-42
import { setFlag } from "socket-function/require/compileFlags";
import * as forge from "node-forge";
import os from "os";
import fsSync from "fs";
import { cache, lazy } from "socket-function/src/caching";
import { isNode } from "socket-function/src/misc";
import sha265 from "js-sha256";
import { trustCertificate } from "socket-function/src/certStore";
import { measureBlock, measureFnc, measureWrap } from "socket-function/src/profiling/measure";
import { getNodeIdDomain, getNodeIdDomainMaybeUndefined, getNodeIdLocation } from "socket-function/src/nodeCache";
import { SocketFunction } from "socket-function/SocketFunction";
import { resetAllNodeCallFactories } from "socket-function/src/nodeCache";
import { getKeyStore, DEV_getKeyStorePath, DEV_listKeyStoreApps } from "./persistentLocalStorage";
import { ellipsize } from "../strings";
setFlag(require, "node-forge", "allowclient", true);
setFlag(require, "js-sha256", "allowclient", true);
const timeInDay = 1000 * 60 * 60 * 24;
export const CA_NOT_FOUND_ERROR = "18aa7318-f88f-4d2d-b41f-3daf4a433827";
export const identityStorageKey = "machineCA_14";
export type IdentityStorageType = { domain: string; certB64: string; keyB64: string };
function getIdentityStore(domain: string) {
return getKeyStore(domain, identityStorageKey);
}
// Just used for machine maintenance (finding a machine's identity file on disk, ex to copy it to
// another machine over ssh). NEVER access this unless explicitly given permission.
export function DEV_getIdentityFilePath(domain: string): string {
return DEV_getKeyStorePath({ appName: domain, key: identityStorageKey });
}
// Every domain this machine has an identity under. Machine maintenance only, like the path above.
export function DEV_listIdentityDomains(): string[] {
return DEV_listKeyStoreApps(identityStorageKey);
}
export interface X509KeyPair { domain: string; cert: Buffer; key: Buffer; }
export function getCommonName(cert: Buffer | string) {
let subject = parseCert(cert).subject;
let commonName = subject.getField("CN")?.value as string | undefined;
if (!commonName) {
throw new Error(`No common name in subject: ${subject.attributes.map(x => `${x.shortName || x.name}=${x.value}`).join(", ")}`);
}
return commonName;
}
export function createX509(
config: {
domain: string;
issuer: X509KeyPair | "self";
lifeSpan: number;
keyPair: {
publicKey: forge.Ed25519PublicKey;
privateKey: forge.Ed25519PrivateKey;
};
}
): X509KeyPair {
return measureBlock(function createX509() {
let { domain, issuer, lifeSpan, keyPair } = config;
let certObj = forge.pki.createCertificate();
certObj.publicKey = keyPair.publicKey as unknown as forge.pki.PublicKey;
certObj.serialNumber = "01";
// Give it 5 minutes before now. If we give it too much time, it can look like the cert is really old, which will trigger various processes to try to get a fresher one (as if it lasts for 1 hour, but we set notBefore to 1 month ago, it looks 1 month old, and so almost expired, when it isn't...)
certObj.validity.notBefore = new Date(Date.now() - 1000 * 60 * 5);
certObj.validity.notAfter = new Date(Date.now() + lifeSpan);
const commonNameAttrs = [{ name: "commonName", value: domain }];
certObj.setSubject(commonNameAttrs);
if (issuer === "self") {
certObj.setIssuer(commonNameAttrs);
} else {
certObj.setIssuer(forge.pki.certificateFromPem(issuer.cert.toString()).subject.attributes);
}
let extensions = [];
const isCA = issuer === "self";
if (isCA) {
extensions.push({ name: "basicConstraints", cA: true });
}
let localHostDomain = "127-0-0-1." + domain.split(".").slice(-2).join(".");
extensions.push(...[
{ name: "keyUsage", keyCertSign: isCA, digitalSignature: true, nonRepudiation: true, keyEncipherment: true, dataEncipherment: true },
{ name: "subjectKeyIdentifier" },
{
name: "subjectAltName",
altNames: [
{ type: 2, value: domain },
{ type: 2, value: "*." + domain },
{ type: 2, value: localHostDomain },
// NOTE: No longer allow 127.0.0.1 ({ type: 7, ip: "127.0.0.1" }), to make this more secure. We might enable this behavior behind a flag, for development.
]
},
// NOTE: nameConstraints require our forked node-forge ("node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6"). Chrome ignores them (https://bugs.chromium.org/p/chromium/issues/detail?id=1072083), but our own validation (validateCACert/validateCertificate) enforces them.
{
name: "nameConstraints",
permittedSubtrees: [
{ type: 2, value: forge.util.encodeUtf8(domain) },
{ type: 2, value: forge.util.encodeUtf8(localHostDomain) },
]
},
]);
certObj.setExtensions(extensions);
measureBlock(function sign() {
if (issuer === "self") {
certObj.sign(keyPair.privateKey as any);
} else {
certObj.sign(forge.ed25519.privateKeyFromPem(issuer.key.toString()) as any);
}
});
return measureBlock(function toPems() {
return {
domain,
cert: Buffer.from(forge.pki.certificateToPem(certObj)),
key: Buffer.from(privateKeyToPem(keyPair.privateKey)),
};
});
});
}
export function privateKeyToPem(key: forge.Ed25519PrivateKey) {
return forge.ed25519.privateKeyToPem(key);
}
export function parseCert(PEMorDER: string | Buffer) {
return forge.pki.certificateFromPem(normalizeCertToPEM(PEMorDER));
}
function getED25519PublicKey(certParsed: forge.pki.Certificate): forge.Ed25519PublicKey {
let publicKey = certParsed.publicKey;
if (!("publicKeyBytes" in publicKey)) {
throw new Error(`Only ED25519 certificates are supported, the certificate public key is not ED25519 (subject: ${certParsed.subject.getField("CN")?.value})`);
}
return publicKey as unknown as forge.Ed25519PublicKey;
}
// Gets a unique value to represent the public key
export function getPublicIdentifier(PEMorDER: string | Buffer): Buffer {
return Buffer.from(getED25519PublicKey(parseCert(PEMorDER)).publicKeyBytes);
}
export const sign = measureWrap(function sign(keyPair: { key: string | Buffer }, data: unknown): string {
let dataStr = JSON.stringify(data);
let privateKey = forge.ed25519.privateKeyFromPem(keyPair.key.toString());
return privateKey.sign(dataStr);
});
export function verify(cert: string, signature: string, data: unknown) {
let certObj = parseCert(cert);
let publicKey = getED25519PublicKey(certObj);
let dataStr = JSON.stringify(data);
if (!publicKey.verify(dataStr, signature)) {
throw new Error(`Signature verification failed. Signature: ${JSON.stringify(signature)} | Data: ${ellipsize(dataStr, 1024)}`);
}
}
function normalizeCertToPEM(PEMorDER: string | Buffer): string {
if (PEMorDER.toString().startsWith("-----BEGIN CERTIFICATE-----")) {
return PEMorDER.toString();
}
PEMorDER = PEMorDER.toString("base64");
return "-----BEGIN CERTIFICATE-----\n" + PEMorDER + "\n-----END CERTIFICATE-----";
}
// Base32 (RFC 4648, lowercase, unpadded), as domain names are case-insensitive, which rules out base64/hex-with-case
const base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567";
function encodeBase32(bytes: Buffer): string {
let result = "";
let bitCount = 0;
let value = 0;
for (let byte of bytes) {
value = (value << 8) | byte;
bitCount += 8;
while (bitCount >= 5) {
result += base32Alphabet[(value >>> (bitCount - 5)) & 31];
bitCount -= 5;
}
}
if (bitCount > 0) {
result += base32Alphabet[(value << (5 - bitCount)) & 31];
}
return result;
}
function getDomainPartFromPublicKey(publicKey: { publicKeyBytes: Buffer } | Buffer) {
let bytes: Buffer;
if ("publicKeyBytes" in publicKey) {
bytes = publicKey.publicKeyBytes;
} else {
bytes = publicKey;
}
return "b" + encodeBase32(Buffer.from(sha265.sha256.array(Buffer.from(bytes)))).slice(0, 20);
}
export function validateCACert(domain: string, cert: string | Buffer) {
let certParsed = parseCert(cert);
let subject = certParsed.subject.getField("CN").value as string;
let localhostDomain = "127-0-0-1." + subject.split(".").slice(-2).join(".");
let domainParts = subject.split(".").reverse();
let rootDomainParsed = [domainParts.shift(), domainParts.shift()].reverse().join(".");
if (rootDomainParsed !== domain) {
// This is important, as our trust store contains more then just OUR certificates, so if we allow any domains then real domains can impersonate anyone! It has to be OUR domain to be trusted!
throw new Error(`Certificate root domain should be ${domain}, but is ${rootDomainParsed}`);
}
// TODO: Maybe just skip if it isn't a hash string?
if (domainParts[0] === "noproxy") {
domainParts.shift();
}
let certExpectedPublicKeyPart = (domainParts.shift() || "").split("-").slice(-1)[0];
let certActualPublicKeyPart = getDomainPartFromPublicKey(getED25519PublicKey(certParsed));
if (certExpectedPublicKeyPart !== certActualPublicKeyPart) {
throw new Error(`Certificate public key in the url is ${certExpectedPublicKeyPart}, but in the cert is ${certActualPublicKeyPart}`);
}
// ALSO, require name constraints to be present, and to restrict to the "CN"
let nameConstraints = certParsed.getExtension("nameConstraints") as any;
if (!nameConstraints) {
throw new Error(`Certificate must have nameConstraints`);
}
let subtrees = nameConstraints.permittedSubtrees;
if (!subtrees) {
throw new Error(`Certificate must have nameConstraints.permittedSubtrees`);
}
let subtreeValues = subtrees.map((x: any) => x.value);
// Ignore localhostDomain, as it can always safely be allowed (the same machine is always allowed).
subtreeValues = subtreeValues.filter((x: string) => x !== localhostDomain);
if (subtreeValues.length !== 1 || subtreeValues[0] !== subject) {
throw new Error(`Certificate must have a single constrained domain (had ${JSON.stringify(subtreeValues)})`);
}
validateAltNames(certParsed, subject);
}
export function validateCertificate(domain: string, cert: Buffer | string, issuerCert: Buffer | string) {
validateCACert(domain, issuerCert);
let certParsed = parseCert(cert);
let subject = certParsed.subject.getField("CN").value as string;
let localhostDomain = "127-0-0-1." + subject.split(".").slice(-2).join(".");
let domainParts = subject.split(".").reverse();
let rootDomainParsed = [domainParts.shift(), domainParts.shift()].reverse().join(".");
if (rootDomainParsed !== domain) {
throw new Error(`Certificate root domain should be ${domain}, but is ${rootDomainParsed}`);
}
// TODO: Maybe just skip if it isn't a hash string?
if (domainParts[0] === "noproxy") {
domainParts.shift();
}
let issuerCertParsed = parseCert(issuerCert);
let issuerExpectedPublicKeyPart = domainParts.shift() || "";
let issuerActualPublicKeyPart = getDomainPartFromPublicKey(getED25519PublicKey(issuerCertParsed));
if (issuerExpectedPublicKeyPart !== issuerActualPublicKeyPart) {
throw new Error(`Issuer public key in the url is ${issuerExpectedPublicKeyPart}, but in the cert is ${issuerActualPublicKeyPart}`);
}
// Take the last part
let certExpectedPublicKeyPart = domainParts.shift() || "";
let certActualPublicKeyPart = getDomainPartFromPublicKey(getED25519PublicKey(certParsed));
if (certExpectedPublicKeyPart !== certActualPublicKeyPart) {
throw new Error(`Certificate public key in the url is ${certExpectedPublicKeyPart}, but in the cert is ${certActualPublicKeyPart}`);
}
let nameConstraints = issuerCertParsed.getExtension("nameConstraints") as any;
if (!nameConstraints) {
throw new Error(`CA must have nameConstraints`);
}
let subtrees = nameConstraints.permittedSubtrees;
if (!subtrees) {
throw new Error(`CA must have nameConstraints.permittedSubtrees`);
}
let subtreeValues = subtrees.map((x: any) => x.value);
// Ignore localhostDomain, as it can always safely be allowed (the same machine is always allowed).
subtreeValues = subtreeValues.filter((x: string) => x !== localhostDomain);
if (subtreeValues.length !== 1) {
throw new Error(`CA must have a single constrained domain (had ${JSON.stringify(subtreeValues)})`);
}
let subtree = subtreeValues[0];
if (subtree !== subject && !subject.endsWith("." + subtree)) {
throw new Error(`Certificate must be a subtree of the CA (CA: ${subtree}, cert: ${subject})`);
}
validateAltNames(certParsed, subject);
// Verify issuer ACTUALLY signed certParsed
if (!issuerCertParsed.verify(certParsed)) {
throw new Error(`Issuer did not sign certificate`);
}
}
// Require alt names to be either equal to "CN", or a subtree of "CN"
function validateAltNames(certParsed: forge.pki.Certificate, subject: string) {
let localhostDomain = "127-0-0-1." + subject.split(".").slice(-2).join(".");
let altNamesObj = certParsed.getExtension("subjectAltName") as any;
let altNames = altNamesObj?.altNames.map((x: any) => x.value);
// Allow localhostDomain, as it can always safely be allowed
altNames = altNames.filter((x: string) => x !== localhostDomain);
if (
altNames.some((x: string) =>
!(
x === subject
|| x.endsWith("." + subject)
// NOTE: We don't allow 127.0.0.1 (|| x === Buffer.from([127, 0, 0, 1]).toString()), because it is so easy to publish a 127.0.0.1 A record, and even to generate a real cert, so we should just do that, and keep it secure. If we need this for development we can put it behind a flag, so non-development instances are still secure.
)
)
) {
throw new Error(`Invalid alt names. Must be subtrees of the subject (CN) ${JSON.stringify(subject)}, was ${JSON.stringify(altNames)}`);
}
}
export function generateKeyPair() {
return measureBlock(function generateKeyPair() {
// NOTE: We use ED25519 because it can generate keys about 10X faster than RSA (which is still slow, ~6ms on my machine, so we DEFINITELY don't want it to be 10X slower!) - https://security.stackexchange.com/a/236943/282367
return forge.ed25519.generateKeyPair();
});
}
export function generateCA(domain: string) {
const keyPair = generateKeyPair();
let caPublicKeyPart = getDomainPartFromPublicKey(keyPair.publicKey);
let fullDomain = `${caPublicKeyPart}.${domain}`;
return createX509({ domain: fullDomain, issuer: "self", keyPair, lifeSpan: timeInDay * 365 * 20 });
}
let identityCA = cache((domain: string) => lazy((): X509KeyPair => {
let identityCACached = getIdentityStore(domain);
let caCached = identityCACached.get();
if (!caCached) {
console.log(`Generating new identity CA`);
let value = generateCA(domain);
caCached = {
domain: value.domain,
certB64: value.cert.toString("base64"),
keyB64: value.key.toString("base64"),
};
identityCACached.set(caCached);
}
let result = {
domain: caCached.domain,
cert: Buffer.from(caCached.certB64, "base64"),
key: Buffer.from(caCached.keyB64, "base64"),
};
trustCertificate(result.cert.toString());
return result;
}));
// IMPORTANT! We do not embed any debug info in this domain. If we did, it would be useful, but... potentally a security vulnerability, as if the debug info (such as a prefix) is used to identify what a certificate is for, it would be easy for an attack to forge this (as the debug info won't be secured). So it is much better to keep the certificate opaque, and then require any metadata to be actually vetted (and hopefully stored in a UI, showing IP, time, etc).
export function createCertFromCA(config: {
CAKeyPair: X509KeyPair;
}): X509KeyPair {
return measureBlock(function createCertFromCA() {
let { CAKeyPair } = config;
const keyPair = generateKeyPair();
let domainKeyPart = getDomainPartFromPublicKey(keyPair.publicKey);
let fullDomain = `${domainKeyPart}.${config.CAKeyPair.domain}`;
return createX509({
domain: fullDomain,
issuer: CAKeyPair,
keyPair,
lifeSpan: timeInDay * 365 * 10,
});
});
}
export function getMachineId(domainNameOrNodeId: string, domain: string) {
return decodeNodeIdAssert(domainNameOrNodeId, domain, "allowMissingThreadId").machineId;
}
export type NodeIdParts = {
threadId: string;
machineId: string;
domain: string;
port: number;
};
export function decodeNodeId(nodeId: string, domain: string, allowMissingThreadId?: "allowMissingThreadId"): NodeIdParts | undefined {
let locationObj = getNodeIdLocation(nodeId);
if (!locationObj) {
return undefined;
}
let parts = locationObj.address.split(".");
// NOTE: We have to only allow localhost domains on our own domain, as the underlying domain gets stripped when we're looking at the machineId. So if we allowed localhost domains on other domains, a server could trick us into connecting to it, and then once the connection is established, it could talk back and we would think it has a localhost machineId, which is implicitly trusted, which would then give it access to everything.
if (locationObj.address === `127-0-0-1.${domain}` && nodeId.includes(":")) {
return {
threadId: "",
machineId: parts.at(-3) || "",
domain: parts.slice(-2).join("."),
port: locationObj.port,
};
}
let isValid = parts.length >= 4 || allowMissingThreadId && parts.length === 3;
if (!isValid) {
return undefined;
}
return {
threadId: parts.at(-4) || "",
machineId: parts.at(-3) || "",
domain: parts.slice(-2).join(".") || "",
port: locationObj.port,
};
}
export function decodeNodeIdAssert(nodeId: string, domain: string, allowMissingThreadId?: "allowMissingThreadId"): NodeIdParts {
let result = decodeNodeId(nodeId, domain, allowMissingThreadId);
if (!result) {
throw new Error(`Invalid nodeId: ${JSON.stringify(nodeId)}`);
}
return result;
}
export function encodeNodeId(parts: NodeIdParts) {
return `${parts.threadId}.${parts.machineId}.${parts.domain}:${parts.port}`;
}
export async function setIdentityCARaw(domain: string, json: string) {
let identityCACached = getIdentityStore(domain);
let obj = JSON.parse(json) as {
domain: string;
certB64: string;
keyB64: string;
};
let ca = {
domain: obj.domain,
cert: Buffer.from(obj.certB64, "base64"),
key: Buffer.from(obj.keyB64, "base64"),
};
trustCertificate(ca.cert.toString());
identityCA(domain).set(ca);
getThreadKeyCertBase(domain).reset();
identityCACached.set(obj);
resetAllNodeCallFactories();
}
// NOTE: The identity CA is available synchronously (storage is fs/localStorage, both synchronous), so this only exists for backwards compatibility with startup code that awaits it.
export async function loadIdentityCA(domain: string) {
identityCA(domain)();
}
export function getIdentityCA(domain: string): X509KeyPair {
return identityCA(domain)();
}
// TODO: Replace this with a database, so it is easy for us to trust CAs cross machine, and even have multiple users, etc, etc.
export function getIdentityCAPromise(domain: string): X509KeyPair {
return identityCA(domain)();
}
export function getOwnMachineId(domain: string) {
return getMachineId(getIdentityCA(domain).domain, domain);
}
export function getOwnThreadId(domain: string) {
return decodeNodeIdAssert(getThreadKeyCert(domain).domain, domain).threadId;
}
/** Part of the machineId comes from the publicKey, so we can use it to verify.
Fairly weak: it only proves the id names this key, not that the caller holds the key - usually a
better workflow should be used, with a back and forth (ex, validateCertificate over a signed
exchange). In some cases it is sufficient, such as exposing source maps to the client. */
export function verifyMachineIdForPublicKey(config: {
machineId: string;
publicKey: Buffer;
}): boolean {
let { machineId, publicKey } = config;
let domainPart = getDomainPartFromPublicKey(publicKey);
// machineId is the bare key-hash label, but also accept legacy "hash.domain.tld" forms
return machineId.split(".")[0] === domainPart;
}
// NOTE: We don't have a cache per CA, as... the CA should be set first
// TODO: Maybe throw if they try to change the CA after they generate any certificates?
// TODO: Regenerate certificates after enough time (as thread certs should be relatively short lived, so it is plausible for them to expire). We will also need to provide a callback so that users of the cert can update the cert they are using as well.
export function getThreadKeyCert(domain: string) {
return getThreadKeyCertBase(domain)();
}
const getThreadKeyCertBase = cache((domain: string) => lazy(() => {
let ca = getIdentityCA(domain);
return createCertFromCA({ CAKeyPair: ca });
}));
export function getOwnNodeId(): string {
let nodeId = SocketFunction.mountedNodeId;
if (!nodeId) {
throw new Error(`Node must be mounted before nodeId is accessed`);
}
return nodeId;
}
export function getOwnNodeIdAllowUndefined() {
return SocketFunction.mountedNodeId;
}