carlos / platform Public

Bound private result capture and recipient transport

Paul Campbell pushed by paul@keymail.dev c9961ae9f9a94c476bc0ed58dff8138b7bf720a6 parent cd7e917
2 files changed, +282 −0
  • internal/maintenance/result.go +87 −0
  • internal/maintenance/result_test.go +195 −0
diff --git a/internal/maintenance/result.go b/internal/maintenance/result.go
new file mode 100644
index 000000000..112acf059
--- /dev/null
+++ b/internal/maintenance/result.go
@@ -0,0 +1,87 @@
+package maintenance
+
+import (
+ "context"
+ "crypto/ecdh"
+ "encoding/base64"
+ "errors"
+ "io"
+ "os"
+ "time"
+)
+
+const MaxResultBytes = 64 << 10
+const MaxExecutionDuration = 10 * time.Minute
+
+var (
+ ErrResult = errors.New("maintenance: private result unavailable")
+ ErrResultOverflow = errors.New("maintenance: private result exceeds limit")
+)
+
+// CaptureResult takes ownership of a dedicated stdout read pipe and closes it
+// on every return. It returns complete bytes only after EOF within the deadline.
+// Closing this pipe does not prove the command or its descendants have stopped;
+// the worker must separately check Wait and containment before publishing success.
+func CaptureResult(ctx context.Context, pipe *os.File) ([]byte, error) {
+ if pipe == nil {
+ return nil, ErrResult
+ }
+ defer pipe.Close()
+ if ctx == nil {
+ return nil, ErrResult
+ }
+ deadline, ok := ctx.Deadline()
+ remaining := time.Until(deadline)
+ if !ok || remaining <= 0 || remaining > MaxExecutionDuration || ctx.Err() != nil {
+ return nil, ErrResult
+ }
+ info, err := pipe.Stat()
+ if err != nil || info.Mode()&os.ModeNamedPipe == 0 {
+ return nil, ErrResult
+ }
+ stop := context.AfterFunc(ctx, func() { _ = pipe.Close() })
+ defer stop()
+ body, err := io.ReadAll(io.LimitReader(pipe, MaxResultBytes+1))
+ if err != nil || ctx.Err() != nil {
+ clear(body)
+ return nil, ErrResult
+ }
+ if len(body) > MaxResultBytes {
+ clear(body)
+ return nil, ErrResultOverflow
+ }
+ if err := pipe.Close(); err != nil || ctx.Err() != nil {
+ clear(body)
+ return nil, ErrResult
+ }
+ return body, nil
+}
+
+func SealResult(pub *ecdh.PublicKey, binding Binding, body []byte) (string, error) {
+ if binding.Direction != DirectionResult || len(body) > MaxResultBytes {
+ return "", ErrResult
+ }
+ sealed, err := Seal(pub, binding, body)
+ if err != nil {
+ return "", ErrResult
+ }
+ return sealed, nil
+}
+
+func OpenResult(priv *ecdh.PrivateKey, binding Binding, sealed string) ([]byte, error) {
+ const overhead = len(envelopePrefix) + ephemeralBytes + nonceBytes + tagBytes
+ if binding.Direction != DirectionResult || len(sealed) > base64.StdEncoding.EncodedLen(overhead+MaxResultBytes) {
+ return nil, ErrResult
+ }
+ body, err := Open(priv, binding, sealed)
+ if err != nil {
+ return nil, ErrResult
+ }
+ // The decoded bound is necessary even after the encoded-length check:
+ // base64 padding can give adjacent raw lengths the same encoded length.
+ if len(body) > MaxResultBytes {
+ clear(body)
+ return nil, ErrResult
+ }
+ return body, nil
+}
diff --git a/internal/maintenance/result_test.go b/internal/maintenance/result_test.go
new file mode 100644
index 000000000..5d6d83521
--- /dev/null
+++ b/internal/maintenance/result_test.go
@@ -0,0 +1,195 @@
+package maintenance
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "os"
+ "strconv"
+ "testing"
+ "time"
+)
+
+func resultPipe(t *testing.T) (*os.File, *os.File) {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = r.Close(); _ = w.Close() })
+ return r, w
+}
+func resultContext(t *testing.T) context.Context {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ t.Cleanup(cancel)
+ return ctx
+}
+
+func TestCaptureResultCompleteBoundaries(t *testing.T) {
+ for _, n := range []int{0, 1, MaxResultBytes} {
+ t.Run(strconv.Itoa(n), func(t *testing.T) {
+ r, w := resultPipe(t)
+ body := bytes.Repeat([]byte{'x'}, n)
+ done := make(chan error, 1)
+ go func() { _, e := w.Write(body); _ = w.Close(); done <- e }()
+ got, err := CaptureResult(resultContext(t), r)
+ if err != nil || !bytes.Equal(got, body) {
+ t.Fatalf("size%d capture failed: %v", n, err)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.Stat(); err == nil {
+ t.Fatal("capture retained its owned read pipe")
+ }
+ })
+ }
+}
+
+func TestCaptureResultOverflowWithoutEOF(t *testing.T) {
+ r, w := resultPipe(t)
+ done := make(chan error, 1)
+ go func() { _, err := w.Write(bytes.Repeat([]byte{'x'}, MaxResultBytes+1)); done <- err }()
+ body, err := CaptureResult(resultContext(t), r)
+ if body != nil || err != ErrResultOverflow {
+ t.Fatalf("overflow = %d bytes, %v", len(body), err)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ // The write end is deliberately still open: overflow cannot require EOF.
+ if _, err := w.Stat(); err != nil {
+ t.Fatal("writer closed before overflow observation")
+ }
+}
+
+func TestCaptureResultCancellationDiscardsPartialBytes(t *testing.T) {
+ r, w := resultPipe(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ type answer struct {
+ body []byte
+ err error
+ }
+ done := make(chan answer, 1)
+ go func() { b, e := CaptureResult(ctx, r); done <- answer{b, e} }()
+ if _, err := w.Write([]byte("synthetic-private-fragment")); err != nil {
+ t.Fatal(err)
+ }
+ cancel()
+ select {
+ case got := <-done:
+ if got.body != nil || got.err != ErrResult {
+ t.Fatal("cancel returned partial bytes or success")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("cancellation did not unblock the actual pipe")
+ }
+ if _, err := r.Stat(); err == nil {
+ t.Fatal("cancel retained read descriptor")
+ }
+ if _, err := w.Stat(); err != nil {
+ t.Fatal("capture claimed to terminate independent writer")
+ }
+}
+
+func TestCaptureResultRequiresEOFBeforeDeadline(t *testing.T) {
+ r, _ := resultPipe(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ b, e := CaptureResult(ctx, r)
+ if b != nil {
+ done <- nil
+ return
+ }
+ done <- e
+ }()
+ select {
+ case err := <-done:
+ if err != ErrResult {
+ t.Fatal("open writer accepted without EOF")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("deadline did not close pipe")
+ }
+}
+
+func TestCaptureResultRefusesUnboundedOrNonpipeInput(t *testing.T) {
+ long, cancelLong := context.WithTimeout(context.Background(), MaxExecutionDuration+time.Minute)
+ defer cancelLong()
+ expired, cancelExpired := context.WithCancel(resultContext(t))
+ cancelExpired()
+ for _, ctx := range []context.Context{nil, context.Background(), long, expired} {
+ r, _ := resultPipe(t)
+ if b, e := CaptureResult(ctx, r); b != nil || e != ErrResult {
+ t.Fatal("invalid context accepted")
+ }
+ if _, e := r.Stat(); e == nil {
+ t.Fatal("refusal retained read pipe")
+ }
+ }
+ f, e := os.CreateTemp(t.TempDir(), "regular")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if b, e := CaptureResult(resultContext(t), f); b != nil || e != ErrResult {
+ t.Fatal("regular file accepted as private stdout")
+ }
+ if b, e := CaptureResult(resultContext(t), nil); b != nil || e != ErrResult {
+ t.Fatal("nil pipe accepted")
+ }
+}
+
+func TestResultEnvelopePurposeAndLimits(t *testing.T) {
+ key, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fingerprint, err := RecipientFingerprint(key.PublicKey())
+ if err != nil {
+ t.Fatal(err)
+ }
+ binding := Binding{Version: 1, Direction: DirectionResult, Account: "account", App: "home", Host: "test.example", RequestID: "request", AttemptID: "attempt", Recipient: fingerprint}
+ for _, n := range []int{0, 1, MaxResultBytes} {
+ body := bytes.Repeat([]byte{'r'}, n)
+ sealed, err := SealResult(key.PublicKey(), binding, body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := OpenResult(key, binding, sealed)
+ if err != nil || !bytes.Equal(body, got) {
+ t.Fatalf("result roundtrip size%d: %v", n, err)
+ }
+ }
+ tooLarge := bytes.Repeat([]byte{'r'}, MaxResultBytes+1)
+ if _, err := SealResult(key.PublicKey(), binding, tooLarge); err != ErrResult {
+ t.Fatal("oversized seal accepted")
+ }
+ // Generic crypto accepts this size. Base64's rounding also fits the result
+ // encoded-length bound, so the separate decoded-length check must reject it.
+ sealed, err := Seal(key.PublicKey(), binding, tooLarge)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b, err := OpenResult(key, binding, sealed); b != nil || err != ErrResult {
+ t.Fatal("oversized decoded result accepted")
+ }
+ for _, direction := range []Direction{DirectionInput, DirectionEnvironment} {
+ wrong := binding
+ wrong.Direction = direction
+ if _, err := SealResult(key.PublicKey(), wrong, []byte("ok")); err != ErrResult {
+ t.Fatal("wrong seal purpose accepted")
+ }
+ sealed, err := Seal(key.PublicKey(), wrong, []byte("ok"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b, err := OpenResult(key, wrong, sealed); b != nil || err != ErrResult {
+ t.Fatal("wrong open purpose accepted")
+ }
+ }
+}