243 lines
9 KiB
JavaScript
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
|
|
};
|