carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package archive
2
3import (
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.
25const 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.
30const 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.
35var ErrSealed = errors.New("archive: sealed object does not open")
36
37func 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).
53func 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
96func 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.
108func 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.
119func 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.
183func 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