Skip to content
Closed
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
9 changes: 8 additions & 1 deletion cmd/run/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/codefly-dev/cli/pkg/orchestration"
runfixture "github.com/codefly-dev/cli/pkg/run/fixture"
"github.com/codefly-dev/cli/pkg/run/identity"
runworkcontext "github.com/codefly-dev/cli/pkg/run/workcontext"
"github.com/codefly-dev/cli/pkg/solutionrun"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
"github.com/codefly-dev/core/resources"
Expand Down Expand Up @@ -58,6 +59,7 @@ import (

type launcher struct {
vouch *identity.Launcher
authority *runworkcontext.Authority
workspace *resources.Workspace
env *environments.Environment
flow launcherFlow
Expand Down Expand Up @@ -130,7 +132,7 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re
return err
}
cluster := &launcher{
vouch: launcherIdentity, workspace: workspace, env: env, flow: flow, fixture: fixture,
vouch: launcherIdentity, authority: flow.WorkContextAuthority(), workspace: workspace, env: env, flow: flow, fixture: fixture,
stateDir: stateDir,
identities: identities,
signer: signer,
Expand Down Expand Up @@ -178,6 +180,11 @@ func (cluster *launcher) close() {
default:
close(cluster.stop)
}
// The key set is served for the life of the run and no longer: a listener
// outliving the run would answer for an issuer nothing is still minting.
if cluster.authority != nil {
_ = cluster.authority.Close()
}
}

// declare records identity workloads the run vouches for, with their delivery
Expand Down
23 changes: 23 additions & 0 deletions pkg/environments/work_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package environments

// WorkContextConfiguration is the workspace configuration group a service
// verifies inbound Work Contexts against: where the key set is served and the
// issuer it must be signed under.
//
// Like FederationConfiguration, the group stays AUTHORED and is never
// render-owned: a composition with a host declares it, pointing at the host's
// key set. A native run derives it too, for the composition that has no host
// to point at, and it is handed last so it settles over what an authored group
// leaves empty rather than displacing one.
//
// Without that, a service whose identity comes from the host cannot boot on
// its own, because nothing local serves the key set its verifier loads.
const WorkContextConfiguration = "work-context"

// The two keys a verifier is configured from. A key set without a pinned
// issuer would admit a capability any holder of a key in that set signed under
// any issuer name, so neither is optional.
const (
WorkContextKeySetURLKey = "authority-jwks-url"
WorkContextIssuerKey = "issuer"
)
52 changes: 51 additions & 1 deletion pkg/orchestration/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package orchestration

import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"maps"
Expand All @@ -20,6 +22,7 @@ import (
"github.com/codefly-dev/cli/pkg/environments"
"github.com/codefly-dev/cli/pkg/internal/selectionguard"
"github.com/codefly-dev/cli/pkg/remotenetwork"
runworkcontext "github.com/codefly-dev/cli/pkg/run/workcontext"
"github.com/codefly-dev/core/architecture"
"github.com/codefly-dev/core/configurations"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
Expand All @@ -35,6 +38,11 @@ import (
)

type Flow struct {
// workContext serves the key set a service verifies inbound Work Contexts
// against, for the composition that authors no work-context group. Created
// here because the loader that layers its group is bound here.
workContext *runworkcontext.Authority

// configurationReferences orders a consumer after the producers its
// workspace configurations reference (configurationReferenceOption); every
// rebuild of the graph carries it.
Expand Down Expand Up @@ -423,6 +431,35 @@ func (f FlowFailure) Error() string {
return fmt.Sprintf("%s: %s", f.Service, f.Message)
}

// declaredWorkspaceConfigurationGroups names every workspace-configuration
// group a service in this workspace depends on. A declared group nothing
// supplies is refused by the configuration manager before any service reads a
// value from it, so a local run routes them in empty rather than leaving a
// module that composes no host unable to start.
func declaredWorkspaceConfigurationGroups(ctx context.Context, workspace *resources.Workspace) []string {
refs, err := workspace.LoadServiceWithModules(ctx)
if err != nil {
return nil
}
var groups []string
for _, ref := range refs {
service, loadErr := workspace.LoadService(ctx, ref)
if loadErr != nil {
continue
}
for _, group := range service.WorkspaceConfigurationDependencies {
if !slices.Contains(groups, group) {
groups = append(groups, group)
}
}
}
return groups
}

// WorkContextAuthority is the key set this run serves, for the composition
// that authors no work-context group. The launcher closes it with the run.
func (flow *Flow) WorkContextAuthority() *runworkcontext.Authority { return flow.workContext }

func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *environments.Environment, mode Mode, opts ...FlowOption) (*Flow, error) {
w := wool.Get(ctx).In("NewFlow")

Expand Down Expand Up @@ -501,7 +538,19 @@ func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resour
if err != nil {
return nil, w.Wrap(err)
}
configurationManager.WithLoader(localReader)
// The run serves the key set a service verifies inbound Work Contexts
// against, layered over the loader so it is part of the set the manager
// resolves DECLARED dependencies from. A composition that authors the group
// keeps its own; this answers only for the one that does not.
runID := make([]byte, 8)
if _, readErr := rand.Read(runID); readErr != nil {
return nil, w.Wrap(readErr)
}
workContext, startErr := runworkcontext.Start(hex.EncodeToString(runID))
if startErr != nil {
return nil, w.Wrap(startErr)
}
configurationManager.WithLoader(runworkcontext.WithKeySet(localReader, workContext, declaredWorkspaceConfigurationGroups(ctx, workspace)))
// Bound here, with the loader: the resolution plans producer discovery
// against the root's group names, so a manager that loaded a root group and
// a world that cannot name it would resolve that group's references for
Expand Down Expand Up @@ -553,6 +602,7 @@ func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resour
}

flow := &Flow{
workContext: workContext,
workspace: workspace,
graphWorkspace: graphWorkspace,
moduleSeeds: options.moduleClosureSeeds,
Expand Down
114 changes: 114 additions & 0 deletions pkg/run/workcontext/authority.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Package workcontext serves the verification key set a native run stands in
// for the host with.
//
// A service that verifies an inbound Work Context loads its key set from an
// endpoint, and the one transport rule for that endpoint already admits
// "plaintext http to a loopback host, on a local run only". Nothing served it,
// so a module whose identity comes from the host could not boot on its own.
// This serves it, over that admitted hop, for the life of the run.
//
// The key set is the run's own and so is the issuer stamped beside it. A
// verifier pins the issuer it accepts, so a capability signed here is
// structurally unacceptable to a deployed service, which pins the host's.
package workcontext

import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)

// KeySetPath is where the run serves its key set. It is the whole route: the
// consumer reads the URL verbatim and appends nothing.
const KeySetPath = "/.well-known/work-context-jwks.json"

// keyID names the run's verification key inside the set.
const keyID = "local-run"

// Authority is the key set a native run serves, and the issuer it stamps.
type Authority struct {
issuer string
public ed25519.PublicKey
private ed25519.PrivateKey
listener net.Listener
server *http.Server
}

// Start mints the run's signing key and serves its public half on loopback.
//
// runID distinguishes one run's issuer from another's, so two runs on one
// machine never accept each other's capabilities.
func Start(runID string) (*Authority, error) {
if runID == "" {
return nil, fmt.Errorf("a run's work-context authority is issued under the run's own identity, and this run has none")
}
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("cannot mint this run's work-context key: %w", err)
}
// Loopback only: the admitted hop is a loopback one, and a key set reachable
// from off the machine is not what was admitted.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("cannot serve this run's work-context key set: %w", err)
}
authority := &Authority{
issuer: fmt.Sprintf("codefly://local-run/%s", runID),
public: public,
private: private,
listener: listener,
}
mux := http.NewServeMux()
mux.HandleFunc("GET "+KeySetPath, authority.serveKeySet)
authority.server = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = authority.server.Serve(listener) }()
return authority, nil
}

