Bound encrypted maintenance input without rewriting signed JSON
2 files changed,
+245
−0
internal/maintenance/input.go+127 −0internal/maintenance/input_test.go+118 −0
CI passed — run details
diff --git a/internal/maintenance/input.go b/internal/maintenance/input.go| new file mode 100644 |
| index 000000000..a84e25bd0 |
| --- /dev/null |
| +++ b/internal/maintenance/input.go |
| @@ -0,0 +1,127 @@ |
| +package maintenance |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "encoding/binary" |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "unicode/utf8" |
| +) |
| + |
| +const ( |
| + MaxInputBytes = 64 << 10 |
| + MaxArguments = 32 |
| + MaxArgumentBytes = 8 << 10 |
| + inputPrefix = "CMI1" |
| + maxInputFrame = len(inputPrefix) + 4 + 4*MaxArguments + MaxArgumentBytes + 4 + MaxInputBytes |
| +) |
| + |
| +var ErrInput = errors.New("maintenance: invalid private input") |
| + |
| +// Input contains arguments for the pinned artifact and its exact JSON stdin. |
| +// The worker supplies the existing database path; this value grants no authority |
| +// to select an executable, shell, database, environment, or working directory. |
| +type Input struct { |
| + Arguments []string |
| + Stdin []byte |
| +} |
| + |
| +func validInput(in Input) bool { |
| + if len(in.Arguments) == 0 || len(in.Arguments) > MaxArguments || len(in.Stdin) > MaxInputBytes || !utf8.Valid(in.Stdin) || !json.Valid(in.Stdin) { |
| + return false |
| + } |
| + total := 0 |
| + for _, arg := range in.Arguments { |
| + if len(arg) > MaxArgumentBytes-total || strings.ContainsRune(arg, 0) || !utf8.ValidString(arg) { |
| + return false |
| + } |
| + total += len(arg) |
| + } |
| + return true |
| +} |
| + |
| +// EncodeInput retains whitespace, escapes, and every other byte of signed input. |
| +// JSON semantics, including allowed fields, remain the app command's concern. |
| +func EncodeInput(in Input) ([]byte, error) { |
| + if !validInput(in) { |
| + return nil, ErrInput |
| + } |
| + out := binary.BigEndian.AppendUint32([]byte(inputPrefix), uint32(len(in.Arguments))) |
| + for _, arg := range in.Arguments { |
| + out = appendField(out, []byte(arg)) |
| + } |
| + return appendField(out, in.Stdin), nil |
| +} |
| + |
| +// DecodeInput returns owned data only after checking the complete bounded frame. |
| +func DecodeInput(frame []byte) (Input, error) { |
| + if len(frame) > maxInputFrame || len(frame) < 8 || !bytes.Equal(frame[:4], []byte(inputPrefix)) { |
| + return Input{}, ErrInput |
| + } |
| + count := binary.BigEndian.Uint32(frame[4:8]) |
| + if count == 0 || count > MaxArguments { |
| + return Input{}, ErrInput |
| + } |
| + rest := frame[8:] |
| + take := func() ([]byte, bool) { |
| + if len(rest) < 4 { |
| + return nil, false |
| + } |
| + n := binary.BigEndian.Uint32(rest[:4]) |
| + rest = rest[4:] |
| + if uint64(n) > uint64(len(rest)) { |
| + return nil, false |
| + } |
| + value := rest[:int(n)] |
| + rest = rest[int(n):] |
| + return value, true |
| + } |
| + in := Input{Arguments: make([]string, 0, int(count))} |
| + for range count { |
| + arg, ok := take() |
| + if !ok { |
| + return Input{}, ErrInput |
| + } |
| + in.Arguments = append(in.Arguments, string(arg)) |
| + } |
| + stdin, ok := take() |
| + if !ok || len(rest) != 0 { |
| + return Input{}, ErrInput |
| + } |
| + in.Stdin = stdin |
| + if !validInput(in) { |
| + return Input{}, ErrInput |
| + } |
| + in.Stdin = bytes.Clone(stdin) |
| + return in, nil |
| +} |
| + |
| +func SealInput(pub *ecdh.PublicKey, binding Binding, in Input) (string, error) { |
| + if binding.Direction != DirectionInput { |
| + return "", ErrInput |
| + } |
| + frame, err := EncodeInput(in) |
| + if err != nil { |
| + return "", ErrInput |
| + } |
| + defer clear(frame) |
| + sealed, err := Seal(pub, binding, frame) |
| + if err != nil { |
| + return "", ErrInput |
| + } |
| + return sealed, nil |
| +} |
| + |
| +func OpenInput(priv *ecdh.PrivateKey, binding Binding, sealed string) (Input, error) { |
| + if binding.Direction != DirectionInput { |
| + return Input{}, ErrInput |
| + } |
| + frame, err := Open(priv, binding, sealed) |
| + if err != nil { |
| + return Input{}, ErrInput |
| + } |
| + defer clear(frame) |
| + return DecodeInput(frame) |
| +} |
diff --git a/internal/maintenance/input_test.go b/internal/maintenance/input_test.go| new file mode 100644 |
| index 000000000..8e578d70c |
| --- /dev/null |
| +++ b/internal/maintenance/input_test.go |
| @@ -0,0 +1,118 @@ |
| +package maintenance |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "encoding/binary" |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestInputExactSignedBytes(t *testing.T) { |
| + in := Input{Arguments: []string{"operator-v1", "", "space value", "$(not-a-shell)"}, Stdin: []byte(" \n{\"signed\": \"\\u003c\", \"n\": 1e0} \n")} |
| + frame, err := EncodeInput(in) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := DecodeInput(frame) |
| + if err != nil || !reflect.DeepEqual(in, got) { |
| + t.Fatalf("round trip: %v", err) |
| + } |
| + clear(frame) |
| + if !reflect.DeepEqual(in, got) { |
| + t.Fatal("decoded input aliases frame") |
| + } |
| + priv, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fingerprint, _ := RecipientFingerprint(priv.PublicKey()) |
| + binding := Binding{Version: 1, Direction: DirectionInput, Account: "account", App: "home", Host: "test.example", RequestID: "request", AttemptID: "attempt", Recipient: fingerprint} |
| + sealed, err := SealInput(priv.PublicKey(), binding, in) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opened, err := OpenInput(priv, binding, sealed) |
| + if err != nil || !reflect.DeepEqual(in, opened) { |
| + t.Fatalf("sealed round trip: %v", err) |
| + } |
| + for _, direction := range []Direction{DirectionResult, DirectionEnvironment} { |
| + wrong := binding |
| + wrong.Direction = direction |
| + if _, err := SealInput(priv.PublicKey(), wrong, in); err != ErrInput { |
| + t.Fatal("accepted wrong sealing direction") |
| + } |
| + // An otherwise valid envelope of the wrong purpose must also be refused. |
| + frame, _ := EncodeInput(in) |
| + ciphertext, err := Seal(priv.PublicKey(), wrong, frame) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := OpenInput(priv, wrong, ciphertext); err != ErrInput { |
| + t.Fatal("accepted wrong opening direction") |
| + } |
| + } |
| +} |
| + |
| +func TestInputLimitsAndRefusals(t *testing.T) { |
| + maxJSON := []byte(`"` + strings.Repeat("x", MaxInputBytes-2) + `"`) |
| + args := make([]string, MaxArguments) |
| + args[0] = strings.Repeat("x", MaxArgumentBytes) |
| + boundary := Input{Arguments: args, Stdin: maxJSON} |
| + frame, err := EncodeInput(boundary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := DecodeInput(frame); err != nil { |
| + t.Fatal(err) |
| + } |
| + bad := []Input{ |
| + {nil, []byte(`{}`)}, |
| + {make([]string, MaxArguments+1), []byte(`{}`)}, |
| + {[]string{strings.Repeat("x", MaxArgumentBytes), "x"}, []byte(`{}`)}, |
| + {[]string{"ok\x00bad"}, []byte(`{}`)}, |
| + {[]string{string([]byte{255})}, []byte(`{}`)}, |
| + {[]string{"ok"}, append(bytes.Clone(maxJSON), ' ')}, |
| + {[]string{"ok"}, nil}, |
| + {[]string{"ok"}, []byte(`{} {}`)}, |
| + {[]string{"ok"}, []byte{'"', 255, '"'}}, |
| + } |
| + for i, in := range bad { |
| + if _, err := EncodeInput(in); err != ErrInput { |
| + t.Fatalf("case %d encode accepted", i) |
| + } |
| + // Construct unchecked frames so decoder limits cannot rely on the encoder. |
| + raw := binary.BigEndian.AppendUint32([]byte(inputPrefix), uint32(len(in.Arguments))) |
| + for _, arg := range in.Arguments { |
| + raw = appendField(raw, []byte(arg)) |
| + } |
| + raw = appendField(raw, in.Stdin) |
| + if _, err := DecodeInput(raw); err != ErrInput { |
| + t.Fatalf("case %d decode accepted", i) |
| + } |
| + } |
| +} |
| + |
| +func TestInputMalformedFrames(t *testing.T) { |
| + frame, _ := EncodeInput(Input{[]string{"operator-v1", "argument"}, []byte(`{"ok":true}`)}) |
| + for n := 0; n < len(frame); n++ { |
| + if _, err := DecodeInput(frame[:n]); err != ErrInput { |
| + t.Fatalf("accepted truncation %d", n) |
| + } |
| + } |
| + for _, offset := range []int{0, 4, 8, 8 + 4 + len("operator-v1"), 8 + 4 + len("operator-v1") + 4 + len("argument")} { |
| + broken := bytes.Clone(frame) |
| + binary.BigEndian.PutUint32(broken[offset:offset+4], ^uint32(0)) |
| + if _, err := DecodeInput(broken); err != ErrInput { |
| + t.Fatalf("accepted corrupt frame at %d", offset) |
| + } |
| + } |
| + if _, err := DecodeInput(append(bytes.Clone(frame), 0)); err != ErrInput { |
| + t.Fatal("accepted trailing bytes") |
| + } |
| + if _, err := DecodeInput(make([]byte, maxInputFrame+1)); err != ErrInput { |
| + t.Fatal("accepted oversized frame") |
| + } |
| +} |