Hardened JavaScript
Watch: Object-capability Programming in Secure Javascript (August 2019)
The first 15 minutes cover much of the material below. The last 10 minutes are Q&A.
Example Hardened JavaScript Code
The example below demonstrates several features of Hardened JavaScript.
const makeCounter = () => {
let count = 0;
return harden({
incr: () => (count += 1),
decr: () => (count -= 1),
});
};
const counter = makeCounter();
counter.incr();
const n = counter.incr();
assert(n === 2);
We'll unpack this a bit below, but for now please note the use of functions and records:
makeCounter
is a function.- Each call to
makeCounter
creates a new instance:- a new record with two properties,
incr
anddecr
, and - a new
count
variable.
- a new record with two properties,
- The
incr
anddecr
properties are visible from outside the object. - The
count
variable is encapsulated; only theincr
anddecr
methods can access it. - Each of these instances is isolated from each other.
Separation of Duties
Suppose we want to keep track of the number of people inside a room by having an entryGuard
count up when people enter the room and an exitGuard
count down when people exit the room.
We can give the entryGuard
access to the incr
function and give the exitGuard
access to the decr
function.
entryGuard.use(counter.incr);
exitGuard.use(counter.decr);
The result is that the entryGuard
can only count up and the exitGuard
can only count down.
Eventual send syntax
The entryGuard ! use(counter.incr);
code in the video uses a proposed syntax for eventual send, which we will get to soon.
Object Capabilities (ocaps)
The separation of duties illustrates the core idea of object capabilities: an object reference familiar from object programming is a permission.
In this figure, Alice says: bob.greet(carol)
If object Bob has no reference to object Carol, then Bob cannot invoke Carol; Bob can't provoke whatever behavior Carol would have.
If Alice has a reference to Bob and invokes Bob, passing Carol as an argument, then Alice has both used her permission to invoke Bob and given Bob permission to invoke Carol.
We refer to these object references as object capabilities or ocaps.
The Principle of Least Authority (POLA)
OCaps give us a natural way to express the principle of least authority, where each object is only given the permission it needs to do its legitimate job, e.g., only giving the entryGuard
the ability to increment the counter.
This limits the damage that can happen if there is an exploitable bug.
Watch: Navigating the Attack Surface
to achieve a multiplicative reduction in risk. 15 min
Tool Support: eslint config
eslint configuration for Jessie
The examples in this section are written using Jessie, our recommended style for writing JavaScript smart contracts. This eslint
configuration provides tool support.
- If working from an empty directory, a package.json file must first be created by running
yarn init
oryarn init -y
. - From there, we can install eslint into our project along with the jessie.js eslint-plugin by running
yarn add eslint @jessie.js/eslint-plugin
. - The final step is to set up our project's eslint configuration inside of the package.json file by adding the following code block.
"eslintConfig" : {
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 6
},
"extends": [
"plugin:@jessie.js/recommended"
]
}
Now the contents of the package.json file should look similiar to the snippet below.
{
"name": "eslint-config-test",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"type": "module",
"devDependencies": {
"@jessie.js/eslint-plugin": "^0.1.3",
"eslint": "^8.6.0"
},
"eslintConfig": {
"parserOptions": { "sourceType": "module", "ecmaVersion": 6 },
"extends": ["plugin:@jessie.js/recommended"]
}
}
Linting jessie.js Code
- Put
// @jessie-check
at the beginning of your.js
source file. - Run
yarn eslint --fix path/to/your-source.js
- If eslint finds issues with the code, follow the linter's advice to edit your file, and then repeat the step above.
The details of Jessie have evolved with experience. As a result, here we use (count += 1)
whereas the video shows { return count++; }
.
Objects and the maker Pattern
Let's unpack the makeCounter
example a bit.
JavaScript is somewhat novel in that objects need not belong to any class; they can just stand on their own:
const origin = {
getX: () => 0,
getY: () => 0,
distance: other => Math.sqrt(other.getX() ** 2 + other.getY() ** 2),
};
const x0 = origin.getX();
assert(x0 === 0);
We can make a new such object each time a function is called using the maker pattern:
const makePoint = (x, y) => {
return {
getX: () => x,
getY: () => y,
};
};
const p11 = makePoint(1, 1);
const d = origin.distance(p11);
assert(Math.abs(d - 1.414) < 0.001);
Use lexically scoped variables rather than properties of this
.
The style above avoids boilerplate such as this.x = x; this.y = y
.
Use arrow functions
We recommend arrow function syntax rather than function makePoint(x, y) { ... }
declarations for conciseness and to avoid this
.
Defensive Objects with harden()
By default, anyone can clobber the properties of our objects so that they fail to conform to the expected API:
p11.getX = () => 'I am not a number!';
const d2 = origin.distance(p11);
assert(Number.isNaN(d2));
Worse yet is to clobber a property so that it misbehaves but covers its tracks so that we don't notice:
p11.getY = () => {
missiles.launch(); // !!!
return 1;
};
const d3 = origin.distance(p11);
assert(Math.abs(d3 - 1.414) < 0.001);
Our goal is defensive correctness: a program is defensively correct if it remains correct despite arbitrary behavior on the part of its clients. For further discussion, see Concurrency Among Strangers and other Agoric papers on Robust Composition.
To prevent tampering, use the harden function, which is a deep form of Object.freeze.
const makePoint = (x, y) => {
return harden({
getX: () => x,
getY: () => y,
});
};
Any attempt to modify the properties of a hardened object throws:
const p11 = makePoint(1, 1);
p11.getX = () => 1; // throws
harden()
should be called on all objects that will be transferred across a trust boundary. It's important to harden()
an object before exposing the object by returning it or passing it to some other function.
harden(), classes, and details
Note that hardening a class instance also hardens the class. For more details, see harden API in the ses
package
Objects with State
Now let's review the makeCounter
example:
const makeCounter = () => {
let count = 0;
return harden({
incr: () => (count += 1),
// ...
});
};
Each call to makeCounter
creates a new encapsulated count
variable along with incr
and decr
functions. The incr
and decr
functions access the count
variable from their lexical scope as usual in JavaScript closures.
To see how this works in detail, you may want to step through this visualization of the code:
Hardening JavaScript: Strict Mode
The first step to hardening JavaScript is understanding that Hardened JavaScript is always in strict mode.
One way that you would notice this is if you try to assign a value to a frozen property: this will throw a TypeError
rather than silently failing.
Operating in strict mode yields the important benefits of complete encapsulation (no caller
etc.) and reliable static scoping.
Hardening JavaScript: Frozen Built-ins
One form of authority that is too widely available in ordinary JavaScript is the ability to redefine built-ins (shown above as "mutable primordials"). Consider this changePassword
function:
const oldPasswords = [];
function changePassword(before, after) {
if (oldPasswords.includes(after)) throw Error('cannot reuse');
oldPasswords.push(after);
// ... update DB to after
}
In ordinary JavaScript we run the risk of stolen passwords because someone might have redefined the includes
method on Array
objects:
Object.assign(Array.prototype, {
includes: specimen => {
fetch('/pwned-db', { method: 'POST', body: JSON.stringify(specimen) });
return false;
},
});
In Hardened JavaScript, the Object.assign
fails because Array.prototype
and all other standard, built-in objects are immutable.
Compatibility issues with ses
/ Hardened JavaScript
Certain libraries that make tweaks to the standard built-ins may fail in Hardened JavaScript.
The SES wiki tracks compatibility reports for NPM packages, including potential workarounds.
Hardening JavaScript: Limiting Globals with Compartments
A globally available function such as fetch
means that every object, including a simple string manipulation function, can access the network. In order to eliminate this sort of excess authority, Object-capabity discipline calls for limiting globals to immutable data and deterministic functions (eliminating "ambient authority" in the diagram above).
Hardened JavaScript includes a Compartment
API for enforcing OCap discipline. Only the standard, built-in objects such as Object
, Array
, and Promise
are globally available by default (with an option for carefully controlled exceptions such as console.log
). With the default Compartment
options, the non-deterministic Math.random
and Date.now()
are not available. (Earlier versions of Hardened JavaScript provided Compartment
with a Date.now()
that always returned NaN
.)
Almost all existing JS code was written to run under Node.js or inside a browser, so it's easy to conflate the environment features with JavaScript itself. For example, you may be surprised that Buffer
and require
are Node.js additions and not part of JavaScript.
The conventional globals defined by browser or Node.js hosts are not available by default in a Compartment
, whether authority-bearing or not:
- authority-bearing:
window
,document
,process
,console
setImmediate
,clearImmediate
,setTimeout
- but
Promise
is available, so sometimesPromise.resolve().then(_ => fn())
suffices - see also Timer Service
- but
require
(Useimport
module syntax instead.)localStorage
- SwingSet orthogonal persistence means state lives indefinitely in ordinary variables and data structures and need not be explicitly written to storage.
- For high cardinality data, see the
@agoric/store
package.
global
(UseglobalThis
instead.)
- authority-free but host-defined:
Buffer
URL
andURLSearchParams
TextEncoder
,TextDecoder
WebAssembly
In compartments used to load Agoric smart contracts, globalThis
is hardened, following OCap discipline. These compartments have console
and assert
globals from the ses
package. Don't rely on console.log
for printing, though; it is for debugging only, and in a blockchain consensus context, it may do nothing at all.
You can create a new Compartment
object. When you do, you can decide whether to enforce OCap discipline by calling harden(compartment.globalThis)
or not. If not, beware that all objects in the compartment have authority to communicate with all other objects via properties of globalThis
.
Types: Advisory
Type checking JavaScript files with TypeScript can help prevent certain classes of coding errors. We recommend this style rather than writing in TypeScript syntax to remind ourselves that the type annotations really are only for lint tools and do not have any effect at runtime:
// @ts-check
/** @param {number} init */
const makeCounter = init => {
let value = init;
return {
incr: () => {
value += 1;
return value;
}
};
};
If we're not careful, our clients can cause us to misbehave:
> const evil = makeCounter('poison')
> evil2.incr()
'poison1'
or worse:
> const evil2 = makeCounter({ valueOf: () => { console.log('launch the missiles!'); return 1; } });
> evil2.incr()
launch the missiles!
2
Types: Defensive
To be defensively correct, we need runtime validation for any inputs that cross trust boundaries:
import Nat from `@endo/nat`;
/** @param {number | bignum} init */
const makeCounter = init => {
let value = Nat(init);
return harden({
increment: () => {
value += 1n;
return value;
},
});
};
> makeCounter('poison')
Uncaught TypeError: poison is a string but must be a bigint or a number
From OCaps to Electronic Rights: Mint and Purse
The Hardened JavaScript techniques above are powerful enough to express the core of ERTP and its security properties in just 30 lines. Careful study of this 8 minute presentation segment provides a firm foundation for writing smart contracts with Zoe.
const makeMint = () => {
const ledger = makeWeakMap();
const issuer = harden({
makeEmptyPurse: () => mint.makePurse(0)
});
const mint = harden({
makePurse: initialBalance => {
const purse = harden({
getIssuer: () => issuer,
getBalance: () => ledger.get(purse),
deposit: (amount, src) => {
Nat(ledger.get(purse) + Nat(amount));
ledger.set(src, Nat(ledger.get(src) - amount));
ledger.set(purse, ledger.get(purse) + amount);
},
withdraw: amount => {
const newPurse = issuer.makeEmptyPurse();
newPurse.deposit(amount, purse);
return newPurse;
}
});
ledger.set(purse, initialBalance);
return purse;
}
});
return mint;
};