// Issuer is the issuer this run stamps and a verifier must pin.
func (a *Authority) Issuer() string { return a.issuer }

// KeySetURL is the full route the key set is served at.
func (a *Authority) KeySetURL() string {
return fmt.Sprintf("http://%s%s", a.listener.Addr().String(), KeySetPath)
}

// Signer is the run's private key, for minting capabilities under this issuer.
func (a *Authority) Signer() (string, ed25519.PrivateKey) { return keyID, a.private }

// Close stops serving the key set.
func (a *Authority) Close() error {
if a == nil || a.server == nil {
return nil
}
return a.server.Close()
}

// keySet is the document a verifier parses: a JWKS carrying Ed25519 signing
// keys, which is the only shape the Work Context verifier admits.
func (a *Authority) keySet() any {
return map[string]any{"keys": []map[string]string{{
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"kid": keyID,
"x": base64.RawURLEncoding.EncodeToString(a.public),
}}}
}

func (a *Authority) serveKeySet(w http.ResponseWriter, _ *http.Request) {
document, err := json.Marshal(a.keySet())
if err != nil {
http.Error(w, "cannot encode the key set", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/jwk-set+json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(document)
}
128 changes: 128 additions & 0 deletions pkg/run/workcontext/authority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package workcontext

import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)

// fetch reads the key set the authority serves over its admitted loopback hop.
func fetch(t *testing.T, authority *Authority) map[string]any {
t.Helper()
response, err := http.Get(authority.KeySetURL())
if err != nil {
t.Fatalf("the key set is not reachable at the URL the run hands out: %v", err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
t.Fatalf("the key set answered %d", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
var document map[string]any
if err := json.Unmarshal(body, &document); err != nil {
t.Fatalf("the key set is not JSON: %v", err)
}
return document
}

// The verifier admits exactly one shape: an OKP/Ed25519 signing key whose `x`
// is raw-URL base64 of a 32-byte public key. Emitting a padded encoding, a
// different curve or a JWK alg the parser rejects would leave the run serving a
// document no service can load, which is the failure this guards.
func TestServedKeySetIsTheShapeAVerifierAdmits(t *testing.T) {
authority, err := Start("run-1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = authority.Close() }()

keys, ok := fetch(t, authority)["keys"].([]any)
if !ok || len(keys) != 1 {
t.Fatalf("the key set must carry exactly one key, got %v", keys)
}
key, ok := keys[0].(map[string]any)
if !ok {
t.Fatalf("the key is not an object")
}
for field, want := range map[string]string{"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig"} {
if key[field] != want {
t.Errorf("%s is %v, and a verifier admits only %q", field, key[field], want)
}
}
if kid, _ := key["kid"].(string); kid == "" {
t.Error("the key carries no key id, so a token cannot name which key signed it")
}
encoded, _ := key["x"].(string)
if strings.ContainsAny(encoded, "=+/") {
t.Errorf("x must be raw-URL base64, got %q", encoded)
}
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("x does not decode as raw-URL base64: %v", err)
}
if len(decoded) != ed25519.PublicKeySize {
t.Fatalf("x is %d bytes, and an Ed25519 public key is %d", len(decoded), ed25519.PublicKeySize)
}
}

// The published half must be the signing half's: a run that serves a key set
// unrelated to what it signs with refuses every capability it mints, which is
// indistinguishable at the far end from a key set that was never served.
func TestThePublishedKeyVerifiesWhatTheRunSigns(t *testing.T) {
authority, err := Start("run-1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = authority.Close() }()

key, _ := fetch(t, authority)["keys"].([]any)[0].(map[string]any)
encoded, _ := key["x"].(string)
published, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatal(err)
}
keyID, private := authority.Signer()
if keyID != key["kid"] {
t.Errorf("the signer names key %q, the key set publishes %v", keyID, key["kid"])
}
message := []byte("a capability this run minted")
if !ed25519.Verify(ed25519.PublicKey(published), message, ed25519.Sign(private, message)) {
t.Fatal("the published key does not verify what the run signs")
}
}

// Two runs must not accept each other's capabilities. A verifier pins the
// issuer, so the issuer is what separates them.
func TestEachRunIssuesUnderItsOwnIssuer(t *testing.T) {
first, err := Start("run-1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = first.Close() }()
second, err := Start("run-2")
if err != nil {
t.Fatal(err)
}
defer func() { _ = second.Close() }()

if first.Issuer() == second.Issuer() {
t.Fatalf("both runs issue under %q, so each would accept the other's capabilities", first.Issuer())
}
if !strings.Contains(first.Issuer(), "run-1") {
t.Errorf("the issuer does not name the run: %q", first.Issuer())
}
}

// A run with no identity cannot issue under one.
func TestARunWithoutAnIdentityServesNothing(t *testing.T) {
if _, err := Start(""); err == nil {
t.Fatal("a run with no identity was allowed to issue capabilities")
}
}
Loading
Loading