|
| 1 | +# SLSA Verification |
| 2 | + |
| 3 | +This package provides SLSA (Supply chain Levels for Software Artifacts) verification for Leeway's remote cache. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The verifier validates that cached artifacts have not been tampered with by: |
| 8 | +1. Loading the Sigstore Bundle attestation |
| 9 | +2. Verifying the signature using Sigstore's public good instance |
| 10 | +3. Checking the transparency log entry (Rekor) |
| 11 | +4. Comparing the artifact hash with the expected hash from the attestation |
| 12 | + |
| 13 | +## Architecture |
| 14 | + |
| 15 | +``` |
| 16 | +┌─────────────────┐ |
| 17 | +│ Remote Cache │ |
| 18 | +│ (S3/GCS) │ |
| 19 | +└────────┬────────┘ |
| 20 | + │ |
| 21 | + ├─── artifact.tar.gz |
| 22 | + └─── artifact.tar.gz.att (Sigstore Bundle) |
| 23 | + │ |
| 24 | + ▼ |
| 25 | + ┌──────────────┐ |
| 26 | + │ Verifier │ |
| 27 | + │ │ |
| 28 | + │ 1. Load │ |
| 29 | + │ 2. Verify │ |
| 30 | + │ 3. Check │ |
| 31 | + │ 4. Compare │ |
| 32 | + └──────────────┘ |
| 33 | + │ |
| 34 | + ▼ |
| 35 | + ┌──────────────┐ |
| 36 | + │ Result │ |
| 37 | + │ │ |
| 38 | + │ ✅ Valid │ |
| 39 | + │ ❌ Invalid │ |
| 40 | + └──────────────┘ |
| 41 | +``` |
| 42 | + |
| 43 | +## Usage |
| 44 | + |
| 45 | +### In Code |
| 46 | + |
| 47 | +```go |
| 48 | +import "github.com/gitpod-io/leeway/pkg/leeway/cache/slsa" |
| 49 | + |
| 50 | +// Create verifier |
| 51 | +verifier := slsa.NewVerifier( |
| 52 | + "github.com/gitpod-io/gitpod-next", // Source URI |
| 53 | + []string{}, // Trusted roots (empty = use Sigstore public good) |
| 54 | +) |
| 55 | + |
| 56 | +// Verify artifact |
| 57 | +err := verifier.VerifyArtifact( |
| 58 | + ctx, |
| 59 | + "/path/to/artifact.tar.gz", |
| 60 | + "/path/to/artifact.tar.gz.att", |
| 61 | +) |
| 62 | + |
| 63 | +if err != nil { |
| 64 | + // Verification failed |
| 65 | + var verificationErr slsa.VerificationFailedError |
| 66 | + if errors.As(err, &verificationErr) { |
| 67 | + log.Errorf("Verification failed: %s", verificationErr.Reason) |
| 68 | + } |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +### Error Handling |
| 73 | + |
| 74 | +The verifier returns `VerificationFailedError` for all verification failures: |
| 75 | + |
| 76 | +```go |
| 77 | +type VerificationFailedError struct { |
| 78 | + Reason string |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +**Common error reasons**: |
| 83 | +- `"failed to load attestation bundle: ..."` - Attestation file is missing or malformed |
| 84 | +- `"signature verification failed: ..."` - Signature is invalid or certificate chain is broken |
| 85 | +- `"no subject in attestation"` - SLSA provenance is missing subject |
| 86 | +- `"SLSA provenance subject has no SHA256 digest"` - Subject hash is empty |
| 87 | +- `"hash mismatch: expected X, got Y"` - Artifact has been tampered with |
| 88 | + |
| 89 | +## Testing |
| 90 | + |
| 91 | +### Unit Tests |
| 92 | + |
| 93 | +Run the standard test suite: |
| 94 | + |
| 95 | +```bash |
| 96 | +cd pkg/leeway/cache/slsa |
| 97 | +go test -v |
| 98 | +``` |
| 99 | + |
| 100 | +**Tests included**: |
| 101 | +- `TestNewVerifier` - Verifier initialization |
| 102 | +- `TestAttestationKey` - Attestation key generation |
| 103 | +- `TestVerifier_calculateSHA256` - Hash calculation |
| 104 | +- `TestVerifier_VerifyArtifact_MissingFiles` - Error handling for missing files |
| 105 | +- `TestVerifier_VerifyArtifact_InvalidAttestation` - Error handling for invalid attestations |
| 106 | + |
| 107 | +### Manual Testing with Real Attestations |
| 108 | + |
| 109 | +To test the verifier with real attestations from S3, create a test file: |
| 110 | + |
| 111 | +**File**: `manual_test.go` |
| 112 | + |
| 113 | +```go |
| 114 | +// +build manual |
| 115 | + |
| 116 | +package slsa |
| 117 | + |
| 118 | +import ( |
| 119 | + "context" |
| 120 | + "encoding/base64" |
| 121 | + "encoding/json" |
| 122 | + "os" |
| 123 | + "testing" |
| 124 | +) |
| 125 | + |
| 126 | +// TestVerifyRealAttestation tests verification with a real attestation from S3 |
| 127 | +// Run with: go test -tags=manual -v -run TestVerifyRealAttestation |
| 128 | +func TestVerifyRealAttestation(t *testing.T) { |
| 129 | + artifactPath := "/tmp/test-artifact.tar.gz" |
| 130 | + attestationPath := "/tmp/test-attestation.json" |
| 131 | + |
| 132 | + // Check if files exist |
| 133 | + if _, err := os.Stat(artifactPath); os.IsNotExist(err) { |
| 134 | + t.Skip("Real artifact not found at /tmp/test-artifact.tar.gz") |
| 135 | + } |
| 136 | + if _, err := os.Stat(attestationPath); os.IsNotExist(err) { |
| 137 | + t.Skip("Real attestation not found at /tmp/test-attestation.json") |
| 138 | + } |
| 139 | + |
| 140 | + verifier := NewVerifier("github.com/gitpod-io/gitpod-next", []string{}) |
| 141 | + ctx := context.Background() |
| 142 | + |
| 143 | + t.Log("Testing with real attestation from S3...") |
| 144 | + err := verifier.VerifyArtifact(ctx, artifactPath, attestationPath) |
| 145 | + if err != nil { |
| 146 | + t.Logf("Verification failed: %v", err) |
| 147 | + // Note: This may fail if attestations use non-standard format |
| 148 | + // See: https://github.com/gitpod-io/leeway/pull/275 |
| 149 | + } else { |
| 150 | + t.Log("✅ Verification succeeded!") |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +// TestEmptyHashWithRealAttestation validates the empty hash check |
| 155 | +// Run with: go test -tags=manual -v -run TestEmptyHashWithRealAttestation |
| 156 | +func TestEmptyHashWithRealAttestation(t *testing.T) { |
| 157 | + attestationPath := "/tmp/test-attestation.json" |
| 158 | + |
| 159 | + if _, err := os.Stat(attestationPath); os.IsNotExist(err) { |
| 160 | + t.Skip("Real attestation not found at /tmp/test-attestation.json") |
| 161 | + } |
| 162 | + |
| 163 | + // Read and parse the attestation |
| 164 | + data, err := os.ReadFile(attestationPath) |
| 165 | + if err != nil { |
| 166 | + t.Fatalf("Failed to read attestation: %v", err) |
| 167 | + } |
| 168 | + |
| 169 | + var att struct { |
| 170 | + Content struct { |
| 171 | + DsseEnvelope struct { |
| 172 | + Payload string `json:"payload"` |
| 173 | + } `json:"DsseEnvelope"` |
| 174 | + } `json:"Content"` |
| 175 | + } |
| 176 | + if err := json.Unmarshal(data, &att); err != nil { |
| 177 | + t.Fatalf("Failed to parse attestation: %v", err) |
| 178 | + } |
| 179 | + |
| 180 | + // Decode and check the payload |
| 181 | + payloadBytes, err := base64.StdEncoding.DecodeString(att.Content.DsseEnvelope.Payload) |
| 182 | + if err != nil { |
| 183 | + t.Fatalf("Failed to decode payload: %v", err) |
| 184 | + } |
| 185 | + |
| 186 | + var payload struct { |
| 187 | + Subject []struct { |
| 188 | + Digest struct { |
| 189 | + Sha256 string `json:"sha256"` |
| 190 | + } `json:"digest"` |
| 191 | + } `json:"subject"` |
| 192 | + } |
| 193 | + if err := json.Unmarshal(payloadBytes, &payload); err != nil { |
| 194 | + t.Fatalf("Failed to parse payload: %v", err) |
| 195 | + } |
| 196 | + |
| 197 | + if len(payload.Subject) == 0 { |
| 198 | + t.Fatal("No subject in payload") |
| 199 | + } |
| 200 | + |
| 201 | + hash := payload.Subject[0].Digest.Sha256 |
| 202 | + t.Logf("Subject hash: %s", hash) |
| 203 | + |
| 204 | + if hash == "" { |
| 205 | + t.Log("✅ Empty hash detected - validation would trigger") |
| 206 | + } else { |
| 207 | + t.Logf("Hash is present: %s", hash) |
| 208 | + } |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +**How to run**: |
| 213 | + |
| 214 | +1. **Download real attestations from S3**: |
| 215 | + ```bash |
| 216 | + # Set AWS credentials |
| 217 | + export AWS_ACCESS_KEY_ID="..." |
| 218 | + export AWS_SECRET_ACCESS_KEY="..." |
| 219 | + export AWS_SESSION_TOKEN="..." |
| 220 | + |
| 221 | + # Download artifact and attestation |
| 222 | + aws s3 cp s3://your-bucket/artifact.tar.gz /tmp/test-artifact.tar.gz |
| 223 | + aws s3 cp s3://your-bucket/artifact.tar.gz.att /tmp/test-attestation.json |
| 224 | + ``` |
| 225 | + |
| 226 | +2. **Create the test file**: |
| 227 | + ```bash |
| 228 | + # Copy the code above into manual_test.go |
| 229 | + cd pkg/leeway/cache/slsa |
| 230 | + ``` |
| 231 | + |
| 232 | +3. **Run the tests**: |
| 233 | + ```bash |
| 234 | + # Test with real attestation |
| 235 | + go test -tags=manual -v -run TestVerifyRealAttestation |
| 236 | + |
| 237 | + # Test hash extraction |
| 238 | + go test -tags=manual -v -run TestEmptyHashWithRealAttestation |
| 239 | + |
| 240 | + # Run all manual tests |
| 241 | + go test -tags=manual -v |
| 242 | + ``` |
| 243 | + |
| 244 | +4. **Clean up**: |
| 245 | + ```bash |
| 246 | + # Remove the test file when done |
| 247 | + rm manual_test.go |
| 248 | + ``` |
| 249 | + |
| 250 | +**Expected results**: |
| 251 | + |
| 252 | +- **Before PR #275** (attestation format fix): |
| 253 | + ``` |
| 254 | + Verification failed: SLSA verification failed: failed to load attestation bundle: |
| 255 | + proto: (line 1:88): unknown field "Content" |
| 256 | + ``` |
| 257 | + This is expected - current attestations use non-standard format. |
| 258 | + |
| 259 | +- **After PR #275** (attestation format fix): |
| 260 | + ``` |
| 261 | + ✅ Verification succeeded! |
| 262 | + ``` |
| 263 | + New attestations will use standard Sigstore Bundle v0.3 format. |
| 264 | + |
| 265 | +## Logging |
| 266 | + |
| 267 | +The verifier uses structured logging (logrus) for observability: |
| 268 | + |
| 269 | +**Debug logs** (verification start): |
| 270 | +``` |
| 271 | +level=debug msg="Starting SLSA verification" artifact=/path/to/artifact.tar.gz attestation=/path/to/attestation.att |
| 272 | +``` |
| 273 | + |
| 274 | +**Info logs** (verification success): |
| 275 | +``` |
| 276 | +level=info msg="SLSA verification successful" artifact=/path/to/artifact.tar.gz expectedHash=abc123... actualHash=abc123... verificationMs=45 |
| 277 | +``` |
| 278 | + |
| 279 | +**Error logs** (verification failure): |
| 280 | +``` |
| 281 | +level=error msg="SLSA verification failed: signature verification failed: ..." |
| 282 | +``` |
| 283 | + |
| 284 | +**Fields**: |
| 285 | +- `artifact` - Path to artifact file |
| 286 | +- `attestation` - Path to attestation file |
| 287 | +- `expectedHash` - Hash from attestation |
| 288 | +- `actualHash` - Hash of artifact |
| 289 | +- `verificationMs` - Verification duration in milliseconds |
| 290 | + |
| 291 | +## Performance |
| 292 | + |
| 293 | +Typical verification times: |
| 294 | +- **Fast path** (embedded Rekor entry): 20-50ms |
| 295 | +- **Network path** (fetch trusted root): 100-200ms (first time only, then cached) |
| 296 | + |
| 297 | +The verifier uses embedded transparency log entries from the attestation, so no network calls to Rekor are needed during verification. |
| 298 | + |
| 299 | +## Troubleshooting |
| 300 | + |
| 301 | +### "failed to load attestation bundle: proto: unknown field 'Content'" |
| 302 | + |
| 303 | +**Cause**: Attestation uses non-standard format with capital "Content" field. |
| 304 | + |
| 305 | +**Solution**: This is fixed by PR #275. After merging, new attestations will use standard format. |
| 306 | + |
| 307 | +**Workaround**: Rebuild the package (verification fails → cache miss → rebuild). |
| 308 | + |
| 309 | +### "signature verification failed" |
| 310 | + |
| 311 | +**Cause**: Signature is invalid, certificate chain is broken, or transparency log entry is invalid. |
| 312 | + |
| 313 | +**Solution**: Check that: |
| 314 | +1. Attestation file is not corrupted |
| 315 | +2. Artifact has not been tampered with |
| 316 | +3. Sigstore public good instance is accessible |
| 317 | + |
| 318 | +### "hash mismatch: expected X, got Y" |
| 319 | + |
| 320 | +**Cause**: Artifact has been modified after signing. |
| 321 | + |
| 322 | +**Solution**: This indicates tampering. Do not use the artifact. Rebuild from source. |
| 323 | + |
| 324 | +### "SLSA provenance subject has no SHA256 digest" |
| 325 | + |
| 326 | +**Cause**: Attestation is malformed - subject exists but hash is empty. |
| 327 | + |
| 328 | +**Solution**: Regenerate the attestation with correct SLSA provenance. |
| 329 | + |
| 330 | +## References |
| 331 | + |
| 332 | +- **Sigstore Bundle Format**: https://docs.sigstore.dev/about/bundle/ |
| 333 | +- **SLSA Provenance**: https://slsa.dev/provenance/ |
| 334 | +- **sigstore-go Library**: https://github.com/sigstore/sigstore-go |
| 335 | +- **Rekor Transparency Log**: https://docs.sigstore.dev/rekor/overview/ |
| 336 | + |
| 337 | +## Related PRs |
| 338 | + |
| 339 | +- **PR #275**: Fix attestation format generation (use protojson.Marshal) |
| 340 | +- **PR #276**: Replace slsa-verifier with sigstore-go (this implementation) |
| 341 | + |
| 342 | +--- |
| 343 | + |
| 344 | +*Last updated: November 14, 2024* |
0 commit comments