From e6f8866b1aa7395f16e47944935ad83071b14531 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 2 Aug 2026 09:41:38 -0500 Subject: [PATCH] =?UTF-8?q?feat(transparency):=20make=20the=20signing=20wi?= =?UTF-8?q?re=20operable=20=E2=80=94=20GT-588=20criterion=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire existed and was tested: a decorator over IAuditEntryRepository, a Merkle tree, COSE_Sign1, a JSONL ledger and a cross-language interop job in CI. And still nothing was signed in any environment, for a reason that was not in the code — the chart did not declare the switch. Transparency appeared in no values.yaml, no configmap and no deployment, so an operator holding real keys had no way to turn it on short of inventing a --set over a key that does not exist, which Helm accepts in silence and which enables nothing. What ships: - values.yaml declares the switch, OFF, with the ledger path, the issuer and transparency-service identities and the names of the secret keys. - the configmap emits the non-secret keys ONLY when signing is on, for the same reason Otlp does: a key present in the configmap suggests there is a signed record when there is not. - the deployment reads both Ed25519 seeds from an existing Secret. The chart never generates, defaults or versions key material. - the ledger gets its own volume, and asking for persistence without a claim FAILS the render rather than quietly mounting an emptyDir under the name of a persistent volume. A ledger erased on every restart is what the gap calls decorative. Five tests read the CHART rather than the code, on purpose: what was missing was the operating surface, and a test over the classes would have stayed green for as long as the gap was open. One of them asserts the default is still OFF — verified by flipping it to true and watching it go red. Another asserts the wire stays a DECORATOR: a list of explicit callers only signs the paths somebody remembered. The criterion is still NOT met and the row says so: no environment enables this yet, and doing so needs key custody, which is an owner decision rather than a code change. What changed is that it is now a deployment decision instead of being impossible. --- product/infra/helm/README.md | 41 +++++++ .../templates/configmap.yaml | 10 ++ .../templates/deployment.yaml | 28 +++++ .../helm/evolith-tracker-api/values.yaml | 28 +++++ .../TransparencyOperabilityTests.cs | 106 ++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs diff --git a/product/infra/helm/README.md b/product/infra/helm/README.md index 01aa747f..4f04c57c 100644 --- a/product/infra/helm/README.md +++ b/product/infra/helm/README.md @@ -50,6 +50,47 @@ for c in evolith-tracker-postgres evolith-tracker-api evolith-tracker-web; do done ``` +## Turning on the transparency ledger (GT-588) + +The Tracker can sign every governance decision it records — a COSE_Sign1 statement plus a Merkle +receipt, in the shape RFC 9943 (SCITT) defines — so an auditor can verify the record without +trusting the producer. **It ships OFF**, and turning it on is a deployment decision because it +changes what the product claims about its own audit trail and it requires real key material. + +The application **refuses to start** with signing enabled and no seeds. That is deliberate: falling +back to a development key would produce a ledger that looks signed and proves nothing, which is +worse than no ledger because it invites trust. + +Two Ed25519 seeds are required, and they must be **different**. RFC 9943 places receipt authority in +an entity separate from the issuer; with one key the receipt is a self-assertion and the governance +rule `AUD-TRANSP-04` rejects it. + +```bash +# 1. Generate two 32-byte seeds. Keep them in your secret store — never in git. +kubectl create secret generic tracker-transparency \ + --from-literal=Transparency__IssuerKeySeedBase64="$(openssl rand -base64 32)" \ + --from-literal=Transparency__TransparencyServiceKeySeedBase64="$(openssl rand -base64 32)" + +# 2. A PersistentVolumeClaim for the ledger. A ledger on the pod's ephemeral filesystem is erased +# on every restart, and a transparency log that deletes itself is exactly what the gap calls +# decorative — so the chart REFUSES to render without a claim. +kubectl apply -f your-ledger-pvc.yaml + +# 3. Enable it. +helm upgrade --install tracker-api product/infra/helm/evolith-tracker-api \ + --set transparency.enabled=true \ + --set transparency.existingSecretName=tracker-transparency \ + --set transparency.persistence.enabled=true \ + --set transparency.persistence.existingClaim=tracker-transparency-ledger +``` + +Verify with `evolith audit verify` against the ledger path. The governance rule `AUD-TRANSP-01` +fails a ledger that is missing or empty, so a signature that silently stopped being emitted shows +up at the next evaluation rather than never. + +**What the chart never does:** generate, default or version key material. Every seed comes from the +deployment's own secret store. + ## Notes - **Connection string.** The .NET config system cannot concatenate a password diff --git a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml index 90180c23..ffc95395 100644 --- a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml +++ b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml @@ -35,6 +35,16 @@ data: Authentication__Ums__Issuer: {{ .Values.auth.ums.issuer | quote }} Authentication__Ums__RequireHttpsMetadata: {{ .Values.auth.ums.requireHttpsMetadata | quote }} Cors__Origins__0: {{ .Values.cors.origins | quote }} + {{- if .Values.transparency.enabled }} + # GT-588 — se emite SOLO cuando la firma esta activa, por la misma razon que Otlp: una clave + # presente en el configmap sugiere que hay expediente firmado cuando no lo hay. + Transparency__Enabled: "true" + Transparency__LedgerPath: {{ .Values.transparency.ledgerPath | quote }} + Transparency__Issuer: {{ .Values.transparency.issuer | quote }} + Transparency__IssuerKeyId: {{ .Values.transparency.issuerKeyId | quote }} + Transparency__TransparencyServiceIssuer: {{ .Values.transparency.transparencyServiceIssuer | quote }} + Transparency__TransparencyServiceKeyId: {{ .Values.transparency.transparencyServiceKeyId | quote }} + {{- end }} {{- if .Values.otlp.endpoint }} # T-049 / GT-616 — solo se emite cuando hay colector. Emitirlo vacio seria equivalente a no # emitirlo (la app hace retorno temprano), pero dejaria una clave en el configmap que sugiere diff --git a/product/infra/helm/evolith-tracker-api/templates/deployment.yaml b/product/infra/helm/evolith-tracker-api/templates/deployment.yaml index 5152dd2a..6123169c 100644 --- a/product/infra/helm/evolith-tracker-api/templates/deployment.yaml +++ b/product/infra/helm/evolith-tracker-api/templates/deployment.yaml @@ -94,6 +94,22 @@ spec: name: {{ .Values.agentRuntime.existingSecretName }} key: {{ .Values.agentRuntime.apiKeyKey }} {{- end }} + {{- if and .Values.transparency.enabled .Values.transparency.existingSecretName }} + # GT-588 — las semillas Ed25519 llegan del deposito de secretos del despliegue, nunca + # del chart. Si `enabled` esta activo y falta el Secret, la aplicacion FALLA AL + # ARRANCAR: es preferible un pod que no levanta a uno que sirve un expediente sin + # firmar mientras el chart dice que lo firma. + - name: Transparency__IssuerKeySeedBase64 + valueFrom: + secretKeyRef: + name: {{ .Values.transparency.existingSecretName }} + key: {{ .Values.transparency.issuerKeySeedKey }} + - name: Transparency__TransparencyServiceKeySeedBase64 + valueFrom: + secretKeyRef: + name: {{ .Values.transparency.existingSecretName }} + key: {{ .Values.transparency.transparencyServiceKeySeedKey }} + {{- end }} - name: TMPDIR value: /tmp {{- with .Values.containerSecurityContext }} @@ -138,9 +154,21 @@ spec: successThreshold: {{ .Values.probes.startup.successThreshold }} {{- end }} volumeMounts: + {{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }} + - name: transparency-ledger + mountPath: {{ dir .Values.transparency.ledgerPath | quote }} + {{- end }} # readOnlyRootFilesystem is true; give .NET a writable temp dir. - name: tmp mountPath: /tmp volumes: + {{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }} + # GT-588 — el ledger de transparencia NO puede vivir en un emptyDir: se borraria en cada + # reinicio del pod, y un log de transparencia que se borra solo es exactamente lo que la + # ficha llama decorativo. Se exige un PVC que aporta el despliegue. + - name: transparency-ledger + persistentVolumeClaim: + claimName: {{ required "transparency.persistence.existingClaim es obligatorio cuando la persistencia del ledger esta activa" .Values.transparency.persistence.existingClaim }} + {{- end }} - name: tmp emptyDir: {} diff --git a/product/infra/helm/evolith-tracker-api/values.yaml b/product/infra/helm/evolith-tracker-api/values.yaml index bb1e0706..3d719531 100644 --- a/product/infra/helm/evolith-tracker-api/values.yaml +++ b/product/infra/helm/evolith-tracker-api/values.yaml @@ -253,3 +253,31 @@ networkPolicy: protocol: TCP - port: 5432 protocol: TCP + +# GT-588 — firma del expediente (RFC 9943 / SCITT). DESACTIVADA por defecto y a proposito: +# encenderla cambia lo que el producto promete sobre su propia auditoria, y exige material de +# clave real. La aplicacion se NIEGA a arrancar con `enabled: true` sin semillas, en vez de caer +# a una clave de desarrollo: un ledger que parece firmado y no prueba nada induce confianza y es +# peor que no tener ledger. +# +# Las semillas NO viven aqui. `existingSecretName` apunta a un Secret que crea el despliegue +# (kubectl/sealed-secrets/KMS); este chart nunca genera ni versiona claves. +transparency: + enabled: false + # Ruta del ledger JSONL. DEBE caer en un volumen persistente: un ledger en el filesystem + # efimero del pod desaparece en cada reinicio, y un log de transparencia que se borra solo es + # exactamente lo que la ficha llama decorativo. + ledgerPath: /var/lib/evolith/transparency/ledger.jsonl + # Volumen del ledger. Sin esto la ruta de arriba vive en el contenedor y se pierde. + persistence: + enabled: false + existingClaim: "" + issuer: evolith-tracker + issuerKeyId: tracker-issuer + # El Servicio de Transparencia es una entidad SEPARADA por RFC 9943. Compartir clave con el + # Issuer convierte el recibo en una autoafirmacion, y `AUD-TRANSP-04` lo rechaza. + transparencyServiceIssuer: evolith-tracker-transparency + transparencyServiceKeyId: tracker-ts + existingSecretName: "" + issuerKeySeedKey: Transparency__IssuerKeySeedBase64 + transparencyServiceKeySeedKey: Transparency__TransparencyServiceKeySeedBase64 diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs new file mode 100644 index 00000000..f4ae69d6 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs @@ -0,0 +1,106 @@ +namespace Tracker.Tests.Infrastructure.Transparency; + +/// +/// GT-588 — que la firma del expediente se pueda ENCENDER en un despliegue. +/// +/// El cable existía y estaba probado: un decorador de IAuditEntryRepository, un árbol +/// de Merkle, COSE_Sign1, un ledger JSONL y su interop cross-lenguaje en CI. Y sin embargo ninguna +/// decisión se firmaba en ningún entorno, por una razón que no estaba en el código: **el chart no +/// declaraba la palanca**. `Transparency` no aparecía en `values.yaml`, ni en el configmap, ni en +/// el deployment, así que un operador con claves en la mano no tenía por dónde activarla salvo +/// inventando un `--set` sobre una clave inexistente — que Helm acepta en silencio y no enciende +/// nada. +/// +/// Estas pruebas leen el chart, no el código. Es a propósito: lo que faltaba era la +/// superficie de operación, y una prueba sobre las clases habría seguido verde todo el tiempo que +/// el gap estuvo abierto. Es la misma lección que `ObservabilityConventionTests` dejó escrita para +/// las trazas — un valor que no está DECLARADO no se documenta ni se revisa. +/// +/// Ninguna de ellas enciende la firma. Que un entorno la active con claves reales es una +/// decisión de despliegue y de custodia de secretos, no algo que una prueba deba forzar. +/// +public class TransparencyOperabilityTests +{ + private static string Chart() => + Path.Combine(RepoRoot(), "product/infra/helm/evolith-tracker-api"); + + private static string Valores() => File.ReadAllText(Path.Combine(Chart(), "values.yaml")); + private static string ConfigMap() => File.ReadAllText(Path.Combine(Chart(), "templates/configmap.yaml")); + private static string Deployment() => File.ReadAllText(Path.Combine(Chart(), "templates/deployment.yaml")); + + [Fact] + public void ElChartDeclaraLaPalancaDeFirma() + { + Valores().Should().Contain("transparency:", + "un `--set transparency.enabled=true` sobre una clave que no existe en values.yaml no " + + "falla, no se documenta y no se revisa: simplemente no enciende nada"); + ConfigMap().Should().Contain("Transparency__Enabled", + "sin esta clave el configmap no puede activar la firma ni aunque el operador tenga claves"); + } + + [Fact] + public void SigueApagadaPorDefecto() + { + // La guarda existe para que encenderla sea POSIBLE, no para encenderla. Activar la firma + // cambia lo que el producto promete sobre su propio expediente y exige claves reales. + Valores().Should().MatchRegex(@"transparency:\s*\r?\n(\s+#[^\r\n]*\r?\n)*\s+enabled:\s*false", + "el defecto sigue siendo APAGADO"); + } + + [Fact] + public void LasSemillasVienenDeUnSecretYNuncaDelChart() + { + var deployment = Deployment(); + + deployment.Should().Contain("Transparency__IssuerKeySeedBase64", + "sin esta variable el pod arranca sin material de clave y la aplicacion se niega a " + + "firmar — correctamente, pero entonces el chart promete algo que no ocurre"); + deployment.Should().Contain("secretKeyRef", + "las semillas Ed25519 llegan del deposito de secretos del despliegue"); + + // Lo que NO debe estar. Una semilla por defecto convertiria el ledger en algo que parece + // firmado y no prueba nada, que es peor que no tener ledger: induce confianza. + Valores().Should().NotMatchRegex(@"KeySeed\w*:\s*[""']?[A-Za-z0-9+/]{16,}", + "el chart NUNCA versiona material de clave"); + } + + [Fact] + public void ElLedgerExigeVolumenPersistente() + { + var deployment = Deployment(); + + deployment.Should().Contain("transparency-ledger", + "el ledger necesita su propio volumen"); + deployment.Should().Contain("persistentVolumeClaim", + "un ledger en el filesystem efimero del pod se borra en cada reinicio, y un log de " + + "transparencia que se borra solo es exactamente lo que la ficha llama decorativo"); + deployment.Should().Contain("required \"transparency.persistence.existingClaim", + "pedir persistencia sin PVC debe FALLAR el render, no montar silenciosamente un " + + "emptyDir con nombre de volumen persistente"); + } + + [Fact] + public void ElCableSigueSiendoUnDecoradorYNoUnCambioEnCadaLlamante() + { + // Si alguien sustituye el decorador por llamadas explicitas, el quinto camino que se + // añada mañana no firmara y nadie lo notara. Esta prueba es sobre la FORMA del cable. + var registro = File.ReadAllText(Path.Combine( + RepoRoot(), + "src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs")); + + registro.Should().Contain("Decorate", + "el cable firma los caminos que YA existen y el que alguien añada sin acordarse de " + + "esta ficha; una lista de llamantes explicitos solo firma los que alguien recordo"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { + dir = dir.Parent; + } + dir.Should().NotBeNull("la prueba necesita ubicar la raiz del repositorio"); + return dir!.FullName; + } +}