// @ts-check
/**
* Credential service - SSH credentials for Git hosts, scoped per organisation
* (`scope: 'org'`) or per user (`scope: 'user'`).
*
* Storage model: credentials live ENTIRELY in Vault - there is no ObjectBase
* row. Each credential is one KV2 secret under
*
* orgas/data/{orgId}/credentials/{ref}
*
* where `ref` is the implicit reference used as `credentialsRef` on repository
* shares:
* - org: {orgId}/{name}
* - user: {userUID}/{name}
*
* Secret payload:
* { private_key, public_key, host, ssh_user, host_key }
*
* The public key (OpenSSH format) is returned by the API for registration at
* the Git host; the private key NEVER leaves Vault.
*
* One credential per (org|user, name). The same Git host used by several
* users/orgs gets one separate key pair each.
*
* @import {ExpressRequestAuthorized} from '../../types.js'
*/
import { generateKeyPairSync } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync, readFileSync } from 'node:fs';
import { getSecretsFromVault, saveSecretsToVault } from '@commtool/vault-secrets';
import { isAdmin } from '../../utils/authChecks.js';
import { apiError } from '../../utils/apiEnvelope.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
const execFileAsync = promisify(execFile);
/** KV2 credentials root for one organisation. */
const credentialsRootFor = (orgId) => `orgas/data/${orgId}/credentials`;
/** KV2 secret path for one credential (ref = {UID}/{name}). */
const vaultPathFor = (orgId, ref) => `${credentialsRootFor(orgId)}/${ref}`;
/** Validate a credential name (path-safe, used in the Vault path). */
const isValidName = (name) => typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name);
/** Validate a Git host name (hostname / host:port). */
const isValidHost = (host) => typeof host === 'string' && /^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/.test(host.trim());
/** Validate a known_hosts line: "<hostname|hash> <keytype> <base64 key>". */
const isValidHostKey = (hostKey) =>
typeof hostKey === 'string' &&
/^[^\s]+\s+(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|ssh-dss)\s+[A-Za-z0-9+/=]+$/.test(hostKey.trim());
/**
* Scan the SSH host keys of a Git host via `ssh-keyscan`.
*
* The host key is the server identity of the Git host (used for
* `StrictHostKeyChecking` when the worker clones). It is NOT the credential
* key pair — the credential is the client identity.
*
* `ssh-keyscan` runs on the backend because browsers cannot execute it. The
* host is validated strictly (hostname + optional port) and passed as a plain
* argv element (no shell), so there is no command injection surface.
*
* Only ed25519 keys are collected — the modern, strongly recommended type.
* @param {string} rawHost - hostname, optionally with `:port`
* @returns {Promise<{host: string, keys: string[]}>} known_hosts lines, e.g.
* `["git.commtool.org ssh-ed25519 AAAAC3N..."]`
*/
export const scanHostKey = async (rawHost) => {
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
if (!host || !isValidHost(host)) {
throw apiError(422, 'INVALID_HOST', 'host must be a valid hostname (optionally with port)');
}
// ssh-keyscan wants the port as `-p` argument, not as part of the
// hostname. Split "host:port" so a non-standard SSH port (e.g.
// `git.commtool.org:45231`) scans the right endpoint.
const portMatch = host.match(/^(.*):([0-9]{1,5})$/);
const hostname = portMatch ? portMatch[1] : host;
const port = portMatch ? portMatch[2] : null;
let stdout = '';
try {
const args = ['-t', 'ed25519', '-T', '5'];
if (port) args.push('-p', port);
args.push(hostname);
// -T 5: connect timeout. -t ed25519: only modern keys.
const res = await execFileAsync('ssh-keyscan', args, {
timeout: 15000,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
stdout = res.stdout;
} catch (e) {
// ssh-keyscan returns non-zero when a host is unreachable; it also
// prints diagnostics to stderr. Surfacing a friendly message avoids a
// raw spawn error leaking into the envelope.
const stderr = e?.stderr ? String(e.stderr) : '';
const detail = stderr.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 2).join('; ');
throw apiError(502, 'HOST_SCAN_FAILED', detail ? `Host key scan failed: ${detail}` : `Host key scan failed for ${host}`);
}
// known_hosts lines look like: "git.commtool.org ssh-ed25519 AAAAC3N...".
// Drop "#"-comments and empty lines, dedupe.
const keys = [...new Set(
stdout.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#')),
)];
return { host, keys };
};
/**
* Encode an ed25519 public key in DER (SPKI) form as the OpenSSH wire format
* used by Git hosts ("ssh-ed25519 AAAA..."):
* string "ssh-ed25519"
* string raw 32-byte public key
* The SPKI DER for ed25519 is exactly 44 bytes: 12 byte header + 32 byte key.
* @param {Buffer} derPub
* @returns {string}
*/
const toOpenSshPublicKey = (derPub) => {
const raw = derPub.subarray(12); // strip SPKI header (12 bytes for ed25519)
const algo = Buffer.from('ssh-ed25519', 'utf8');
const wire = Buffer.concat([
Buffer.from([0, 0, 0, algo.length]), algo,
Buffer.from([0, 0, 0, raw.length]), raw,
]);
return `ssh-ed25519 ${wire.toString('base64')}`;
};
/**
* Map a Vault secret payload + ref to the wire object (never the private key).
* @param {string} ref {UID}/{name}
* @param {string} orgId organization UID (to derive scope)
* @param {Object} data Vault payload
*/
const toCredential = (ref, orgId, data) => {
const uid = ref.slice(0, ref.indexOf('/'));
const name = ref.slice(ref.indexOf('/') + 1);
return {
UID: ref,
Title: name,
Display: name,
Data: {
scope: uid === orgId ? 'org' : 'user',
ref,
name,
host: data.host ?? '',
sshUser: data.ssh_user ?? 'git',
publicKey: data.public_key ?? '',
hostKey: data.host_key ?? '',
},
};
};
/**
* List credential names directly under a Vault directory. Returns [] when the
* path does not exist (no credentials yet).
* @param {string} path
* @returns {Promise<string[]>}
*/
const listVaultKeys = async (path) => {
try {
const keys = await getSecretsFromVault(path, { list: true });
return Array.isArray(keys) ? keys : [];
} catch {
return [];
}
};
/**
* Read one credential secret. Returns null when missing OR when the private
* key was blanked (a soft/blanked delete leaves an empty private_key behind —
* such entries are zombies and must not surface in the API).
* @param {string} path
* @returns {Promise<Object|null>}
*/
const readVaultSecret = async (path) => {
try {
const secret = (await getSecretsFromVault(path)) ?? null;
if (secret && !secret.private_key) return null;
return secret;
} catch {
return null;
}
};
/**
* Permanently delete a KV2 secret (all versions) via the metadata API.
*
* `@commtool/vault-secrets` has no delete function, so we issue the DELETE
* directly. The KV2 metadata endpoint (`{mount}/metadata/{path}`) removes the
* secret including version history; the `data` endpoint alone would only soft-
* delete the latest version.
* @param {string} path KV2 data path, e.g. `orgas/data/{orgId}/credentials/{ref}`
* @returns {Promise<boolean>} true when the secret was deleted
*/
const deleteVaultSecret = async (path) => {
const vaultAddr = process.env.VAULT_ADDR || 'https://vault.commtool.org';
let token = process.env.VAULT_TOKEN;
if (!token) {
const tokenPath = process.env.VAULT_TOKEN_FILE || '/vault-token';
if (fs.existsSync(tokenPath)) {
token = fs.readFileSync(tokenPath, 'utf8').trim();
}
}
if (!token) throw new Error('No Vault token available for delete operation');
// data path -> metadata path: orgas/data/X -> orgas/metadata/X
const metadataPath = path.replace(/^([^/]+)\/data\//, '$1/metadata/');
const apiPath = `${vaultAddr}/v1/${metadataPath}`;
const response = await fetch(apiPath, {
method: 'DELETE',
headers: { 'X-Vault-Token': token },
});
if (!response.ok) {
throw new Error(`Vault delete failed (${response.status}) at ${apiPath}`);
}
return true;
};
/**
* Soft-delete the latest version of a KV2 secret via the data API.
*
* Some tokens only get `delete` on the data endpoint (not the metadata one).
* A soft delete makes the current version unreadable (read returns 404) —
* good enough for a credential to disappear from the API, though version
* history remains in Vault.
* @param {string} path KV2 data path
* @returns {Promise<boolean>} true when the soft delete succeeded
*/
const softDeleteVaultSecret = async (path) => {
const vaultAddr = process.env.VAULT_ADDR || 'https://vault.commtool.org';
let token = process.env.VAULT_TOKEN;
if (!token) {
const tokenPath = process.env.VAULT_TOKEN_FILE || '/vault-token';
if (fs.existsSync(tokenPath)) {
token = fs.readFileSync(tokenPath, 'utf8').trim();
}
}
if (!token) throw new Error('No Vault token available for delete operation');
const apiPath = `${vaultAddr}/v1/${path}`;
const response = await fetch(apiPath, {
method: 'DELETE',
headers: { 'X-Vault-Token': token },
});
if (!response.ok) {
throw new Error(`Vault soft delete failed (${response.status}) at ${apiPath}`);
}
return true;
};
/**
* Split a credentialsRef ({UID}/{name}) and authorize:
* - user credential: only the owning user (or org admin)
* - org credential: only org admins
* @param {ExpressRequestAuthorized} req
* @param {string} ref
* @returns {Promise<{uid: string, name: string}>}
*/
const authorizeRef = async (req, ref) => {
const sep = typeof ref === 'string' ? ref.indexOf('/') : -1;
if (sep <= 0 || sep === ref.length - 1) {
throw apiError(422, 'INVALID_CREDENTIAL', 'credentialsRef must be "{UID}/{name}"');
}
const uid = ref.slice(0, sep);
const name = ref.slice(sep + 1);
if (uid === req.session.root) {
// org credential — org admin only
if (await isAdmin(req.session)) return { uid, name };
throw apiError(403, 'CREDENTIAL_NOT_CHANGEABLE', 'Org credentials are only accessible by admins');
}
if (uid === req.session.user) {
// own user credential
return { uid, name };
}
if (await isAdmin(req.session)) return { uid, name };
throw apiError(403, 'CREDENTIAL_FORBIDDEN', 'Credential is not accessible by this user');
};
/**
* Create a credential: generates an ed25519 key pair and persists it entirely
* in Vault. `ref` (= credentialsRef) is `{orgId}/{name}` for org scope and
* `{userUID}/{name}` for user scope.
* @param {ExpressRequestAuthorized} req
* @returns {Promise<Object>} credential object
*/
export const createCredential = async (req) => {
try {
const orgId = req.session.root;
const userId = req.session.user;
const body = req.body || {};
const scope = body.scope === 'user' ? 'user' : 'org';
const name = typeof body.name === 'string' ? body.name.trim() : '';
const host = typeof body.host === 'string' ? body.host.trim() : '';
const sshUser = typeof body.sshUser === 'string' && body.sshUser.trim() ? body.sshUser.trim() : 'git';
const hostKey = typeof body.hostKey === 'string' && body.hostKey.trim() ? body.hostKey.trim() : null;
if (!isValidName(name)) throw apiError(422, 'INVALID_CREDENTIAL', 'name must be 1-64 chars of [a-zA-Z0-9._-]');
if (!host || !isValidHost(host)) throw apiError(422, 'INVALID_CREDENTIAL', 'host must be a valid hostname (optionally with port)');
if (hostKey && !isValidHostKey(hostKey)) {
throw apiError(422, 'INVALID_HOST_KEY', 'hostKey must be a known_hosts line like "<host> <keytype> <base64>"');
}
const uid = scope === 'user' ? userId : orgId;
if (scope === 'org' && !(await isAdmin(req.session))) {
throw apiError(403, 'CREDENTIAL_NOT_CHANGEABLE', 'Org credentials require admin rights');
}
const ref = `${uid}/${name}`;
const path = vaultPathFor(orgId, ref);
// Duplicate check within the scope — one key pair per (org|user, name).
// A blanked (deleted) zombie entry must NOT block re-creating the name.
const ownKeys = [];
for (const key of await listVaultKeys(credentialsRootFor(orgId))) {
if (key === uid || key === orgId) {
for (const name_ of await listVaultKeys(vaultPathFor(orgId, key))) {
const secret = await readVaultSecret(vaultPathFor(orgId, `${key}/${name_}`));
if (secret) ownKeys.push(name_);
}
}
}
if (ownKeys.includes(name)) {
throw apiError(422, 'CREDENTIAL_EXISTS', `A ${scope} credential named "${name}" already exists`);
}
// ed25519 key pair. The private key stays in Vault (never in Members);
// the public key is stored in the OpenSSH format ("ssh-ed25519 ...") so
// it can be pasted directly into GitLab/GitHub as a deploy key.
const { publicKey: derPub, privateKey: privPem } = generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'der' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const publicKey = toOpenSshPublicKey(Buffer.from(derPub));
const payload = {
private_key: privPem,
public_key: publicKey,
host,
ssh_user: sshUser,
host_key: hostKey ?? '',
};
await saveSecretsToVault(payload, path);
return { success: true, result: toCredential(ref, orgId, payload) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* List credentials visible to the caller:
* - org-scoped credentials: org admins only
* - user-scoped credentials: the owning user sees his own; org admins see all
* @param {ExpressRequestAuthorized} req
* @returns {Promise<Array>} array of credential objects (never private keys)
*/
export const listCredentials = async (req) => {
try {
const orgId = req.session.root;
const userId = req.session.user;
const admin = await isAdmin(req.session);
const result = [];
const uidKeys = await listVaultKeys(credentialsRootFor(orgId));
for (const uid of uidKeys) {
if (!admin && uid !== userId) continue;
const names = await listVaultKeys(vaultPathFor(orgId, uid));
for (const name of names) {
const secret = await readVaultSecret(vaultPathFor(orgId, `${uid}/${name}`));
if (secret) result.push(toCredential(`${uid}/${name}`, orgId, secret));
}
}
result.sort((a, b) => (a.Title < b.Title ? -1 : a.Title > b.Title ? 1 : 0));
return { success: true, result };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Get a single credential.
* @param {ExpressRequestAuthorized} req
* @param {string} ref {UID}/{name}
* @returns {Promise<Object>}
*/
export const getCredential = async (req, ref) => {
try {
await authorizeRef(req, ref);
const secret = await readVaultSecret(vaultPathFor(req.session.root, ref));
if (!secret) throw apiError(404, 'CREDENTIAL_NOT_FOUND', 'Credential not found');
return { success: true, result: toCredential(ref, req.session.root, secret) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Delete a credential: removes the Vault secret.
* @param {ExpressRequestAuthorized} req
* @param {string} ref {UID}/{name}
* @returns {Promise<Object>}
*/
export const deleteCredential = async (req, ref) => {
try {
await authorizeRef(req, ref);
const path = vaultPathFor(req.session.root, ref);
const existing = await readVaultSecret(path);
if (!existing) throw apiError(404, 'CREDENTIAL_NOT_FOUND', 'Credential not found');
// Removal cascade: prefer a permanent KV2 metadata delete (removes the
// secret incl. history). If the token lacks delete on metadata, try a
// soft delete on the data endpoint (latest version becomes unreadable).
// Blanking the private key is the last resort.
try {
await deleteVaultSecret(path);
} catch {
try {
await softDeleteVaultSecret(path);
} catch {
await saveSecretsToVault({ private_key: '' }, path);
}
}
return { success: true, result: { UID: ref, removed: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};