diff --git a/docs/design/2026-09-29-guest-reconnect-authorization.md b/docs/design/2026-09-29-guest-reconnect-authorization.md index 8fa952d..4de52a4 100644 --- a/docs/design/2026-09-29-guest-reconnect-authorization.md +++ b/docs/design/2026-09-29-guest-reconnect-authorization.md @@ -64,6 +64,9 @@ and nonces use canonical unpadded base64url. Golden-vector tests pin the format. Signature verification alone is not authorization: the corresponding pending store row must still exist and be consumed under current authority. +The follow-on [session-RPC contract](2026-09-30-guest-reconnect-rpc.md) defines +shared method names, payloads and bounded strict decoders without enabling routes. + ## Integration work required before enabling - Atomically enroll through the guest bootstrap exchange with capability diff --git a/docs/design/2026-09-30-guest-reconnect-rpc.md b/docs/design/2026-09-30-guest-reconnect-rpc.md new file mode 100644 index 0000000..5d4544c --- /dev/null +++ b/docs/design/2026-09-30-guest-reconnect-rpc.md @@ -0,0 +1,66 @@ +# Guest reconnect session-RPC contract + +This slice defines messages and strict decoders in `protocol/runner` for the +[authorization foundation](2026-09-29-guest-reconnect-authorization.md). +It does not register handlers, advertise support, enable reconnect or change the +signed transcript. Existing guests retain their one-connection bootstrap behavior. + +The existing session-RPC envelope carries these method names and payloads: + +| Method | Origin | Request fields | Successful response | +| --- | --- | --- | --- | +| `enroll_guest_reconnect` | Fresh guest, through its assigned runner | `protocol`, `token`, `boot_epoch`, `public_key` | Existing bootstrap `{"env":{...}}` shape | +| `begin_guest_reconnect` | Runner only | `protocol` | `GuestReconnectChallenge` | +| `accept_guest_reconnect` | Runner only | `protocol`, `attempt_id`, `signature` | `epoch`, `token`, `expires_in_sec` | + +`protocol` is exactly 1. Tokens and public keys encode 32 bytes; signatures encode +64 bytes. All use canonical unpadded base64url. Boot and attempt identifiers are +1–256 printable ASCII bytes. Epoch and TTL are positive; TTL is a uint32 count of +seconds. An RPC refusal uses `OK=false` with exactly `{"error":""}`; the +closed codes are `invalid`, `expired`, `fenced` and `unavailable`. A successful +payload requires `OK=true`; callers must never decode a refusal as success. + +Use the exported `DecodeGuestReconnect*` functions at untrusted boundaries. +Unlike plain `json.Unmarshal`, they enforce an inclusive 4096-byte raw payload +limit, including whitespace, and reject duplicate, unknown, missing, case-aliased, +null and trailing values. Failed decoding returns a zero value and a fixed error +without input text. Encoding remains standard `json.Marshal` on the typed structs. +The enrollment environment response retains the existing bootstrap response +contract and its existing size behavior; the 4 KiB cryptographic-message cap must +not truncate an authorized environment. The new strict response decoders cover +challenge, acceptance and fixed refusal payloads, not environment delivery. + +The enrollment responder must atomically authorize/spend/pin the key, then resolve +and deliver the environment through the existing secret resolver. A secret +resolution failure does not restore a spent token. The hosted internal cell-api +acknowledgment is not the guest-facing response: the gateway must turn successful +authorization into the environment response only after commit, just as the legacy +exchange resolves secrets after spending. Returning only an acknowledgment to the +guest would break initial boot and is not this contract. + +Workload scope is deliberately absent from requests. The transport owns the +original socket's connection generation; the host owns the assigned session and +placement lookup. Hosted membership and policy remain cell-api responsibilities. +Begin/accept must not be exposed as unrestricted guest-originated RPC methods. +A host must compare a decoded challenge with its expected session, placement and +connection incarnation before showing it to a guest. Decoding and signature +verification never replace durable expiry, single-use and current-authority checks. +No challenge or enrollment acknowledgment permits replacing a live relay. A fresh +accepted epoch must fence the old relay before any input or credential RPC. + +One shared public protocol is preferable to gateway-local strings and DTOs, which +would allow hosted and self-hosted consumers to drift. Enabling methods before the +consumer/authorization path exists is deliberately deferred. Separate bounded +request/response decoders avoid guessing a message type from attacker input. + +Tests pin literal JSON shapes and method names, reject malformed and oversized +messages, enforce canonical cryptographic encodings, preserve zero results on +failure, and execute a challenge/sign/decode/verify example. Fuzzing exercises all +decoders with the same input corpus. This is library execution, not a live guest +or VM qualification result. + +Next integration must add both control-plane handlers, capability negotiation, +initial and cold-resume enrollment ordering, bounded guest peer handling, +configuration refresh, and relay takeover. It must retain the five-second +challenge/handshake budget and prove original-socket fencing, replay refusal, +tenant isolation and real PID/PTY/agent continuity before advertising support. diff --git a/protocol/runner/reconnect_rpc.go b/protocol/runner/reconnect_rpc.go new file mode 100644 index 0000000..5b3b4a7 --- /dev/null +++ b/protocol/runner/reconnect_rpc.go @@ -0,0 +1,221 @@ +package runner + +import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "errors" + "io" +) + +// Reconnect methods use the existing session-RPC envelope. These names alone do +// not enable a handler or advertise support. Enrollment is the fresh guest's +// single-use bootstrap exchange; begin and accept are runner-originated for a +// surviving guest. Hosts must never forward arbitrary guest-originated begin or +// accept calls, and must derive scope from the original authenticated socket and +// stored placement, never a payload. Authorization is still control-plane work. +const ( + MethodEnrollGuestReconnect = "enroll_guest_reconnect" + MethodBeginGuestReconnect = "begin_guest_reconnect" + MethodAcceptGuestReconnect = "accept_guest_reconnect" + // GuestReconnectPayloadLimit bounds requests and cryptographic responses, + // including JSON whitespace. The authorized enrollment environment response + // retains the existing bootstrap response contract and is not subject to it. + // Transport frame limits remain independently required. + GuestReconnectPayloadLimit = 4 << 10 +) + +// GuestReconnectEnrollRequest spends the initial bootstrap token and pins the +// public key and boot epoch atomically. It contains no selectable workload scope. +// Tokens and keys are canonical unpadded base64url of 32 bytes. Use the bounded +// DecodeGuestReconnectEnrollRequest at untrusted boundaries, not json.Unmarshal. +type GuestReconnectEnrollRequest struct { + Protocol uint64 `json:"protocol"` + Token string `json:"token"` + BootEpoch string `json:"boot_epoch"` + PublicKey string `json:"public_key"` +} + +// GuestReconnectBeginRequest asks the control plane to create a fresh pending +// challenge. It neither replaces a token nor authorizes relay takeover. +type GuestReconnectBeginRequest struct { + Protocol uint64 `json:"protocol"` +} + +// GuestReconnectAcceptRequest presents a signature over the stored challenge. +// The caller cannot select a key, challenge, session, placement or connection epoch. +// A structurally valid proof still requires signature and durable authority checks. +type GuestReconnectAcceptRequest struct { + Protocol uint64 `json:"protocol"` + AttemptID string `json:"attempt_id"` + Signature string `json:"signature"` +} + +// GuestReconnectEnrollResponse returns the freshly resolved environment after +// atomic enrollment/spend succeeds, matching the existing bootstrap exchange. +// The control plane must resolve secrets only after authorization; a resolution +// failure does not undo the spend. This secret-bearing response is deliberately +// outside the 4 KiB cryptographic-message limit, like the existing exchange. +// No response may be cached, logged or delivered before enrollment commits. +type GuestReconnectEnrollResponse struct { + Env map[string]string `json:"env"` +} + +// GuestReconnectAcceptResponse carries newly committed connection authority. +// Epoch is per enrolled boot, not a placement or controller generation. A lost +// response requires a fresh challenge; this bearer response must never be cached +// for replay. ExpiresInSec describes the token TTL, not the challenge lifetime. +type GuestReconnectAcceptResponse struct { + Epoch uint64 `json:"epoch"` + Token string `json:"token"` + ExpiresInSec uint32 `json:"expires_in_sec"` +} + +// GuestReconnectErrorResponse is the entire refusal payload. Error is one of +// invalid, expired, fenced or unavailable; no peer, provider or database text may +// be substituted. In session-RPC it accompanies an envelope with OK=false. +type GuestReconnectErrorResponse struct { + Error string `json:"error"` +} + +var errGuestReconnectMessage = errors.New("runner: invalid guest reconnect message") + +// DecodeGuestReconnectEnrollRequest strictly decodes one bounded version-1 +// enrollment. All reconnect decoders reject missing, unknown, duplicate, +// case-aliased, null, trailing or oversized data and malformed field values. On +// failure they return a zero value and a fixed error containing no input bytes. +func DecodeGuestReconnectEnrollRequest(payload []byte) (GuestReconnectEnrollRequest, error) { + var v GuestReconnectEnrollRequest + if decodeReconnectObject(payload, &v, "protocol", "token", "boot_epoch", "public_key") != nil || v.Protocol != GuestReconnectProtocol || !reconnectID(v.BootEpoch) { + return GuestReconnectEnrollRequest{}, errGuestReconnectMessage + } + if _, ok := reconnectBytes(v.Token, 32); !ok { + return GuestReconnectEnrollRequest{}, errGuestReconnectMessage + } + if _, ok := reconnectBytes(v.PublicKey, ed25519.PublicKeySize); !ok { + return GuestReconnectEnrollRequest{}, errGuestReconnectMessage + } + return v, nil +} + +// DecodeGuestReconnectBeginRequest applies the strict bounded reconnect wire +// rules. No challenge, identity or scope fields are accepted from the requester. +func DecodeGuestReconnectBeginRequest(payload []byte) (GuestReconnectBeginRequest, error) { + var v GuestReconnectBeginRequest + if decodeReconnectObject(payload, &v, "protocol") != nil || v.Protocol != GuestReconnectProtocol { + return GuestReconnectBeginRequest{}, errGuestReconnectMessage + } + return v, nil +} + +// DecodeGuestReconnectAcceptRequest checks shape and canonical signature +// encoding only. The control plane must verify proof, expiry and current authority. +func DecodeGuestReconnectAcceptRequest(payload []byte) (GuestReconnectAcceptRequest, error) { + var v GuestReconnectAcceptRequest + if decodeReconnectObject(payload, &v, "protocol", "attempt_id", "signature") != nil || v.Protocol != GuestReconnectProtocol || !reconnectID(v.AttemptID) { + return GuestReconnectAcceptRequest{}, errGuestReconnectMessage + } + if _, ok := reconnectBytes(v.Signature, ed25519.SignatureSize); !ok { + return GuestReconnectAcceptRequest{}, errGuestReconnectMessage + } + return v, nil +} + +// DecodeGuestReconnectChallenge applies strict wire and signed-transcript +// validation. The receiving host must also compare session, placement and host +// incarnation with its expected authority before presenting a challenge to a guest. +func DecodeGuestReconnectChallenge(payload []byte) (GuestReconnectChallenge, error) { + var v GuestReconnectChallenge + if decodeReconnectObject(payload, &v, "protocol", "session_id", "boot_epoch", "host_incarnation", "attempt_id", "placement_generation", "challenge") != nil { + return GuestReconnectChallenge{}, errGuestReconnectMessage + } + if _, err := v.SigningMessage(); err != nil { + return GuestReconnectChallenge{}, errGuestReconnectMessage + } + return v, nil +} + +// DecodeGuestReconnectAcceptResponse checks the fresh token's canonical encoding +// and positive epoch/TTL. It does not validate store authority or lease freshness. +func DecodeGuestReconnectAcceptResponse(payload []byte) (GuestReconnectAcceptResponse, error) { + var v GuestReconnectAcceptResponse + if decodeReconnectObject(payload, &v, "epoch", "token", "expires_in_sec") != nil || v.Epoch == 0 || v.ExpiresInSec == 0 { + return GuestReconnectAcceptResponse{}, errGuestReconnectMessage + } + if _, ok := reconnectBytes(v.Token, 32); !ok { + return GuestReconnectAcceptResponse{}, errGuestReconnectMessage + } + return v, nil +} + +// DecodeGuestReconnectErrorResponse accepts only the four fixed reconnect +// refusal codes, with the same size and exact-field checks as successful messages. +func DecodeGuestReconnectErrorResponse(payload []byte) (GuestReconnectErrorResponse, error) { + var v GuestReconnectErrorResponse + if decodeReconnectObject(payload, &v, "error") != nil { + return GuestReconnectErrorResponse{}, errGuestReconnectMessage + } + switch v.Error { + case "invalid", "expired", "fenced", "unavailable": + return v, nil + } + return GuestReconnectErrorResponse{}, errGuestReconnectMessage +} + +func reconnectID(id string) bool { + if len(id) == 0 || len(id) > 256 { + return false + } + for i := range id { + if id[i] < 0x21 || id[i] > 0x7e { + return false + } + } + return true +} + +// Check the flat object before typed decoding: encoding/json alone folds field +// case and accepts duplicate keys. No raw decoder error may escape this boundary. +func decodeReconnectObject(payload []byte, out any, names ...string) error { + if len(payload) > GuestReconnectPayloadLimit { + return errGuestReconnectMessage + } + d := json.NewDecoder(bytes.NewReader(payload)) + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return errGuestReconnectMessage + } + seen := make(map[string]bool, len(names)) + for d.More() { + token, err = d.Token() + name, ok := token.(string) + if err != nil || !ok || seen[name] { + return errGuestReconnectMessage + } + known := false + for _, want := range names { + if name == want { + known = true + break + } + } + if !known { + return errGuestReconnectMessage + } + var raw json.RawMessage + if d.Decode(&raw) != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return errGuestReconnectMessage + } + seen[name] = true + } + if token, err = d.Token(); err != nil || token != json.Delim('}') || len(seen) != len(names) { + return errGuestReconnectMessage + } + if _, err = d.Token(); err != io.EOF { + return errGuestReconnectMessage + } + if json.Unmarshal(payload, out) != nil { + return errGuestReconnectMessage + } + return nil +} diff --git a/protocol/runner/reconnect_rpc_test.go b/protocol/runner/reconnect_rpc_test.go new file mode 100644 index 0000000..6c1346d --- /dev/null +++ b/protocol/runner/reconnect_rpc_test.go @@ -0,0 +1,221 @@ +package runner_test + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/tokencanopy/rainier/protocol/runner" + "reflect" + "strings" + "testing" +) + +func TestReconnectChallengeRejectsUnknownWireAuthority(t *testing.T) { + payload := `{"protocol":1,"session_id":"session_test","boot_epoch":"boot_test","host_incarnation":"7","attempt_id":"attempt_test","placement_generation":3,"challenge":"` + strings.Repeat("A", 43) + `","creator":"attacker_test"}` + if _, err := runner.DecodeGuestReconnectChallenge([]byte(payload)); err == nil { + t.Fatal("accepted unknown authority field in reconnect challenge") + } +} + +func TestReconnectRPCWireContract(t *testing.T) { + if runner.MethodEnrollGuestReconnect != "enroll_guest_reconnect" || runner.MethodBeginGuestReconnect != "begin_guest_reconnect" || runner.MethodAcceptGuestReconnect != "accept_guest_reconnect" || runner.GuestReconnectPayloadLimit != 4096 { + t.Fatal("reconnect wire constants changed") + } + for _, tc := range reconnectMessages() { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.decode([]byte(tc.wire)) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(got) + if err != nil || string(encoded) != tc.wire { + t.Fatalf("wire changed: %s (%v)", encoded, err) + } + // Exactly 4096 bytes is permitted; the next whitespace byte must refuse. + bounded := tc.wire + strings.Repeat(" ", 4096-len(tc.wire)) + if _, err := tc.decode([]byte(bounded)); err != nil { + t.Fatal("boundary refused", err) + } + if _, err := tc.decode([]byte(bounded + " ")); err == nil { + t.Fatal("oversized whitespace bypassed bound") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(tc.wire), &fields); err != nil { + t.Fatal(err) + } + first := "" + for key := range fields { + first = key + break + } + invalid := map[string]string{ + "unknown": strings.TrimSuffix(tc.wire, "}") + `,"workspace":"attacker_test"}`, + "duplicate": strings.TrimSuffix(tc.wire, "}") + `,"` + first + `":` + string(fields[first]) + `}`, + "escaped_duplicate": strings.TrimSuffix(tc.wire, "}") + `,"\u` + fmt.Sprintf("%04x", first[0]) + first[1:] + `":` + string(fields[first]) + `}`, + "case": strings.Replace(tc.wire, `"`+first+`":`, `"`+strings.ToUpper(first)+`":`, 1), + "trailing": tc.wire + ` {"sensitive":"payload_test"}`, + "trailing_scalar": tc.wire + ` true`, + "array": "[" + tc.wire + "]", "null": "null", "empty": "", "truncated": tc.wire[:len(tc.wire)-1], + } + for key := range fields { + original := fields[key] + delete(fields, key) + value, _ := json.Marshal(fields) + invalid["missing_"+key] = string(value) + fields[key] = json.RawMessage("null") + value, _ = json.Marshal(fields) + invalid["null_"+key] = string(value) + fields[key] = json.RawMessage("[]") + value, _ = json.Marshal(fields) + invalid["type_"+key] = string(value) + fields[key] = original + } + for name, payload := range invalid { + t.Run(name, func(t *testing.T) { + got, err := tc.decode([]byte(payload)) + if err == nil { + t.Fatal("accepted malformed message") + } + if err.Error() != "runner: invalid guest reconnect message" { + t.Fatal("non-fixed error") + } + if !reflect.ValueOf(got).IsZero() { + t.Fatal("failed decode retained authority") + } + }) + } + }) + } +} + +type reconnectMessageCase struct { + name, wire string + decode func([]byte) (any, error) +} + +func reconnectMessages() []reconnectMessageCase { + token := strings.Repeat("A", 43) + signature := strings.Repeat("A", 86) + return []reconnectMessageCase{ + {"enroll", `{"protocol":1,"token":"` + token + `","boot_epoch":"boot_test","public_key":"` + token + `"}`, func(b []byte) (any, error) { return runner.DecodeGuestReconnectEnrollRequest(b) }}, + {"begin", `{"protocol":1}`, func(b []byte) (any, error) { return runner.DecodeGuestReconnectBeginRequest(b) }}, + {"accept", `{"protocol":1,"attempt_id":"attempt_test","signature":"` + signature + `"}`, func(b []byte) (any, error) { return runner.DecodeGuestReconnectAcceptRequest(b) }}, + + {"challenge", `{"protocol":1,"session_id":"session_test","boot_epoch":"boot_test","host_incarnation":"7","attempt_id":"attempt_test","placement_generation":3,"challenge":"` + token + `"}`, func(b []byte) (any, error) { return runner.DecodeGuestReconnectChallenge(b) }}, + {"accepted", `{"epoch":2,"token":"` + token + `","expires_in_sec":120}`, func(b []byte) (any, error) { return runner.DecodeGuestReconnectAcceptResponse(b) }}, + {"refused", `{"error":"fenced"}`, func(b []byte) (any, error) { return runner.DecodeGuestReconnectErrorResponse(b) }}, + } +} + +func TestReconnectRPCRejectsInvalidValues(t *testing.T) { + for _, tc := range reconnectMessages() { + var fields map[string]json.RawMessage + _ = json.Unmarshal([]byte(tc.wire), &fields) + mutations := map[string][]string{ + "protocol": {"0", "2", "-1", "1.0", "1e0", "18446744073709551616"}, + "epoch": {"0", "-1", "18446744073709551616"}, + "placement_generation": {"0", "-1"}, + "expires_in_sec": {"0", "-1", "4294967296"}, "error": {`"internal-secret_test"`, `""`, `"FENCED"`}, + } + for _, key := range []string{"token", "signature", "public_key", "challenge"} { + if raw, ok := fields[key]; ok { + var original string + _ = json.Unmarshal(raw, &original) + // Nonzero unused base64 bits, padding, short/long and forbidden alphabets. + for _, bad := range []string{"", original + "=", original[:len(original)-1], original + "A", strings.Repeat("+", len(original)), original[:len(original)-1] + "B"} { + b, _ := json.Marshal(bad) + mutations[key] = append(mutations[key], string(b)) + } + } + } + for _, key := range []string{"attempt_id", "boot_epoch", "session_id", "host_incarnation"} { + for _, bad := range []string{"", strings.Repeat("a", 257), "white space", "line\nbreak", "unicode_é"} { + b, _ := json.Marshal(bad) + mutations[key] = append(mutations[key], string(b)) + } + } + for key, values := range mutations { + original, exists := fields[key] + if !exists { + continue + } + for i, value := range values { + t.Run(fmt.Sprintf("%s/%s/%d", tc.name, key, i), func(t *testing.T) { + fields[key] = json.RawMessage(value) + payload, _ := json.Marshal(fields) + if got, err := tc.decode(payload); err == nil || !reflect.ValueOf(got).IsZero() { + t.Fatal("invalid field value accepted or retained") + } + }) + } + fields[key] = original + } + } + for _, code := range []string{"invalid", "expired", "fenced", "unavailable"} { + v, err := runner.DecodeGuestReconnectErrorResponse([]byte(`{"error":"` + code + `"}`)) + if err != nil || v.Error != code { + t.Fatal("known refusal rejected") + } + } +} + +// Exercise the public codec and signed transcript as real consumers do. Schema +// validity and cryptographic proof remain distinct from durable authorization. +func ExampleDecodeGuestReconnectAcceptRequest() { + public, private, _ := ed25519.GenerateKey(rand.Reader) + challenge := runner.GuestReconnectChallenge{Protocol: 1, SessionID: "session_test", BootEpoch: "boot_test", HostIncarnation: "7", AttemptID: "attempt_test", PlacementGeneration: 3, Challenge: base64.RawURLEncoding.EncodeToString(make([]byte, 32))} + wire, _ := json.Marshal(challenge) + decoded, err := runner.DecodeGuestReconnectChallenge(wire) + if err != nil { + panic(err) + } + transcript, _ := decoded.SigningMessage() + proof := runner.GuestReconnectAcceptRequest{Protocol: 1, AttemptID: decoded.AttemptID, Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, transcript))} + wire, _ = json.Marshal(proof) + request, err := runner.DecodeGuestReconnectAcceptRequest(wire) + if err != nil { + panic(err) + } + fmt.Println("signature verified:", decoded.VerifyProof(base64.RawURLEncoding.EncodeToString(public), request.Signature) == nil) + // Output: signature verified: true +} + +func FuzzReconnectRPCDecoders(f *testing.F) { + cases := reconnectMessages() + for _, tc := range cases { + f.Add(tc.wire) + } + f.Add(`{"protocol":1,"protocol":1}`) + f.Fuzz(func(t *testing.T, payload string) { + for _, tc := range cases { + value, err := tc.decode([]byte(payload)) + if err != nil { + if !reflect.ValueOf(value).IsZero() || err.Error() != "runner: invalid guest reconnect message" { + t.Fatal("unsafe error result") + } + continue + } + if len(payload) > 4096 { + t.Fatal("size bound bypassed") + } + canonical, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + again, err := tc.decode(canonical) + if err != nil || !reflect.DeepEqual(value, again) { + t.Fatal("successful decoding is not stable") + } + } + }) +} + +func TestReconnectEnrollmentKeepsBootstrapEnvironmentShape(t *testing.T) { + response := runner.GuestReconnectEnrollResponse{Env: map[string]string{"SYNTHETIC_VALUE": "fixture_test"}} + body, err := json.Marshal(response) + if err != nil || string(body) != `{"env":{"SYNTHETIC_VALUE":"fixture_test"}}` { + t.Fatal("enrollment changed the bootstrap environment contract") + } +}