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; + } +}