// 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 };