Files
server-deploy/internal/appbundle/verify.go
T

313 lines
9.6 KiB
Go

// Package appbundle authenticates a bounded local bundle as bytes.
package appbundle
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"server-deploy/internal/wire"
)
const (
manifestLimit int64 = 1 << 20
fileLimit int64 = 4 << 20
payloadLimit int64 = 16 << 20
)
var (
idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
repositoryPart = regexp.MustCompile(`^[a-z0-9]+(?:[._-]+[a-z0-9]+)*$`)
pathPart = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
)
type Manifest struct {
ProtocolVersion int `json:"protocolVersion"`
AppID string `json:"appId"`
Version string `json:"version"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
Platforms []string `json:"platforms"`
Components []Component `json:"components"`
Files []File `json:"files"`
}
type Component struct {
Name string `json:"name"`
Image string `json:"image"`
}
type File struct {
Path string `json:"path"`
Digest string `json:"digest"`
}
type Verified struct {
Manifest Manifest
Files map[string][]byte
Digest string
}
// Verify authenticates the exact manifest and listed payload bytes. The expected
// digest is a caller trust anchor, not publisher authentication. Compose syntax
// and execution safety are not evaluated. The caller must use a trusted staging
// directory with trusted ancestors and prevent concurrent mutation; os.Root and
// identity checks do not provide a transaction against hostile concurrent writers.
func Verify(directory, expectedDigest string) (Verified, error) {
if !filepath.IsAbs(directory) {
return Verified{}, errors.New("bundle directory must be absolute")
}
// A trailing separator can make Lstat follow a directory symlink on Unix.
// Normalize it before checking the caller-selected root itself.
directory = filepath.Clean(directory)
if !digestPattern.MatchString(expectedDigest) {
return Verified{}, errors.New("invalid expected manifest digest")
}
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return Verified{}, errors.New("invalid bundle directory")
}
root, err := os.OpenRoot(directory)
if err != nil {
return Verified{}, errors.New("cannot open bundle directory")
}
defer root.Close()
openedInfo, err := root.Stat(".")
if err != nil || !os.SameFile(info, openedInfo) {
return Verified{}, errors.New("bundle directory changed")
}
raw, err := readRegular(root, "manifest.json", manifestLimit)
if err != nil {
return Verified{}, errors.New("cannot read bounded regular manifest")
}
if hash(raw) != expectedDigest {
return Verified{}, errors.New("manifest digest mismatch")
}
var manifest Manifest
if err := wire.Decode(bytes.NewReader(raw), &manifest, manifestLimit); err != nil {
// Never propagate decoder errors: some include offending input values.
return Verified{}, errors.New("invalid manifest JSON")
}
tree, err := validate(manifest)
if err != nil {
return Verified{}, err
}
files := make(map[string][]byte, len(manifest.Files))
remaining := payloadLimit
if err := readInventory(root, tree, "", files, &remaining); err != nil {
return Verified{}, err
}
return Verified{Manifest: manifest, Files: files, Digest: expectedDigest}, nil
}
func hash(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// readRegular caps both the pre-open size and actual bytes read. The one extra
// byte detects growth or oversize without an unbounded read, even after Stat.
// Names are single validated path segments relative to an already-open Root.
func readRegular(root *os.Root, name string, limit int64) ([]byte, error) {
info, err := root.Lstat(name)
if err != nil || !info.Mode().IsRegular() || info.Size() < 0 || info.Size() > limit {
return nil, errors.New("invalid file type or size")
}
f, err := root.Open(name)
if err != nil {
return nil, errors.New("cannot open file")
}
defer f.Close()
openedInfo, err := f.Stat()
if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) || openedInfo.Size() != info.Size() {
return nil, errors.New("file changed before read")
}
data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil || int64(len(data)) > limit || int64(len(data)) != openedInfo.Size() {
return nil, errors.New("file read exceeds bounds or changed")
}
return data, nil
}
type inventory struct {
children map[string]*inventory
digest string
}
func validate(m Manifest) (*inventory, error) {
if m.ProtocolVersion != 1 || m.Runtime != "compose" || !idPattern.MatchString(m.AppID) || !versionPattern.MatchString(m.Version) {
return nil, errors.New("unsupported or invalid manifest identity")
}
if len(m.Platforms) < 1 || len(m.Platforms) > 2 {
return nil, errors.New("invalid platforms")
}
platforms := make(map[string]bool)
for _, platform := range m.Platforms {
if (platform != "linux/amd64" && platform != "linux/arm64") || platforms[platform] {
return nil, errors.New("invalid platforms")
}
platforms[platform] = true
}
if len(m.Components) < 1 || len(m.Components) > 32 {
return nil, errors.New("invalid component count")
}
names := make(map[string]bool)
for _, c := range m.Components {
if !idPattern.MatchString(c.Name) || names[c.Name] || !validImage(c.Image) {
return nil, errors.New("invalid component")
}
names[c.Name] = true
}
if len(m.Files) < 1 || len(m.Files) > 128 {
return nil, errors.New("invalid file count")
}
tree := &inventory{children: map[string]*inventory{"manifest.json": {}}}
paths := make(map[string]bool)
for _, file := range m.Files {
if !validPath(file.Path) || file.Path == "manifest.json" || !digestPattern.MatchString(file.Digest) || paths[file.Path] {
return nil, errors.New("invalid file declaration")
}
paths[file.Path] = true
node := tree
parts := strings.Split(file.Path, "/")
for i, part := range parts {
next, exists := node.children[part]
if i == len(parts)-1 {
if exists {
return nil, errors.New("conflicting file paths")
}
node.children[part] = &inventory{digest: file.Digest}
} else {
if exists && next.children == nil {
return nil, errors.New("conflicting file paths")
}
if !exists {
next = &inventory{children: make(map[string]*inventory)}
node.children[part] = next
}
node = next
}
}
}
if !paths[m.Entrypoint] {
return nil, errors.New("entrypoint must be a listed file")
}
return tree, nil
}
func validImage(image string) bool {
repo, pin, found := strings.Cut(image, "@")
if !found || !digestPattern.MatchString(pin) {
return false
}
for _, part := range strings.Split(repo, "/") {
if !repositoryPart.MatchString(part) {
return false
}
}
return true
}
func validPath(path string) bool {
if len(path) == 0 || len(path) > 240 {
return false
}
for _, part := range strings.Split(path, "/") {
if len(part) > 100 || !pathPart.MatchString(part) || strings.HasSuffix(part, ".") {
return false
}
base, _, _ := strings.Cut(part, ".")
switch base {
case "con", "prn", "aux", "nul", "conin$", "conout$":
return false
}
if len(base) == 4 && (strings.HasPrefix(base, "com") || strings.HasPrefix(base, "lpt")) && base[3] >= '1' && base[3] <= '9' {
return false
}
}
return true
}
// readInventory enumerates only declared directories, one entry at a time. Each
// directory consumes at most its declared child count plus one entry; extras
// fail immediately, with no unbounded ReadDir or recursive filesystem walk.
func readInventory(root *os.Root, node *inventory, prefix string, files map[string][]byte, remaining *int64) error {
dir, err := root.Open(".")
if err != nil {
return errors.New("cannot enumerate bundle")
}
defer dir.Close()
seen := make(map[string]bool, len(node.children))
for {
entries, err := dir.ReadDir(1)
if err != nil && err != io.EOF {
return errors.New("cannot enumerate bundle")
}
if len(entries) == 0 {
if err == io.EOF {
break
}
return errors.New("invalid directory enumeration")
}
entry := entries[0]
name := entry.Name()
child, exists := node.children[name]
if !exists || seen[name] {
return errors.New("unexpected bundle entry")
}
seen[name] = true
info, statErr := root.Lstat(name)
if statErr != nil || info.Mode()&os.ModeSymlink != 0 {
return errors.New("invalid bundle entry")
}
if child.children != nil {
if !info.IsDir() {
return errors.New("expected bundle directory")
}
sub, openErr := root.OpenRoot(name)
if openErr != nil {
return errors.New("cannot open bundle subdirectory")
}
openedInfo, statErr := sub.Stat(".")
if statErr != nil || !os.SameFile(info, openedInfo) {
sub.Close()
return errors.New("bundle subdirectory changed")
}
err := readInventory(sub, child, prefix+name+"/", files, remaining)
sub.Close()
if err != nil {
return err
}
continue
}
if !info.Mode().IsRegular() {
return errors.New("expected regular bundle file")
}
if prefix == "" && name == "manifest.json" {
continue
}
limit := min(fileLimit, *remaining)
data, readErr := readRegular(root, name, limit)
if readErr != nil {
return errors.New("cannot read bounded regular payload")
}
if hash(data) != child.digest {
return errors.New("payload digest mismatch")
}
*remaining -= int64(len(data))
files[prefix+name] = data
}
if len(seen) != len(node.children) {
return errors.New("missing bundle entry")
}
return nil
}