196 lines
7.4 KiB
EmacsLisp
196 lines
7.4 KiB
EmacsLisp
// el-secrets — Secrets management for el-ui.
|
|
//
|
|
// A Secret is a value with a redacted display contract. Source plugins
|
|
// fetch by key from env, Vault, AWS Secrets Manager, or in-memory.
|
|
// `SecretsResolver` composes a chain and resolves required keys at startup.
|
|
|
|
// ── Errors ───────────────────────────────────────────────────────────────────
|
|
|
|
let SECRET_ERR_MISSING: String = "secret.missing"
|
|
let SECRET_ERR_SOURCE: String = "secret.source_error"
|
|
let SECRET_ERR_PERMISSION: String = "secret.permission_denied"
|
|
|
|
// ── Source kinds ─────────────────────────────────────────────────────────────
|
|
|
|
let SRC_ENV: String = "env"
|
|
let SRC_VAULT: String = "vault"
|
|
let SRC_AWS_SECRETS: String = "aws_secrets"
|
|
let SRC_IN_MEMORY: String = "in_memory"
|
|
|
|
// ── Source descriptor ───────────────────────────────────────────────────────
|
|
|
|
type SecretsSource {
|
|
kind: String // env | vault | aws_secrets | in_memory
|
|
config_json: String // source-specific (e.g. {"prefix":"EL_SECRET_"} or {"path":"secret/data/app"})
|
|
}
|
|
|
|
fn source_env(prefix: String) -> SecretsSource {
|
|
{ "kind": "env", "config_json": "{\"prefix\":\"" + prefix + "\"}" }
|
|
}
|
|
|
|
fn source_vault(addr: String, token: String, mount: String) -> SecretsSource {
|
|
let cfg: String = "{\"addr\":\"" + addr + "\",\"token\":\"" + token
|
|
+ "\",\"mount\":\"" + mount + "\"}"
|
|
{ "kind": "vault", "config_json": cfg }
|
|
}
|
|
|
|
fn source_aws(region: String) -> SecretsSource {
|
|
{ "kind": "aws_secrets", "config_json": "{\"region\":\"" + region + "\"}" }
|
|
}
|
|
|
|
fn source_in_memory() -> SecretsSource {
|
|
{ "kind": "in_memory", "config_json": "{\"map\":{}}" }
|
|
}
|
|
|
|
// ── Source lookup ───────────────────────────────────────────────────────────
|
|
//
|
|
// Each source kind has its own fetch fn. `source_get` dispatches by kind.
|
|
|
|
fn source_get(src: SecretsSource, key: String) -> String {
|
|
if str_eq(src.kind, "env") {
|
|
let prefix: String = json_get(src.config_json, "prefix")
|
|
return env(prefix + str_to_upper(str_replace(key, ".", "_")))
|
|
}
|
|
if str_eq(src.kind, "vault") {
|
|
let addr: String = json_get(src.config_json, "addr")
|
|
let token: String = json_get(src.config_json, "token")
|
|
let mount: String = json_get(src.config_json, "mount")
|
|
return vault_kv_get_at(addr, token, mount, key)
|
|
}
|
|
if str_eq(src.kind, "aws_secrets") {
|
|
let region: String = json_get(src.config_json, "region")
|
|
return aws_secrets_get(region, key)
|
|
}
|
|
if str_eq(src.kind, "in_memory") {
|
|
let map: String = json_get(src.config_json, "map")
|
|
return json_get(map, key)
|
|
}
|
|
""
|
|
}
|
|
|
|
fn in_memory_insert(src: SecretsSource, key: String, value: String) -> SecretsSource {
|
|
let map: String = json_get(src.config_json, "map")
|
|
let map = json_set(map, key, "\"" + value + "\"")
|
|
let cfg = json_set(src.config_json, "map", map)
|
|
{ "kind": src.kind, "config_json": cfg }
|
|
}
|
|
|
|
// ── Secret<T> — redacted wrapper ────────────────────────────────────────────
|
|
//
|
|
// A Secret stores its raw value behind a tag. `secret_display` always
|
|
// returns `[REDACTED]`. `secret_expose` returns the actual string.
|
|
// Serializers should call `secret_display` by default.
|
|
|
|
type Secret {
|
|
tag: String // marker: "secret"
|
|
raw: String // the actual value — never log this directly
|
|
}
|
|
|
|
fn secret_new(value: String) -> Secret {
|
|
{ "tag": "secret", "raw": value }
|
|
}
|
|
|
|
fn secret_display(s: Secret) -> String {
|
|
"[REDACTED]"
|
|
}
|
|
|
|
fn secret_expose(s: Secret) -> String {
|
|
s.raw
|
|
}
|
|
|
|
// Disposable read-once guard — like Rust's `SecretGuard`. Consumes the secret
|
|
// once and zeroes it. Useful at boundaries: read JWT signing key once, hand to
|
|
// the signing routine, never readable again.
|
|
type SecretGuard {
|
|
consumed: Bool
|
|
raw: String
|
|
}
|
|
|
|
fn guard_new(s: Secret) -> SecretGuard {
|
|
{ "consumed": false, "raw": s.raw }
|
|
}
|
|
|
|
fn guard_take(g: SecretGuard) -> String {
|
|
if g.consumed { return "" }
|
|
g.raw
|
|
}
|
|
|
|
// ── SecretsResolver — builder ───────────────────────────────────────────────
|
|
//
|
|
// Sources are tried in order. First non-empty hit wins.
|
|
// `require(key)` marks a key as required at startup; `resolve()` fails if any
|
|
// required key is unresolved.
|
|
|
|
type SecretsResolver {
|
|
sources_json: String // JSON array of SecretsSource
|
|
required_json: String // JSON array of required keys
|
|
}
|
|
|
|
fn resolver_new() -> SecretsResolver {
|
|
{ "sources_json": "[]", "required_json": "[]" }
|
|
}
|
|
|
|
fn resolver_source(r: SecretsResolver, src: SecretsSource) -> SecretsResolver {
|
|
{ "sources_json": json_array_push(r.sources_json, json_encode(src)),
|
|
"required_json": r.required_json }
|
|
}
|
|
|
|
fn resolver_require(r: SecretsResolver, key: String) -> SecretsResolver {
|
|
{ "sources_json": r.sources_json,
|
|
"required_json": json_array_push(r.required_json, "\"" + key + "\"") }
|
|
}
|
|
|
|
// Resolved bag. Keys -> Secret. Failure if any required key is missing.
|
|
type ResolvedSecrets {
|
|
map_json: String // JSON: key -> raw value
|
|
errors: String // JSON array of missing required keys
|
|
}
|
|
|
|
fn resolver_resolve(r: SecretsResolver) -> ResolvedSecrets {
|
|
let resolved: String = "{}"
|
|
let errors: String = "[]"
|
|
let n: Int = json_array_len(r.required_json)
|
|
let i: Int = 0
|
|
while i < n {
|
|
let key: String = json_array_get(r.required_json, i)
|
|
let value: String = lookup_chain(r.sources_json, key)
|
|
if str_eq(value, "") {
|
|
let errors = json_array_push(errors, "\"" + key + "\"")
|
|
}
|
|
if !str_eq(value, "") {
|
|
let resolved = json_set(resolved, key, "\"" + value + "\"")
|
|
}
|
|
let i = i + 1
|
|
}
|
|
{ "map_json": resolved, "errors": errors }
|
|
}
|
|
|
|
fn lookup_chain(sources_json: String, key: String) -> String {
|
|
let n: Int = json_array_len(sources_json)
|
|
let i: Int = 0
|
|
while i < n {
|
|
let src_json: String = json_array_get(sources_json, i)
|
|
let src: SecretsSource = { "kind": json_get(src_json, "kind"),
|
|
"config_json": json_get(src_json, "config_json") }
|
|
let v: String = source_get(src, key)
|
|
if !str_eq(v, "") { return v }
|
|
let i = i + 1
|
|
}
|
|
""
|
|
}
|
|
|
|
fn resolved_require(r: ResolvedSecrets, key: String) -> Secret {
|
|
let raw: String = json_get(r.map_json, key)
|
|
secret_new(raw)
|
|
}
|
|
|
|
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
|
|
|
let src: SecretsSource = source_in_memory()
|
|
let src = in_memory_insert(src, "jwt.key", "test-key-for-testing-only")
|
|
let resolver: SecretsResolver = resolver_source(resolver_new(), src)
|
|
let resolver = resolver_require(resolver, "jwt.key")
|
|
let resolved: ResolvedSecrets = resolver_resolve(resolver)
|
|
let jwt: Secret = resolved_require(resolved, "jwt.key")
|
|
println("[el-secrets] jwt.key = " + secret_display(jwt))
|