Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module chainguard.dev/apko
go 1.26.0

require (
chainguard.dev/sdk v0.1.184
chainguard.dev/sdk v0.1.191
github.com/chainguard-dev/clog v1.8.1
github.com/charmbracelet/log v1.0.0
github.com/go-git/go-git/v5 v5.19.2
Expand All @@ -17,6 +17,7 @@ require (
github.com/klauspost/pgzip v1.2.6
github.com/package-url/packageurl-go v0.1.6
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0
github.com/pjbgf/sha1cd v0.6.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
Expand Down Expand Up @@ -104,7 +105,6 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
chainguard.dev/go-grpc-kit v0.18.0 h1:kr451ml4eBgNcc0ytqcSSPZ3KGtyfHRrrBcYxW/oZwI=
chainguard.dev/go-grpc-kit v0.18.0/go.mod h1:N1ZZiV3KkNHFlIS6RPhLT9ppfObC7aa1hqWMPmNiTA0=
chainguard.dev/sdk v0.1.184 h1:L9SLup6giGc+qGUDZ7iuMKL5wC0rg6US23L2k5tbg5k=
chainguard.dev/sdk v0.1.184/go.mod h1:EdDABW102LAdDIbUXoTokZlRlyTa6D2ml0tC9Agnpbs=
chainguard.dev/sdk v0.1.191 h1:9HEpIHedW8CAzZyTpLP8hdeVv1XLVGKw3C+SuCE6Ueg=
chainguard.dev/sdk v0.1.191/go.mod h1:EdDABW102LAdDIbUXoTokZlRlyTa6D2ml0tC9Agnpbs=
cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
Expand Down
83 changes: 83 additions & 0 deletions internal/sha1cd/sha1cd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package sha1cd computes SHA-1 digests with collision detection.
//
// apk identifies control sections, signatures and individual files by SHA-1,
// and signs legacy indexes over SHA-1, so we cannot stop computing it. What we
// can do is refuse to trust a digest computed over input that carries the
// cryptanalytic signature of a SHA-1 collision attack. Callers finalise a hash
// with [Sum], which runs that check and reports [ErrCollision] instead of
// returning a digest that must not be used.
//
// For input with no collision signature the digests are identical to
// crypto/sha1, so this is a drop-in replacement. For input that does collide
// the digest differs (the underlying implementation rehashes to a safe value),
// which is a second reason never to use a digest without checking first.
package sha1cd

import (
"errors"
"hash"

upstream "github.com/pjbgf/sha1cd"
)

// Size is the length in bytes of a SHA-1 digest.
const Size = upstream.Size

// ErrCollision reports that the hashed input exhibits the characteristics of a
// SHA-1 collision attack, so its digest cannot be trusted to identify content.
var ErrCollision = errors.New("sha1 collision attack detected in hashed input")

// New returns a [hash.Hash] computing SHA-1 with collision detection.
//
// Finalise it with [Sum] rather than its own Sum method: collision detection is
// only conclusive once the digest is finalised, and Sum is what checks it.
func New() hash.Hash {
return upstream.New()
}

// Sum finalises h and returns its digest.
//
// When h came from [New], the digest is checked before it is returned and
// ErrCollision is reported instead if the input collides.
//
// A hash that cannot detect collisions, such as sha256, is simply finalised.
// That passthrough is for callers holding a hash.Hash whose algorithm is only
// known at runtime, so that whichever hash they end up with is checked if it is
// SHA-1. Where the algorithm is known at the call site, call [SumBytes] or that
// digest's own package instead, so the code does not read as though SHA-1 were
// involved when it is not.
func Sum(h hash.Hash) ([]byte, error) {
crh, ok := h.(upstream.CollisionResistantHash)
if !ok {
return h.Sum(nil), nil
}
Comment on lines +63 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passthrough silently permits the exact mistake the package exists to prevent:

sha1cd.Sum(sha1.New())  // returns a digest and a nil error; no collision check, no signal

*sha1.digest isn't a CollisionResistantHash, so it takes the !ok branch and comes back looking like a checked digest. The doc comment above asks callers to use SumBytes where the algorithm is known statically, but that's a convention, and nothing enforces it. Given that crypto.SHA1 is still registered in every apko build (see my comment on rsa.go) and gosec G505 is both //nolint-suppressible and excluded for _test.go, this is the whole guarantee resting on documentation.

The var h hash.Hash = sha1cd.New() pattern at expandapk.go:456 is also the only site that actually needs the polymorphism, and even there the two possibilities are known at compile time.

Cheap fix that keeps the sha256 path working — reject any non-detecting hash of SHA-1's size:

crh, ok := h.(upstream.CollisionResistantHash)
if !ok {
	if h.Size() == Size {
		return nil, fmt.Errorf("refusing to finalise a %d-byte hash that cannot detect collisions", Size)
	}
	return h.Sum(nil), nil
}

That turns a silent bypass into a loud one at the only place it can happen, and costs nothing for sha256.

Relatedly: TestSumPassesThroughOtherHashes pins the positive case, but there's no negative case asserting Sum refuses a collision-blind SHA-1 — which is the behaviour that matters here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..... and the package was created in the first place because "casts" were assumed to be ugly.

I actually originally wanted to directly import sha1cd library, and indeed cast each hash explicitely. Cause imho that's a better way than an arbitrary internal indirection - which is not obvious directly. Like it was done in melange before at https://github.com/chainguard-dev/melange/pull/2357/changes

Maybe i should rewrite this code with direct sha1cd usage.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the current shape is the problem — but I'd locate it in Sum's parameter type, not in the wrapper existing. And I think there's a third option that's better than either of the two you're weighing, including better than what I suggested above.

Direct usage with explicit casts is worse for exactly this property. melange#2357 is the evidence: the cast and the flag check are repeated per call site, the error string is duplicated, and sha1cd.New().(sha1cd.CollisionResistantHash) is an unchecked assertion that panics if upstream ever changes New()'s return type. apko has ~10 sites. There's no chokepoint, so every new site is one forgotten if collision away from a silently collision-blind digest — and that's a code-review problem forever rather than a compiler problem once.

The v0.6.0 bump sharpens this. Upstream just changed observable behaviour between v0.5.0 and v0.6.0 (pjbgf/sha1cd#206 removing self-registration). Direct usage spreads that coupling across every call site; a wrapper is the one place to absorb it and the one place you'd notice.

But don't take my earlier suggestion either. I proposed a concrete type embedding upstream.CollisionResistantHash. That has a trap I missed: embedding promotes a collision-blind Sum([]byte) []byte, so h.Sum(nil) still compiles and returns 7117b3cb… with no error. You'd end up with a package Sum(*Hash) ([]byte, error) and a method Sum([]byte) []byte — same name, opposite safety. Worse than what it fixes. (Embedding also drops MarshalBinary, and an exported embedded field lets &sha1cd.Hash{} panic or inject an arbitrary implementation.)

The third option: don't satisfy hash.Hash at all. Checking the call sites, every one consumes the hash purely as an io.Writerio.Copy at install.go:47, package_getter.go:314, resolveapk.go:50, expandapk.go:655; io.TeeReader at install.go:93; io.MultiWriter at install_test.go:379. The only place that names hash.Hash is expandapk.go:456, and only because one variable is reused for the sha256 branch. Nothing needs Sum.

// Hash computes SHA-1 with collision detection.
//
// It is deliberately an io.Writer and not a hash.Hash: finalising goes through
// Finalize, which can report a collision, and a Sum method would offer a
// collision-blind way to finalise it.
type Hash struct{ crh upstream.CollisionResistantHash }

func New() *Hash {
	return &Hash{crh: upstream.New().(upstream.CollisionResistantHash)}
}

func (h *Hash) Write(p []byte) (int, error) { return h.crh.Write(p) }
func (h *Hash) Reset()                      { h.crh.Reset() }
func (h *Hash) Size() int                   { return Size }
func (h *Hash) BlockSize() int              { return h.crh.BlockSize() }

func (h *Hash) Finalize() ([]byte, error) {
	sum, collision := h.crh.CollisionResistantSum(nil)
	if collision {
		return nil, ErrCollision
	}
	return sum, nil
}

I built and tested this. io.Copy, io.TeeReader and chunked writes all work, collision detection fires, Reset works, SumBytes is unchanged. And both bypasses become compile errors:

h.Sum undefined (type *sha1cd.Hash has no field or method Sum)
cannot use h (variable of type *sha1cd.Hash) as hash.Hash value: missing method Sum

No casts at any call site, and the single type assertion lives in New() where one test covers it.

expandapk.go:456 needs a small restructure, since it's the one polymorphic site. Something like keeping the writer and the finaliser separate:

sha1h := sha1cd.New()
var h io.Writer = sha1h
finalize := sha1h.Finalize
// …in the maxStreamsReached branch:
sha256h := sha256.New()
h, finalize = sha256h, func() ([]byte, error) { return sha256h.Sum(nil), nil }

That also retires the comment at :454 that currently has to explain why sha1cd.Sum is being handed a sha256 hash.

One caller does legitimately use the collision-blind path: checksums_test.go:67 takes h.Sum(nil) to get the mitigated digest for the header, which is what makes TestCheckSumsRejectsCollision prove the bypass is closed — the sharpest test in the PR, so worth preserving. Simplest is to pin it as a constant in sha1cdtest next to the vector (7117b3cb9225aaf0d8ef1a40e493957b0bf8693d), which also means an upstream change to the safe-rehash derivation fails a test instead of passing quietly. TestSumPassesThroughOtherHashes retires with the passthrough.

Worth noting the blast radius is contained either way: internal/sha1cd is only importable from chainguard.dev/apko/..., so no consumer outside the module can be affected. Of the 8 sha1cd.Sum call sites, 6 already pass a concrete sha1cd.New(); only expandapk.go:491/:526 — the same variable in one loop — use the hash.Hash polymorphism.

Net effect on my original point: the "no negative test for the passthrough" gap disappears, because there's nothing left to test — the bypass stops compiling. That's the version of "casts are ugly" I'd argue for: one cast, in one constructor, instead of a convention every future call site has to remember.

sum, collision := crh.CollisionResistantSum(nil)
if collision {
return nil, ErrCollision
}
return sum, nil
}

// SumBytes returns the SHA-1 of data, or ErrCollision if data exhibits the
// characteristics of a SHA-1 collision attack.
func SumBytes(data []byte) ([]byte, error) {
sum, collision := upstream.Sum(data)
if collision {
return nil, ErrCollision
}
return sum[:], nil
}
115 changes: 115 additions & 0 deletions internal/sha1cd/sha1cd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package sha1cd

import (
"crypto/sha256"
"encoding/hex"
"errors"
"testing"

"chainguard.dev/apko/internal/sha1cd/sha1cdtest"
)

// Digests of input with no collision signature must match crypto/sha1, so that
// swapping the implementation does not change any checksum apk cares about.
var golden = map[string]string{
"": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
"abc": "a9993e364706816aba3e25717850c26c9cd0d89d",
"hello world": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",
}

func TestSumBytesGolden(t *testing.T) {
for in, want := range golden {
sum, err := SumBytes([]byte(in))
if err != nil {
t.Errorf("SumBytes(%q): %v", in, err)
continue
}
if got := hex.EncodeToString(sum); got != want {
t.Errorf("SumBytes(%q) = %s, want %s", in, got, want)
}
}
}

func TestSumGolden(t *testing.T) {
for in, want := range golden {
h := New()
if _, err := h.Write([]byte(in)); err != nil {
t.Errorf("Write(%q): %v", in, err)
continue
}
sum, err := Sum(h)
if err != nil {
t.Errorf("Sum(%q): %v", in, err)
continue
}
if got := hex.EncodeToString(sum); got != want {
t.Errorf("Sum(%q) = %s, want %s", in, got, want)
}
if len(sum) != Size {
t.Errorf("Sum(%q) is %d bytes, want %d", in, len(sum), Size)
}
}
}

func TestSumBytesCollision(t *testing.T) {
sum, err := SumBytes(sha1cdtest.Shattered(t))
if !errors.Is(err, ErrCollision) {
t.Errorf("SumBytes(shattered) = %x, %v; want ErrCollision", sum, err)
}
if sum != nil {
t.Errorf("SumBytes(shattered) returned a digest %x alongside the error", sum)
}
}

// The collision is only detectable once the digest is finalised, so check that
// it is caught however the input was fed in.
func TestSumCollision(t *testing.T) {
data := sha1cdtest.Shattered(t)

for _, chunk := range []int{1, 7, 64, 320} {
h := New()
for i := 0; i < len(data); i += chunk {
if _, err := h.Write(data[i:min(i+chunk, len(data))]); err != nil {
t.Fatalf("chunk %d: write: %v", chunk, err)
}
}
sum, err := Sum(h)
if !errors.Is(err, ErrCollision) {
t.Errorf("chunk %d: Sum = %x, %v; want ErrCollision", chunk, sum, err)
}
if sum != nil {
t.Errorf("chunk %d: Sum returned a digest %x alongside the error", chunk, sum)
}
}
}

// Sum also finalises hashes that cannot detect collisions, so callers choosing a
// digest algorithm at runtime have a single finalisation path.
func TestSumPassesThroughOtherHashes(t *testing.T) {
h := sha256.New()
if _, err := h.Write([]byte("hello world")); err != nil {
t.Fatal(err)
}
sum, err := Sum(h)
if err != nil {
t.Fatalf("Sum(sha256): %v", err)
}
const want = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if got := hex.EncodeToString(sum); got != want {
t.Errorf("Sum(sha256) = %s, want %s", got, want)
}
}
60 changes: 60 additions & 0 deletions internal/sha1cd/sha1cdtest/shattered.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package sha1cdtest provides a real SHA-1 collision, shared by the tests that
// check collision detection actually fires.
//
// This cannot be a shattered_test.go: Go refuses to import a package built only
// from _test.go files ("no non-test Go files"), so a shared fixture has to live
// in an ordinary file. Three things keep it out of production code anyway. It is
// under internal/, so nothing outside apko can reach it. [Shattered] takes a
// [testing.TB], which only a test can supply. And no non-test file imports this
// package, so it is never linked into any apko binary — `go list -deps` over the
// commands does not mention it.
package sha1cdtest

import (
"encoding/hex"
"testing"
)

// shattered is the first 320 bytes of shattered-1.pdf, the identical-prefix
// SHA-1 collision published as SHAttered (https://shattered.io). Both halves of
// that collision share these bytes, and they are what the collision detection
// recognises, so hashing them must be refused.
const shattered = "255044462d312e330a25e2e3cfd30a0a0a312030206f626a0a3c3c2f57696474" +
"682032203020522f4865696768742033203020522f547970652034203020522f" +
"537562747970652035203020522f46696c7465722036203020522f436f6c6f72" +
"53706163652037203020522f4c656e6774682038203020522f42697473506572" +
"436f6d706f6e656e7420383e3e0a73747265616d0affd8fffe00245348412d31" +
"20697320646561642121212121852fec092339759c39b1a1c63c4c97e1fffe01" +
"7346dc9166b67e118f029ab621b2560ff9ca67cca8c7f85ba84c79030c2b3de2" +
"18f86db3a90901d5df45c14f26fedfb3dc38e96ac22fe7bd728f0e45bce046d2" +
"3c570feb141398bb552ef5a0a82be331fea48037b8b5d71f0e332edf93ac3500" +
"eb4ddc0decc1a864790c782c76215660dd309791d06bd0af3f98cda4bc4629b1"

// Shattered returns a fresh copy of the colliding bytes. Hashing them with
// chainguard.dev/apko/internal/sha1cd reports sha1cd.ErrCollision.
func Shattered(tb testing.TB) []byte {
tb.Helper()

b, err := hex.DecodeString(shattered)
if err != nil {
tb.Fatalf("corrupt shattered vector: %v", err)
}
if len(b) != 320 {
tb.Fatalf("shattered vector is %d bytes, want 320", len(b))
}
return b
}
31 changes: 27 additions & 4 deletions pkg/apk/apk/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"errors"
"fmt"
"io"
Expand All @@ -38,6 +39,7 @@ import (
"go.opentelemetry.io/otel"
"golang.org/x/sync/errgroup"

"chainguard.dev/apko/internal/sha1cd"
"chainguard.dev/apko/pkg/apk/auth"
sign "chainguard.dev/apko/pkg/apk/signature"
)
Expand Down Expand Up @@ -446,11 +448,11 @@ func parseRepositoryIndex(ctx context.Context, u string, keys map[string][]byte,
for _, sig := range sigs {
// compute the digest if not already done
if _, hasDigest := indexDigest[sig.DigestAlgorithm]; !hasDigest {
h := sig.DigestAlgorithm.New()
if n, err := h.Write(indexData); err != nil || n != len(indexData) {
return nil, fmt.Errorf("unable to hash data: %w", err)
digest, err := hashIndex(sig.DigestAlgorithm, indexData)
if err != nil {
return nil, err
Comment on lines +451 to +453

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviour change worth noting: this aborts the whole index fetch, where the old code fell through to the next signature on a per-signature failure. Since indexDigest memoizes per algorithm, the only way to reach it is a SHA-1 collision on a SHA-1-signed index — and no repo currently dual-signs across algorithms (Wolfi and apk.cgr.dev are RSA256-only, three keys on the latter; Alpine v3.21/v3.22/edge are RSA-only), so with nothing to fall through to, continue and return reach the same outcome today. Still marginally prefer continue alongside the Warnf already in the else branch below, for consistency with how every other per-signature failure is handled.

Separately, and probably its own PR rather than this one: an index where the SHA-1 signature verifies but the SHA-256 signature fails should be rejected outright, and breaking on first success currently accepts it. That asymmetry has no benign explanation — a SHA-1 collision preserves the SHA-1 signature and necessarily breaks the SHA-256 one, while corruption would break both — which makes it a strictly better collision detector than counter-cryptanalysis, catching any collision rather than the 32 disturbance vectors in sha1cd's table. It's inert against the current state of the Chainguard and Alpine indexes per above, so nothing regresses and nothing improves today; the value is having it in place before Alpine adds RSA256 alongside RSA, which is exactly when the attack becomes attractive.

}
indexDigest[sig.DigestAlgorithm] = h.Sum(nil)
indexDigest[sig.DigestAlgorithm] = digest
}
if err := sign.RSAVerifyDigest(indexDigest[sig.DigestAlgorithm], sig.DigestAlgorithm, sig.Signature, keys[sig.KeyID]); err == nil {
verified = true
Expand Down Expand Up @@ -483,6 +485,27 @@ func parseRepositoryIndex(ctx context.Context, u string, keys map[string][]byte,
return index, err
}

// hashIndex digests the index data under the algorithm a signature was made
// with.
func hashIndex(algorithm crypto.Hash, indexData []byte) ([]byte, error) {
switch algorithm {
case crypto.SHA1:
// Legacy apk index signatures are RSA over SHA-1, so this digest carries
// collision detection: an index crafted around a SHA-1 collision must be
// rejected, not verified against someone else's signature.
digest, err := sha1cd.SumBytes(indexData)
if err != nil {
return nil, fmt.Errorf("hashing repository index: %w", err)
}
return digest, nil
case crypto.SHA256:
digest := sha256.Sum256(indexData)
return digest[:], nil
default:
return nil, fmt.Errorf("unsupported index digest algorithm: %s", algorithm)
}
}

type indexOpts struct {
ignoreSignatures bool
noSignatureIndexes []string
Expand Down
Loading