// Peer model: CRUD on _dd_peers. // // Peer secrets are stored AES-256-GCM sealed; 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. const db = require("@saltcorn/data/db"); const { seal, open, randomSecret } = require("./crypto"); const rowToPeer = (row) => { if (!row) return null; 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, // sealed components kept out of plain accessors -- use peerSecret() }; }; const listPeers = async () => { const rows = await db.select("_dd_peers", {}, { orderBy: "peer_id" }); return rows.map(rowToPeer); }; const findPeer = async (peerId) => { const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId }); return rowToPeer(row); }; const findPeerByEnvId = async (envId) => { return await db.selectMaybeOne("_dd_peers", { env_id: envId }); }; // Returns the plaintext 32-byte secret for an existing peer by id. // Throws if the peer is missing or has no sealed secret. const peerSecret = async (peerId) => { const row = await db.selectMaybeOne("_dd_peers", { 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 open({ ciphertext: Buffer.from(row.peer_secret_ciphertext, "hex"), iv: Buffer.from(row.peer_secret_iv, "hex"), tag: Buffer.from(row.peer_secret_tag, "hex") }); }; const peerSecretByEnvId = async (envId) => { const row = await findPeerByEnvId(envId); if (!row) { return null; } return await peerSecret(row.peer_id); }; // Create a new peer. If `existingSecret` is provided, use it; otherwise // generate a fresh one. Returns { peer, secret } where secret is the plaintext // Buffer -- only available at this single moment. const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => { if (!envId || !baseUrl) { throw new Error("addPeer requires envId and baseUrl"); } const dup = await findPeerByEnvId(envId); if (dup) { throw new Error(`peer with env_id ${envId} already exists`); } const secret = existingSecret || randomSecret(); const sealed = seal(secret); const row = { env_id: envId, label: label || null, base_url: baseUrl, peer_secret_ciphertext: sealed.ciphertext.toString("hex"), peer_secret_iv: sealed.iv.toString("hex"), peer_secret_tag: sealed.tag.toString("hex"), require_tls: requireTls ? 1 : 0, created_at: new Date().toISOString(), last_seen_at: null }; // noid: _dd_peers' PK is peer_id (serial on PG), not "id"; without this // Saltcorn's default RETURNING id makes the insert fail on Postgres with // 'column "id" does not exist'. The peer_id is auto-assigned; re-select below. await db.insert("_dd_peers", row, { noid: true }); const fresh = await findPeerByEnvId(envId); return { peer: rowToPeer(fresh), secret: secret }; }; const rotatePeerSecret = async (peerId) => { const peer = await findPeer(peerId); if (!peer) { throw new Error(`peer ${peerId} not found`); } const secret = randomSecret(); const sealed = seal(secret); await db.updateWhere("_dd_peers", { peer_secret_ciphertext: sealed.ciphertext.toString("hex"), peer_secret_iv: sealed.iv.toString("hex"), peer_secret_tag: sealed.tag.toString("hex") }, { peer_id: peerId }); return { peer: peer, secret: secret }; }; const deletePeer = async (peerId) => { await db.deleteWhere("_dd_peers", { peer_id: peerId }); await db.deleteWhere("_dd_anchors", { peer_id: peerId }); }; const touchPeerLastSeen = async (peerId) => { await db.updateWhere("_dd_peers", { last_seen_at: new Date().toISOString() }, { peer_id: peerId }); }; module.exports = { listPeers, findPeer, findPeerByEnvId, peerSecret, peerSecretByEnvId, addPeer, rotatePeerSecret, deletePeer, touchPeerLastSeen };