pamoja

Trust and operation

Signed updates#

A device in the field has to be fixable, and the thing that fixes it is the most dangerous input it will ever accept. pamoja treats a release as a signed statement about an image rather than as the image itself: a manifest names the devices it is for, the slot it belongs in, and the digest the image must hash to, and the publisher signs that. The device verifies the signature against a key it was anchored to, streams the image while hashing it, and refuses anything whose digest does not match what was signed.

An update also has to survive being wrong. The image is written to the slot the device is not running from, and the first boot into it is a trial: unless the new image confirms itself, the next boot goes back to the one that worked.

What the example does#

It signs a manifest for an image, checks the envelope on the device against the key that device is anchored to, then writes the image into the spare slot sixteen bytes at a time. The device is provisioned first as it left the factory, running sequence 1 from slot 0, so there is a working image to keep. The staged image gets its trial boot and is confirmed. Finally the same release comes back signed by a key the device does not trust.

The manifest commits to a SHA-256 over the image, and the library computes it, so a publisher does not add a hashing dependency just to name the image it is releasing. The vendor and class identifiers are sixteen bytes a vendor assigns itself; a device takes firmware only for the pair it was built as.

It proves:

  • Verifying the envelope hands back the manifest, so the device learns which slot the release is for from the signature rather than from whoever sent it.
  • The digest in the manifest is the one the library computes over the image, so a publisher that hashed the wrong bytes cannot produce a release that stages.
  • Staging completes only because every byte the manifest declared arrived and hashed to that digest, which the device recomputes as the pieces come in.
  • The release lands in the slot the device is not running from, so the working image is never overwritten.
  • The first boot into the staged image is a trial, and confirming it is what leaves the slot confirmed rather than reverting on the next boot.
  • A release signed by a key the device is not anchored to is refused, even though the manifest inside it is the one that was just accepted, because the signature is checked before anything in the manifest is read.

Rust#

From examples/tests/guides/update.rs:

Rust
use pamoja_security::DeviceIdentity;
use pamoja_update::{
    image_digest, Device, Envelope, Manifest, MemoryStore, PayloadFormat, SlotState, SlotStore,
    Updater, ENVELOPE_MAX, STRUCTURE_VERSION,
};

// The publisher's key signs releases; devices in the field are anchored to its public
// half and will take firmware from nobody else.
let publisher = DeviceIdentity::from_seed(&[7u8; 32]);

// Who the release is for. Both identifiers are sixteen bytes a vendor assigns itself,
// and a device takes firmware only for the pair it was built as.
const VENDOR: [u8; 16] = [10; 16];
const FLOW_METER: [u8; 16] = [11; 16];

// 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.
let image = b"firmware for a flow meter, version two";
let manifest = Manifest {
    structure_version: STRUCTURE_VERSION,
    sequence: 2,
    vendor_id: VENDOR,
    class_id: FLOW_METER,
    format: PayloadFormat::Raw,
    storage: 1,
    digest: image_digest(image),
    size: image.len() as u32,
    expires: 0,
};

// Signing it produces the envelope that travels with the image.
let mut buf = [0u8; ENVELOPE_MAX];
let written = manifest
    .sign(&publisher, &mut buf)
    .expect("a signed release");
let envelope = &buf[..written];
let sequence = manifest.sequence;
println!("published sequence {sequence} in a {written}-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.
let device = Device {
    vendor_id: manifest.vendor_id,
    class_id: manifest.class_id,
    anchor: publisher.public(),
};
let opened = Envelope::decode(envelope).expect("a well-formed envelope");
match opened.verify(&device.anchor) {
    Ok(release) => println!("accepted  a release for slot {}", release.storage),
    Err(error) => println!("refused   {error}"),
}

// 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.
let mut updater = Updater::new(device, MemoryStore::new(2, 4096));
updater.provision(0, 1).expect("the shipped image");
let mut staging = updater.begin(envelope).expect("a release for this device");
for piece in image.chunks(16) {
    staging.write(piece).expect("the next piece");
}
let (received, total) = staging.progress();
println!("staged    {received} of {total} bytes");
let slot = staging.finish().expect("the image matched its digest");
println!("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.
let decision = updater.on_boot().expect("a decision");
println!("booting   {}", decision.action());
updater.confirm().expect("it came up");
let state = updater.store().record(slot).expect("the new slot").state;
println!("confirmed slot {slot} is now {state:?}");

// The same release signed by a key this device is not anchored to gets nowhere.
let impostor = DeviceIdentity::from_seed(&[90u8; 32]);
let mut forged = [0u8; ENVELOPE_MAX];
let signed = manifest
    .sign(&impostor, &mut forged)
    .expect("a signed release");
match updater.stage(&forged[..signed], image) {
    Ok(_) => println!("a forged release was accepted, which should never happen"),
    Err(error) => println!("forged    refused: {error}"),
}

TypeScript#

From bindings/node/guides/update.ts:

TypeScript
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}`)
}

Python#

From bindings/python/guides/update.py:

Python
from pamoja.core import PamojaError
from pamoja.security import DeviceIdentity
from pamoja.update import (
    BootAction,
    Manifest,
    SlotState,
    Updater,
    image_digest,
    sign_manifest,
    verify_envelope,
)

# The publisher's key signs releases; devices in the field are anchored to its public half
# and will take firmware from nobody else.
publisher = DeviceIdentity.from_seed(bytes([7]) * 32)
vendor = bytes([0x0A]) * 16
device_class = bytes([0x0B]) * 16

# 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.
image = b"firmware for a flow meter, version two"
manifest = Manifest(
    sequence=2,
    vendor_id=vendor,
    class_id=device_class,
    storage=1,
    digest=image_digest(image),
    size=len(image),
)
envelope = sign_manifest(manifest, publisher)
print(f"published sequence {manifest.sequence} in a {len(envelope)}-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.
opened = verify_envelope(envelope, publisher.public_key)
print(f"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.
fleet = Updater(vendor, device_class, publisher.public_key, 2, 4096)
fleet.provision(0, 1)
fleet.begin(envelope)
for at in range(0, len(image), 16):
    fleet.write(image[at : at + 16])
print(f"staged    {fleet.progress().written} of {len(image)} bytes")
slot = fleet.finish()
print(f"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.
print(f"booting   {fleet.on_boot().action}")
fleet.confirm()
print(f"confirmed slot {slot} is now {fleet.slot_record(slot).state}")

# The same release signed by a key this device is not anchored to gets nowhere.
impostor = DeviceIdentity.from_seed(bytes([90]) * 32)
try:
    fleet.stage(sign_manifest(manifest, impostor), image)
    print("a forged release was accepted, which should never happen")
except PamojaError as error:
    print(f"forged    refused: {error}")

C##

From bindings/dotnet/samples/Pamoja.Guides/UpdateGuide.cs:

C#
// The publisher's key signs releases; devices in the field are anchored to its
// public half and will take firmware from nobody else.
byte[] seed = new byte[32];
Array.Fill(seed, (byte)7);
using var publisher = new DeviceIdentity(seed);
byte[] vendor = Enumerable.Repeat((byte)0x0A, 16).ToArray();
byte[] deviceClass = Enumerable.Repeat((byte)0x0B, 16).ToArray();

// 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 is taken on trust.
byte[] image = Encoding.ASCII.GetBytes("firmware for a flow meter, version two");
var manifest = new Manifest(
    Sequence: 2,
    VendorId: vendor,
    ClassId: deviceClass,
    Storage: 1,
    Digest: Update.ImageDigest(image),
    Size: (uint)image.Length);
byte[] envelope = Update.SignManifest(manifest, publisher);
Console.WriteLine(
    $"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.
Manifest opened = Update.VerifyEnvelope(envelope, publisher.PublicKey);
Console.WriteLine($"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.
using var fleet = new Updater(vendor, deviceClass, publisher.PublicKey, 2, 4096);
fleet.Provision(0, 1);
fleet.Begin(envelope);
for (int at = 0; at < image.Length; at += 16)
{
    fleet.Write(image.AsSpan(at, Math.Min(16, image.Length - at)));
}

Console.WriteLine($"staged    {fleet.CurrentProgress().Written} of {image.Length} bytes");
byte slot = fleet.Finish();
Console.WriteLine($"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 it came up, which is what makes a bad release survivable.
Console.WriteLine($"booting   {fleet.OnBoot().Action}");
fleet.Confirm();
Console.WriteLine($"confirmed slot {slot} is now {fleet.Record(slot).State}");

// The same release signed by a key this device is not anchored to gets nowhere.
byte[] impostorSeed = new byte[32];
Array.Fill(impostorSeed, (byte)90);
using var impostor = new DeviceIdentity(impostorSeed);
try
{
    fleet.Stage(Update.SignManifest(manifest, impostor), image);
    Console.WriteLine("a forged release was accepted, which should never happen");
}
catch (PamojaException error)
{
    Console.WriteLine($"forged    refused: {error.Message}");
}

Reference#

Edit this page on GitHub