diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt new file mode 100644 index 000000000..d14a79121 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt @@ -0,0 +1,236 @@ +package dev.tramai.build.quality + +/** + * Certificate consumption: M40-M43 (0.7.1g1P2). + * + * These rules produce a **positive, attributed fact** — [CertificateConsumption.Valid] — and the only + * way to obtain one is through [verifyCertificateConsumption], which derives every predicate from real + * authority inputs: the base certificate ledger, the cited admission, the exact base authorization + * set and the verifier's own fresh measurement. + * + * That matters because lifecycle rules must not be able to act on a claim: + * + * - M44 and M47 take `Set`, never raw digests, so a caller cannot + * state "trust me, this digest was consumed". The attribution is carried by the value. + * - M43 provenance is derived here by looking the certificate up in the base ledger. There is no + * `fromBase` flag for a caller to assert, so the Boolean cannot drift from the truth. + * - The Valid constructor is `private` (with `@ConsistentCopyVisibility` on the class), so no code + * outside the verification itself can construct one - not even other files in this module, which + * is where the Gradle call sites live. The proof and the fact share a scope by construction: there + * is no path to a Valid that skips M43-M42. This is deliberately not a capability system: the goal + * is that no ordinary call site can accidentally manufacture consumption authority. + * + * Consumption is bounded, not a licence. A certificate names one exact source digest, one exact + * target digest and one exact admission set, and it may only be consumed by a transition whose fresh + * measurement actually projects to that target: + * + * ``` + * M43 the certificate is present in the base with the payload it was minted with (the consuming + * candidate cannot invent the semantic upgrade it consumes - the M31 analogue) + * M40 toDigest == the verifier's fresh authority projection (never candidate-supplied: T7) + * M41 fromDigest == the cited admission's own populationDigest (the historical authority) + * M42 admissionSetDigest == the exact base authorization set (bounded: no different, later set) + * ``` + * + * Every predicate is fail-closed: a missing or mismatched value yields [CertificateConsumption.Invalid], + * never a pass by omission. Each predicate is its own function so the refusal order is readable, and + * the whole consumption is one chain: the first predicate that refuses decides, and only a + * certificate satisfying all of them yields `Valid`. + */ +sealed interface CertificateConsumption { + /** + * The proven fact: this certificate validly authorises this migration in this transition. + * + * The constructor is **private**, so no code outside this class - including other files in the + * same module, which is where the Gradle call sites live - can construct one. The only production + * path is [Valid.verify], which is the verification itself: it derives M43 provenance from the + * base ledger and runs M40-M42 before the fact exists. `@ConsistentCopyVisibility` keeps the + * generated `copy()` private too, so it is not an escape hatch either. + */ + @ConsistentCopyVisibility + data class Valid private constructor( + val fromDigest: String, + val toDigest: String, + val admissionSetDigest: String, + ) : CertificateConsumption { + companion object { + /** + * M43 + M40 + M41 + M42, derived from base authority. The only way a [Valid] comes into + * existence. + * + * @param certificate the certificate being cited + * @param baseCertificates the certificate ledger as it exists in the authority base. M43 + * is derived from it, not asserted: a certificate that is absent, or whose enforced + * payload differs from the base copy, cannot be consumed. + * @param citedAdmissionPopulationDigest the `populationDigest` of the admission being + * consumed (M41). + * @param baseAdmissionIdentities the exact identities authorised in the base (M42). + * @param freshAuthorityProjectionHash the authority-v2 projection the verifier computed + * from its own fresh measurement (M40). Callers must pass measured data, never + * candidate data. + */ + internal fun verify( + certificate: MutationAuthorityDigestCertificate, + baseCertificates: MutationAuthorityDigestCertificates, + citedAdmissionPopulationDigest: String, + baseAdmissionIdentities: List, + freshAuthorityProjectionHash: String, + ): CertificateConsumption { + val inBase = baseCertificates.byFromDigest()[certificate.fromDigest] + val refusal = + provenance(certificate, inBase) + ?: targetDigest(certificate, freshAuthorityProjectionHash) + ?: algorithmPair(certificate) + ?: sourceDigest(certificate, citedAdmissionPopulationDigest) + ?: admissionSet(certificate, baseAdmissionIdentities) + return refusal?.let { CertificateConsumption.Invalid(it) } + ?: Valid( + fromDigest = certificate.fromDigest, + toDigest = certificate.toDigest, + admissionSetDigest = certificate.admissionSetDigest, + ) + } + } + } + + /** The refusal, carrying the named rule that rejected it. */ + data class Invalid( + val diagnostic: VerificationDiagnostic, + ) : CertificateConsumption +} + +/** + * The consumption entry point for call sites: delegates to [CertificateConsumption.Valid.verify], so + * there is exactly one implementation of the proof and no second route to a fact. + */ +fun verifyCertificateConsumption( + certificate: MutationAuthorityDigestCertificate, + baseCertificates: MutationAuthorityDigestCertificates, + citedAdmissionPopulationDigest: String, + baseAdmissionIdentities: List, + freshAuthorityProjectionHash: String, +): CertificateConsumption = + CertificateConsumption.Valid.verify( + certificate = certificate, + baseCertificates = baseCertificates, + citedAdmissionPopulationDigest = citedAdmissionPopulationDigest, + baseAdmissionIdentities = baseAdmissionIdentities, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + +/** + * M43, derived from the base ledger rather than asserted by the caller. Two distinct failures: the + * certificate is not in the base at all (the transition is minting the authority it consumes), or it + * is present but its enforced payload differs (the consumption cites something other than the + * certificate that was minted). + */ +private fun provenance( + certificate: MutationAuthorityDigestCertificate, + inBase: MutationAuthorityDigestCertificate?, +): VerificationDiagnostic? = + when { + inBase == null -> { + failure( + certificate, + "M43: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} is not present in the authority base, so this " + + "transition would consume migration authority it introduces itself. Migration " + + "authority must already exist in the base.", + ) + } + + inBase.enforcedPayload() != certificate.enforcedPayload() -> { + failure( + certificate, + "M43: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} does not match the certificate present in the " + + "authority base. A consumption must cite the base certificate as it was minted.", + ) + } + + else -> { + null + } + } + +private fun targetDigest( + certificate: MutationAuthorityDigestCertificate, + freshAuthorityProjectionHash: String, +): VerificationDiagnostic? = + if (certificate.toDigest == freshAuthorityProjectionHash) { + null + } else { + failure( + certificate, + "M40: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} certifies target digest " + + "${short(certificate.toDigest)}, but this transition's fresh measurement projects to " + + "${short(freshAuthorityProjectionHash)}. A certificate authorises one exact " + + "migration, not any migration.", + ) + } + +private fun algorithmPair(certificate: MutationAuthorityDigestCertificate): VerificationDiagnostic? = + if (certificate.fromAlgorithm == MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1 && + certificate.toAlgorithm == MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2 + ) { + null + } else { + failure( + certificate, + "M41: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} declares the migration " + + "'${certificate.fromAlgorithm}' -> '${certificate.toAlgorithm}', which is not the " + + "'${MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1}' -> " + + "'${MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2}' authority upgrade " + + "this consumption requires.", + ) + } + +private fun sourceDigest( + certificate: MutationAuthorityDigestCertificate, + citedAdmissionPopulationDigest: String, +): VerificationDiagnostic? = + if (certificate.fromDigest == citedAdmissionPopulationDigest) { + null + } else { + failure( + certificate, + "M41: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} does not match the cited admission's historical " + + "population digest ${short(citedAdmissionPopulationDigest)}. A certificate " + + "translates the authority the admission was actually minted under, not a different " + + "one.", + ) + } + +private fun admissionSet( + certificate: MutationAuthorityDigestCertificate, + baseAdmissionIdentities: List, +): VerificationDiagnostic? { + val bound = MutationAuthorityDigestCertificates.admissionSetDigest(baseAdmissionIdentities) + if (certificate.admissionSetDigest == bound) return null + return failure( + certificate, + "M42: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} binds admission set " + + "${short(certificate.admissionSetDigest)}, which is not the exact set of " + + "${baseAdmissionIdentities.size} base authorizations (${short(bound)}). The certificate " + + "is bounded: it cannot cover a different or later admission set.", + ) +} + +private fun failure( + certificate: MutationAuthorityDigestCertificate, + message: String, +): VerificationDiagnostic = + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, + message, + findingId = certificate.fromDigest, + ) + +private fun short(digest: String): String = digest.take(SHORT_HASH_LENGTH) + +/** Prefix length used to identify a digest in diagnostics; the full value stays in findingId. */ +private const val SHORT_HASH_LENGTH = 8 diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt index 09cd01aea..10c4612ae 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt @@ -20,20 +20,28 @@ package dev.tramai.build.quality */ object MutationAuthorityDigestCertificateCeremony { /** - * M45, M46 and M47 over the base/candidate certificate ledgers. + * M44, M45, M46 and M47 over the base/candidate certificate ledgers. * * @param base the certificate ledger as it exists in the PR's authority base * @param candidate the certificate ledger as the PR proposes it * @param baseSha the exact authority base SHA this transition is proposed against + * @param validConsumptions the proven consumptions this transition established through + * [verifyCertificateConsumption]. Only attributed [CertificateConsumption.Valid] facts can be + * passed, so a caller cannot state "this digest was consumed" - the fact carries its own + * proof. M44 and M47 never re-derive it and never treat the absence of a certificate as + * evidence that it was consumed. An empty set is the fail-closed state, so a caller that + * establishes nothing gets the strictest behaviour. */ fun checks( base: MutationAuthorityDigestCertificates, candidate: MutationAuthorityDigestCertificates, baseSha: String, + validConsumptions: Set = emptySet(), ): List = mintChecks(base, candidate, baseSha) + retentionChecks(base, candidate) + - removalChecks(base, candidate) + singleUseChecks(base, candidate, validConsumptions) + + removalChecks(base, candidate, validConsumptions) /** * M45: a newly introduced certificate binds the exact authority base it is proposed against. @@ -94,25 +102,59 @@ object MutationAuthorityDigestCertificateCeremony { return diagnostics } + /** + * M44: single use. A certificate that was consumed in this transition must not also be retained. + * Leaving it in place would let the same migration authority be consumed again by a later + * transition, so consumption and retention are mutually exclusive. + * + * Judged only over consumptions that were **independently established** (M40-M43). A source + * digest that is not in the base is not this rule's business - M43 refuses that citation - so a + * candidate cannot use this rule to fail a transition by citing certificates that do not exist. + */ + private fun singleUseChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + validConsumptions: Set, + ): List { + val diagnostics = mutableListOf() + val baseByFromDigest = base.byFromDigest() + val candidateByFromDigest = candidate.byFromDigest() + for (consumed in validConsumptions.sortedBy { it.fromDigest }) { + val fromDigest = consumed.fromDigest + if (fromDigest !in baseByFromDigest) continue + if (fromDigest in candidateByFromDigest) { + diagnostics += + certificateFailure( + fromDigest, + "M44: the digest-migration certificate for source digest " + + "${short(fromDigest)} was consumed by this transition but is retained in " + + "the candidate. A certificate authorises one migration once.", + ) + } + } + return diagnostics + } + /** * M47: removal custody. A certificate may only disappear by being consumed; it may not be * cancelled silently. * - * Until the certificate-aware consumption path exists there is no way to *prove* a valid - * consumption, so this rule takes its strictly fail-closed half: **any** base certificate absent - * from the candidate fails. That is deliberate, not an unfinished branch. A disappearance is - * never its own evidence - when M40-M44 land, this becomes "unless validly consumed by the same - * transition", judged against an independently established consumption, and never against the - * mere absence of the certificate. + * The consuming half is [validConsumptions] - the fact established by M40-M43, taken here as an + * input rather than rediscovered. That is structural, not conventional: this function has no + * access to the semantic predicates, so a disappearance can never become its own evidence. If + * nothing valid was established (empty set, including the fail-closed default), every base + * certificate absent from the candidate fails, exactly as before the consumption path existed. */ private fun removalChecks( base: MutationAuthorityDigestCertificates, candidate: MutationAuthorityDigestCertificates, + validConsumptions: Set, ): List { val diagnostics = mutableListOf() val candidateByFromDigest = candidate.byFromDigest() for ((fromDigest, _) in base.byFromDigest()) { - if (fromDigest !in candidateByFromDigest) { + val consumed = validConsumptions.any { it.fromDigest == fromDigest } + if (fromDigest !in candidateByFromDigest && !consumed) { diagnostics += certificateFailure( fromDigest, diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateConsumptionTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateConsumptionTest.kt new file mode 100644 index 000000000..8fe22cf50 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateConsumptionTest.kt @@ -0,0 +1,204 @@ +package dev.tramai.build.quality + +import dev.tramai.build.quality.CertificateConsumption.Valid +import dev.tramai.build.quality.MutationAuthorityDigestCertificates.Companion.ALGORITHM_AUTHORITY_V2 +import dev.tramai.build.quality.MutationAuthorityDigestCertificates.Companion.ALGORITHM_RAW_V1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * T14, T15, T16 and T19 at the rule level: consumption is a fact produced from base authority, and + * the lifecycle rules act only on facts the verifier attributed. + * + * Every `Valid` fact below is obtained by running the real verifier over a real base ledger - none is + * hand-constructed - so these tests exercise the same route a Gradle call site will use. + */ +class MutationAuthorityDigestCertificateConsumptionTest { + private val raw = "1".repeat(64) + private val otherRaw = "7".repeat(64) + private val authority = "2".repeat(64) + private val baseSha = "a".repeat(40) + private val identities = listOf("id-a", "id-b") + private val setDigest = MutationAuthorityDigestCertificates.admissionSetDigest(identities) + + private fun certificate( + fromDigest: String = raw, + fromAlgorithm: String = ALGORITHM_RAW_V1, + admissionSetDigest: String = setDigest, + reason: String = "test certificate", + ) = MutationAuthorityDigestCertificate( + fromAlgorithm = fromAlgorithm, + fromDigest = fromDigest, + toAlgorithm = ALGORITHM_AUTHORITY_V2, + toDigest = authority, + admissionSetDigest = admissionSetDigest, + fromBaseSha = baseSha, + reason = reason, + ) + + private fun ledger(vararg certificates: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = certificates.toList()) + + private fun verify( + certificate: MutationAuthorityDigestCertificate = certificate(), + base: MutationAuthorityDigestCertificates = ledger(certificate()), + citedAdmissionPopulationDigest: String = raw, + baseAdmissionIdentities: List = identities, + freshAuthorityProjectionHash: String = authority, + ) = verifyCertificateConsumption( + certificate = certificate, + baseCertificates = base, + citedAdmissionPopulationDigest = citedAdmissionPopulationDigest, + baseAdmissionIdentities = baseAdmissionIdentities, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + + private fun validFact(certificate: MutationAuthorityDigestCertificate = certificate()): Valid { + val result = + verify( + certificate = certificate, + base = ledger(certificate), + citedAdmissionPopulationDigest = certificate.fromDigest, + ) + assertTrue(result is CertificateConsumption.Valid, "expected a valid consumption, got $result") + return result as CertificateConsumption.Valid + } + + private fun refusal( + certificate: MutationAuthorityDigestCertificate = certificate(), + base: MutationAuthorityDigestCertificates = ledger(certificate), + citedAdmissionPopulationDigest: String = raw, + baseAdmissionIdentities: List = identities, + freshAuthorityProjectionHash: String = authority, + ): VerificationDiagnostic { + val result = + verify( + certificate = certificate, + base = base, + citedAdmissionPopulationDigest = citedAdmissionPopulationDigest, + baseAdmissionIdentities = baseAdmissionIdentities, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + assertTrue(result is CertificateConsumption.Invalid, "expected a refusal, got $result") + return (result as CertificateConsumption.Invalid).diagnostic + } + + @Test + fun `a certificate satisfying every predicate yields the attributed fact`() { + val fact = validFact() + + assertEquals(raw, fact.fromDigest) + assertEquals(authority, fact.toDigest) + assertEquals(setDigest, fact.admissionSetDigest) + } + + @Test + fun `M43 a certificate absent from the base cannot be consumed`() { + val diagnostic = refusal(base = MutationAuthorityDigestCertificates.NONE) + + assertTrue(diagnostic.message.contains("M43"), diagnostic.message) + assertEquals(raw, diagnostic.findingId) + } + + @Test + fun `M43 a candidate-minted certificate cannot become a consumption fact`() { + val diagnostic = refusal(base = ledger(certificate(fromDigest = otherRaw))) + + assertTrue(diagnostic.message.contains("M43"), diagnostic.message) + } + + @Test + fun `M43 a certificate whose base payload was rewritten cannot be consumed`() { + val diagnostic = refusal(base = ledger(certificate(reason = "rewritten in the candidate"))) + + assertTrue(diagnostic.message.contains("M43"), diagnostic.message) + } + + @Test + fun `M40 a certificate whose target digest is not this measurement's projection is refused`() { + val diagnostic = refusal(freshAuthorityProjectionHash = "3".repeat(64)) + + assertTrue(diagnostic.message.contains("M40"), diagnostic.message) + } + + @Test + fun `M41 a certificate for a different source digest than the cited admission is refused`() { + val diagnostic = refusal(citedAdmissionPopulationDigest = otherRaw) + + assertTrue(diagnostic.message.contains("M41"), diagnostic.message) + } + + @Test + fun `M41 a certificate declaring a different migration pair is refused`() { + val diagnostic = refusal(certificate = certificate(fromAlgorithm = ALGORITHM_AUTHORITY_V2)) + + assertTrue(diagnostic.message.contains("M41"), diagnostic.message) + } + + @Test + fun `M42 a certificate bounding a different admission set is refused`() { + val diagnostic = refusal(certificate = certificate(admissionSetDigest = "5".repeat(64))) + + assertTrue(diagnostic.message.contains("M42"), diagnostic.message) + } + + @Test + fun `M42 a certificate that also covers an unauthorized identity is refused`() { + val diagnostic = refusal(baseAdmissionIdentities = identities + "id-c") + + assertTrue(diagnostic.message.contains("M42"), diagnostic.message) + } + + @Test + fun `T16 M44 a consumed certificate retained in the candidate fails`() { + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(certificate()), + baseSha = baseSha, + validConsumptions = setOf(validFact()), + ) + + assertTrue(diagnostics.single().message.contains("M44"), diagnostics.single().message) + } + + @Test + fun `T19 M47 a removed certificate with no established consumption still fails`() { + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(), + baseSha = baseSha, + ) + + assertTrue(diagnostics.single().message.contains("M47"), diagnostics.single().message) + } + + @Test + fun `T19 M47 removal is permitted by the fact for that exact certificate`() { + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(), + baseSha = baseSha, + validConsumptions = setOf(validFact()), + ) + + assertTrue(diagnostics.isEmpty(), diagnostics.joinToString { it.message }) + } + + @Test + fun `T19 M47 removal is not permitted by a fact proving a different certificate`() { + val other = certificate(fromDigest = otherRaw) + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(), + baseSha = baseSha, + validConsumptions = setOf(validFact(other)), + ) + + assertTrue(diagnostics.single().message.contains("M47"), diagnostics.single().message) + } +}