diff --git a/.changelog/5707.feature.md b/.changelog/5707.feature.md new file mode 100644 index 00000000000..2fecefc79a3 --- /dev/null +++ b/.changelog/5707.feature.md @@ -0,0 +1,11 @@ +Support detached ROFL components + +Previously each bundle that contained one or more ROFL components also +needed to contain the exact version of the RONL component it was +attaching to. + +This is somewhat awkward to use when we assume a more decentralized +development and deployment of ROFL applications. This commit adds support +for detached ROFL components where the bundle only contains the ROFL and +oasis-node then automatically gets the appropriate RONL component from +another bundle. diff --git a/go/runtime/bundle/bundle.go b/go/runtime/bundle/bundle.go index 7f23b588388..0dae752c48d 100644 --- a/go/runtime/bundle/bundle.go +++ b/go/runtime/bundle/bundle.go @@ -280,19 +280,32 @@ func (bnd *Bundle) Write(fn string) error { return nil } -// ExplodedPath returns the path under the data directory that contains -// all of the exploded bundles. +// ExplodedPath returns the path under the data directory that contains all of the exploded bundles. func ExplodedPath(dataDir string) string { return filepath.Join(dataDir, "runtimes", "bundles") } -// ExplodedPath returns the path that the corresponding asset will be -// written to via WriteExploded. +// DetachedExplodedPath returns the path under the data directory that contains all of the detached +// exploded bundles. +func DetachedExplodedPath(dataDir string) string { + return filepath.Join(ExplodedPath(dataDir), "detached") +} + +// ExplodedPath returns the path that the corresponding asset will be written to via WriteExploded. func (bnd *Bundle) ExplodedPath(dataDir, fn string) string { - // DATADIR/runtimes/bundles/runtimeID-version - subDir := filepath.Join(ExplodedPath(dataDir), - fmt.Sprintf("%s-%s", bnd.Manifest.ID, bnd.Manifest.Version), - ) + var subDir string + switch bnd.Manifest.IsDetached() { + case false: + // DATADIR/runtimes/bundles/manifestHash + subDir = filepath.Join(ExplodedPath(dataDir), + bnd.Manifest.Hash().String(), + ) + case true: + // DATADIR/runtimes/bundles/detached/manifestHash + subDir = filepath.Join(DetachedExplodedPath(dataDir), + bnd.Manifest.Hash().String(), + ) + } if fn == "" { return subDir diff --git a/go/runtime/bundle/bundle_test.go b/go/runtime/bundle/bundle_test.go index 00006e1e661..d658f366020 100644 --- a/go/runtime/bundle/bundle_test.go +++ b/go/runtime/bundle/bundle_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/oasisprotocol/oasis-core/go/common" + "github.com/oasisprotocol/oasis-core/go/runtime/bundle/component" ) func TestBundle(t *testing.T) { @@ -32,15 +33,22 @@ func TestBundle(t *testing.T) { } return id }(), - Executable: "runtime.bin", - SGX: &SGXMetadata{ - Executable: "runtime.sgx", + Components: []*Component{ + { + Kind: component.RONL, + Executable: "runtime.bin", + SGX: &SGXMetadata{ + Executable: "runtime.sgx", + }, + }, }, } bundle := &Bundle{ Manifest: manifest, } + require.False(t, manifest.IsDetached(), "manifest with RONL component should not be detached") + t.Run("Add_Write", func(t *testing.T) { // Generate random assets. randomBuffer := func() []byte { @@ -50,9 +58,9 @@ func TestBundle(t *testing.T) { return b } - err := bundle.Add(manifest.Executable, execBuf) + err := bundle.Add(manifest.Components[0].Executable, execBuf) require.NoError(t, err, "bundle.Add(elf)") - err = bundle.Add(manifest.SGX.Executable, randomBuffer()) + err = bundle.Add(manifest.Components[0].SGX.Executable, randomBuffer()) require.NoError(t, err, "bundle.Add(sgx)") err = bundle.Write(bundleFn) @@ -94,3 +102,82 @@ func TestBundle(t *testing.T) { require.NoError(t, err, "WriteExploded(again)") }) } + +func TestDeatchedBundle(t *testing.T) { + execFile := os.Args[0] + execBuf, err := os.ReadFile(execFile) + if err != nil { + t.Fatalf("failed to read test executable %s: %v", execFile, err) + } + + // Create a synthetic bundle. + // + // Assets will be populated during the Add/Write combined test. + tmpDir := t.TempDir() + bundleFn := filepath.Join(tmpDir, "detached-bundle.orc") + manifest := &Manifest{ + Name: "test-runtime", + ID: func() common.Namespace { + var id common.Namespace + if err := id.UnmarshalHex("c000000000000000ffffffffffffffffffffffffffffffffffffffffffffffff"); err != nil { + panic("failed to unmarshal id") + } + return id + }(), + Components: []*Component{ + // No RONL component in the manifest. + { + Kind: component.ROFL, + Executable: "runtime.bin", + SGX: &SGXMetadata{ + Executable: "runtime.sgx", + }, + }, + }, + } + bundle := &Bundle{ + Manifest: manifest, + } + + require.True(t, manifest.IsDetached(), "manifest without RONL component should be detached") + + t.Run("Add_Write", func(t *testing.T) { + // Generate random assets. + randomBuffer := func() []byte { + b := make([]byte, 1024*256) + _, err := rand.Read(b) + require.NoError(t, err, "rand.Read") + return b + } + + err := bundle.Add(manifest.Components[0].Executable, execBuf) + require.NoError(t, err, "bundle.Add(elf)") + err = bundle.Add(manifest.Components[0].SGX.Executable, randomBuffer()) + require.NoError(t, err, "bundle.Add(sgx)") + + err = bundle.Write(bundleFn) + require.NoError(t, err, "bundle.Write") + }) + + t.Run("Open", func(t *testing.T) { + bundle2, err := Open(bundleFn) + require.NoError(t, err, "Open") + + // Ignore the manifest, the bundle we used to create the file + // will not have it. + delete(bundle2.Manifest.Digests, manifestName) + delete(bundle2.Data, manifestName) + + require.EqualValues(t, bundle, bundle2, "opened bundle mismatch") + }) + + t.Run("Explode", func(t *testing.T) { + err := bundle.WriteExploded(tmpDir) + require.NoError(t, err, "WriteExploded") + + // Abuse the fact that we do an integrity check if the bundle + // is already exploded. + err = bundle.WriteExploded(tmpDir) + require.NoError(t, err, "WriteExploded(again)") + }) +} diff --git a/go/runtime/bundle/component/component.go b/go/runtime/bundle/component/component.go index b93cf2f03c9..31073f8b46b 100644 --- a/go/runtime/bundle/component/component.go +++ b/go/runtime/bundle/component/component.go @@ -74,5 +74,10 @@ func (c *ID) UnmarshalText(text []byte) error { return nil } +// IsRONL returns true iff the component identifier is the special RONL component identifier. +func (c ID) IsRONL() bool { + return c == ID_RONL +} + // ID_RONL is the identifier of the RONL component. var ID_RONL = ID{Kind: RONL, Name: ""} //nolint: revive diff --git a/go/runtime/bundle/manifest.go b/go/runtime/bundle/manifest.go index dc1a343ca4e..c1b5e33164c 100644 --- a/go/runtime/bundle/manifest.go +++ b/go/runtime/bundle/manifest.go @@ -41,6 +41,11 @@ type Manifest struct { Digests map[string]hash.Hash `json:"digests"` } +// Hash returns a cryptographic hash of the CBOR-serialized manifest. +func (m *Manifest) Hash() hash.Hash { + return hash.NewFrom(m) +} + // Validate validates the manifest structure for well-formedness. func (m *Manifest) Validate() error { byID := make(map[component.ID]struct{}) @@ -69,22 +74,23 @@ func (m *Manifest) Validate() error { } } - // Ensure the RONL component is always defined. - if ronl := m.GetComponentByID(component.ID_RONL); ronl == nil { - return fmt.Errorf("runtime must define at least the RONL component") - } - return nil } +// IsDetached returns true iff the manifest does not include a RONL component. Such bundles require +// that the RONL component is provided out-of-band (e.g. in a separate bundle). +func (m *Manifest) IsDetached() bool { + return m.GetComponentByID(component.ID_RONL) == nil +} + // GetAvailableComponents collects all of the available components into a map. func (m *Manifest) GetAvailableComponents() map[component.ID]*Component { availableComps := make(map[component.ID]*Component) for _, comp := range m.Components { availableComps[comp.ID()] = comp } - if _, exists := availableComps[component.ID_RONL]; !exists { - // Needed for supporting legacy manifests -- always available, see Validate above. + if _, exists := availableComps[component.ID_RONL]; !exists && !m.IsDetached() { + // Needed for supporting legacy manifests. availableComps[component.ID_RONL] = m.GetComponentByID(component.ID_RONL) } return availableComps @@ -99,7 +105,7 @@ func (m *Manifest) GetComponentByID(id component.ID) *Component { } // We also support legacy manifests which define the RONL component at the top-level. - if id == component.ID_RONL && len(m.Executable) > 0 { + if id.IsRONL() && len(m.Executable) > 0 { return &Component{ Kind: component.RONL, Executable: m.Executable, diff --git a/go/runtime/host/composite/composite.go b/go/runtime/host/composite/composite.go index a44dbe0520d..6ca827edef3 100644 --- a/go/runtime/host/composite/composite.go +++ b/go/runtime/host/composite/composite.go @@ -72,7 +72,7 @@ func (c *composite) Call(ctx context.Context, body *protocol.Body) (*protocol.Bo } for id, comp := range c.comps { - if id == component.ID_RONL { + if id.IsRONL() { continue // Already handled above. } if !shouldPropagateToComponent(body) { diff --git a/go/runtime/host/host.go b/go/runtime/host/host.go index b597e7d9d56..1f8483b96a2 100644 --- a/go/runtime/host/host.go +++ b/go/runtime/host/host.go @@ -3,6 +3,7 @@ package host import ( "context" + "path/filepath" "github.com/oasisprotocol/oasis-core/go/common" "github.com/oasisprotocol/oasis-core/go/common/node" @@ -38,6 +39,18 @@ type RuntimeBundle struct { // ExplodedDataDir is the path to the data directory under which the bundle has been exploded. ExplodedDataDir string + + // ExplodedDetachedDirs are the paths to the data directories of detached components. + ExplodedDetachedDirs map[component.ID]string +} + +// ExplodedPath returns the path where the given asset for the given component can be found. +func (bnd *RuntimeBundle) ExplodedPath(comp component.ID, fn string) string { + if detachedDir, ok := bnd.ExplodedDetachedDirs[comp]; ok { + return filepath.Join(detachedDir, fn) + } + // Default to the exploded bundle. + return bnd.Bundle.ExplodedPath(bnd.ExplodedDataDir, fn) } // Provisioner is the runtime provisioner interface. diff --git a/go/runtime/host/multi/multi.go b/go/runtime/host/multi/multi.go index eed8b88d85f..7fca156771a 100644 --- a/go/runtime/host/multi/multi.go +++ b/go/runtime/host/multi/multi.go @@ -290,7 +290,7 @@ func (agg *Aggregate) Component(id component.ID) (host.Runtime, bool) { if cr, ok := active.host.(host.CompositeRuntime); ok { return cr.Component(id) } - if id == component.ID_RONL { + if id.IsRONL() { return active.host, true } return nil, false diff --git a/go/runtime/host/sandbox/sandbox.go b/go/runtime/host/sandbox/sandbox.go index 1f6cf380c76..b98a867d0e4 100644 --- a/go/runtime/host/sandbox/sandbox.go +++ b/go/runtime/host/sandbox/sandbox.go @@ -422,9 +422,12 @@ func (r *sandboxedRuntime) startProcess() (err error) { return fmt.Errorf("failed to initialize connection: %w", err) } - // Make sure the version matches what is configured in the bundle. - if bndVersion := r.rtCfg.Bundle.Manifest.Version; *rtVersion != bndVersion { - return fmt.Errorf("version mismatch (runtime reported: %s bundle: %s)", *rtVersion, bndVersion) + if r.rtCfg.Components[0].IsRONL() { + // Make sure the version matches what is configured in the bundle. This check is skipped for + // non-RONL components to support detached bundles. + if bndVersion := r.rtCfg.Bundle.Manifest.Version; *rtVersion != bndVersion { + return fmt.Errorf("version mismatch (runtime reported: %s bundle: %s)", *rtVersion, bndVersion) + } } hp := &HostInitializerParams{ @@ -661,7 +664,7 @@ func DefaultGetSandboxConfig(logger *logging.Logger, sandboxBinaryPath string) G "component", comp.Kind, ) return process.Config{ - Path: hostCfg.Bundle.ExplodedPath(hostCfg.Bundle.ExplodedDataDir, comp.Executable), + Path: hostCfg.Bundle.ExplodedPath(comp.ID(), comp.Executable), Env: map[string]string{ "OASIS_WORKER_HOST": socketPath, }, diff --git a/go/runtime/host/sgx/sgx.go b/go/runtime/host/sgx/sgx.go index b97c54a70b1..88089adcb44 100644 --- a/go/runtime/host/sgx/sgx.go +++ b/go/runtime/host/sgx/sgx.go @@ -182,7 +182,7 @@ func (s *sgxProvisioner) loadEnclaveBinaries(rtCfg host.Config, comp *bundle.Com if comp.SGX == nil || comp.SGX.Executable == "" { return nil, nil, fmt.Errorf("SGX executable not available in bundle") } - sgxExecutablePath := rtCfg.Bundle.ExplodedPath(rtCfg.Bundle.ExplodedDataDir, comp.SGX.Executable) + sgxExecutablePath := rtCfg.Bundle.ExplodedPath(comp.ID(), comp.SGX.Executable) var ( sig, sgxs []byte @@ -198,7 +198,7 @@ func (s *sgxProvisioner) loadEnclaveBinaries(rtCfg host.Config, comp *bundle.Com } if comp.SGX.Signature != "" { - sgxSignaturePath := rtCfg.Bundle.ExplodedPath(rtCfg.Bundle.ExplodedDataDir, comp.SGX.Signature) + sgxSignaturePath := rtCfg.Bundle.ExplodedPath(comp.ID(), comp.SGX.Signature) sig, err = os.ReadFile(sgxSignaturePath) if err != nil { return nil, nil, fmt.Errorf("failed to load SIGSTRUCT: %w", err) diff --git a/go/runtime/registry/config.go b/go/runtime/registry/config.go index b2a51142880..10b49fda979 100644 --- a/go/runtime/registry/config.go +++ b/go/runtime/registry/config.go @@ -226,10 +226,16 @@ func newConfig( //nolint: gocyclo }) } - // Configure runtimes. - rh.Runtimes = make(map[common.Namespace]map[version.Version]*runtimeHost.Config) + // Preprocess runtimes to separate detached from non-detached. + type nameKey struct { + runtime common.Namespace + comp component.ID + } + + var regularBundles []*bundle.Bundle + detachedBundles := make(map[common.Namespace][]*bundle.Bundle) + existingNames := make(map[nameKey]struct{}) for _, path := range config.GlobalConfig.Runtime.Paths { - // Open and explode the bundle. This will call Validate(). var bnd *bundle.Bundle if bnd, err = bundle.Open(path); err != nil { return nil, fmt.Errorf("failed to load runtime bundle '%s': %w", path, err) @@ -237,11 +243,38 @@ func newConfig( //nolint: gocyclo if err = bnd.WriteExploded(dataDir); err != nil { return nil, fmt.Errorf("failed to explode runtime bundle '%s': %w", path, err) } + // Release resources as the bundle has been exploded anyway. + bnd.Data = nil + + switch bnd.Manifest.IsDetached() { + case false: + // A regular non-detached bundle that has the RONL component. + regularBundles = append(regularBundles, bnd) + case true: + // A detached bundle without the RONL component that needs to be attached. + detachedBundles[bnd.Manifest.ID] = append(detachedBundles[bnd.Manifest.ID], bnd) + + // Ensure there are no name conflicts among the components. + for compID := range bnd.Manifest.GetAvailableComponents() { + nk := nameKey{bnd.Manifest.ID, compID} + if _, ok := existingNames[nk]; ok { + return nil, fmt.Errorf("duplicate component '%s' for runtime '%s'", compID, bnd.Manifest.ID) + } + existingNames[nk] = struct{}{} + } + } + } + // Configure runtimes. + rh.Runtimes = make(map[common.Namespace]map[version.Version]*runtimeHost.Config) + for _, bnd := range regularBundles { id := bnd.Manifest.ID if rh.Runtimes[id] == nil { rh.Runtimes[id] = make(map[version.Version]*runtimeHost.Config) } + if _, ok := rh.Runtimes[id][bnd.Manifest.Version]; ok { + return nil, fmt.Errorf("duplicate runtime '%s' version '%s'", id, bnd.Manifest.Version) + } // Get any local runtime configuration. var localConfig map[string]interface{} @@ -255,12 +288,30 @@ func newConfig( //nolint: gocyclo } } + rtBnd := &runtimeHost.RuntimeBundle{ + Bundle: bnd, + ExplodedDataDir: dataDir, + } + + // Merge in detached components. + for _, detachedBnd := range detachedBundles[id] { + for _, detachedComp := range detachedBnd.Manifest.Components { + // Skip components that already exist in the bundle itself. + if bnd.Manifest.GetComponentByID(detachedComp.ID()) != nil { + continue + } + + bnd.Manifest.Components = append(bnd.Manifest.Components, detachedComp) + rtBnd.ExplodedDetachedDirs[detachedComp.ID()] = detachedBnd.ExplodedPath(dataDir, "") + } + } + // Determine what kind of components we want. wantedComponents := []component.ID{ component.ID_RONL, } for _, comp := range bnd.Manifest.Components { - if comp.ID() == component.ID_RONL { + if comp.ID().IsRONL() { continue // Always enabled above. } @@ -285,10 +336,7 @@ func newConfig( //nolint: gocyclo } rh.Runtimes[id][bnd.Manifest.Version] = &runtimeHost.Config{ - Bundle: &runtimeHost.RuntimeBundle{ - Bundle: bnd, - ExplodedDataDir: dataDir, - }, + Bundle: rtBnd, Components: wantedComponents, LocalConfig: localConfig, } @@ -308,6 +356,12 @@ func newConfig( //nolint: gocyclo Bundle: &bundle.Bundle{ Manifest: &bundle.Manifest{ ID: id, + Components: []*bundle.Component{ + { + Kind: component.RONL, + Executable: "mock", + }, + }, }, }, }, diff --git a/go/runtime/registry/host.go b/go/runtime/registry/host.go index f1b1dd0423d..6f8206b02fa 100644 --- a/go/runtime/registry/host.go +++ b/go/runtime/registry/host.go @@ -492,7 +492,7 @@ func (h *runtimeHostHandler) handleHostIdentity() (*protocol.HostIdentityRespons func (h *runtimeHostHandler) NewSubHandler(cr host.CompositeRuntime, comp *bundle.Component) (host.RuntimeHandler, error) { switch comp.Kind { case component.ROFL: - return newSubHandlerROFL(h, cr) + return newSubHandlerROFL(h, cr, comp) default: return nil, fmt.Errorf("cannot create sub-handler for component '%s'", comp.Kind) } diff --git a/go/runtime/registry/host_rofl.go b/go/runtime/registry/host_rofl.go index f5fe27e2d47..743c2b5c021 100644 --- a/go/runtime/registry/host_rofl.go +++ b/go/runtime/registry/host_rofl.go @@ -28,14 +28,15 @@ const ( roflAttachRuntimeTimeout = 2 * time.Second // roflNotifyTimeout is the maximum amount of time runtime notification handling can take. roflNotifyTimeout = 2 * time.Second - // roflLocalStorageKeyPrefix is the implicit local storage prefix for all ROFL keys. - roflLocalStorageKeyPrefix = "rofl." + // roflLocalStorageKeySeparator is the local storage key separator after component ID. + roflLocalStorageKeySeparator = ":" ) // roflHostHandler is a host handler extended for use by ROFL components. type roflHostHandler struct { parent *runtimeHostHandler cr host.CompositeRuntime + comp *bundle.Component client runtimeClient.RuntimeClient eventNotifier *roflEventNotifier @@ -43,7 +44,7 @@ type roflHostHandler struct { logger *logging.Logger } -func newSubHandlerROFL(parent *runtimeHostHandler, cr host.CompositeRuntime) (host.RuntimeHandler, error) { +func newSubHandlerROFL(parent *runtimeHostHandler, cr host.CompositeRuntime, comp *bundle.Component) (host.RuntimeHandler, error) { client, err := parent.env.GetRuntimeRegistry().Client() if err != nil { return nil, err @@ -56,6 +57,7 @@ func newSubHandlerROFL(parent *runtimeHostHandler, cr host.CompositeRuntime) (ho return &roflHostHandler{ parent: parent, cr: cr, + comp: comp, client: client, eventNotifier: newROFLEventNotifier(parent.runtime, client, logger), logger: logger, @@ -72,6 +74,14 @@ func (rh *roflHostHandler) AttachRuntime(rt host.Runtime) error { return rh.eventNotifier.AttachRuntime(rt) } +// getLocalStorageKey returns a properly namespaced version of the local storage key. +func (rh *roflHostHandler) getLocalStorageKey(key []byte) []byte { + result, _ := rh.comp.ID().MarshalText() + result = append(result, roflLocalStorageKeySeparator...) + result = append(result, key...) + return result +} + // Implements protocol.Handler. func (rh *roflHostHandler) Handle(ctx context.Context, rq *protocol.Body) (*protocol.Body, error) { var ( @@ -84,11 +94,11 @@ func (rh *roflHostHandler) Handle(ctx context.Context, rq *protocol.Body) (*prot return rh.handleHostRPCCall(ctx, rq) case rq.HostLocalStorageGetRequest != nil: // Local storage get. - rq.HostLocalStorageGetRequest.Key = append([]byte(roflLocalStorageKeyPrefix), rq.HostLocalStorageGetRequest.Key...) + rq.HostLocalStorageGetRequest.Key = rh.getLocalStorageKey(rq.HostLocalStorageGetRequest.Key) return rh.parent.Handle(ctx, rq) case rq.HostLocalStorageSetRequest != nil: // Local storage set. - rq.HostLocalStorageSetRequest.Key = append([]byte(roflLocalStorageKeyPrefix), rq.HostLocalStorageSetRequest.Key...) + rq.HostLocalStorageSetRequest.Key = rh.getLocalStorageKey(rq.HostLocalStorageSetRequest.Key) return rh.parent.Handle(ctx, rq) case rq.HostSubmitTxRequest != nil: // Transaction submission. diff --git a/runtime/src/attestation.rs b/runtime/src/attestation.rs index 89c2ec2ff2e..52bdb3ed3c2 100644 --- a/runtime/src/attestation.rs +++ b/runtime/src/attestation.rs @@ -26,7 +26,7 @@ pub struct Handler { host: Arc, consensus_verifier: Arc, runtime_id: Namespace, - version: Version, + version: Option, logger: Logger, } @@ -37,7 +37,7 @@ impl Handler { host: Arc, consensus_verifier: Arc, runtime_id: Namespace, - version: Version, + version: Option, ) -> Self { Self { identity, @@ -98,7 +98,7 @@ impl Handler { // TODO: Make async. let consensus_verifier = self.consensus_verifier.clone(); - let version = Some(self.version); + let version = self.version; let runtime_id = self.runtime_id; let policy = tokio::task::block_in_place(move || { // Obtain current quote policy from (verified) consensus state. diff --git a/runtime/src/dispatcher.rs b/runtime/src/dispatcher.rs index bb6b6d29d74..c8f5637b999 100644 --- a/runtime/src/dispatcher.rs +++ b/runtime/src/dispatcher.rs @@ -265,6 +265,14 @@ impl Dispatcher { error!(self.logger, "ROFL application initialization failed"; "err" => ?err); } + // Determine what runtime version to support during remote attestation. For runtimes that + // define a ROFL application, we use `None` to signal that the active version is used. + let version = if app.is_supported() { + None + } else { + Some(protocol.get_config().version) + }; + let state = State { protocol: protocol.clone(), consensus_verifier: consensus_verifier.clone(), @@ -278,7 +286,7 @@ impl Dispatcher { protocol.clone(), consensus_verifier.clone(), protocol.get_runtime_id(), - protocol.get_config().version, + version, ), policy_verifier: Arc::new(PolicyVerifier::new(consensus_verifier)), cache_set: cache::CacheSet::new(protocol.clone()),