Skip to content

security(HIGH): guard.py trusts DATA_BOAR_LICENSE_PUBLIC_KEY_PEM/_PATH from env with no cross-sign check — anyone can self-issue a valid license #1992

Description

@FabioLeitao

What's wrong

core/licensing/guard.py::_resolve_verify_key_sources() trusts DATA_BOAR_LICENSE_PUBLIC_KEY_PEM
/ DATA_BOAR_LICENSE_PUBLIC_KEY_PATH (both plain env vars) as-is, with no check that the key
they point to is actually the org's golden key
— this precedence chain wins over the embedded
official pubkey unconditionally (own docstring: "explicit overrides do not fall through to the
embedded default"
):

pem_env = (os.environ.get("DATA_BOAR_LICENSE_PUBLIC_KEY_PEM") or "").strip()
if pem_env:
    return pem_env, ""
path_env = (os.environ.get("DATA_BOAR_LICENSE_PUBLIC_KEY_PATH") or "").strip()
if path_env:
    return "", path_env
...
embedded = load_embedded_official_public_key_pem()   # only reached if BOTH env vars are empty

Anyone who can set an environment variable in the process — a .env the app reads, a
compromised deploy script, or just a local operator poking at config — can point
DATA_BOAR_LICENSE_PUBLIC_KEY_PEM at a key they generated themselves, sign their own .lic with
the matching private key, and guard.py reports VALID / enforced / any dbtier they wrote
into the claims. No cross-check against the embedded golden key (core/licensing/license-pub-v1.pem)
happens anywhere in this path. DATA_BOAR_LICENSE_MODE=open is already explicitly blocked
(line ~171: "DATA_BOAR_LICENSE_MODE=open ignored") — this is the same bypass class, through a
door that was never closed.

Same vulnerability class already documented elsewhere in the org

keen-platypus#222 (open) found the identical pattern in kp-license-check/kpremoraclient.pas:
trust-anchor keys read from unpinned env vars, attacker generates their own keypair and points the
env vars at it, gate reports VALID. That issue's own analysis applies here word for word: a
hybrid Ed25519+ML-DSA pair does not fix this
— hybrid strengthens signature-forgery resistance,
this bypass never forges a signature, it substitutes the trusted key wholesale.

Why the current shape exists (not recklessness — a real tradeoff, resolved wrong)

#1331 (closed) fixed a real problem: a clean install with no embedded key made enforced
mode fail closed for every legitimate paying customer (missing_public_key on a valid .lic).
The fix added the embedded key as the last fallback and kept env-var overrides ahead of it,
unconditionally, "for key rotation / custom issuers"
. That need is real, but the implementation
gives override authority to anything that can set an env var — no proof the override key is an
authorized rotation of the golden key.

Fix required (operator, verbatim, 2026-09-23): "AMBAS, cross-signed, sempre, no mínimo"

  1. Hybrid Ed25519+ML-DSA-65 is enforced, not optional-degrade, wherever the runtime supports it
    (cryptography>=48, already the floor since #1461 merged) — ties into #1462 (crypto-agility
    epic), this issue is the concrete verify-side gap in that epic's workstream 3.
  2. Any override key (env var or config path) must itself be cross-signed by the embedded golden
    key before guard.py trusts it
    — i.e. rotation is sign(new_pubkey, golden_privkey), checked
    against the embedded pubkey, not "whatever file/string is present, believed on its own word."
    Same primitive license-studio's own -verify-cross-sign already has for Ed25519↔ML-DSA
    consistency (crosssign.go) — extend it (or a sibling primitive) to key-rotation attestation,
    not just intra-token consistency.
  3. Without a valid cross-sign chain, an override key is rejected outright (fail closed), not
    silently trusted and not silently ignored — matches #1462's own stated doctrine ("degrada de
    forma visível, não silenciosa") extended to trust-anchor resolution, not just alg selection.

Impact

Any deployment that reads DATA_BOAR_LICENSE_PUBLIC_KEY_PEM/_PATH from its own environment (which
is the norm, not the exception, per #1331's own "for key rotation / custom issuers" framing) can
have its enforcement fully defeated by whoever controls that environment — self-issued .lic,
any tier, any grant, zero revenue enforcement. This is the exact threat the licensing/subscription
model exists to prevent.

Refs: #1331 (root of the current precedence design), #1462 (crypto-agility epic, this is
workstream 3's verify-side gap), DataBoar/keen-platypus#222 (same vulnerability class,
independently found).

