Skip to content

Conversation

@grypez
Copy link
Contributor

@grypez grypez commented Sep 3, 2024

Closes: #14

In particular, we require that our trusted computing base (which includes the ocap kernel) be endoified so that it may be robustly composed with untrusted code without compromising the consistency of our plan (which, in the case of MetaMask, includes maintaining users' control over and privacy of their identities). Our strategy for meeting this requirement is largely encapsulated in the @ocap/shims/endoify shim, but also includes maintaining the following strict import procedure for all javascript entrypoints in the trusted computing base:

  1. Import trusted code
  2. Import endoify
  3. Import untrusted code

We call steps 1 and 2 in order the trusted prelude of a file.

However, it is still possible for the bundling process to interfere with the endoification of the entrypoints by permuting the order of import statements in their corresponding bundles. Such a permutation could effectively move untrusted code into the trusted computing base, which our plans cannot tolerate. This issue aims to address that possibility with a strategy which is both effective and easily audited.

The strategy is to break out trusted preludes into their own file, and mark them as external (ignored by the bundler and statically copied into dist). In files which import the trusted header, we check if their first line in dist is to import the trusted header.

For total compartmentalization of this security concern, we should implement build tests that checks for first line import '*-trusted-prelude.js'; in minimally reliant proper js.

@grypez grypez force-pushed the grypez/feat-externalization-logic branch from 7e97006 to 117951b Compare September 3, 2024 17:23
Comment on lines 134 to 184
buildEnd: {
order: 'post',
handler(error) {
if (error !== undefined) {
return;
}
const trustedHeaders = Array.from(this.getModuleIds()).filter((moduleId) => isTrustedHeader(moduleId));
const importers = trustedHeaders.map((trustedHeader) => this.getModuleInfo(trustedHeader)?.importers.at(0)!);
importers.forEach(async (moduleId: string) => {
const code = this.getModuleInfo(moduleId)?.code;
if ( code === null || code === undefined ) {
this.error(
`NoCode: Module ${moduleId} was identified as a trusted header importer but has no code at buildEnd.`
);
} else {
const prefix = makeExpectedPrefix(moduleId);
if (code.match(prefix) === null) {
this.error(
`MissingTrustedHeaderImport: Module ${moduleId} was identified as a trusted header importer, ` +
`but does not begin with trusted header import.\n` +
`ExpectedPrefix: ${prefix}\n` +
`ObservedCode: ${code.split(';').at(0)}`);
}
}
});
}
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To remove rollup from the TCB of our security proof, this check should be handled in a build test which gates pushes to prod. It would look similar to what is implemented here, but would need its own method for identifying which files are meant to be trusted, be it systematic or declared.

@grypez grypez force-pushed the grypez/feat-externalization-logic branch from 117951b to cd4878c Compare September 3, 2024 20:09
@grypez grypez marked this pull request as ready for review September 3, 2024 20:34
@grypez grypez requested a review from a team as a code owner September 3, 2024 20:34
Copy link
Member

@rekmarks rekmarks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might I suggest "trusted prelude" instead of "trusted header"? In my mind, "header" in the context of a source file connotes metadata and/or config options, while "prelude" is something that is actually executed. See also: the "synchronous prelude" of an async function.

Copy link
Member

@rekmarks rekmarks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some further suggested name changes:

  • plugins/ -> build/
    • Just "plugins" invites the question "plugins for what?". Since the file names contain the word "plugin" and the name of the... "plug-ee", there's already no question that they're plugins or what they plug in to.
  • rollup-plugin-endoify-trusted-header.ts -> rollup-plugin-js-trusted-prelude.ts
  • vite-plugin-endoify-html-files.ts -> vite-plugin-html-trusted-prelude.ts

Note: the exports of the files should match the file names.

Edit: Since both plugins are in fact Vite plugins, we could also do this, which I think I prefer:

  • vite-plugins/
    • html-trusted-prelude.ts
    • js-trusted-prelude.ts

Copy link
Member

@rekmarks rekmarks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor requested changes, mostly related to documentation and the naming scheme for the plugins.

I'll note what I believe is one limitation of the JS plugin: if we ever observe that the import order has changed, the build will fail with no recourse. Instead, could we not parameterize the entry point files to the plugin and insert the import statement ourselves? I don't think this would negatively impact devex, since we could still import the types of the trusted header / prelude files. In any event, something to consider.

console.log(expectedPrefix);
return expectedPrefix;
};
return {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The internals of this would benefit from some documentation links and/or a description of what the different properties are for.


/**
* Vite plugin to ensure that the following are true:
* - Every entrypoint contains at most one import from a *trusted-header file.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this plugin actually check if any of the files are entrypoints, or just if they imported a trusted header / prelude?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't check for entrypoints. For now I have corrected the doc string to bundles. The current functionality ought to be sufficient for our purposes---to ensure our entrypoints have trusted headers. The risk of omitting the entrypoint check is that a dev could get the impression they have a trusted header even though their code is vulnerable to being imported after a malicious initialization. Given our organization processes, I'm comfortable with that risk.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I should have specified: my comment was more about the comment on L6, which says that the plugin ensures that something is true about entrypoints, but actually it just enforces the property for any file it comes across.

Copy link
Contributor Author

@grypez grypez Sep 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This incorrect description has been corrected in the latest docs commit.

/**
 * A Vite plugin to ensure the following.
 * - Every declared trusted prelude is handled externally (automatically merged into {@link RollupOptions.external}).
 * - Every declared trusted prelude importer:
 *   - Is a declared entry point (throws during {@link Plugin.buildStart} otherwise).
 *   - Imports at most one declared trusted prelude (throws during {@link Plugin.generateBundle} otherwise).
 *   - Begins by importing its declared trusted prelude (prepended during {@link Plugin.generateBundle} if missing).
 *
 * ...
 */

@grypez
Copy link
Contributor Author

grypez commented Sep 5, 2024

I'll note what I believe is one limitation of the JS plugin: if we ever observe that the import order has changed, the build will fail with no recourse. Instead, could we not parameterize the entry point files to the plugin and insert the import statement ourselves? I don't think this would negatively impact devex, since we could still import the types of the trusted header / prelude files. In any event, something to consider.

Build failures with inscrutable root causes are a bad time, agreed.

Since import statements are idempotent, we could also leave the import statement in the source code and still prepend the import statement instead of checking for it. The bundled entrypoint of foo.ts would then typically look like this:

import"foo-trusted-prelude.js";import"foo-trusted-prelude.js";import{X as a,Rf as g}from"./bar.js";...

And sometimes like this:

import"foo-trusted-prelude.js";import{X as a,Rf as g}from"./bar.js";import"foo-trusted-prelude.js";...

@rekmarks rekmarks changed the title build(extension): Introduce module externalization logic build(extension): Robustify JavaScript module externalization Sep 5, 2024
@grypez grypez force-pushed the grypez/feat-externalization-logic branch from 248f8f2 to 72fc236 Compare September 5, 2024 23:51
@grypez
Copy link
Contributor Author

grypez commented Sep 9, 2024

The latest implementation does not rely on the naming convention *-trusted-prelude.js, instead requiring explicit declarations in vite.config.ts, but the naming convention is still recommended for reasons of collocation and obviation.

@grypez grypez force-pushed the grypez/feat-externalization-logic branch from 7620df9 to 060cebe Compare September 9, 2024 19:36
@grypez grypez requested a review from rekmarks September 9, 2024 19:39
@grypez
Copy link
Contributor Author

grypez commented Sep 9, 2024

Notably the refactored plugins are not test covered, but the reviewer is encouraged to check out the following branches and run yarn build to verify behavior.

Allow multiple entry points with separate trusted headers.

git checkout grypez/feat-externalization-logic-valid-multi-entry

Throw because background.js was not declared an entry point.

git checkout grypez/feat-externalization-logic-invalid-non-entry

Throw because background.js gets a transitive trusted prelude import through shared.js.

git checkout grypez/feat-externalization-logic-invalid-multi-prelude

Warn and correct when interfering plugin changes first import.

git checkout grypez/feat-externalization-logic-interfering-plugin

The implementation throws errors only if its configuration assumptions are violated.
Otherwise, it proactively externalizes trusted preludes and attempts to correct any
correctness-breaking import permutations that may have occurred during bundling,
merely warning that some corrective action was necessary but not breaking the build.

The configuration assumptions are as follows.
 - If a file has a declared trusted prelude, the file must be declared an entry point.
 - If a file has a declared trusted prelude, it must not transitively import more than
     one declared trusted prelude.

The result is a plugin which ensures that a declared trusted prelude file is never
imported after any other code, bandaging bundles which unintentionally violate this
constraint and rejecting source code which intentionally violates this constaint.
@grypez grypez force-pushed the grypez/feat-externalization-logic branch from 26a761c to 92ce904 Compare September 10, 2024 18:22
Copy link
Member

@rekmarks rekmarks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Just one minor thing and we're there.

Comment on lines 121 to 131
getTrustedPrelude = (context: PluginContext, importer: string) => {
const trustedPrelude = resolvedTrustingImporters.get(importer);
// Ensure importer was declared and recognized as a trusting importer.
if (!trustedPrelude) {
// Shouldn't be possible without heavy interference from other plugins.
context.error(
`Module "${importer}" was identified as but not declared as a trusted prelude importer.`,
);
}
return trustedPrelude;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a double take reading this, because I wasn't sure if trustedPrelude referred to a filename or the actual trusted prelude content. This confusion can be precluded by:

Suggested change
getTrustedPrelude = (context: PluginContext, importer: string) => {
const trustedPrelude = resolvedTrustingImporters.get(importer);
// Ensure importer was declared and recognized as a trusting importer.
if (!trustedPrelude) {
// Shouldn't be possible without heavy interference from other plugins.
context.error(
`Module "${importer}" was identified as but not declared as a trusted prelude importer.`,
);
}
return trustedPrelude;
};
getTrustedPreludeFileName = (context: PluginContext, importer: string) => {
const trustedPreludeFileName = resolvedTrustingImporters.get(importer);
// Ensure importer was declared and recognized as a trusting importer.
if (!trustedPreludeFileName) {
// Shouldn't be possible without heavy interference from other plugins.
context.error(
`Module "${importer}" was identified as but not declared as a trusted prelude importer.`,
);
}
return trustedPreludeFileName;
};

@grypez grypez enabled auto-merge (squash) September 10, 2024 23:42
Copy link
Member

@rekmarks rekmarks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@grypez grypez merged commit 21cf8a8 into main Sep 10, 2024
@grypez grypez deleted the grypez/feat-externalization-logic branch September 10, 2024 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

build(extension): Introduce our own module externalization logic for JavaScript entry points

3 participants