Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-11 00:29:15 -05:00
pkg-proxy/internal/handler/integrity.go
Simon Chrzanowski bf74d982d7 Verify npm and Composer archives against their SHA-1 shasum
npm versions published before dist.integrity existed carry only a SHA-1
dist.shasum, and Composer metadata lists only a SHA-1 dist.shasum, so
neither could be checked.

- Accept native integrity given as an algorithm-prefixed hex checksum,
  verified with integrity's exact-digest check, so a recorded "sha1-<hex>"
  is also checked on cache hits
- npm: fall back to dist.shasum when dist.integrity is missing or cannot
  be parsed; a usable dist.integrity still takes precedence
- Composer: verify an archive against its dist.shasum, except another
  commit of a branch fetched in place of the listed one, which the listed
  shasum does not describe
- Fetch unverified as before when no shasum is listed, as Packagist does
  for GitHub archives, or it is not a SHA-1 digest
2026-10-14 18:04:31 +02:00

222 lines
5.9 KiB
Go

package handler
import (
"fmt"
"io"
"github.com/git-pkgs/integrity"
)
type integrityChecks struct {
contentHash integrity.SRI
native integrity.SRI
// checksum is native metadata given as a single algorithm-prefixed hex
// checksum, such as a registry's SHA-1 shasum, which is not SRI.
checksum integrity.Digest
hasChecksum bool
algorithms []integrity.Algorithm
}
func newIntegrityChecks(contentHash, native string) (integrityChecks, error) {
checks := integrityChecks{}
if contentHash != "" {
digest, err := integrity.ParseHex(integrity.SHA256, contentHash)
if err != nil {
return integrityChecks{}, fmt.Errorf("parse content_hash: %w", err)
}
checks.contentHash = integrity.SRI{digest}
checks.algorithms = append(checks.algorithms, integrity.SHA256)
}
if native != "" {
digests, err := integrity.ParseSRI(native)
if err != nil {
checksum, checksumErr := integrity.ParseChecksum(native)
if checksumErr != nil {
return integrityChecks{}, fmt.Errorf("parse integrity: %w", err)
}
checks.checksum, checks.hasChecksum = checksum, true
checks.algorithms = append(checks.algorithms, checksum.Algorithm())
return checks, nil
}
checks.native = digests
for _, digest := range digests {
checks.algorithms = append(checks.algorithms, digest.Algorithm())
}
}
return checks, nil
}
// verifyIntegrity wraps body to check it against the declared integrity as
// it is read. verified, called once body has been read, reports a mismatch as
// ErrArtifactDigestMismatch, and also a body that was not read to its end,
// since only a complete body can be checked. An empty declared checks
// nothing.
func verifyIntegrity(body io.ReadCloser, declared string) (_ io.Reader, verified func() error, _ error) {
if declared == "" {
return body, func() error { return nil }, nil
}
checks, err := newIntegrityChecks("", declared)
if err != nil {
return nil, nil, err
}
var mismatch string
reader, err := checks.newVerifyingReader(body, func(reason string) { mismatch = reason }, false)
if err != nil {
return nil, nil, err
}
return reader, func() error {
if !reader.(*verifyingReader).verified {
return fmt.Errorf("%w: body was not read to its end", ErrArtifactDigestMismatch)
}
if mismatch != "" {
return fmt.Errorf("%w: %s", ErrArtifactDigestMismatch, mismatch)
}
return nil
}, nil
}
func (c integrityChecks) wrap(source io.ReadCloser, onMismatch func(string)) (io.ReadCloser, error) {
if len(c.algorithms) == 0 {
return source, nil
}
verified, err := c.newVerifyingReader(source, onMismatch, false)
if err != nil {
return nil, err
}
seeker, ok := source.(io.Seeker)
if !ok {
return verified, nil
}
return &seekableVerifyingReader{
source: source,
verified: verified,
seeker: seeker,
}, nil
}
// wrapFailOnMismatch is wrap for bytes that have not been checked anywhere
// else: a mismatch is also returned from Read as ErrArtifactDigestMismatch in
// place of io.EOF, so the caller can abort rather than complete the response.
func (c integrityChecks) wrapFailOnMismatch(source io.ReadCloser, onMismatch func(string)) (io.ReadCloser, error) {
return c.newVerifyingReader(source, onMismatch, true)
}
func (c integrityChecks) newVerifyingReader(source io.ReadCloser, onMismatch func(string), failOnMismatch bool) (io.ReadCloser, error) {
if len(c.algorithms) == 0 {
return source, nil
}
reader, err := integrity.NewReader(source, c.algorithms...)
if err != nil {
return nil, fmt.Errorf("create integrity reader: %w", err)
}
return &verifyingReader{
source: source,
reader: reader,
checks: c,
onMismatch: onMismatch,
failOnMismatch: failOnMismatch,
}, nil
}
// verifyingReader forwards Close to its source and reports completed digest
// mismatches after its shared integrity reader observes EOF.
type verifyingReader struct {
source io.ReadCloser
reader *integrity.Reader
checks integrityChecks
onMismatch func(reason string)
verified bool
failOnMismatch bool
mismatched bool
}
// seekableVerifyingReader verifies ordinary reads until a successful seek,
// after which reads bypass whole-object verification for range responses.
type seekableVerifyingReader struct {
source io.ReadCloser
verified io.ReadCloser
seeker io.Seeker
bypass bool
}
func (r *seekableVerifyingReader) Read(p []byte) (int, error) {
if r.bypass {
return r.source.Read(p)
}
return r.verified.Read(p)
}
func (r *seekableVerifyingReader) Seek(offset int64, whence int) (int64, error) {
position, err := r.seeker.Seek(offset, whence)
if err == nil {
r.bypass = true
}
return position, err
}
func (r *seekableVerifyingReader) Close() error {
return r.verified.Close()
}
func (r *verifyingReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
if err == io.EOF {
r.verify()
if r.failOnMismatch && r.mismatched {
return n, ErrArtifactDigestMismatch
}
}
return n, err
}
func (r *verifyingReader) Close() error {
return r.source.Close()
}
func (r *verifyingReader) verify() {
if r.verified {
return
}
r.verified = true
result := r.reader.Result()
if !result.Complete {
return
}
if len(r.checks.contentHash) > 0 {
if err := result.Verify(r.checks.contentHash); err != nil {
r.mismatched = true
r.onMismatch("content_hash: " + err.Error())
}
}
if len(r.checks.native) > 0 {
if err := result.Verify(r.checks.native); err != nil {
r.mismatched = true
r.onMismatch("integrity: " + err.Error())
}
}
if r.checks.hasChecksum {
if err := result.VerifyDigest(r.checks.checksum); err != nil {
r.mismatched = true
r.onMismatch("integrity: " + err.Error())
}
}
}
// sha1Checksum returns a registry's hexadecimal SHA-1 shasum as the
// "sha1-<hex>" checksum newIntegrityChecks accepts, or "" when it is empty or
// not a SHA-1 digest.
func sha1Checksum(shasum string) string {
if shasum == "" {
return ""
}
digest, err := integrity.ParseHex(integrity.SHA1, shasum)
if err != nil {
return ""
}
return digest.Checksum()
}