import { writeFileSync } from 'node:fs' import type { FastifyInstance } from 'fastify' import type { CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts' import { HttpError, strict } from './http.ts' import type { Log } from './log.ts' import { readJsonObject } from './process.ts' import { decrypt, encrypt, encryptionKey } from './secrets.ts' /** * A credential store: the list under key of config.json at configPath, of the kinds the application gives, with the * names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the first write * creates it. */ export type CredentialStore = { configPath: string key: string kinds: CredentialKind[] reserved?: string[] missingAsEmpty: boolean } /** A credential with its secrets decrypted; a secret that is not kept is absent. */ export type CredentialValues = { name: string; kind: string; values: Record } /** * The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes * are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full. */ export const POSTGRESQL_KIND: CredentialKind = { kind: 'postgresql', label: 'PostgreSQL connection', fields: [ { key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false }, { key: 'port', label: 'Port', help: 'Usually 5432.', type: 'port', required: true, secret: false }, { key: 'database', label: 'Database', help: 'The database to connect to.', type: 'text', required: true, secret: false }, { key: 'user', label: 'User', help: 'The role to log in as.', type: 'text', required: true, secret: false }, { key: 'ssl_mode', label: 'SSL mode', help: 'disable: no SSL; require: SSL without checking the server\'s certificate; verify-full: SSL with the certificate checked against the trusted CAs and the host name.', type: 'choice', choices: ['disable', 'require', 'verify-full'], required: true, secret: false, }, { key: 'password', label: 'Password', help: 'The password of the role.', type: 'text', required: false, secret: true }, ], } const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const text = (value: unknown) => typeof value === 'string' && value.trim() !== '' /** * The problems of the value of the store's key in config.json, by , . or .., * each with what it must be; empty when every value is allowed. */ export function credentialProblems(entries: unknown, store: Pick): Record { if (entries === undefined) { return {} } if (!Array.isArray(entries)) { return { [store.key]: 'must be a list of credentials' } } const problems: Record = {} const reserved = store.reserved ?? [] entries.forEach((entry: unknown, index) => { const at = `${store.key}.${index}` if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { problems[at] = 'must be an object with name and kind' return } const record = entry as Record const { name } = record if (!text(name)) { problems[`${at}.name`] = 'must be a name' } else if (reserved.includes(name as string)) { problems[`${at}.name`] = `must not be ${reserved.join(' or ')}` } else if (entries.findIndex((other) => (other as Record | null)?.name === name) !== index) { problems[`${at}.name`] = 'must differ from the names of the other credentials' } const kind = store.kinds.find((item) => item.kind === record.kind) if (kind === undefined) { problems[`${at}.kind`] = `must be one of ${store.kinds.map((item) => item.kind).join(', ')}` return } const keys = ['name', 'kind', ...kind.fields.map((field) => field.key)] const unknown = Object.keys(record).filter((key) => !keys.includes(key)) if (unknown.length > 0) { problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not a field of ${kind.kind}` } for (const field of kind.fields) { const value = record[field.key] const path = `${at}.${field.key}` if (value === undefined) { if (field.required && !field.secret) { problems[path] = `must be set: ${field.help}` } } else if (field.secret) { if (!text(value)) { problems[path] = 'must be an encrypted secret' } } else if (field.type === 'port') { if (!(Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 65535)) { problems[path] = 'must be a port number from 1 to 65535' } } else if (field.type === 'guid') { if (typeof value !== 'string' || !GUID.test(value)) { problems[path] = 'must be a GUID' } } else if (field.type === 'choice') { if (!field.choices?.includes(value as string)) { problems[path] = `must be one of ${field.choices?.join(', ')}` } } else if (!text(value)) { problems[path] = `must be set: ${field.help}` } } }) return problems } /** The entries of the store's key in config.json; a value that is not a list reads as none. */ function entriesOf(data: Record, store: CredentialStore): Record[] { const entries = data[store.key] return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : [] } /** The key of the secrets, a file of ENCRYPTION_KEY_FILE that cannot be read being 500. */ function key(): Buffer | null { try { return encryptionKey() } catch (error) { throw new HttpError(500, (error as Error).message) } } /** config.json of the store with its version, a file that cannot be read being 500. */ function read(store: CredentialStore) { try { return readJsonObject(store.configPath, { missingAsEmpty: store.missingAsEmpty }) } catch (error) { throw new HttpError(500, `${(error as Error).message}.`) } } /** The credentials of the entries, without their secrets. */ function toProfiles(data: Record, store: CredentialStore): CredentialProfile[] { return entriesOf(data, store).map((entry) => { const kind = store.kinds.find((item) => item.kind === entry.kind) const fields = kind?.fields ?? [] const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '') return { original: typeof entry.name === 'string' ? entry.name : null, name: typeof entry.name === 'string' ? entry.name : '', kind: typeof entry.kind === 'string' ? entry.kind : '', values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])), secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])), stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])), } }) } /** * The entries of the credentials, with each secret: kept from the entry named original for null, none for '', else * the new secret through seal, which encrypts it; for validation seal only marks it. A port of digits is a number, * other text is kept as typed; an empty field that is not required is left out. */ function toEntries(profiles: CredentialProfile[], data: Record, store: CredentialStore, seal: (secret: string) => string) { const stored = entriesOf(data, store) return profiles.map((profile) => { const kind = store.kinds.find((item) => item.kind === profile.kind) const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original) const entry: Record = { name: profile.name.trim(), kind: profile.kind } for (const field of kind?.fields ?? []) { if (field.secret) { const secret = profile.secrets[field.key] ?? null const value = secret === null ? (previous?.kind === profile.kind ? previous?.[field.key] : undefined) : secret === '' ? undefined : seal(secret) if (value !== undefined) { entry[field.key] = value } continue } const value = (profile.values[field.key] ?? '').trim() if (value !== '' || field.required) { entry[field.key] = field.type === 'port' && /^\d+$/.test(value) ? Number(value) : value } } return entry }) } /** * The credential named name of the store with its secrets decrypted. A config.json whose credentials are not valid * is 500, a name that does not exist 404, and a secret without a key, or that the key does not decrypt, 400. */ export function credentialValues(store: CredentialStore, name: string): CredentialValues { const { data } = read(store) const [problem] = Object.entries(credentialProblems(data[store.key], store)) if (problem !== undefined) { throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`) } const entry = entriesOf(data, store).find((item) => item.name === name) if (entry === undefined) { throw new HttpError(404, `The credential ${name} does not exist.`) } const kind = store.kinds.find((item) => item.kind === entry.kind)! const values: Record = {} for (const field of kind.fields) { const value = entry[field.key] if (value === undefined) { continue } if (!field.secret) { values[field.key] = value as string | number continue } const secretKey = key() if (secretKey === null) { throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} needs ENCRYPTION_KEY or ENCRYPTION_KEY_FILE in the server's environment.`) } try { values[field.key] = decrypt(value as string, secretKey) } catch { throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`) } } return { name, kind: kind.kind, values } } /** * Registers the routes of the store under prefix: read, validate and write edit the store's key of config.json, * keeping its other keys, with each new secret encrypted by the key of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE. A * secret is never answered. */ export function registerCredentialRoutes(app: FastifyInstance, store: CredentialStore, prefix: string, log: Log) { const record = { type: 'object', additionalProperties: { type: 'string' } } const secrets = { type: 'object', additionalProperties: { type: ['string', 'null'] } } const flags = { type: 'object', additionalProperties: { type: 'boolean' } } const profiles = { type: 'array', items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, kind: { type: 'string' }, values: record, secrets, stored: flags }), } /** The problems of the credentials by ., a new secret included when the server has no key. */ function problemsOf(value: CredentialProfile[], data: Record): Record { const problems: Record = {} for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) { const [, index, field] = path.split('.') problems[field === undefined ? index ?? store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.` } if (key() === null) { value.forEach((profile, index) => { for (const [field, secret] of Object.entries(profile.secrets)) { if (secret !== null && secret !== '') { problems[`${index}.${field}`] ??= 'A secret needs ENCRYPTION_KEY or ENCRYPTION_KEY_FILE in the server\'s environment.' } } }) } return problems } function document(file: { data: Record; version: string }): CredentialsDocument { return { version: file.version, value: toProfiles(file.data, store), path: store.configPath, encryption: key() !== null, kinds: store.kinds, reserved: store.reserved ?? [], } } app.get(`${prefix}/read`, async (): Promise => document(read(store))) app.post<{ Body: { value: CredentialProfile[] } }>(`${prefix}/validate`, { schema: { body: strict({ value: profiles }) }, }, async (request) => ({ problems: problemsOf(request.body.value, read(store).data), message: '' })) // The file is read, compared and written synchronously. app.post<{ Body: { version: string; value: CredentialProfile[] } }>(`${prefix}/write`, { schema: { body: strict({ version: { type: 'string' }, value: profiles }) }, }, async (request): Promise => { const file = read(store) if (file.version !== request.body.version) { throw new HttpError(409, `${store.configPath} changed since it was read. Close the dialog and open it again.`) } const problems = problemsOf(request.body.value, file.data) if (Object.keys(problems).length > 0) { throw new HttpError(422, 'The credentials are not valid.', problems) } const sealing = key()! const others = { ...file.data } delete others[store.key] const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing)) try { writeFileSync(store.configPath, `${JSON.stringify({ ...others, [store.key]: entries }, null, 2)}\n`) } catch (error) { throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`) } log.event(`file write ${store.configPath} (${store.key})`) return document(read(store)) }) }