feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

This commit is contained in:
jubnl
2026-08-20 00:50:27 +02:00
parent a6fb82833f
commit d9f6151f53
7 changed files with 309 additions and 2 deletions
+14
View File
@@ -260,6 +260,19 @@ export function derivePaths(raw: RawEnv) {
};
}
/**
* Security posture (spec: docs/superpowers/specs/2026-08-19-storage-admin-config-design.md).
* encryptionKeySet is true only when ENCRYPTION_KEY is explicitly set — the
* silent JWT-derived fallback (config.ts) is deliberately NOT reflected here:
* saving a credentialed storage backend fails closed on the fallback, naming
* the variable. (deriveBackup.encryptionKeyFromEnv answers a different
* question — whether the key file must ride inside backup archives — and
* stays untouched.)
*/
export function deriveSecurity(raw: RawEnv) {
return { encryptionKeySet: Boolean(raw.ENCRYPTION_KEY?.trim()) };
}
export function deriveNet(raw: RawEnv) {
return {
allowInternalNetwork: parseBool(raw.ALLOW_INTERNAL_NETWORK) === true,
@@ -284,6 +297,7 @@ export function deriveAll(raw: RawEnv) {
backup: deriveBackup(raw),
db: deriveDb(raw),
paths: derivePaths(raw),
security: deriveSecurity(raw),
net: deriveNet(raw),
};
}
+1
View File
@@ -19,6 +19,7 @@ export {
deriveBackup,
deriveDb,
derivePaths,
deriveSecurity,
deriveNet,
} from './derive';
export * from './parsers';
@@ -41,3 +41,8 @@ export function maybe_encrypt_api_key(value: unknown) {
return encrypt_api_key(trimmed);
}
/** True exactly for values carrying the enc:v1: envelope produced by encrypt_api_key. */
export function is_encrypted_api_key(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
}
+137
View File
@@ -0,0 +1,137 @@
import {
MASKED_SETTING_VALUE,
storageSecretFields,
type StorageBackend,
type StorageBackendTypeId,
type StorageConfig,
} from '@trek/shared';
import {
decrypt_api_key,
is_encrypted_api_key,
maybe_encrypt_api_key,
} from '../common/crypto/apiKeyCrypto';
import { StorageBackendError } from './storage.types';
/**
* Secret-field plumbing for admin-managed storage config (spec:
* docs/superpowers/specs/2026-08-19-storage-admin-config-design.md). Secrets
* live encrypted INSIDE the storage.backends JSON (the LLM addon-config
* precedent), on exactly the fields the shared type registry marks `secret`.
* Pure functions — the registry's seed import and the admin service share
* them.
*/
export interface PlaintextSecret {
backend: string;
field: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Map one backend's secret-field values; returns a new backend object. */
function mapSecrets(backend: StorageBackend, fn: (value: unknown, field: string) => unknown): StorageBackend {
const fields = storageSecretFields(backend.type);
if (fields.length === 0) return backend;
const options: Record<string, unknown> = { ...backend.options };
for (const field of fields) options[field] = fn(options[field], field);
return { ...backend, options } as StorageBackend;
}
/** Secret fields whose value is neither empty nor an enc:v1: envelope. */
export function listPlaintextSecrets(config: StorageConfig): PlaintextSecret[] {
const found: PlaintextSecret[] = [];
for (const backend of config.backends) {
for (const field of storageSecretFields(backend.type)) {
const value = (backend.options as Record<string, unknown>)[field];
if (typeof value === 'string' && value !== '' && !is_encrypted_api_key(value)) {
found.push({ backend: backend.name, field });
}
}
}
return found;
}
/** The seed file has no mask round-trip — a literal mask sentinel is always a mistake. */
export function assertNoMaskSentinels(config: StorageConfig): void {
for (const backend of config.backends) {
for (const field of storageSecretFields(backend.type)) {
if ((backend.options as Record<string, unknown>)[field] === MASKED_SETTING_VALUE) {
throw new StorageBackendError(
`backend '${backend.name}' field '${field}' is the mask sentinel — a mask can never become a stored value`,
);
}
}
}
}
/** Fail-closed refusal text: the silent JWT-derived fallback key is not accepted for credentials. */
export function encryptionGateError(secret: PlaintextSecret): string {
return (
`backend '${secret.backend}' has a plaintext secret '${secret.field}' but ENCRYPTION_KEY is not set — ` +
'set ENCRYPTION_KEY explicitly to save credentialed storage backends (the JWT-derived fallback key is not accepted)'
);
}
/** Encrypt every secret field (idempotent — enc:v1: values pass through unchanged). */
export function encryptStorageSecrets(config: StorageConfig): StorageConfig {
return {
...config,
backends: config.backends.map((backend) =>
mapSecrets(backend, (value) =>
typeof value === 'string' && value !== '' ? maybe_encrypt_api_key(value) : value,
),
),
};
}
/**
* Resolve mask echoes: a secret submitted as the mask is replaced by the
* stored (still-encrypted) value for the same backend name. A mask with no
* stored counterpart (rename, or a new backend) can never become a stored
* value — callers turn this throw into the 400.
*/
export function unmaskStorageConfig(config: StorageConfig, storedBackends: unknown): StorageConfig {
const stored = new Map<string, Record<string, unknown>>();
if (Array.isArray(storedBackends)) {
for (const entry of storedBackends) {
if (isRecord(entry) && typeof entry.name === 'string' && isRecord(entry.options)) {
stored.set(entry.name, entry.options);
}
}
}
return {
...config,
backends: config.backends.map((backend) =>
mapSecrets(backend, (value, field) => {
if (value !== MASKED_SETTING_VALUE) return value;
const storedValue = stored.get(backend.name)?.[field];
if (typeof storedValue !== 'string' || storedValue === '') {
throw new StorageBackendError(
`re-enter the secret '${field}' for '${backend.name}' — a mask can never become a stored value (new or renamed backend)`,
);
}
return storedValue;
}),
),
};
}
/** GET masking: secret fields always render as the sentinel, never a stored value. */
export function maskBackendOptions(
type: StorageBackendTypeId,
options: Record<string, string | number | string[]>,
): Record<string, string | number | string[]> {
const masked = { ...options };
for (const field of storageSecretFields(type)) masked[field] = MASKED_SETTING_VALUE;
return masked;
}
/** Audit redaction: secret values become '***' (names and shapes survive). */
export function redactStorageSecrets(config: StorageConfig): StorageConfig {
return {
...config,
backends: config.backends.map((backend) => mapSecrets(backend, () => '***')),
};
}