Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apitypes/manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2026 Edgeless Systems GmbH
// SPDX-License-Identifier: BUSL-1.1

package apitypes

// SetManifestRequest is the request body of POST /v1/manifest.
type SetManifestRequest struct {
// Manifest is the JSON-encoded manifest to set.
Manifest []byte `json:"manifest"`
// Policies are the policies referenced by the manifest.
Policies [][]byte `json:"policies"`
// PreviousTransitionHash is the expected hash of the latest transition, used for
// compare-and-swap. If unset, the update is not conditional.
PreviousTransitionHash []byte `json:"previous_transition_hash,omitempty"`
// Signature is the workload owner's signature over the manifest.
//
// Over HTTP this is the only supported way to authorize an update to an existing
// manifest, because the Coordinator can't authenticate the caller by its client
// certificate as it does for aTLS-based gRPC calls.
Signature []byte `json:"signature,omitempty"`
}

// SetManifestResponse is the response body of POST /v1/manifest.
type SetManifestResponse struct {
// Version is the Coordinator version.
Version string `json:"version"`
// RootCA is the PEM-encoded certificate of the deployment's root CA.
RootCA []byte `json:"root_ca"`
// MeshCA is the PEM-encoded certificate of the deployment's mesh CA.
MeshCA []byte `json:"mesh_ca"`
// SeedSharesDoc is only set when the initial manifest was set.
SeedSharesDoc *SeedShareDocument `json:"seed_shares_doc,omitempty"`
}

// SeedShareDocument contains the secret seed, encrypted for the seedshare owners.
type SeedShareDocument struct {
// SeedShares holds the seed, encrypted once per seedshare owner.
SeedShares []SeedShare `json:"seed_shares"`
// Salt is used together with the seed to derive the Coordinator's secrets.
Salt []byte `json:"salt"`
}

// SeedShare is the secret seed, encrypted with a single seedshare owner's public key.
type SeedShare struct {
// PublicKey is the hex-encoded public key of the seedshare owner.
PublicKey string `json:"public_key"`
// EncryptedSeed is the seed, encrypted with PublicKey.
EncryptedSeed []byte `json:"encrypted_seed"`
}
16 changes: 10 additions & 6 deletions coordinator/internal/httpapi/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/edgelesssys/contrast/internal/atls"
"github.com/edgelesssys/contrast/internal/constants"
"github.com/edgelesssys/contrast/internal/manifest"
"google.golang.org/grpc/status"
)

var (
Expand Down Expand Up @@ -142,15 +143,18 @@ func (h *AttestationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}

func writeJSONError(w http.ResponseWriter, status int, err error) {
func writeJSONError(w http.ResponseWriter, statusCode int, err error) {
log.Print(err.Error())

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

apiErr := &apitypes.AttestationError{
Version: constants.Version,
Err: err.Error(),
w.WriteHeader(statusCode)

apiErr := &apitypes.APIError{
Version: constants.Version,
StatusCode: statusCode,
// Unwrap gRPC status errors, so that clients don't see the gRPC framing of an
// error that didn't travel over gRPC. For other errors, this is err.Error().
Err: status.Convert(err).Message(),
}
if errEncode := json.NewEncoder(w).Encode(apiErr); errEncode != nil {
log.Printf("encoding error response %v failed: %v", err, errEncode)
Expand Down
1 change: 1 addition & 0 deletions coordinator/internal/httpapi/attest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func TestAttestationHandler(t *testing.T) {
var apiErr apitypes.AttestationError
require.NoError(json.NewDecoder(res.Body).Decode(&apiErr))
require.Contains(apiErr.Err, tc.expErr.Error())
require.Equal(tc.expStatus, apiErr.StatusCode)
} else if res.StatusCode == http.StatusOK {
var resp apitypes.AttestationResponse
require.NoError(json.NewDecoder(res.Body).Decode(&resp))
Expand Down
133 changes: 133 additions & 0 deletions coordinator/internal/httpapi/manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2026 Edgeless Systems GmbH
// SPDX-License-Identifier: BUSL-1.1

package httpapi

import (
"context"
"encoding/json"
"errors"
"io"
"mime"
"net/http"

"github.com/edgelesssys/contrast/apitypes"
"github.com/edgelesssys/contrast/internal/constants"
"github.com/edgelesssys/contrast/internal/userapi"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// maxSetManifestBodySize limits the accepted request body size. A manifest and its policies
// are much smaller than this, but they're the largest input the Coordinator accepts.
const maxSetManifestBodySize = 16 << 20 // 16 MiB

// ManifestSetter sets a manifest. It is a *userapi.Server at runtime, but can be stubbed in tests.
type ManifestSetter interface {
SetManifest(context.Context, *userapi.SetManifestRequest) (*userapi.SetManifestResponse, error)
}

// SetManifestHandler handles POST requests to /v1/manifest.
//
// It's a thin translation layer in front of the gRPC UserAPI's SetManifest: the request is
// converted to its protobuf equivalent, handled by the same server logic, and the response is
// converted back to JSON.
type SetManifestHandler struct {
UserAPI ManifestSetter
}

// ServeHTTP implements [http.Handler].
func (h *SetManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}
if mediaType != "application/json" {
writeJSONError(w, http.StatusUnsupportedMediaType, errContentType)
return
}

bodyReader := http.MaxBytesReader(w, r.Body, maxSetManifestBodySize)
defer bodyReader.Close()
body, err := io.ReadAll(bodyReader)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeJSONError(w, http.StatusRequestEntityTooLarge, maxBytesErr)
return
}
writeJSONError(w, http.StatusBadRequest, err)
return
}

var req apitypes.SetManifestRequest
if err := json.Unmarshal(body, &req); err != nil {
writeJSONError(w, http.StatusBadRequest, err)
return
}

resp, err := h.UserAPI.SetManifest(r.Context(), &userapi.SetManifestRequest{
Manifest: req.Manifest,
Policies: req.Policies,
PreviousTransitionHash: req.PreviousTransitionHash,
Signature: req.Signature,
})
if err != nil {
writeJSONError(w, httpStatusFromGRPC(err), err)
return
}

w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(setManifestResponse(resp)); err != nil {
writeJSONError(w, http.StatusInternalServerError, err)
}
}

// setManifestResponse converts the gRPC response to its wire-format equivalent.
func setManifestResponse(resp *userapi.SetManifestResponse) *apitypes.SetManifestResponse {
out := &apitypes.SetManifestResponse{
Version: constants.Version,
RootCA: resp.GetRootCA(),
MeshCA: resp.GetMeshCA(),
}
doc := resp.GetSeedSharesDoc()
if doc == nil {
return out
}

shares := make([]apitypes.SeedShare, 0, len(doc.GetSeedShares()))
for _, share := range doc.GetSeedShares() {
shares = append(shares, apitypes.SeedShare{
PublicKey: share.GetPublicKey(),
EncryptedSeed: share.GetEncryptedSeed(),
})
}
out.SeedSharesDoc = &apitypes.SeedShareDocument{
SeedShares: shares,
Salt: doc.GetSalt(),
}
return out
}

// httpStatusFromGRPC maps the gRPC status codes returned by the UserAPI to HTTP status codes.
func httpStatusFromGRPC(err error) int {
switch status.Code(err) {
case codes.InvalidArgument:
return http.StatusBadRequest
case codes.PermissionDenied:
return http.StatusForbidden
case codes.FailedPrecondition:
return http.StatusPreconditionFailed
default:
return http.StatusInternalServerError
}
}
Loading
Loading