Skip to content

Commit d60f163

Browse files
committed
coordinator: add POST /v1/manifest handler
1 parent 67ac4ad commit d60f163

6 files changed

Lines changed: 406 additions & 6 deletions

File tree

apitypes/manifest.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package apitypes
5+
6+
// SetManifestRequest is the request body of POST /v1/manifest.
7+
type SetManifestRequest struct {
8+
// Manifest is the JSON-encoded manifest to set.
9+
Manifest []byte `json:"manifest"`
10+
// Policies are the policies referenced by the manifest.
11+
Policies [][]byte `json:"policies"`
12+
// PreviousTransitionHash is the expected hash of the latest transition, used for
13+
// compare-and-swap. If unset, the update is not conditional.
14+
PreviousTransitionHash []byte `json:"previous_transition_hash,omitempty"`
15+
// Signature is the workload owner's signature over the manifest.
16+
//
17+
// Over HTTP this is the only supported way to authorize an update to an existing
18+
// manifest, because the Coordinator can't authenticate the caller by its client
19+
// certificate as it does for aTLS-based gRPC calls.
20+
Signature []byte `json:"signature,omitempty"`
21+
}
22+
23+
// SetManifestResponse is the response body of POST /v1/manifest.
24+
type SetManifestResponse struct {
25+
// Version is the Coordinator version.
26+
Version string `json:"version"`
27+
// RootCA is the PEM-encoded certificate of the deployment's root CA.
28+
RootCA []byte `json:"root_ca"`
29+
// MeshCA is the PEM-encoded certificate of the deployment's mesh CA.
30+
MeshCA []byte `json:"mesh_ca"`
31+
// SeedSharesDoc is only set when the initial manifest was set.
32+
SeedSharesDoc *SeedShareDocument `json:"seed_shares_doc,omitempty"`
33+
}
34+
35+
// SeedShareDocument contains the secret seed, encrypted for the seedshare owners.
36+
type SeedShareDocument struct {
37+
// SeedShares holds the seed, encrypted once per seedshare owner.
38+
SeedShares []SeedShare `json:"seed_shares"`
39+
// Salt is used together with the seed to derive the Coordinator's secrets.
40+
Salt []byte `json:"salt"`
41+
}
42+
43+
// SeedShare is the secret seed, encrypted with a single seedshare owner's public key.
44+
type SeedShare struct {
45+
// PublicKey is the hex-encoded public key of the seedshare owner.
46+
PublicKey string `json:"public_key"`
47+
// EncryptedSeed is the seed, encrypted with PublicKey.
48+
EncryptedSeed []byte `json:"encrypted_seed"`
49+
}

coordinator/internal/httpapi/attest.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/edgelesssys/contrast/internal/atls"
2020
"github.com/edgelesssys/contrast/internal/constants"
2121
"github.com/edgelesssys/contrast/internal/manifest"
22+
"google.golang.org/grpc/status"
2223
)
2324

2425
var (
@@ -142,15 +143,18 @@ func (h *AttestationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
142143
}
143144
}
144145

145-
func writeJSONError(w http.ResponseWriter, status int, err error) {
146+
func writeJSONError(w http.ResponseWriter, statusCode int, err error) {
146147
log.Print(err.Error())
147148

148149
w.Header().Set("Content-Type", "application/json")
149-
w.WriteHeader(status)
150-
151-
apiErr := &apitypes.AttestationError{
152-
Version: constants.Version,
153-
Err: err.Error(),
150+
w.WriteHeader(statusCode)
151+
152+
apiErr := &apitypes.APIError{
153+
Version: constants.Version,
154+
StatusCode: statusCode,
155+
// Unwrap gRPC status errors, so that clients don't see the gRPC framing of an
156+
// error that didn't travel over gRPC. For other errors, this is err.Error().
157+
Err: status.Convert(err).Message(),
154158
}
155159
if errEncode := json.NewEncoder(w).Encode(apiErr); errEncode != nil {
156160
log.Printf("encoding error response %v failed: %v", err, errEncode)

coordinator/internal/httpapi/attest_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ func TestAttestationHandler(t *testing.T) {
169169
var apiErr apitypes.AttestationError
170170
require.NoError(json.NewDecoder(res.Body).Decode(&apiErr))
171171
require.Contains(apiErr.Err, tc.expErr.Error())
172+
require.Equal(tc.expStatus, apiErr.StatusCode)
172173
} else if res.StatusCode == http.StatusOK {
173174
var resp apitypes.AttestationResponse
174175
require.NoError(json.NewDecoder(res.Body).Decode(&resp))
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package httpapi
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"errors"
10+
"io"
11+
"mime"
12+
"net/http"
13+
14+
"github.com/edgelesssys/contrast/apitypes"
15+
"github.com/edgelesssys/contrast/internal/constants"
16+
"github.com/edgelesssys/contrast/internal/userapi"
17+
"google.golang.org/grpc/codes"
18+
"google.golang.org/grpc/status"
19+
)
20+
21+
// maxSetManifestBodySize limits the accepted request body size. A manifest and its policies
22+
// are much smaller than this, but they're the largest input the Coordinator accepts.
23+
const maxSetManifestBodySize = 16 << 20 // 16 MiB
24+
25+
// ManifestSetter sets a manifest. It is a *userapi.Server at runtime, but can be stubbed in tests.
26+
type ManifestSetter interface {
27+
SetManifest(context.Context, *userapi.SetManifestRequest) (*userapi.SetManifestResponse, error)
28+
}
29+
30+
// SetManifestHandler handles POST requests to /v1/manifest.
31+
//
32+
// It's a thin translation layer in front of the gRPC UserAPI's SetManifest: the request is
33+
// converted to its protobuf equivalent, handled by the same server logic, and the response is
34+
// converted back to JSON.
35+
type SetManifestHandler struct {
36+
UserAPI ManifestSetter
37+
}
38+
39+
// ServeHTTP implements [http.Handler].
40+
func (h *SetManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
41+
if r.Method != http.MethodPost {
42+
w.WriteHeader(http.StatusMethodNotAllowed)
43+
return
44+
}
45+
46+
contentType := r.Header.Get("Content-Type")
47+
mediaType, _, err := mime.ParseMediaType(contentType)
48+
if err != nil {
49+
writeJSONError(w, http.StatusBadRequest, err)
50+
return
51+
}
52+
if mediaType != "application/json" {
53+
writeJSONError(w, http.StatusUnsupportedMediaType, errContentType)
54+
return
55+
}
56+
57+
bodyReader := http.MaxBytesReader(w, r.Body, maxSetManifestBodySize)
58+
defer bodyReader.Close()
59+
body, err := io.ReadAll(bodyReader)
60+
if err != nil {
61+
var maxBytesErr *http.MaxBytesError
62+
if errors.As(err, &maxBytesErr) {
63+
writeJSONError(w, http.StatusRequestEntityTooLarge, maxBytesErr)
64+
return
65+
}
66+
writeJSONError(w, http.StatusBadRequest, err)
67+
return
68+
}
69+
70+
var req apitypes.SetManifestRequest
71+
if err := json.Unmarshal(body, &req); err != nil {
72+
writeJSONError(w, http.StatusBadRequest, err)
73+
return
74+
}
75+
76+
resp, err := h.UserAPI.SetManifest(r.Context(), &userapi.SetManifestRequest{
77+
Manifest: req.Manifest,
78+
Policies: req.Policies,
79+
PreviousTransitionHash: req.PreviousTransitionHash,
80+
Signature: req.Signature,
81+
})
82+
if err != nil {
83+
writeJSONError(w, httpStatusFromGRPC(err), err)
84+
return
85+
}
86+
87+
w.Header().Set("Content-Type", "application/json")
88+
enc := json.NewEncoder(w)
89+
enc.SetEscapeHTML(false)
90+
if err := enc.Encode(setManifestResponse(resp)); err != nil {
91+
writeJSONError(w, http.StatusInternalServerError, err)
92+
}
93+
}
94+
95+
// setManifestResponse converts the gRPC response to its wire-format equivalent.
96+
func setManifestResponse(resp *userapi.SetManifestResponse) *apitypes.SetManifestResponse {
97+
out := &apitypes.SetManifestResponse{
98+
Version: constants.Version,
99+
RootCA: resp.GetRootCA(),
100+
MeshCA: resp.GetMeshCA(),
101+
}
102+
doc := resp.GetSeedSharesDoc()
103+
if doc == nil {
104+
return out
105+
}
106+
107+
shares := make([]apitypes.SeedShare, 0, len(doc.GetSeedShares()))
108+
for _, share := range doc.GetSeedShares() {
109+
shares = append(shares, apitypes.SeedShare{
110+
PublicKey: share.GetPublicKey(),
111+
EncryptedSeed: share.GetEncryptedSeed(),
112+
})
113+
}
114+
out.SeedSharesDoc = &apitypes.SeedShareDocument{
115+
SeedShares: shares,
116+
Salt: doc.GetSalt(),
117+
}
118+
return out
119+
}
120+
121+
// httpStatusFromGRPC maps the gRPC status codes returned by the UserAPI to HTTP status codes.
122+
func httpStatusFromGRPC(err error) int {
123+
switch status.Code(err) {
124+
case codes.InvalidArgument:
125+
return http.StatusBadRequest
126+
case codes.PermissionDenied:
127+
return http.StatusForbidden
128+
case codes.FailedPrecondition:
129+
return http.StatusPreconditionFailed
130+
default:
131+
return http.StatusInternalServerError
132+
}
133+
}

0 commit comments

Comments
 (0)