Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ permissions:
contents: read

jobs:
test-macos-pipeline-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ${{ env.TEST_SOURCE_REPO }}
ref: ${{ env.TEST_SOURCE_REF }}
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Check macOS pipeline contracts
run: |
python3 -m unittest discover -s scripts/macos -p 'test_*.py' -v
sh -n scripts/macos/example/build.sh

test:
runs-on: [self-hosted, linux, x64, kvm]
steps:
Expand Down
219 changes: 219 additions & 0 deletions cmd/publish-macos-base/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// publish-macos-base is an offline administrator tool, not an HTTP filesystem API.
package main

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"

"github.com/kernel/hypeman/lib/images"
)

type audit struct {
Version int `json:"version"`
Status string `json:"status"`
OSVersion string `json:"os_version"`
OSBuild string `json:"os_build"`
Architecture string `json:"architecture"`
AgentSHA256 string `json:"agent_sha256"`
PlistSHA256 string `json:"plist_sha256"`
ToolchainSHA256 string `json:"toolchain_sha256"`
ToolchainVersion string `json:"toolchain_version"`
HomeBaselineSHA256 string `json:"home_baseline_sha256"`
AuditScriptSHA256 string `json:"audit_script_sha256"`
}

type receipt struct {
Version int `json:"version"`
Reference string `json:"reference"`
Digest string `json:"digest"`
Audit audit `json:"audit"`
AuditSHA256 string `json:"audit_sha256"`
PayloadSHA256 map[string]string `json:"payload_sha256"`
PublishedAt time.Time `json:"published_at"`
}