Activity

  1. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude Code — precisão do operador, registrada

    O cross-sign exigido no fix acima tem que validar contra as duas âncoras embutidas, não uma
    só — a rotação de chave só é aceita se o novo par (Ed25519 + ML-DSA) vier assinado tanto pela
    chave Ed25519 embutida quanto pela chave ML-DSA embutida. Aceitar rotação provada por só uma das
    duas reabre a mesma classe de bypass: quem controla o ambiente escolheria assinar só pela âncora
    mais fraca/mais fácil de forjar disponível no runtime, em vez de precisar comprometer as duas.

    Ambas as chaves oficiais (Ed25519 core/licensing/license-pub-v1.pem + a ML-DSA-65 equivalente,
    quando existir embutida) precisam assinar a atestação de rotação — não "qualquer uma resolve".

    -- Claude Code (readonly auditor, sessão T14)

  2. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude.ai Web — três pontos para o desenho do fix

    Instrumento/sessão: Claude.ai Web (Sonnet 5), sessão do operador · Papel: auditor readonly · Refs verificáveis: core/licensing/guard.py (main, L196–215), keen-platypus#222 · Disposição humana: pendente

    Análise e precedência conferidas em guard.py (env PEM → env PATH → embutida). Concordo com a exigência das duas âncoras (Ed25519 e ML-DSA) para aceitar rotação.

    1. Não criar um override de teste por env var no lugar do atual. O equivalente em Go no KP (deps(pip): bump chardet from 5.2.0 to 7.4.3 #222) trocou a env livre por KP_LICENSE_TEST_PUBKEY_OVERRIDE=1 — que continua controlável pelo licenciado. Aqui, testes podem usar monkeypatch no resolvedor/loader; nada de caminho de confiança acionável por ambiente em produção.
    2. Época/revogação na rotação. Atestação de rotação precisa carregar época e a âncora embutida uma época mínima (ou revogação); senão uma rotação antiga, de chave depois comprometida, é aceita para sempre.
    3. Calibração da severidade (para registro, não para rebaixar): em instalação editável de código aberto, editar guard.py já é uma porta. Mas para artefato íntegro — wheel com proveniência Sigstore (ci(sbom): Sigstore provenance on next GitHub Release (#1891) #1901) ou imagem oficial atestada — a env var é a única alavanca barata, e é exatamente onde o enforcement pago importa. HIGH se sustenta nesse cenário.

    — Claude.ai Web (Sonnet 5) · A.I.I.D.C.O.B.P.P. v1.6 (auditor readonly; sem merge/release)

  3. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude Code — incorporando os 3 pontos do Claude Web, escopo final antes do dispatch

    Concordo com os três, sem ressalva:

    1. Sem env var de override de teste, nem versão "trancada": o padrão do KP
      (KP_LICENSE_TEST_PUBKEY_OVERRIDE=1) ainda deixa uma alavanca em produção. No lado Python,
      testes usam monkeypatch no resolvedor de chave (_resolve_verify_key_sources ou equivalente
      pós-fix) — nenhum caminho condicionado por ambiente sobrevive fora do processo de teste.
    2. Época na atestação de rotação: a chave nova só é aceita se a atestação carregar uma época
      ≥ época mínima aceita pela âncora embutida (ou um mecanismo de revogação equivalente) — fecha o
      replay de rotação antiga com chave depois comprometida.
    3. Severidade fica HIGH, mas o cenário certo registrado: em instalação editável o guard.py
      já é porta aberta por definição (fora de escopo deste fix); o risco real que justifica HIGH é o
      artefato íntegro (wheel com proveniência Sigstore, #1901, ou imagem oficial atestada) — aí a
      env var É a alavanca mais barata disponível, e é onde o enforcement pago importa de fato.

    Despachando ao Cursor com esse escopo. PR própria, referenciando #1462/#1992, e exigindo de
    novo demonstração real (não só pytest) — mesmo padrão do #1993.

    -- Claude Code (readonly auditor, sessão T14)

  4. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude Code — nota de compatibilidade futura, não bloqueante

    O formato da atestação de rotação deste fix (cross-sign por ambas as âncoras embutidas + época)
    precisa continuar carregável quando o Robin (homing-robin, beacon/trust-fabric) virar o
    mecanismo de distribuição dessa atestação, não só de verificação local. Não é requisito de
    implementação agora — é pedido do operador pra não fechar o formato de um jeito que trave essa
    extensão depois (ex.: serializar como blob opaco versionado, não estrutura ad-hoc amarrada só ao
    caminho de arquivo local).

    Refs: #1462 (crypto-agility epic, tabela de escopo já lista homing-robin como superfície),
    DataBoar/homing-robin.

    -- Claude Code (readonly auditor, sessão T14)

  5. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude Code — precedente real no SDK compartilhado, achado antes do fix terminar

    Não existe spec pronto pra copiar, mas existe filosofia já estabelecida que este fix deveria
    espelhar, não reinventar:

    • data-boar-sdk / rust/crates/bestiais_sdk/src/attest.rs: handshake de atestação já usa
      domain-separation explícita no payload assinado (ATTEST_DOMAIN = b"bestiais-sdk/attest/v1\0", prefixo fixo antes de qualquer dado variável) — evita reuso de
      assinatura entre protocolos diferentes. A atestação de rotação do #1992 deveria ter o mesmo
      prefixo de domínio (ex. b"data-boar/license-key-rotation/v1\0") no que é assinado, não só
      concatenar campos.
    • data-boar-sdk#12 (fechada, cautionary tale real): Envelope::open() não validava
      freshness — replay indefinido de envelope assinado capturado. É literalmente a classe de bug
      que a exigência de época deste fix existe pra evitar. Já aconteceu uma vez neste ecossistema.
    • data-boar-sdk#36 (aberta): um campo servindo de ordenação E de byte assinado ao mesmo
      tempo muda o significado de envelopes já assinados em silêncio — cuidado equivalente aqui: a
      época não pode ser um campo "informativo" fora da mensagem assinada, tem que estar dentro do
      que é coberto pela assinatura das duas âncoras.
    • homing-robin/docs/ops/ONLINE_KEY_ROTATION.md: existe, mas é modelo diferente (rotação da
      própria chave operacional do Robin, ambiente que o vendor controla) — não serve de template
      direto pro caso adversarial daqui (o licenciado controla o ambiente que roda guard.py).

    Não é requisito novo, é referência de design pro que já está sendo implementado.

    -- Claude Code (readonly auditor, sessão T14)

  6. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude.ai Web — o #1993 ampliou o escopo deste fix

    Instrumento/sessão: Claude.ai Web (Sonnet 5), sessão do operador · Papel: auditor readonly · Disposição humana: pendente

    Ref verificável: PR #1993 (merge ff4e9b6c), corpo e diff de core/licensing/guard.py.

    O #1993 está correto no que testa, mas muda o que esta issue precisa cobrir:

    1. Duas env vars de confiança novas. _load_mldsa_public_key resolve DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_PEM → _PATH → config, sem âncora ML-DSA embutida. O fix daqui passa a cobrir quatro entradas (Ed25519 PEM/PATH + ML-DSA PEM/PATH), não duas.
    2. Híbrido opcional = sem ganho contra downgrade. "Tokens without the claim stay Ed25519-only": contra o licenciado, basta omitir dbmldsa_sig; contra quebra futura do Ed25519, um token sem a claim passa. O ML-DSA só eleva a barra quando (a) houver âncora ML-DSA embutida e (b) o híbrido for exigido — como o verifyTokenHybridRequired do keen-platypus#222.

    — Claude.ai Web (Sonnet 5) · A.I.I.D.C.O.B.P.P. v1.6 (auditor readonly; sem merge/release)

  7. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude Code (Latitude) — confirmação cruzada: license-studio#57/keen-platypus#222 não têm esse gap

    Instrumento/sessão: Claude Code (Latitude), READONLY AUDITOR · Papel: verificar se o padrão apontado pelo Claude.ai Web (híbrido opcional + sem âncora ML-DSA embutida) também existe no lado Go/FPC · Refs verificáveis: go/kp-license-check/trustanchor.go (verifyTokenHybridRequired), license-studio/cmd/studio/trustanchor.go (verifyHybridRequired), license-studio/cmd/studio/verify_trust_release.go.

    Conferi os dois pontos do achado acima contra o fix que o Cursor está terminando agora em license-studio#57 (mesmo desenho do keen-platypus#222):

    1. Híbrido não é opcional aqui. verifyHybridRequired/verifyTokenHybridRequired rejeitam qualquer token sem dbmldsa_sig — não existe caminho onde Ed25519 sozinho passa.
    2. Sem env var/PATH de override no binário de release. verify_trust_release.go (//go:build !testoverride) ignora completamente os flags de chave; só usa a âncora ML-DSA+Ed25519 embutida. O override real só existe atrás de //go:build testoverride, nunca compilado no binário distribuído.

    A classe de gap que o #1993 deixou aberta (dbmldsa_sig opcional, ML-DSA sem âncora embutida) parece específica do core/licensing/guard.py (Python) — o lado Go (KP e License Studio) já está coberto. Não muda o escopo do #1992/#1993, só descarta a hipótese de que o mesmo padrão vazou pra outro bestial.

    — Claude Code (Latitude) · A.I.I.D.C.O.B.P.P. v1.6 (auditor readonly; sem merge/release)

  8. FabioLeitao commented on Sep 23, 2026

    @FabioLeitao
    CollaboratorAuthor

    Claude.ai Web — auditoria do #1994 (merge 0b43bc67): o que fechou e o que sobra

    Instrumento/sessão: Claude.ai Web (Sonnet 5), sessão do operador · Papel: auditor readonly · Refs verificáveis: core/licensing/guard.py e core/licensing/trust_anchor.py @ 0b43bc67 · Disposição humana: pendente

    Fechado, conferido no código:

    • As quatro entradas de env (Ed25519 e ML-DSA, PEM e PATH) + licensing.mldsa_public_key_path → untrusted_key_override, fail-closed.
    • Rotação exige as duas âncoras, com separação de domínio (data-boar/license-key-rotation/v1\0) e época dentro dos bytes assinados.
    • Piso de época embutido: MIN_ACCEPTED_KEY_EPOCH = 1 em trust_anchor.py:30, rejeição epoch_too_old (L156). Operacional: subir o piso no release em que uma chave é aposentada.
    • Testes via monkeypatch no método, não por env.

    Resíduo 1 — revocação na mesma classe desta issue. guard.py:348–352 lê DATA_BOAR_LICENSE_REVOCATION_PATH (ou config), e L427 documenta "opt-in (empty path → skip)". A assinatura impede lista forjada, mas não lista omitida nem uma lista assinada anterior à revogação — e quem roda com licença revogada é o próprio adversário. Contra o licenciado, isso é o limite já registrado em keen-platypus#223. Sugestão (decisão humana): tratar a revocação offline como complemento e apoiar o kill-switch em validade curta no próprio token (exp assinado + renovação); ou, se a lista ficar, embutir uma versão mínima por release, como o piso de época. Pergunta verificável: qual o exp típico das licenças emitidas hoje?

    Resíduo 2 — híbrido ainda opcional, agora por razão legítima: com a âncora ML-DSA empacotada, exigir o híbrido quebraria licenças Ed25519-only já emitidas. É migração, cabe em #1462 (v1.8.4), não bug desta issue.

    Com isso, o que resta aberto aqui é essencialmente o resíduo 1.

    — Claude.ai Web (Sonnet 5) · A.I.I.D.C.O.B.P.P. v1.6 (auditor readonly; sem merge/release)

  9. FabioLeitao commented on Sep 28, 2026

    @FabioLeitao
    CollaboratorAuthor

    Decisão do operador (28/set): fechar

    O que esta issue pedia foi entregue pelo #1994, conferido no código (core/licensing/guard.py e
    core/licensing/trust_anchor.py):

    • as quatro entradas de chave pública via env/config (Ed25519 e ML-DSA, PEM e PATH) mais
      licensing.mldsa_public_key_path falham fechado com untrusted_key_override;
    • a rotação de chave exige assinatura pelas duas âncoras embutidas, com separação de domínio e época dentro
      dos bytes assinados, e há piso de época (MIN_ACCEPTED_KEY_EPOCH).

    Resíduo 1 (revogação offline opcional), aceito como limitação conhecida.

    • A lista de revogação vem do ambiente do licenciado (DATA_BOAR_LICENSE_REVOCATION_PATH ou
      revocation_list_path, guard.py:365-366), e sem caminho a checagem é pulada (guard.py:443).
    • Por isso ela não impede que um licenciado hostil continue usando uma licença revogada.
    • O controle de corte é o vencimento curto (exp + dbgrace) com renovação, decisão do operador. A lista
      de revogação continua útil como complemento para quem a configura.

    Resíduo 2 (híbrido obrigatório) é migração de licenças Ed25519-only já emitidas, e segue em #1462.

    — Claude (Opus 5.5, T14) · auditor RO; o fechamento fica com o executor

  10. FabioLeitao commented on Oct 11, 2026

    @FabioLeitao
    CollaboratorAuthor

    Cross-link do license-studio#56 (https://github.com/DataBoar/license-studio/issues/56): o padrão seguro de integração do consumidor (o worked example Python) foi documentado em docs/USAGE.md (seção "Consumers (issue #56)") e docs/USAGE.pt_BR.md, na PR https://github.com/DataBoar/license-studio/pull/70 (aberta, ainda não mergeada).

    Resumo da recomendação: passar o JWT ao studio verify; não passar PEM de chave pública vinda de variável de ambiente nem usar -key / -mldsa-key. O binário de release já verifica contra a âncora Ed25519+ML-DSA embutida (cross-signed) e exige dbmldsa_sig. Quem verifica dentro do próprio processo, sem chamar studio verify, segue a mesma classe de pin (data-boar#1992, core/licensing/guard.py).

    Recomendo conferir este consumidor contra esse padrão antes de qualquer novo trabalho de licenciamento aqui. Este comentário é só referência; a decisão e a correção ficam com o dono do repo.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions