From 4b0e691cde24195044e34bed1f8e88617311e5db Mon Sep 17 00:00:00 2001 From: mitchell Date: Mon, 22 Jun 2026 12:50:58 -0400 Subject: [PATCH] Add artifactcrypto package: chunked AES-256-GCM content encryption (ENG-1631) New leaf package providing content encryption for private ingredient artifacts under a single organization-wide AES-256 key. - Encrypt/Decrypt stream the v1 payload format with bounded memory; each chunk is sealed with a fresh CSPRNG nonce, with the chunk index and a final-chunk flag bound into the AEAD so reorder/truncation are detected. - The header (magic marker, version, key id, key fingerprint, chunk size) is hashed into every chunk's AAD, so tampering or stripping it fails closed. - Fingerprint plus a header-only pre-flight CheckKey reject a wrong key before the body is read. - Decrypt writes to a sibling temp file and renames on success, so a failed decrypt never leaves partial or unauthenticated plaintext at the destination. Tests cover round-trip across sizes, a fail-closed tamper suite, nonce uniqueness, wrong-key pre-flight, a v1 golden vector, and an import-boundary check (stdlib + internal/errs only). The package is CGO-free so pkg/runtime's reachable set stays CGO-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/artifactcrypto/artifactcrypto.go | 254 ++++++++++++++++++ .../artifactcrypto/artifactcrypto_test.go | 251 +++++++++++++++++ internal/artifactcrypto/decrypt.go | 131 +++++++++ internal/artifactcrypto/encrypt.go | 95 +++++++ internal/artifactcrypto/golden_test.go | 101 +++++++ internal/artifactcrypto/imports_test.go | 51 ++++ internal/artifactcrypto/tamper_test.go | 176 ++++++++++++ .../artifactcrypto/testdata/golden_v1.bin | Bin 0 -> 308 bytes 8 files changed, 1059 insertions(+) create mode 100644 internal/artifactcrypto/artifactcrypto.go create mode 100644 internal/artifactcrypto/artifactcrypto_test.go create mode 100644 internal/artifactcrypto/decrypt.go create mode 100644 internal/artifactcrypto/encrypt.go create mode 100644 internal/artifactcrypto/golden_test.go create mode 100644 internal/artifactcrypto/imports_test.go create mode 100644 internal/artifactcrypto/tamper_test.go create mode 100644 internal/artifactcrypto/testdata/golden_v1.bin diff --git a/internal/artifactcrypto/artifactcrypto.go b/internal/artifactcrypto/artifactcrypto.go new file mode 100644 index 0000000000..822f508c6a --- /dev/null +++ b/internal/artifactcrypto/artifactcrypto.go @@ -0,0 +1,254 @@ +// Package artifactcrypto encrypts and decrypts artifacts under a caller-supplied +// 32-byte AES-256 key. +// +// An artifact is encrypted as a sequence of AES-256-GCM chunks. Each chunk is +// sealed with a fresh random 96-bit nonce, and the chunk index and a +// final-chunk flag are bound into the AEAD additional data so chunk reordering +// and truncation are detected. A header (magic marker, format version, key id, +// and key fingerprint) precedes the chunks; its bytes are hashed into every +// chunk's AAD, so altering or stripping the header makes decryption fail. +// +// The header records a key id and a SHA-256 fingerprint of the key, never the +// key bytes. +package artifactcrypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "io" + + "github.com/ActiveState/cli/internal/errs" +) + +var ( + // ErrBadMagic indicates the payload does not begin with the v1 magic marker. + ErrBadMagic = errs.New("not an encrypted artifact (bad magic marker)") + // ErrUnsupportedVersion indicates a payload format version this build cannot read. + ErrUnsupportedVersion = errs.New("unsupported payload format version") + // ErrCorruptPayload indicates a tampered, reordered, or otherwise unauthentic payload. + ErrCorruptPayload = errs.New("corrupt or tampered payload") + // ErrTruncated indicates the payload ended before a final chunk was authenticated. + ErrTruncated = errs.New("truncated payload (no authenticated final chunk)") + // ErrWrongKey indicates the supplied key does not match the payload's key fingerprint. + ErrWrongKey = errs.New("key does not match payload fingerprint") + // ErrInvalidKeySize indicates the supplied key is not a 32-byte AES-256 key. + ErrInvalidKeySize = errs.New("key must be 32 bytes (AES-256)") +) + +const ( + // KeySize is the required key length in bytes (AES-256). + KeySize = 32 + // nonceSize is the AES-GCM standard 96-bit nonce. + nonceSize = 12 + // tagSize is the AES-GCM authentication tag length. + tagSize = 16 + // formatVersion is the payload format version this build writes. + formatVersion = 1 + // DefaultChunkSize is the plaintext size of every chunk except the last. + DefaultChunkSize = 1 << 20 // 1 MiB + // maxChunkSize is the largest chunk size accepted from a parsed header. + maxChunkSize = 64 << 20 // 64 MiB + // maxHeaderLen is the largest serialized header accepted from a stream. + maxHeaderLen = 64 << 10 // 64 KiB +) + +// magicMarker marks the start of an encrypted payload. +const magicMarker = "ActiveStateEncrypted" + +// randReader is the source of nonce randomness; tests override it to produce +// deterministic output. +var randReader io.Reader = rand.Reader + +// encChunkSize is the plaintext chunk size used when encrypting; tests shrink it +// to exercise multi-chunk paths on small inputs. The size used is recorded in +// the header, so decryption adapts to it. +var encChunkSize = DefaultChunkSize + +// Header is a parsed payload header. It carries only public metadata, never key +// bytes. +type Header struct { + Version uint8 + ChunkSize uint32 + KeyID string + Fingerprint string // "sha256:" over the raw key bytes + + raw []byte // exact serialized header bytes, bound into every chunk's AAD +} + +// Fingerprint returns an identifier for a key as "sha256:". +func Fingerprint(key []byte) string { + sum := sha256.Sum256(key) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// CheckKey reports whether key matches the fingerprint recorded in the header, +// returning ErrWrongKey if it does not. +func (h Header) CheckKey(key []byte) error { + if len(key) != KeySize { + return ErrInvalidKeySize + } + if Fingerprint(key) != h.Fingerprint { + return ErrWrongKey + } + return nil +} + +// ParseHeader reads the header from src, consuming exactly the header bytes and +// leaving src positioned at the first chunk. +func ParseHeader(src io.Reader) (Header, error) { + var lenBuf [4]byte + if _, err := io.ReadFull(src, lenBuf[:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return Header{}, ErrBadMagic + } + return Header{}, errs.Wrap(err, "reading header length") + } + hdrLen := binary.BigEndian.Uint32(lenBuf[:]) + if hdrLen == 0 || hdrLen > maxHeaderLen { + return Header{}, ErrCorruptPayload + } + raw := make([]byte, hdrLen) + if _, err := io.ReadFull(src, raw); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return Header{}, ErrTruncated + } + return Header{}, errs.Wrap(err, "reading header") + } + return parseHeaderBytes(raw) +} + +// parseHeaderBytes decodes the serialized header fields from raw. It is strict: +// any trailing bytes, a bad magic marker, an out-of-range chunk size, or an +// unreadable field is rejected. +func parseHeaderBytes(raw []byte) (Header, error) { + r := bytes.NewReader(raw) + + magic := make([]byte, len(magicMarker)) + if _, err := io.ReadFull(r, magic); err != nil { + return Header{}, ErrBadMagic + } + if string(magic) != magicMarker { + return Header{}, ErrBadMagic + } + + version, err := r.ReadByte() + if err != nil { + return Header{}, ErrCorruptPayload + } + if version != formatVersion { + return Header{}, ErrUnsupportedVersion + } + + var u32 [4]byte + if _, err := io.ReadFull(r, u32[:]); err != nil { + return Header{}, ErrCorruptPayload + } + chunkSize := binary.BigEndian.Uint32(u32[:]) + if chunkSize == 0 || chunkSize > maxChunkSize { + return Header{}, ErrCorruptPayload + } + + keyID, err := readLenPrefixed(r) + if err != nil { + return Header{}, ErrCorruptPayload + } + fingerprint, err := readLenPrefixed(r) + if err != nil { + return Header{}, ErrCorruptPayload + } + + if r.Len() != 0 { // trailing bytes in the header are not allowed + return Header{}, ErrCorruptPayload + } + + return Header{ + Version: version, + ChunkSize: chunkSize, + KeyID: string(keyID), + Fingerprint: string(fingerprint), + raw: raw, + }, nil +} + +// serializeHeader returns the header byte sequence that is length-prefixed onto +// the wire and hashed into every chunk's AAD. +func serializeHeader(keyID, fingerprint string, chunkSize uint32) []byte { + var b bytes.Buffer + b.WriteString(magicMarker) + b.WriteByte(formatVersion) + var u32 [4]byte + binary.BigEndian.PutUint32(u32[:], chunkSize) + b.Write(u32[:]) + writeLenPrefixed(&b, []byte(keyID)) + writeLenPrefixed(&b, []byte(fingerprint)) + return b.Bytes() +} + +// headerHash returns the SHA-256 of the serialized header, which is folded into +// every chunk's AAD. +func (h Header) headerHash() [sha256.Size]byte { + return sha256.Sum256(h.raw) +} + +// makeAAD builds the additional authenticated data for one chunk: +// headerHash || uint64(index) || finalFlag. +func makeAAD(headerHash [sha256.Size]byte, index uint64, final bool) []byte { + var indexBytes [8]byte + binary.BigEndian.PutUint64(indexBytes[:], index) + + aad := make([]byte, 0, len(headerHash)+len(indexBytes)+1) + aad = append(aad, headerHash[:]...) + aad = append(aad, indexBytes[:]...) + aad = append(aad, finalByte(final)) + return aad +} + +func finalByte(final bool) byte { + if final { + return 1 + } + return 0 +} + +// newGCM validates the key length and returns an AES-256-GCM AEAD. +func newGCM(key []byte) (cipher.AEAD, error) { + if len(key) != KeySize { + return nil, ErrInvalidKeySize + } + block, err := aes.NewCipher(key) + if err != nil { + // Unreachable: the key length is validated above. + return nil, ErrInvalidKeySize + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, errs.Wrap(err, "initializing GCM") + } + return gcm, nil +} + +func writeLenPrefixed(b *bytes.Buffer, p []byte) { + var l [2]byte + binary.BigEndian.PutUint16(l[:], uint16(len(p))) + b.Write(l[:]) + b.Write(p) +} + +func readLenPrefixed(r *bytes.Reader) ([]byte, error) { + var l [2]byte + if _, err := io.ReadFull(r, l[:]); err != nil { + return nil, err + } + n := int(binary.BigEndian.Uint16(l[:])) + p := make([]byte, n) + if _, err := io.ReadFull(r, p); err != nil { + return nil, err + } + return p, nil +} diff --git a/internal/artifactcrypto/artifactcrypto_test.go b/internal/artifactcrypto/artifactcrypto_test.go new file mode 100644 index 0000000000..cd124013a8 --- /dev/null +++ b/internal/artifactcrypto/artifactcrypto_test.go @@ -0,0 +1,251 @@ +package artifactcrypto + +import ( + "archive/zip" + "bytes" + "crypto/rand" + "encoding/binary" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +// testKey is a fixed 32-byte AES-256 key used across tests. +var testKey = bytes.Repeat([]byte{0x42}, KeySize) + +// withChunkSize temporarily shrinks the encryption chunk size so multi-chunk +// behavior can be exercised on small inputs. +func withChunkSize(t *testing.T, size int) { + t.Helper() + prev := encChunkSize + encChunkSize = size + t.Cleanup(func() { encChunkSize = prev }) +} + +// encryptToBytes encrypts plaintext under testKey and returns the payload. +func encryptToBytes(t *testing.T, plaintext []byte, keyID string) []byte { + t.Helper() + var buf bytes.Buffer + if err := Encrypt(bytes.NewReader(plaintext), &buf, testKey, keyID); err != nil { + t.Fatalf("Encrypt: %v", err) + } + return buf.Bytes() +} + +// decryptToBytes decrypts payload to a temp file under testKey and returns the +// recovered plaintext along with the destination path. +func decryptToBytes(t *testing.T, payload, key []byte) ([]byte, string, error) { + t.Helper() + dest := filepath.Join(t.TempDir(), "out.bin") + err := Decrypt(bytes.NewReader(payload), dest, key) + if err != nil { + return nil, dest, err + } + got, readErr := os.ReadFile(dest) + if readErr != nil { + t.Fatalf("reading decrypted output: %v", readErr) + } + return got, dest, nil +} + +func TestRoundTrip(t *testing.T) { + withChunkSize(t, 64) + sizes := []int{ + 0, // empty + 1, // sub-chunk + 63, // just under a chunk + 64, // exactly one chunk + 65, // one chunk + 1 + 128, // exactly two chunks + 64*3 + 7, // multiple chunks + partial + } + for _, n := range sizes { + plaintext := make([]byte, n) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + payload := encryptToBytes(t, plaintext, "test-key-01") + got, _, err := decryptToBytes(t, payload, testKey) + if err != nil { + t.Fatalf("size %d: Decrypt: %v", n, err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("size %d: round-trip mismatch", n) + } + } +} + +// TestRoundTripPreservesZip checks that a ZIP survives an Encrypt/Decrypt +// round-trip byte-for-byte and still opens as a valid archive. +func TestRoundTripPreservesZip(t *testing.T) { + withChunkSize(t, 64) // exercise multi-chunk on a small archive + + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + files := map[string]string{ + "pkg/__init__.py": "print('hello from a private wheel')\n", + "pkg/module.py": bytes.NewBuffer(make([]byte, 300)).String(), + "pkg-1.0.dist-info/METADATA": "Name: pkg\nVersion: 1.0\n", + } + for name, body := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(w, body); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + original := zbuf.Bytes() + + payload := encryptToBytes(t, original, "wheel-key") + got, dest, err := decryptToBytes(t, payload, testKey) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(got, original) { + t.Fatal("decrypted bytes differ from the original wheel") + } + zr, err := zip.OpenReader(dest) + if err != nil { + t.Fatalf("decrypted file is not a valid zip: %v", err) + } + defer zr.Close() + if len(zr.File) != len(files) { + t.Fatalf("zip entry count = %d, want %d", len(zr.File), len(files)) + } +} + +func TestFingerprint(t *testing.T) { + fp := Fingerprint(testKey) + if len(fp) != len("sha256:")+64 { + t.Fatalf("unexpected fingerprint length: %q", fp) + } + if fp[:7] != "sha256:" { + t.Fatalf("fingerprint missing sha256 prefix: %q", fp) + } + // A different key yields a different fingerprint. + other := bytes.Repeat([]byte{0x01}, KeySize) + if Fingerprint(other) == fp { + t.Fatal("distinct keys produced identical fingerprints") + } +} + +func TestWrongKeyRejectedBeforeBody(t *testing.T) { + payload := encryptToBytes(t, []byte("secret wheel contents"), "kid") + + // The reader errors past the header, so reaching the body fails the test. + wrongKey := bytes.Repeat([]byte{0x99}, KeySize) + headerLen := binary.BigEndian.Uint32(payload[:4]) + headerOnly := payload[:4+int(headerLen)] + src := io.MultiReader(bytes.NewReader(headerOnly), failingReader{}) + + dest := filepath.Join(t.TempDir(), "out.bin") + err := Decrypt(src, dest, wrongKey) + if !errors.Is(err, ErrWrongKey) { + t.Fatalf("expected ErrWrongKey, got %v", err) + } + assertNoOutput(t, dest) +} + +func TestParseHeaderAndCheckKey(t *testing.T) { + payload := encryptToBytes(t, []byte("hello"), "my-key-id") + h, err := ParseHeader(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + if h.Version != formatVersion { + t.Errorf("version = %d, want %d", h.Version, formatVersion) + } + if h.KeyID != "my-key-id" { + t.Errorf("keyID = %q, want %q", h.KeyID, "my-key-id") + } + if h.Fingerprint != Fingerprint(testKey) { + t.Errorf("fingerprint = %q, want %q", h.Fingerprint, Fingerprint(testKey)) + } + if err := h.CheckKey(testKey); err != nil { + t.Errorf("CheckKey(correct) = %v, want nil", err) + } + if err := h.CheckKey(bytes.Repeat([]byte{0x00}, KeySize)); !errors.Is(err, ErrWrongKey) { + t.Errorf("CheckKey(wrong) = %v, want ErrWrongKey", err) + } +} + +func TestNonceUniquenessAcrossChunks(t *testing.T) { + withChunkSize(t, 16) + // Many chunks, so a reused nonce would show up as a duplicate below. + const chunks = 200 + plaintext := make([]byte, 16*chunks) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + payload := encryptToBytes(t, plaintext, "kid") + + nonces := extractNonces(t, payload) + if len(nonces) < chunks { + t.Fatalf("expected at least %d chunks, parsed %d", chunks, len(nonces)) + } + seen := make(map[string]int, len(nonces)) + for i, n := range nonces { + if prev, dup := seen[string(n)]; dup { + t.Fatalf("nonce reused between chunk %d and chunk %d", prev, i) + } + seen[string(n)] = i + } +} + +func TestKeySizeValidation(t *testing.T) { + short := make([]byte, 16) + if err := Encrypt(bytes.NewReader(nil), io.Discard, short, "kid"); !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("Encrypt(short key) = %v, want ErrInvalidKeySize", err) + } + payload := encryptToBytes(t, []byte("x"), "kid") + dest := filepath.Join(t.TempDir(), "out.bin") + if err := Decrypt(bytes.NewReader(payload), dest, short); !errors.Is(err, ErrInvalidKeySize) { + t.Errorf("Decrypt(short key) = %v, want ErrInvalidKeySize", err) + } + assertNoOutput(t, dest) +} + +// extractNonces walks the chunk frames of a payload and returns each chunk's +// nonce. It mirrors the on-wire framing the encoder writes. +func extractNonces(t *testing.T, payload []byte) [][]byte { + t.Helper() + off := 4 + int(binary.BigEndian.Uint32(payload[:4])) // skip header + var nonces [][]byte + for off < len(payload) { + // frame: finalFlag(1) | nonce(12) | ctLen(4) | ct(ctLen) + nonce := payload[off+1 : off+1+nonceSize] + ctLen := int(binary.BigEndian.Uint32(payload[off+1+nonceSize : off+1+nonceSize+4])) + nonces = append(nonces, append([]byte(nil), nonce...)) + final := payload[off] == 1 + off += 1 + nonceSize + 4 + ctLen + if final { + break + } + } + return nonces +} + +func assertNoOutput(t *testing.T, dest string) { + t.Helper() + if _, err := os.Stat(dest); !errors.Is(err, os.ErrNotExist) { + t.Errorf("destination %q exists after a failed decrypt (stat err = %v)", dest, err) + } + // No stray temp siblings should be left behind either. + matches, _ := filepath.Glob(dest + ".tmp-*") + if len(matches) != 0 { + t.Errorf("temp files left behind after failed decrypt: %v", matches) + } +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { + return 0, errors.New("body should not have been read") +} diff --git a/internal/artifactcrypto/decrypt.go b/internal/artifactcrypto/decrypt.go new file mode 100644 index 0000000000..e816e4168a --- /dev/null +++ b/internal/artifactcrypto/decrypt.go @@ -0,0 +1,131 @@ +package artifactcrypto + +import ( + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "errors" + "io" + "os" + "path/filepath" + + "github.com/ActiveState/cli/internal/errs" +) + +// Decrypt reads an encrypted payload from src, verifies it under the supplied +// 32-byte AES-256 key, and writes the recovered plaintext to destPath. +// +// Decrypt fails closed: it streams into a sibling temporary file and renames it +// onto destPath only after the entire payload verifies. On any failure the +// temporary file is removed and destPath is left untouched. +func Decrypt(src io.Reader, destPath string, key []byte) (rerr error) { + header, err := ParseHeader(src) + if err != nil { + return errs.Wrap(err, "unable to parse header") + } + if err := header.CheckKey(key); err != nil { + return errs.Wrap(err, "unable to verify key") // body never read + } + gcm, err := newGCM(key) + if err != nil { + return errs.Wrap(err, "unable to initialize decryption") + } + headerHash := header.headerHash() + + // A sibling temp file keeps the rename onto destPath atomic on the same filesystem. + tmp, err := os.CreateTemp(filepath.Dir(destPath), filepath.Base(destPath)+".tmp-*") + if err != nil { + return errs.Wrap(err, "creating temp output") + } + tmpName := tmp.Name() + defer func() { + if rerr == nil { + return + } + // Discard the partial output on failure, surfacing any cleanup errors. + // tmp is nil once it has already been closed for the rename. + if tmp != nil { + if err := tmp.Close(); err != nil { + rerr = errs.Pack(rerr, errs.Wrap(err, "closing temp output")) + } + } + if err := os.Remove(tmpName); err != nil { + rerr = errs.Pack(rerr, errs.Wrap(err, "removing temp output")) + } + }() + + if err := decryptBody(src, tmp, gcm, headerHash, header.ChunkSize); err != nil { + return errs.Wrap(err, "unable to decrypt body") + } + + // Close before renaming, and clear tmp so the deferred cleanup neither + // double-closes nor runs at all on success. + cerr := tmp.Close() + tmp = nil + if cerr != nil { + return errs.Wrap(cerr, "closing temp output") + } + if err := os.Rename(tmpName, destPath); err != nil { + return errs.Wrap(err, "finalizing output") + } + return nil +} + +// decryptBody streams the chunk sequence from src, authenticating each chunk and +// writing its plaintext to dst. It returns an error (and writes no further +// plaintext) on the first authentication or framing failure, and requires that +// the stream end exactly after an authenticated final chunk. +func decryptBody(src io.Reader, dst io.Writer, gcm cipher.AEAD, headerHash [sha256.Size]byte, chunkSize uint32) error { + maxCT := int(chunkSize) + tagSize + var index uint64 + for { + var frame [1 + nonceSize + 4]byte + _, err := io.ReadFull(src, frame[:]) + if errors.Is(err, io.EOF) { + // Clean end at a chunk boundary, but we never saw a final chunk. + return ErrTruncated + } + if errors.Is(err, io.ErrUnexpectedEOF) { + return ErrTruncated + } + if err != nil { + return errs.Wrap(err, "reading chunk frame") + } + + finalFlag := frame[0] + if finalFlag != 0 && finalFlag != 1 { + return ErrCorruptPayload + } + nonce := frame[1 : 1+nonceSize] + ctLen := int(binary.BigEndian.Uint32(frame[1+nonceSize:])) + if ctLen < tagSize || ctLen > maxCT { + return ErrCorruptPayload + } + + ct := make([]byte, ctLen) + if _, err := io.ReadFull(src, ct); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return ErrTruncated + } + return errs.Wrap(err, "reading chunk body") + } + + pt, err := gcm.Open(nil, nonce, ct, makeAAD(headerHash, index, finalFlag == 1)) + if err != nil { + return ErrCorruptPayload // tamper, reorder, wrong final flag, or wrong key + } + if _, err := dst.Write(pt); err != nil { + return errs.Wrap(err, "writing plaintext") + } + index++ + + if finalFlag == 1 { + // Reject any trailing data after the final chunk. + var extra [1]byte + if _, err := io.ReadFull(src, extra[:]); !errors.Is(err, io.EOF) { + return ErrCorruptPayload + } + return nil + } + } +} diff --git a/internal/artifactcrypto/encrypt.go b/internal/artifactcrypto/encrypt.go new file mode 100644 index 0000000000..99adc96170 --- /dev/null +++ b/internal/artifactcrypto/encrypt.go @@ -0,0 +1,95 @@ +package artifactcrypto + +import ( + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "io" + + "github.com/ActiveState/cli/internal/errs" +) + +// Encrypt reads the artifact from src and writes the encrypted payload to dst, +// sealed under the supplied 32-byte AES-256 key. keyID is recorded in the +// header. Encryption streams with memory bounded by the chunk size. A zero-byte +// input produces a single empty final chunk. +func Encrypt(src io.Reader, dst io.Writer, key []byte, keyID string) error { + gcm, err := newGCM(key) + if err != nil { + return errs.Wrap(err, "unable to initialize encryption") + } + + raw := serializeHeader(keyID, Fingerprint(key), uint32(encChunkSize)) + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(raw))) + if _, err := dst.Write(lenBuf[:]); err != nil { + return errs.Wrap(err, "writing header length") + } + if _, err := dst.Write(raw); err != nil { + return errs.Wrap(err, "writing header") + } + headerHash := sha256.Sum256(raw) + + // Ping-pong buffers: we read one chunk ahead so we know whether the chunk + // in hand is the final one (the last chunk may be short, including empty). + bufA := make([]byte, encChunkSize) + bufB := make([]byte, encChunkSize) + + cur, curN, err := readChunk(src, bufA) + if err != nil { + return errs.Wrap(err, "reading plaintext") + } + var index uint64 + for { + next, nextN, err := readChunk(src, bufB) + if err != nil { + return errs.Wrap(err, "reading plaintext") + } + final := nextN == 0 // nothing more to read: the chunk in hand is final + if err := sealChunk(dst, gcm, headerHash, index, final, cur[:curN]); err != nil { + return errs.Wrap(err, "unable to seal chunk") + } + if final { + return nil + } + cur, bufB = next, cur // reuse the buffer we just drained for the next read-ahead + curN = nextN + index++ + } +} + +// readChunk fills buf with up to len(buf) bytes, treating EOF (with or without +// a partial fill) as a normal short read rather than an error. It returns the +// number of bytes read; n == 0 means the stream is exhausted. +func readChunk(r io.Reader, buf []byte) ([]byte, int, error) { + n, err := io.ReadFull(r, buf) + if err == io.EOF || err == io.ErrUnexpectedEOF { + return buf, n, nil + } + if err != nil { + return buf, n, err + } + return buf, n, nil +} + +// sealChunk seals one plaintext chunk under a fresh nonce and writes its frame: +// finalFlag(1) || nonce(12) || ciphertextLen(uint32) || ciphertext+tag. +func sealChunk(dst io.Writer, gcm cipher.AEAD, headerHash [sha256.Size]byte, index uint64, final bool, plaintext []byte) error { + nonce := make([]byte, nonceSize) + if _, err := io.ReadFull(randReader, nonce); err != nil { + return errs.Wrap(err, "generating nonce") + } + ct := gcm.Seal(nil, nonce, plaintext, makeAAD(headerHash, index, final)) + + var frame [1 + nonceSize + 4]byte + frame[0] = finalByte(final) + copy(frame[1:1+nonceSize], nonce) + binary.BigEndian.PutUint32(frame[1+nonceSize:], uint32(len(ct))) + if _, err := dst.Write(frame[:]); err != nil { + return errs.Wrap(err, "writing chunk frame") + } + if _, err := dst.Write(ct); err != nil { + return errs.Wrap(err, "writing chunk body") + } + return nil +} diff --git a/internal/artifactcrypto/golden_test.go b/internal/artifactcrypto/golden_test.go new file mode 100644 index 0000000000..daac8b3449 --- /dev/null +++ b/internal/artifactcrypto/golden_test.go @@ -0,0 +1,101 @@ +package artifactcrypto + +import ( + "bytes" + "flag" + "os" + "path/filepath" + "testing" +) + +var update = flag.Bool("update", false, "regenerate golden test vectors") + +const goldenFile = "testdata/golden_v1.bin" + +// seqReader is a deterministic byte source (0,1,2,...,255,0,...) that pins +// nonces so the golden payload is byte-for-byte reproducible. +type seqReader struct{ n byte } + +func (s *seqReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = s.n + s.n++ + } + return len(p), nil +} + +// Fixed inputs that keep the golden payload stable. +var ( + goldenKey = bytes.Repeat([]byte{0x24}, KeySize) + goldenPlaintext = []byte("the quick brown fox jumps over the lazy dog, twice over now") + goldenKeyID = "golden-key-id" + goldenChunkSize = 16 +) + +func generateGolden(t *testing.T) []byte { + t.Helper() + prevRand, prevChunk := randReader, encChunkSize + randReader, encChunkSize = &seqReader{}, goldenChunkSize + t.Cleanup(func() { randReader, encChunkSize = prevRand, prevChunk }) + + var buf bytes.Buffer + if err := Encrypt(bytes.NewReader(goldenPlaintext), &buf, goldenKey, goldenKeyID); err != nil { + t.Fatalf("Encrypt: %v", err) + } + return buf.Bytes() +} + +// TestGoldenVectorV1 compares the encoder output against the committed golden +// payload byte-for-byte. Run `go test -update` to regenerate after an +// intentional format change. +func TestGoldenVectorV1(t *testing.T) { + got := generateGolden(t) + + if *update { + if err := os.MkdirAll(filepath.Dir(goldenFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenFile, got, 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %d bytes to %s", len(got), goldenFile) + return + } + + want, err := os.ReadFile(goldenFile) + if err != nil { + t.Fatalf("reading golden vector (run `go test -update` to create it): %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("payload format drifted from golden vector (%d bytes vs %d); run `go test -update` if intentional", len(got), len(want)) + } +} + +// TestGoldenVectorDecrypts confirms the committed golden payload decrypts to the +// expected plaintext and parses to the expected header. +func TestGoldenVectorDecrypts(t *testing.T) { + payload, err := os.ReadFile(goldenFile) + if err != nil { + t.Skipf("golden vector not present (run `go test -update`): %v", err) + } + + h, err := ParseHeader(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + if h.Version != formatVersion || h.KeyID != goldenKeyID || h.Fingerprint != Fingerprint(goldenKey) { + t.Fatalf("unexpected header: %+v", h) + } + + dest := filepath.Join(t.TempDir(), "golden.out") + if err := Decrypt(bytes.NewReader(payload), dest, goldenKey); err != nil { + t.Fatalf("Decrypt: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, goldenPlaintext) { + t.Fatalf("golden decrypt mismatch: got %q", got) + } +} diff --git a/internal/artifactcrypto/imports_test.go b/internal/artifactcrypto/imports_test.go new file mode 100644 index 0000000000..2536f33704 --- /dev/null +++ b/internal/artifactcrypto/imports_test.go @@ -0,0 +1,51 @@ +package artifactcrypto + +import ( + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +// allowedNonStdlibImports lists the non-stdlib packages this package may import +// directly. +var allowedNonStdlibImports = map[string]bool{ + "github.com/ActiveState/cli/internal/errs": true, +} + +// TestImportsStdlibOnly checks that the non-test source imports only the standard +// library and the packages in allowedNonStdlibImports. A standard library import +// path's first segment contains no dot; a module path's does. +func TestImportsStdlibOnly(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + checked := 0 + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + checked++ + f, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parsing %s: %v", name, err) + } + for _, imp := range f.Imports { + path := strings.Trim(imp.Path.Value, `"`) + first, _, _ := strings.Cut(path, "/") + isStdlib := !strings.Contains(first, ".") + if isStdlib || allowedNonStdlibImports[path] { + continue + } + t.Errorf("%s imports %q, which is neither stdlib nor on the allowlist "+ + "(allowed non-stdlib: internal/errs only)", name, path) + } + } + if checked == 0 { + t.Fatal("no non-test source files were checked") + } +} diff --git a/internal/artifactcrypto/tamper_test.go b/internal/artifactcrypto/tamper_test.go new file mode 100644 index 0000000000..70f6b0dc9e --- /dev/null +++ b/internal/artifactcrypto/tamper_test.go @@ -0,0 +1,176 @@ +package artifactcrypto + +import ( + "bytes" + "encoding/binary" + "errors" + "path/filepath" + "testing" +) + +// TestTamperFailsClosed runs the full tamper suite: every mutation of a valid +// payload must cause Decrypt to fail and leave no plaintext at the destination. +func TestTamperFailsClosed(t *testing.T) { + withChunkSize(t, 32) + plaintext := bytes.Repeat([]byte("private wheel bytes "), 8) // ~160 bytes => several chunks + keyID := "test-key-01" + + headerLen := func(p []byte) int { return int(binary.BigEndian.Uint32(p[:4])) } + + cases := []struct { + name string + mutate func(p []byte) []byte + wantErr error // nil means "any error is acceptable, just must fail closed" + }{ + { + name: "header bit-flip (keyID region)", + mutate: func(p []byte) []byte { + // Flip a byte inside the keyID region of the header. + idx := bytes.Index(p, []byte(keyID)) + p[idx] ^= 0xFF + return p + }, + wantErr: ErrCorruptPayload, + }, + { + name: "magic strip", + mutate: func(p []byte) []byte { + p[4] ^= 0xFF // first magic byte + return p + }, + wantErr: ErrBadMagic, + }, + { + name: "fingerprint strip", + mutate: func(p []byte) []byte { + // Last byte of the header is the last fingerprint char. + p[4+headerLen(p)-1] ^= 0xFF + return p + }, + wantErr: ErrWrongKey, // CheckKey rejects before the body is read + }, + { + name: "body bit-flip", + mutate: func(p []byte) []byte { + // Flip a ciphertext byte deep in the first chunk. + p[len(p)-5] ^= 0xFF + return p + }, + wantErr: ErrCorruptPayload, + }, + { + name: "chunk reorder", + mutate: func(p []byte) []byte { + return swapFirstTwoChunks(t, p) + }, + wantErr: ErrCorruptPayload, + }, + { + name: "truncation (drop final chunk)", + mutate: func(p []byte) []byte { + return dropLastChunk(t, p) + }, + wantErr: ErrTruncated, + }, + { + name: "truncation (mid-chunk-body cut)", + mutate: func(p []byte) []byte { + return p[:len(p)-3] + }, + wantErr: ErrTruncated, + }, + { + name: "final flag cleared on last chunk", + mutate: func(p []byte) []byte { + off := lastChunkOffset(t, p) + p[off] = 0 // was 1 + return p + }, + wantErr: ErrCorruptPayload, + }, + { + name: "trailing data appended after final chunk", + mutate: func(p []byte) []byte { + return append(p, 0x00, 0x01, 0x02) + }, + wantErr: ErrCorruptPayload, + }, + { + name: "ctLen inflated past chunk bound", + mutate: func(p []byte) []byte { + off := 4 + headerLen(p) // first chunk frame + binary.BigEndian.PutUint32(p[off+1+nonceSize:], 0xFFFFFFFF) + return p + }, + wantErr: ErrCorruptPayload, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload := encryptToBytes(t, plaintext, keyID) + tampered := tc.mutate(append([]byte(nil), payload...)) + + dest := filepath.Join(t.TempDir(), "out.bin") + err := Decrypt(bytes.NewReader(tampered), dest, testKey) + if err == nil { + t.Fatal("tampered payload decrypted without error") + } + if tc.wantErr != nil && !errors.Is(err, tc.wantErr) { + t.Fatalf("error = %v, want %v", err, tc.wantErr) + } + assertNoOutput(t, dest) + }) + } +} + +// chunkOffsets returns the byte offset of each chunk frame in a payload. +func chunkOffsets(t *testing.T, p []byte) []int { + t.Helper() + off := 4 + int(binary.BigEndian.Uint32(p[:4])) + var offs []int + for off < len(p) { + offs = append(offs, off) + ctLen := int(binary.BigEndian.Uint32(p[off+1+nonceSize : off+1+nonceSize+4])) + final := p[off] == 1 + off += 1 + nonceSize + 4 + ctLen + if final { + break + } + } + return offs +} + +func lastChunkOffset(t *testing.T, p []byte) int { + offs := chunkOffsets(t, p) + if len(offs) == 0 { + t.Fatal("no chunks found") + } + return offs[len(offs)-1] +} + +func swapFirstTwoChunks(t *testing.T, p []byte) []byte { + offs := chunkOffsets(t, p) + if len(offs) < 2 { + t.Fatalf("need at least 2 chunks to reorder, found %d", len(offs)) + } + frame := func(off int) []byte { + ctLen := int(binary.BigEndian.Uint32(p[off+1+nonceSize : off+1+nonceSize+4])) + end := off + 1 + nonceSize + 4 + ctLen + return p[off:end] + } + c0 := append([]byte(nil), frame(offs[0])...) + c1 := append([]byte(nil), frame(offs[1])...) + + var out bytes.Buffer + out.Write(p[:offs[0]]) // header + outer len + out.Write(c1) + out.Write(c0) + out.Write(p[offs[1]+len(c1):]) // remaining chunks, if any + return out.Bytes() +} + +func dropLastChunk(t *testing.T, p []byte) []byte { + off := lastChunkOffset(t, p) + return p[:off] +} diff --git a/internal/artifactcrypto/testdata/golden_v1.bin b/internal/artifactcrypto/testdata/golden_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..b370bff6901ae4ae81cc961aef914b3c45351695 GIT binary patch literal 308 zcmZQzU?_AVC