mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, () => '***')),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user