145 lines
4.5 KiB
JavaScript
145 lines
4.5 KiB
JavaScript
// 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
|
|
};
|