Signed firmware manifests, streaming image verification, and A/B slots that fall back on their own. One capability of pamoja, one memory-safe Rust core with bindings for TypeScript, Python, and C#.
npm install @pamoja/update
This pulls in @pamoja/native, the compiled engine, and @pamoja/security. npm install pamoja is the whole framework in one package.
The test that runs in CI, spliced here as it ran.
From bindings/node/guides/update.ts:
import { DeviceIdentity } from '@pamoja/security'
import {
BootAction,
SlotState,
Updater,
imageDigest,
signManifest,
verifyEnvelope,
} from '@pamoja/update'
// The publisher's key signs releases; devices in the field are anchored to its public half
// and will take firmware from nobody else.
const publisher = DeviceIdentity.fromSeed(Buffer.alloc(32, 7))
const vendor = Buffer.alloc(16, 0x0a)
const deviceClass = Buffer.alloc(16, 0x0b)
// The release. A manifest says who the image is for, which slot it belongs in, how big it
// is and what it hashes to; nothing about the image itself is taken on trust.
const image = Buffer.from('firmware for a flow meter, version two')
const manifest = {
sequence: 2,
vendorId: vendor,
classId: deviceClass,
storage: 1,
digest: imageDigest(image),
size: image.length,
}
const envelope = signManifest(manifest, publisher)
console.log(`published sequence ${manifest.sequence} in a ${envelope.length}-byte envelope`)
// On the device. It checks the envelope against the key it was anchored to before it
// accepts a single byte of the image.
const opened = verifyEnvelope(envelope, publisher.publicKey())
console.log(`accepted a release for slot ${opened.storage}`)
// It left the factory running sequence 1 from slot 0, so the release goes to the spare slot
// and the image it is running stays where it is.
const fleet = new Updater(vendor, deviceClass, publisher.publicKey(), 2, 4096)
fleet.provision(0, 1)
fleet.begin(envelope)
for (let at = 0; at < image.length; at += 16) {
fleet.write(image.subarray(at, at + 16))
}
console.log(`staged ${fleet.progress().written} of ${image.length} bytes`)
const slot = fleet.finish()
console.log(`written to slot ${slot}, leaving the running image alone`)
// The first boot into a new image is a trial. It reverts on the next boot unless the device
// confirms that it came up, which is what makes a bad release survivable.
console.log(`booting ${fleet.onBoot().action}`)
fleet.confirm()
console.log(`confirmed slot ${slot} is now ${fleet.slotRecord(slot).state}`)
// The same release signed by a key this device is not anchored to gets nowhere.
const impostor = DeviceIdentity.fromSeed(Buffer.alloc(32, 90))
try {
fleet.stage(signManifest(manifest, impostor), image)
console.log('a forged release was accepted, which should never happen')
} catch (error) {
console.log(`forged refused: ${(error as Error).message}`)
}
| Language | Package | Reference |
|---|---|---|
| Rust | pamoja-update |
reference, docs.rs, install |
| TypeScript | @pamoja/update |
reference, install |
| Python | pamoja-update |
reference, install |
| C# | Pamoja.Update |
reference, install |
@pamoja/update reference, every class, function, and type this package exports.MIT
Ergonomic facade over the generated update binding.
A device that cannot be fixed in the field is a device that has to be visited, and some of them are a day's travel away. Signed updates make that a network operation instead: a release carries a manifest naming who it is for and what it hashes to, a device refuses anything not signed by the key it trusts, and an image that fails to confirm itself is rolled back to the one that worked.
The slot state and boot action are re-exported as runtime objects, because the generated enums are types-only.