| 1 | package archive |
| 2 | |
| 3 | import ( |
| 4 | "crypto/aes" |
| 5 | "crypto/cipher" |
| 6 | "crypto/ecdh" |
| 7 | "crypto/rand" |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "encoding/binary" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "io" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/appconfig" |
| 16 | ) |
| 17 | |
| 18 | // ChunkSize is the AES-GCM chunk (§8.2). Each chunk is a 4-byte big-endian |
| 19 | // length and the ciphertext; its nonce is its index, and its associated |
| 20 | // data flags whether it is the last. Every chunk but the last holds exactly |
| 21 | // ChunkSize bytes of plaintext and the last holds fewer (possibly none), so |
| 22 | // the framing of a plaintext under a key is unique, Open knows from a |
| 23 | // chunk's length which flag it must carry, and a stream cut at a chunk |
| 24 | // boundary never ends on a chunk flagged last. |
| 25 | const ChunkSize = 1 << 20 |
| 26 | |
| 27 | // maxChunks bounds a sealed object: the largest decoded archive, plus |
| 28 | // zstd's worst-case expansion of incompressible input (under 1/256, and |
| 29 | // a margin for frame headers), in chunks, plus the final chunk. |
| 30 | const maxChunks = uint64((MaxDecoded+MaxDecoded/256+64<<10)/ChunkSize + 2) |
| 31 | |
| 32 | // ErrSealed is every refusal to open: a data key that is unbound or sealed |
| 33 | // for another app, a chunk that does not authenticate, a stream cut short |
| 34 | // or carrying data after its final chunk. |
| 35 | var ErrSealed = errors.New("archive: sealed object does not open") |
| 36 | |
| 37 | func nonceFor(index uint64, last bool) ([]byte, []byte) { |
| 38 | nonce := make([]byte, 12) |
| 39 | binary.BigEndian.PutUint64(nonce[4:], index) |
| 40 | aad := []byte{0} |
| 41 | if last { |
| 42 | aad[0] = 1 |
| 43 | } |
| 44 | return nonce, aad |
| 45 | } |
| 46 | |
| 47 | // Seal encrypts plain into w under a fresh 256-bit data key and returns |
| 48 | // the data key sealed with appconfig.SealBound (a v2 envelope bound to b, |
| 49 | // with its own fresh ephemeral key and nonce) and the sha256 of what it |
| 50 | // wrote. Never deterministic. SealBound, not appconfig.Seal: Seal writes |
| 51 | // unbound v1 envelopes unless the process turned bound sealing on, and the |
| 52 | // host agent never does (F4). |
| 53 | func Seal(plain io.Reader, pub *ecdh.PublicKey, b appconfig.Binding, w io.Writer) (sealedDataKey, sealedSHA string, err error) { |
| 54 | key := make([]byte, 32) |
| 55 | if _, err := rand.Read(key); err != nil { |
| 56 | return "", "", err |
| 57 | } |
| 58 | defer clear(key) |
| 59 | gcm, err := newGCM(key) |
| 60 | if err != nil { |
| 61 | return "", "", err |
| 62 | } |
| 63 | h := sha256.New() |
| 64 | out := io.MultiWriter(w, h) |
| 65 | buf := make([]byte, ChunkSize) |
| 66 | for index := uint64(0); ; index++ { |
| 67 | if index >= maxChunks { |
| 68 | return "", "", fmt.Errorf("%w: more than %d chunks", ErrTooLarge, maxChunks) |
| 69 | } |
| 70 | n, rerr := io.ReadFull(plain, buf) |
| 71 | last := rerr == io.EOF || rerr == io.ErrUnexpectedEOF |
| 72 | if rerr != nil && !last { |
| 73 | return "", "", rerr |
| 74 | } |
| 75 | nonce, aad := nonceFor(index, last) |
| 76 | ct := gcm.Seal(nil, nonce, buf[:n], aad) |
| 77 | var hdr [4]byte |
| 78 | binary.BigEndian.PutUint32(hdr[:], uint32(len(ct))) |
| 79 | if _, err := out.Write(hdr[:]); err != nil { |
| 80 | return "", "", err |
| 81 | } |
| 82 | if _, err := out.Write(ct); err != nil { |
| 83 | return "", "", err |
| 84 | } |
| 85 | if last { |
| 86 | break |
| 87 | } |
| 88 | } |
| 89 | sealedDataKey, err = appconfig.SealBound(pub, b, key) |
| 90 | if err != nil { |
| 91 | return "", "", err |
| 92 | } |
| 93 | return sealedDataKey, digestString(h), nil |
| 94 | } |
| 95 | |
| 96 | func newGCM(key []byte) (cipher.AEAD, error) { |
| 97 | block, err := aes.NewCipher(key) |
| 98 | if err != nil { |
| 99 | return nil, err |
| 100 | } |
| 101 | return cipher.NewGCM(block) |
| 102 | } |
| 103 | |
| 104 | // boundEnvelope reports whether an appconfig envelope is v2 (bound to an |
| 105 | // account and app). Twin of appconfig's unexported boundPrefix and |
| 106 | // splitVersion (crypto.go): appconfig.Open opens a v1 envelope under any |
| 107 | // binding, so Open checks the version itself. |
| 108 | func boundEnvelope(envelope string) bool { |
| 109 | raw, err := base64.StdEncoding.DecodeString(envelope) |
| 110 | return err == nil && len(raw) > 4 && string(raw[:4]) == "CSB2" |
| 111 | } |
| 112 | |
| 113 | // Open reverses Seal into w. An unbound (v1) data key, a data key sealed |
| 114 | // for another binding, any tampered chunk, a stream cut short and data |
| 115 | // after the final chunk all return ErrSealed. Each chunk is written to w |
| 116 | // only after it authenticates, but the stream as a whole is authentic only |
| 117 | // when Open returns nil: a reader must treat what it got as provisional |
| 118 | // until then. |
| 119 | func Open(sealed io.Reader, sealedDataKey string, priv *ecdh.PrivateKey, b appconfig.Binding, w io.Writer) error { |
| 120 | if !boundEnvelope(sealedDataKey) { |
| 121 | return fmt.Errorf("%w: the data key is not bound to an app", ErrSealed) |
| 122 | } |
| 123 | key, err := appconfig.Open(priv, b, sealedDataKey) |
| 124 | if err != nil { |
| 125 | return fmt.Errorf("%w: data key: %v", ErrSealed, err) |
| 126 | } |
| 127 | defer clear(key) |
| 128 | if len(key) != 32 { |
| 129 | return fmt.Errorf("%w: data key of %d bytes", ErrSealed, len(key)) |
| 130 | } |
| 131 | gcm, err := newGCM(key) |
| 132 | if err != nil { |
| 133 | return err |
| 134 | } |
| 135 | full := uint32(ChunkSize + gcm.Overhead()) |
| 136 | ct := make([]byte, full) |
| 137 | pt := make([]byte, 0, ChunkSize) |
| 138 | for index := uint64(0); ; index++ { |
| 139 | if index >= maxChunks { |
| 140 | return fmt.Errorf("%w: more than %d chunks", ErrSealed, maxChunks) |
| 141 | } |
| 142 | var hdr [4]byte |
| 143 | if _, err := io.ReadFull(sealed, hdr[:]); err != nil { |
| 144 | return truncated(err, index) |
| 145 | } |
| 146 | n := binary.BigEndian.Uint32(hdr[:]) |
| 147 | if n < uint32(gcm.Overhead()) || n > full { |
| 148 | return fmt.Errorf("%w: chunk %d has length %d", ErrSealed, index, n) |
| 149 | } |
| 150 | if _, err := io.ReadFull(sealed, ct[:n]); err != nil { |
| 151 | return truncated(err, index) |
| 152 | } |
| 153 | last := n < full |
| 154 | nonce, aad := nonceFor(index, last) |
| 155 | // Into pt, never ct[:0]: a failed Open may overwrite its dst. |
| 156 | out, err := gcm.Open(pt[:0], nonce, ct[:n], aad) |
| 157 | if err != nil { |
| 158 | return fmt.Errorf("%w: chunk %d does not authenticate", ErrSealed, index) |
| 159 | } |
| 160 | if _, err := w.Write(out); err != nil { |
| 161 | return err |
| 162 | } |
| 163 | if !last { |
| 164 | continue |
| 165 | } |
| 166 | // The final chunk must end the stream: trailing ciphertext would |
| 167 | // otherwise pass Verify against a manifest whose sealed digest |
| 168 | // covers only this authenticated prefix (round-6 finding 6). |
| 169 | var one [1]byte |
| 170 | m, err := io.ReadFull(sealed, one[:]) |
| 171 | if m > 0 { |
| 172 | return fmt.Errorf("%w: data after the final chunk", ErrSealed) |
| 173 | } |
| 174 | if err != io.EOF { |
| 175 | return err |
| 176 | } |
| 177 | return nil |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // truncated classifies a short read: the stream ending is ErrSealed, any |
| 182 | // other read error is the reader's own. |
| 183 | func truncated(err error, index uint64) error { |
| 184 | if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { |
| 185 | return fmt.Errorf("%w: truncated at chunk %d", ErrSealed, index) |
| 186 | } |
| 187 | return err |
| 188 | } |
| 189 | |