Source: RouterProject/credential/service.js

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