sc-site-sync/lib/common/peerStore.js
2026-10-19 01:25:16 -05:00

243 lines
9 KiB
JavaScript

// Peer model: CRUD on the plugin's peers table.
//
// Peer secrets are stored AES-256-GCM sealed (hex columns) under the plugin's
// own session-secret-derived KEK; plaintext only crosses the process boundary at
// pairing time (when the operator copies the secret to the other instance's UI)
// and at HMAC sign/verify time. Rotating the session secret invalidates every
// pairing (the KEK changes, so existing ciphertexts no longer open).
const { createSealer } = require("./aesGcm");
const { randomSecret } = require("./peerProtocol");
// A shared secret is 32 bytes, pasted/displayed as 64 hex characters.
const SECRET_HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
// peer_id is allocated as MAX+1 (not atomic); retry this many times on a collision.
const PEER_ID_INSERT_ATTEMPTS = 5;
// Field definitions (excluding the auto `id` PK) of the registered peers table
// this store reads and writes; the plugin's schema.js registers it with these.
const PEER_TABLE_FIELDS = [
{ name: "peer_id", type: "Integer", is_unique: true },
{ name: "env_id", type: "String", is_unique: true, required: true },
{ name: "label", type: "String" },
{ name: "base_url", type: "String", required: true },
{ name: "peer_secret_ciphertext", type: "String" },
{ name: "peer_secret_iv", type: "String" },
{ name: "peer_secret_tag", type: "String" },
{ name: "require_tls", type: "Integer" },
{ name: "created_at", type: "String", required: true },
{ name: "last_seen_at", type: "String" }
];
// An operator-pasted shared secret as a Buffer; blank -> null (caller generates).
const parseSecretHex = (raw) => {
const s = String(raw || "").trim();
if (!s) {
return null;
}
if (!SECRET_HEX_PATTERN.test(s)) {
throw new Error("shared secret must be 64 hex characters");
}
return Buffer.from(s, "hex");
};
// Validate an operator-entered peer base_url before we ever sign+send requests
// to it. Peers are deliberately allowed to be localhost / private addresses
// (Dev/Test/Prod often share a host or private network), so this is NOT IP
// filtering -- it just rejects unparseable URLs and non-http(s) schemes
// (file:, gopher:, data:, ...) that have no business being an HTTP peer.
const validatePeerUrl = (baseUrl) => {
let u;
try {
u = new URL(baseUrl);
} catch (e) {
throw new Error(`invalid base_url: ${baseUrl}`);
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error(`base_url must use http or https: ${baseUrl}`);
}
if (!u.host) {
throw new Error(`base_url has no host: ${baseUrl}`);
}
};
const rowToPeer = (row) => {
if (!row) {
return null;
}
// sealed components are kept out of plain accessors -- use peerSecret()
return {
peer_id: row.peer_id,
env_id: row.env_id,
label: row.label,
base_url: row.base_url,
require_tls: !!row.require_tls,
created_at: row.created_at,
last_seen_at: row.last_seen_at
};
};
// opts = {
// owner plugin name (error messages, key derivation)
// table the registered peers table (_dd_peers / _ss_peers)
// kek { salt, info } of the plugin's peer-secret KEK (byte-identical
// forever, or existing pairings stop opening)
// dependentTables tables whose rows reference peer_id and go with the peer
// }
const createPeerStore = ({ owner, table, kek, dependentTables }) => {
const sealer = createSealer({ owner: owner, salt: kek.salt, info: kek.info });
// The sealed-secret columns of a peers row for secret (a Buffer).
const sealedColumns = (secret) => {
const sealed = sealer.sealHex(secret);
return {
peer_secret_ciphertext: sealed.ciphertext,
peer_secret_iv: sealed.iv,
peer_secret_tag: sealed.tag
};
};
// The plaintext secret of a raw peers row (throws if it does not open).
const openSecret = (row) => {
return sealer.openHex({ ciphertext: row.peer_secret_ciphertext, iv: row.peer_secret_iv, tag: row.peer_secret_tag });
};
const listPeers = async () => {
const db = require("@saltcorn/data/db");
const rows = await db.select(table, {}, { orderBy: "peer_id" });
return rows.map(rowToPeer);
};
const findPeer = async (peerId) => {
const db = require("@saltcorn/data/db");
return rowToPeer(await db.selectMaybeOne(table, { peer_id: peerId }));
};
// The raw row (with the sealed secret columns), or null.
const findPeerByEnvId = async (envId) => {
const db = require("@saltcorn/data/db");
return await db.selectMaybeOne(table, { env_id: envId });
};
// The plaintext 32-byte secret of an existing peer; throws if the peer is
// missing or has no sealed secret.
const peerSecret = async (peerId) => {
const db = require("@saltcorn/data/db");
const row = await db.selectMaybeOne(table, { peer_id: peerId });
if (!row) {
throw new Error(`peer ${peerId} not found`);
}
if (!row.peer_secret_ciphertext || !row.peer_secret_iv || !row.peer_secret_tag) {
throw new Error(`peer ${peerId} has no sealed secret`);
}
return openSecret(row);
};
// Create a peer with existingSecret, or a freshly generated one. Returns
// { peer, secret } where secret is the plaintext Buffer -- only available at
// this single moment.
const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => {
const db = require("@saltcorn/data/db");
if (!envId || !baseUrl) {
throw new Error("addPeer requires envId and baseUrl");
}
validatePeerUrl(baseUrl);
if (await findPeerByEnvId(envId)) {
throw new Error(`peer with env_id ${envId} already exists`);
}
const secret = existingSecret || randomSecret();
// The peers table is a registered Saltcorn table with an auto `id` PK;
// peer_id is a regular unique column, assigned here (serial-style MAX+1) to
// keep it stable across restore (other rows reference it by value). The
// MAX+1 is not atomic, so on a concurrent-pairing collision (peer_id unique
// violation) recompute and retry rather than losing the one-time secret.
const row = {
peer_id: null,
env_id: envId,
label: label || null,
base_url: baseUrl,
...sealedColumns(secret),
require_tls: requireTls ? 1 : 0,
created_at: new Date().toISOString(),
last_seen_at: null
};
for (let attempt = 1; ; attempt++) {
const nextRs = await db.query(`SELECT COALESCE(MAX(peer_id),0)+1 AS next FROM ${db.getTenantSchemaPrefix()}${table}`);
row.peer_id = nextRs.rows[0].next;
try {
// noid: let Saltcorn assign the `id` PK; peer_id is supplied explicitly.
await db.insert(table, row, { noid: true });
break;
} catch (e) {
if (attempt >= PEER_ID_INSERT_ATTEMPTS || (await findPeerByEnvId(envId))) {
throw e;
}
}
}
return { peer: rowToPeer(await findPeerByEnvId(envId)), secret: secret };
};
// Replace a peer's shared secret: with existingSecret (the value the OTHER
// side just rotated to and displayed) or, when absent, a freshly generated one
// that this side displays for pasting over there.
const rotatePeerSecret = async (peerId, existingSecret) => {
const db = require("@saltcorn/data/db");
const peer = await findPeer(peerId);
if (!peer) {
throw new Error(`peer ${peerId} not found`);
}
const secret = existingSecret || randomSecret();
await db.updateWhere(table, sealedColumns(secret), { peer_id: peerId });
return { peer: peer, secret: secret };
};
// Rows referencing the peer go too: a deleted max peer_id is handed out again
// (MAX+1) to the next pairing, which must never inherit them.
const deletePeer = async (peerId) => {
const db = require("@saltcorn/data/db");
await db.deleteWhere(table, { peer_id: peerId });
for (const dependent of dependentTables) {
await db.deleteWhere(dependent, { peer_id: peerId });
}
};
const touchPeerLastSeen = async (peerId) => {
const db = require("@saltcorn/data/db");
await db.updateWhere(table, { last_seen_at: new Date().toISOString() }, { peer_id: peerId });
};
return {
sealedColumns,
openSecret,
listPeers,
findPeer,
findPeerByEnvId,
peerSecret,
addPeer,
rotatePeerSecret,
deletePeer,
touchPeerLastSeen
};
};
module.exports = {
PEER_TABLE_FIELDS,
parseSecretHex,
createPeerStore
};