func main() {
source := flag.String("source", "", "exclusive stopped macvm bundle")
auditPath := flag.String("audit", "", "guest audit JSON from final GuestService exec")
registry := flag.String("registry", "", "configured internal registry URL")
key := flag.String("registry-secret-file", "", "private file containing internal registry signing secret")
id := flag.String("id", "", "unique lowercase base publication ID (base-...) ")
out := flag.String("output", "", "new receipt file (must not exist)")
stopped := flag.Bool("stopped", false, "attest graceful shutdown and VMM exit; no restart during export")
clean := flag.Bool("clean-install-reviewed", false, "attest fresh installation, no real credentials ever, final audit via GuestService")
timeout := flag.Duration("timeout", 2*time.Hour, "snapshot and publication deadline")
flag.Parse()
if *source == "" || *auditPath == "" || *registry == "" || *key == "" || *out == "" || !*stopped || !*clean || !strings.HasPrefix(*id, "base-") || len(*id) > 128 || strings.Trim(*id, "abcdefghijklmnopqrstuvwxyz0123456789-") != "" || *timeout <= 0 || *timeout > 24*time.Hour {
fmt.Fprintln(os.Stderr, "required: --source --audit --registry --registry-secret-file --id base-... --output --stopped --clean-install-reviewed; timeout must be in (0,24h]")
os.Exit(2)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
ctx, deadline := context.WithTimeout(ctx, *timeout)
defer deadline()
if err := run(ctx, *source, *auditPath, *registry, *key, *id, *out); err != nil {
// Registry/OS errors can contain credentials, paths or server response bodies.
fmt.Fprintln(os.Stderr, "base publication failed; no verified receipt (check private operator diagnostics)")
os.Exit(1)
}
}

func readAudit(path string) (audit, string, error) {
var a audit
f, err := os.Open(path)
if err != nil {
return a, "", err
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, 65537))
if err != nil || len(data) > 65536 {
return a, "", fmt.Errorf("invalid audit size")
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err = dec.Decode(&a); err != nil {
return a, "", err
}
if dec.Decode(new(any)) != io.EOF || a.Version != 1 || a.Status != "pass" || a.Architecture != "arm64" {
return a, "", fmt.Errorf("audit rejected")
}
for _, hash := range []string{a.AgentSHA256, a.PlistSHA256, a.ToolchainSHA256, a.HomeBaselineSHA256, a.AuditScriptSHA256} {
if !hashValid(hash) {
return a, "", fmt.Errorf("audit hash rejected")
}
}
if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)*$`).MatchString(a.OSVersion) ||
!regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString(a.OSBuild) ||
!regexp.MustCompile(`^Xcode [0-9.]+; Build version [a-zA-Z0-9]+$`).MatchString(a.ToolchainVersion) {
return a, "", fmt.Errorf("audit provenance rejected")
}
sum := sha256.Sum256(data)
return a, hex.EncodeToString(sum[:]), nil
}

type deadlineReader struct {
ctx context.Context
reader io.Reader
}

func (r deadlineReader) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(p)
}

func hashValid(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }

func validateSource(root string) error {
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 {
return fmt.Errorf("private real bundle directory required")
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 3 {
return fmt.Errorf("only disk.img, aux.img and config.json allowed")
}
for _, name := range []string{"disk.img", "aux.img", "config.json"} {
info, err := os.Lstat(filepath.Join(root, name))
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
return fmt.Errorf("private regular payload required")
}
}
platform, err := images.ValidateMacOSBundle(root)
if err != nil {
return err
}
if !platform.GuestAgent {
return fmt.Errorf("system guest agent required")
}
return nil
}

func run(ctx context.Context, source, auditPath, registry, key, id, out string) error {
a, auditHash, err := readAudit(auditPath)
if err != nil {
return err
}
if err = validateSource(source); err != nil {
return err
}
// Reserve before publishing: never overwrite a prior release receipt.
output, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
complete := false
defer func() {
output.Close()
if !complete {
os.Remove(out)
}
}()
info, err := os.Lstat(key)
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() < 32 || info.Size() > 4096 {
return fmt.Errorf("private registry signing key required")
}
secret, err := os.ReadFile(key)
if err != nil {
return err
}
secretText := strings.TrimSpace(string(secret))
if len(secretText) < 32 {
return fmt.Errorf("registry signing key is too short")
}
workspace, err := os.MkdirTemp("", "hypeman-base-")
if err != nil {
return err
}
defer os.RemoveAll(workspace)
bundle, err := snapshot(ctx, source, workspace)
if err != nil {
return err
}
if err = validateSource(bundle); err != nil {
return err
}
hashes := map[string]string{}
for _, name := range []string{"disk.img", "aux.img", "config.json"} {
f, err := os.Open(filepath.Join(bundle, name))
if err != nil {
return err
}
h := sha256.New()
_, err = io.Copy(h, deadlineReader{ctx, f})
f.Close()
if err != nil {
return err
}
hashes[name] = hex.EncodeToString(h.Sum(nil))
}
reference, digest, err := publish(ctx, registry, secretText, id, bundle)
if err != nil {
return err
}
r := receipt{1, reference, digest, a, auditHash, hashes, time.Now().UTC()}
if err = json.NewEncoder(output).Encode(r); err != nil {
return err
}
if err = output.Sync(); err != nil {
return err
}
if err = output.Close(); err != nil {
return err
}
complete = true
return nil
}
128 changes: 128 additions & 0 deletions cmd/publish-macos-base/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/kernel/hypeman/lib/images"
)

func goodAudit() audit {
h := strings.Repeat("a", 64)
return audit{Version: 1, Status: "pass", OSVersion: "15.5", OSBuild: "24F74", Architecture: "arm64", AgentSHA256: h, PlistSHA256: h, ToolchainSHA256: h, ToolchainVersion: "Xcode 16.4; Build version 16F6", HomeBaselineSHA256: h, AuditScriptSHA256: h}
}
func TestReadAudit(t *testing.T) {
a := goodAudit()
data, _ := json.Marshal(a)
tests := map[string][]byte{"valid": data, "trailing": append(append([]byte{}, data...), []byte(" {}")...), "unknown": []byte(strings.Replace(string(data), `"status":"pass"`, `"status":"pass","credential":"secret"`, 1)), "oversized": []byte(strings.Repeat(" ", 65537))}
bad := a
bad.Status = "fail"
tests["failed"], _ = json.Marshal(bad)
bad = a
bad.OSVersion = "secret-or-command-output"
tests["unsafe-provenance"], _ = json.Marshal(bad)
bad = a
bad.AgentSHA256 = ""
tests["missing-pin"], _ = json.Marshal(bad)
for name, data := range tests {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.json")
os.WriteFile(path, data, 0600)
_, sum, err := readAudit(path)
if name == "valid" {
if err != nil || !hashValid(sum) {
t.Fatalf("valid audit rejected: %v", err)
}
} else if err == nil {
t.Fatal("invalid audit accepted")
}
})
}
}
func TestDeadlineReader(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := io.ReadAll(deadlineReader{ctx, strings.NewReader("data")})
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation ignored: %v", err)
}
}

func TestFailedPublicationEvidence(t *testing.T) {
root := bundleFixture(t)
dir := t.TempDir()
a, _ := json.Marshal(goodAudit())
auditPath := filepath.Join(dir, "audit.json")
if err := os.WriteFile(auditPath, a, 0600); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "receipt.json")
err := run(context.Background(), root, auditPath, "https://registry.example", filepath.Join(dir, "missing-key"), "base-test", out)
if err == nil {
t.Fatal("missing key accepted")
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatal("failed publication left receipt")
}
if err := os.WriteFile(out, []byte("existing"), 0600); err != nil {
t.Fatal(err)
}
if err := run(context.Background(), root, auditPath, "https://registry.example", "missing-key", "base-test", out); err == nil {
t.Fatal("existing receipt accepted")
}
data, _ := os.ReadFile(out)
if string(data) != "existing" {
t.Fatal("existing receipt changed")
}
}

func bundleFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
os.Chmod(root, 0700)
platform := images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30, GuestAgent: true}
data, _ := json.Marshal(platform)
for name, data := range map[string][]byte{"disk.img": []byte("disk"), "aux.img": []byte("aux"), "config.json": data} {
if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil {
t.Fatal(err)
}
}
return root
}
func TestValidateSource(t *testing.T) {
for _, name := range []string{"valid", "extra", "symlink", "public", "unmanaged", "unknown-metadata", "hardlink"} {
t.Run(name, func(t *testing.T) {
root := bundleFixture(t)
switch name {
case "extra":
os.WriteFile(filepath.Join(root, "token"), []byte("secret"), 0600)
case "symlink":
os.Remove(filepath.Join(root, "aux.img"))
os.Symlink("disk.img", filepath.Join(root, "aux.img"))
case "hardlink":
os.Remove(filepath.Join(root, "aux.img"))
os.Link(filepath.Join(root, "disk.img"), filepath.Join(root, "aux.img"))
case "public":
os.Chmod(filepath.Join(root, "disk.img"), 0644)
case "unmanaged", "unknown-metadata":
data, _ := os.ReadFile(filepath.Join(root, "config.json"))
if name == "unmanaged" {
data = []byte(strings.Replace(string(data), `"guest_agent":true`, `"guest_agent":false`, 1))
} else {
data = []byte(strings.Replace(string(data), `"guest_agent":true`, `"guest_agent":true,"token":"secret"`, 1))
}
os.WriteFile(filepath.Join(root, "config.json"), data, 0600)
}
err := validateSource(root)
if (err == nil) != (name == "valid") {
t.Fatalf("validation result: %v", err)
}
})
}
}
Loading
Loading