feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog.
Release / release (push) Successful in 1m4s
Release / release (push) Successful in 1m4s
This commit is contained in:
1 parent
57ac5296cb
commit
b0b7a4bb7f
13 files changed
+723
-30
No files matched your search
@@ -0,0 +1,305 @@
|
||||
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<string, string | number> }
|
||||
|
||||
/**
|
||||
* 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 <key>, <key>.<index> or <key>.<index>.<field key>,
|
||||
* each with what it must be; empty when every value is allowed.
|
||||
*/
|
||||
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kinds' | 'reserved'>): Record<string, string> {
|
||||
if (entries === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (!Array.isArray(entries)) {
|
||||
return { [store.key]: 'must be a list of credentials' }
|
||||
}
|
||||
const problems: Record<string, string> = {}
|
||||
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<string, unknown>
|
||||
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<string, unknown> | 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<string, unknown>, store: CredentialStore): Record<string, unknown>[] {
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown> = { 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<string, string | number> = {}
|
||||
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 <index>.<field key>, a new secret included when the server has no key. */
|
||||
function problemsOf(value: CredentialProfile[], data: Record<string, unknown>): Record<string, string> {
|
||||
const problems: Record<string, string> = {}
|
||||
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<string, unknown>; 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<CredentialsDocument> => 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<CredentialsDocument> => {
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
|
||||
export { POSTGRESQL_KIND, credentialProblems, credentialValues, registerCredentialRoutes, type CredentialStore, type CredentialValues } from './credentials.ts'
|
||||
export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts'
|
||||
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
|
||||
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
|
||||
export { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
const IV_BYTES = 12
|
||||
const TAG_BYTES = 16
|
||||
|
||||
/**
|
||||
* The key of the secrets: the SHA-256 of ENCRYPTION_KEY, else of the content of the file ENCRYPTION_KEY_FILE names
|
||||
* without its final newline; null when neither is set. A file that cannot be read throws with the reason.
|
||||
*/
|
||||
export function encryptionKey(): Buffer | null {
|
||||
let secret = process.env.ENCRYPTION_KEY
|
||||
if (!secret && process.env.ENCRYPTION_KEY_FILE) {
|
||||
try {
|
||||
secret = readFileSync(process.env.ENCRYPTION_KEY_FILE, 'utf8').replace(/\r?\n$/, '')
|
||||
} catch (error) {
|
||||
throw new Error(`ENCRYPTION_KEY_FILE ${process.env.ENCRYPTION_KEY_FILE} cannot be read: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
return secret ? createHash('sha256').update(secret, 'utf8').digest() : null
|
||||
}
|
||||
|
||||
/** The text encrypted with AES-256-GCM: base64 of a random IV, the ciphertext and the authentication tag. */
|
||||
export function encrypt(text: string, key: Buffer): string {
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv)
|
||||
const data = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
|
||||
return Buffer.concat([iv, data, cipher.getAuthTag()]).toString('base64')
|
||||
}
|
||||
|
||||
/** The text of encrypt's answer; another key, or a changed answer, throws. */
|
||||
export function decrypt(encrypted: string, key: Buffer): string {
|
||||
const bytes = Buffer.from(encrypted, 'base64')
|
||||
const decipher = createDecipheriv(ALGORITHM, key, bytes.subarray(0, IV_BYTES))
|
||||
decipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES))
|
||||
return Buffer.concat([decipher.update(bytes.subarray(IV_BYTES, bytes.length - TAG_BYTES)), decipher.final()]).toString('utf8')
|
||||
}
|
||||
Reference in new issue
Block a user