Skip to content

AUTOBOT_SECRETS_ROOT_KEY is generated by nothing — the canonical secret store is dark on every provisioned deployment #14758

Description

@mrveiss

Summary

AUTOBOT_SECRETS_ROOT_KEY is mandatory for the canonical envelope secret store,
and nothing generates or writes it. On any provisioned deployment the store
is therefore unreachable — and every consumer degrades silently.

Found while scoping #12684. Neither #12684 nor #12681 reports it, and it
invalidates the premise of both.

Evidence

The key is required:

autobot_shared/secrets_envelope.py:49   ROOT_KEY_ENV = "AUTOBOT_SECRETS_ROOT_KEY"
autobot_shared/secrets_envelope.py:126  loads the 32-byte root key; RuntimeError when unset

Nothing provisions it. Searching the whole tree, the only occurrences outside
.py/.md are generated artifacts:

autobot-frontend/src/types/generated/api.ts
autobot-slm-frontend/openapi.json
autobot-slm-frontend/src/types/generated/api.ts

It is absent from roles/backend/templates/backend.env.j2 (which does provision
SECRET_KEY, AUTOBOT_JWT_SECRET, AUTOBOT_INTERNAL_API_KEY and others),
from install.sh, from docker/generate-secrets.sh, and from every playbook.

The consequence is a clean 503:

# autobot-backend/api/envelope_secrets.py
except RuntimeError as exc:
    raise HTTPException(503, "envelope secrets store not configured") from exc

Why it is invisible

Every consumer swallows it:

  • SLM vault_client calls fail, so system_secrets_vault.retrieve_secret
    degrades to legacy-only and warns only for non-404 errors.
  • initialization/lifespan.py hydration catches broadly and logs at debug.
  • retrieve_secret returns None on VaultClientError.

So the canonical store being entirely absent looks identical to "that secret
isn't set". LLC_SECRET_MASTER_KEY is provisioned nowhere either, so LLC
company secrets are in the same state.

Why this is P0 rather than one item among many

It inverts the plan in #12684. That issue asks the SLM to adopt the
canonical store. Consolidating onto it today would migrate secrets into a store
that returns 503 — the migration would appear to succeed and the secrets would
be unreadable.

It is the only part of #12684 that #12681 needs. Getting a provider
credential to an adapter subprocess needs a working vault; it does not need the
SLM store migration or the UI unification.

The specific danger to get right

Generate once and persist. A task that regenerates on every deploy silently
destroys every secret already written — DecryptionError is raised deep and
swallowed at both lifespan.py and retrieve_secret. Nothing would surface it
until an operator noticed a credential had stopped working.

Escrow before first write. Root-key loss is unrecoverable for everything in
the envelope store. It must be in the backup set before anything is written to
that store, not after.

Acceptance criteria

  • Ansible generates 32 random bytes (url-safe base64) once and persists
    idempotently — running the role twice leaves the value byte-identical,
    asserted by a test rather than claimed
  • Written with the same ownership/mode discipline as the other backend
    secrets, and included in the /etc/autobot backup archive
  • docs/runbooks/EMERGENCY_RECOVERY.md records that loss is unrecoverable
  • Host evidence: on a fresh provision, GET /api/v2/secrets/system returns
    something other than 503
  • Absence is loud — an unconfigured store is reported distinctly from
    "secret not found", rather than debug-logged (may fold into the
    observability work in Umbrella: silent failures and telemetry gaps — 4 incidents where systemctl said active and the service was not working #13852)

Activity

  1. mrveiss commented on Aug 22, 2026

    @mrveiss
    OwnerAuthor

    Fixed — verified on base

    Merged via #14784 with 10/10 required checks and 12/12 python-suite shards
    green. Verified against origin/Dev_new_gui, not the PR branch:

    Surface AUTOBOT_SECRETS_ROOT_KEY
    docker/secrets-init.sh present (generates)
    docker/with-secrets.sh present (exports)
    docker/generate-secrets.sh present (pinned override)
    slm-secrets.env.j2 present
    backend.env.j2 present

    Nested-brace occurrences on base: 0. Guard test present.

    What was wrong

    The key is mandatory — load_root_key raises when it is unset — and nothing
    generated or wrote it, so the canonical envelope store was unreachable on every
    provisioned deployment. What made it invisible rather than loud is that every
    consumer swallows the failure: vault_client degrades to legacy-only, lifespan
    hydration logs at debug, retrieve_secret returns None. An entirely absent
    secret store looked exactly like "that secret does not exist".

    Two constraints ruled out copying the sibling secrets, both verified before writing
    anything:

    • The value is base64-decoded and must be exactly 32 bytes. openssl rand -hex 32 is 64 characters and decodes to 48 — rejected. 50,000 samples of the
      chosen scheme decoded to exactly 32 bytes, with the hex form as a control that
      must fail.
    • It derives every vault KEK, so regenerating it makes every already-sealed secret
      undecryptable. secrets-init.sh previously exited early once both signing
      secrets existed, so an existing deployment could never have gained the root
      key. It is now per-key, tested across fresh / re-run / upgrade:
    (a) fresh            -> all three generated
    (b) re-run           -> all "already present — reusing"; file byte-identical
    (c) existing install -> JWT preserved: YES;  root key added: 1
    

    The bug my own verification could not have caught

    A security review found a nested {{ }} inside an open {{ }} in the fresh-install
    set_fact — a TemplateSyntaxError in the same multi-key task that produces
    slm_secret_key, slm_encryption_key, slm_admin_password, slm_auth_token and
    chromadb_auth_token. It would have aborted every greenfield slm_manager
    provision
    , a regression of working functionality rather than a failure to add the
    new key. Upgrades were unaffected — they take the lineinfile back-fill.

    I had validated those files as YAML, and they were valid YAML. YAML validity
    says nothing about Jinja validity, so my check was structurally incapable of
    catching it. The guard now compiles the expressions with Ansible's filters stubbed,
    and it is not vacuous:

    BROKEN (as shipped): TemplateSyntaxError -> expected token ':', got '}'
    FIXED:               compiles
    expressions checked: 7   (asserts >= 3)
    

    Not covered

    The variable is still not registered in env_registry.py. The registry checker
    scans .py files only and cannot see the indirect os.environ.get(ROOT_KEY_ENV)
    read, which is why base was green without it. Registering it requires regenerating
    the env docs; that belongs in its own change rather than inside a security fix.

    The entropy inconsistency noted in review also stands: openssl rand -base64 32
    gives 256 bits while the ansible and busybox paths give ~191. Not a weakness at that
    margin, recorded rather than silently accepted.

    This does not consolidate the secret stores — that is #12684's architecture
    decision. It makes the canonical store reachable, which that decision depends on
    either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions