pamoja.update

Idiomatic signed-update facade.

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.

  1"""Idiomatic signed-update facade.
  2
  3A device that cannot be fixed in the field is a device that has to be visited,
  4and some of them are a day's travel away. Signed updates make that a network
  5operation instead: a release carries a manifest naming who it is for and what it
  6hashes to, a device refuses anything not signed by the key it trusts, and an
  7image that fails to confirm itself is rolled back to the one that worked.
  8"""
  9
 10from __future__ import annotations
 11
 12import enum
 13
 14from pamoja._native import (
 15    BootDecision,
 16    Delegation,
 17    ImageVerifier,
 18    Manifest,
 19    Progress,
 20    SlotRecord,
 21    Updater,
 22    decode_manifest,
 23    encode_manifest,
 24    envelope_body,
 25    open_delegation,
 26    verify_envelope,
 27)
 28from pamoja._native import sign_delegation as _sign_delegation
 29from pamoja._native import image_digest as _image_digest
 30from pamoja._native import sign_manifest as _sign_manifest
 31from pamoja._native import update_format_raw as _format_raw
 32from pamoja._native import update_structure_version as _structure_version
 33from pamoja.security import DeviceIdentity
 34
 35__all__ = [
 36    "FORMAT_RAW",
 37    "STRUCTURE_VERSION",
 38    "BootAction",
 39    "BootDecision",
 40    "Delegation",
 41    "ImageVerifier",
 42    "Manifest",
 43    "Progress",
 44    "SlotRecord",
 45    "SlotState",
 46    "Updater",
 47    "decode_manifest",
 48    "encode_manifest",
 49    "envelope_body",
 50    "open_delegation",
 51    "sign_delegation",
 52    "image_digest",
 53    "sign_manifest",
 54    "verify_envelope",
 55]
 56
 57#: The manifest structure version this build writes.
 58STRUCTURE_VERSION = _structure_version()
 59
 60#: The payload format meaning the payload is the image itself, byte for byte.
 61FORMAT_RAW = _format_raw()
 62
 63
 64class SlotState(str, enum.Enum):
 65    """What a device believes about one slot."""
 66
 67    #: Nothing has been written here.
 68    EMPTY = "Empty"
 69    #: An image is arriving, and ``written`` says how much of it has.
 70    RECEIVING = "Receiving"
 71    #: A complete image that matched its manifest, not yet tried.
 72    STAGED = "Staged"
 73    #: Being tried for the first time; it reverts unless it confirms.
 74    PENDING = "Pending"
 75    #: Tried and confirmed working.
 76    CONFIRMED = "Confirmed"
 77    #: Tried and did not confirm, so it will not be tried again.
 78    FAILED = "Failed"
 79
 80
 81class BootAction(str, enum.Enum):
 82    """What a bootloader should do with what it found."""
 83
 84    #: Nothing new to try; run the confirmed image.
 85    CONFIRMED = "Confirmed"
 86    #: A staged image is being tried for the first time.
 87    TRYING = "Trying"
 88    #: A pending image never confirmed, so it was failed.
 89    REVERTED = "Reverted"
 90
 91
 92def image_digest(image: bytes) -> bytes:
 93    """Hash a complete image, for a publisher filling in a manifest.
 94
 95    The manifest commits to a SHA-256 over the image, and this is that hash, so a
 96    publisher does not need a hashing library of its own just to name the image it
 97    is releasing.
 98
 99    :param image: The complete image the release carries.
100    :returns: The 32-byte digest to put in a :class:`Manifest`.
101    """
102    return _image_digest(bytes(image))
103
104
105def sign_manifest(manifest: Manifest, author: DeviceIdentity) -> bytes:
106    """Sign a manifest into the envelope that is offered to a device.
107
108    :param manifest: What the release says about itself.
109    :param author: The identity signing the release.
110    :returns: The signed envelope.
111    """
112    return _sign_manifest(manifest, DeviceIdentity.native(author))
113
114
115def sign_delegation(delegation: Delegation, anchor: DeviceIdentity) -> bytes:
116    """Sign a delegation, naming a release key the anchor stands behind.
117
118    Keeping the anchor offline and rotating a release key under it is the
119    arrangement to prefer, because the key that signs day to day is the one most
120    likely to be stolen.
121
122    :param delegation: The statement to sign.
123    :param anchor: The anchor identity, which is the root of the trust.
124    :returns: The signed delegation envelope.
125    """
126    return _sign_delegation(delegation, DeviceIdentity.native(anchor))
FORMAT_RAW = 1
STRUCTURE_VERSION = 1
class BootAction(builtins.str, enum.Enum):
82class BootAction(str, enum.Enum):
83    """What a bootloader should do with what it found."""
84
85    #: Nothing new to try; run the confirmed image.
86    CONFIRMED = "Confirmed"
87    #: A staged image is being tried for the first time.
88    TRYING = "Trying"
89    #: A pending image never confirmed, so it was failed.
90    REVERTED = "Reverted"

What a bootloader should do with what it found.

CONFIRMED = <BootAction.CONFIRMED: 'Confirmed'>
TRYING = <BootAction.TRYING: 'Trying'>
REVERTED = <BootAction.REVERTED: 'Reverted'>
class BootDecision:

The decision a device made at boot, already recorded before it was returned.

fallback

The slot to run. It is the same as slot for anything but Reverted.

action

What the bootloader should do: Confirmed, Trying, or Reverted.

slot

The image the decision is about, which for Reverted is the one that failed.

class Delegation:

A statement, signed by the anchor, that a second key may sign releases.

release_key

The public key that may sign manifests while this delegation stands.

expires

When the delegation stops being honoured, in seconds since the Unix epoch, or 0 to never expire.

epoch

Rises with every rotation, so a retired key cannot be reinstated by replaying the statement that once authorised it.

class ImageVerifier:

Hashes an image as it arrives and settles it against its manifest.

def update(self, /, chunk):

Takes the next piece of the image, in order.

def finish(self, /):

Settles the image, returning its digest, and spends this verifier.

Raises if the image is not the one the manifest described.

class Manifest:

What a release says about itself, and what a device checks it against.

digest

The SHA-256 of the payload, which every other guarantee rests on.

size

The payload length in bytes, known before a single byte is accepted.

sequence

Rises with every release, which is what stops an older image being replayed at a device.

format

How the payload is encoded, currently only the raw format.

storage

Which slot the payload belongs in.

structure_version

Which iteration of the manifest format this is.

expires

When this release stops being offered, in seconds since the Unix epoch, or 0 to never expire.

vendor_id

Who built the image, as 16 bytes.

class_id

Which kind of device it is for, as 16 bytes.

class Progress:

How much of an image has arrived.

written

The bytes stored so far.

total

The total the manifest declares.

class SlotRecord:

The record a device keeps about one slot, durable across a reboot.

size

The length of the image in bytes.

written

How many bytes have been stored, which is where a resumed transfer picks up.

state

The state of the slot, by name.

sequence

The sequence number of the image in the slot.

digest

The digest of the image.

class SlotState(builtins.str, enum.Enum):
65class SlotState(str, enum.Enum):
66    """What a device believes about one slot."""
67
68    #: Nothing has been written here.
69    EMPTY = "Empty"
70    #: An image is arriving, and ``written`` says how much of it has.
71    RECEIVING = "Receiving"
72    #: A complete image that matched its manifest, not yet tried.
73    STAGED = "Staged"
74    #: Being tried for the first time; it reverts unless it confirms.
75    PENDING = "Pending"
76    #: Tried and confirmed working.
77    CONFIRMED = "Confirmed"
78    #: Tried and did not confirm, so it will not be tried again.
79    FAILED = "Failed"

What a device believes about one slot.

EMPTY = <SlotState.EMPTY: 'Empty'>
RECEIVING = <SlotState.RECEIVING: 'Receiving'>
STAGED = <SlotState.STAGED: 'Staged'>
PENDING = <SlotState.PENDING: 'Pending'>
CONFIRMED = <SlotState.CONFIRMED: 'Confirmed'>
FAILED = <SlotState.FAILED: 'Failed'>
class Updater:

A device slots, and the rules applied to what is offered for them.

def slot_record(self, /, slot):

Reads what the device believes about one slot.

def provision(self, /, slot, sequence):

Records that a slot already holds a confirmed image at a sequence number.

This is how a device that shipped with firmware says what it is running, so the rollback rule has something to compare against.

def adopt(self, /, envelope, now=None):

Adopts a delegation, so releases signed by the key it names are accepted.

def stage(self, /, envelope, image, now=None):

Checks a manifest and stages an image that is already held whole, returning the slot it went into.

def begin(self, /, envelope, now=None):

Checks a manifest and opens the slot it names for a transfer in pieces.

Every check that can be made without the image runs here, so a release that is not for this device, would roll it back, or does not fit is refused before a byte of it is accepted. The envelope is remembered until finish, and each call after this one reopens the transfer from what the slot records, which is the same path a device takes after a reset.

def write(self, /, chunk):

Takes the next piece of an image opened with begin.

def progress(self, /):

Reports how much of an opened image has arrived.

def finish(self, /):

Finishes an opened image and marks the slot bootable if it matched, returning the slot now holding it.

def on_boot(self, /):

Decides what to run, and records that decision before returning it.

Call this once per boot, before jumping to an image. A staged image becomes pending here, so a device that resets before confirming reverts on the next call rather than trying a broken image forever.

def confirm(self, /):

Confirms the pending image, so it will be run from now on.

def revert(self, /):

Fails the pending image and goes back to the confirmed one.

slot_count

How many slots this device has.

installed_sequence

The highest sequence number the device already holds.

delegation

The delegation this updater currently honours, or None when releases must be signed by the anchor itself.

def decode_manifest(data):

Reads a manifest body back from its bytes.

This reads what a manifest claims; it proves nothing about who wrote it. Use verify_envelope to read one whose signature has been checked.

def encode_manifest(manifest):

Encodes the body of a manifest, which is the part a signature covers.

def envelope_body(data):

Copies out the signed body of an envelope, without checking the signature.

This is what a gateway relays onward unchanged.

def open_delegation(data, anchor_public_key):

Opens a signed delegation against the anchor that should have signed it.

def sign_delegation(delegation: Delegation, anchor: pamoja.security.DeviceIdentity) -> bytes:
116def sign_delegation(delegation: Delegation, anchor: DeviceIdentity) -> bytes:
117    """Sign a delegation, naming a release key the anchor stands behind.
118
119    Keeping the anchor offline and rotating a release key under it is the
120    arrangement to prefer, because the key that signs day to day is the one most
121    likely to be stolen.
122
123    :param delegation: The statement to sign.
124    :param anchor: The anchor identity, which is the root of the trust.
125    :returns: The signed delegation envelope.
126    """
127    return _sign_delegation(delegation, DeviceIdentity.native(anchor))

Sign a delegation, naming a release key the anchor stands behind.

Keeping the anchor offline and rotating a release key under it is the arrangement to prefer, because the key that signs day to day is the one most likely to be stolen.

Parameters
  • delegation: The statement to sign.
  • anchor: The anchor identity, which is the root of the trust. :returns: The signed delegation envelope.
def image_digest(image: bytes) -> bytes:
 93def image_digest(image: bytes) -> bytes:
 94    """Hash a complete image, for a publisher filling in a manifest.
 95
 96    The manifest commits to a SHA-256 over the image, and this is that hash, so a
 97    publisher does not need a hashing library of its own just to name the image it
 98    is releasing.
 99
100    :param image: The complete image the release carries.
101    :returns: The 32-byte digest to put in a :class:`Manifest`.
102    """
103    return _image_digest(bytes(image))

Hash a complete image, for a publisher filling in a manifest.

The manifest commits to a SHA-256 over the image, and this is that hash, so a publisher does not need a hashing library of its own just to name the image it is releasing.

Parameters
  • image: The complete image the release carries. :returns: The 32-byte digest to put in a Manifest.
def sign_manifest(manifest: Manifest, author: pamoja.security.DeviceIdentity) -> bytes:
106def sign_manifest(manifest: Manifest, author: DeviceIdentity) -> bytes:
107    """Sign a manifest into the envelope that is offered to a device.
108
109    :param manifest: What the release says about itself.
110    :param author: The identity signing the release.
111    :returns: The signed envelope.
112    """
113    return _sign_manifest(manifest, DeviceIdentity.native(author))

Sign a manifest into the envelope that is offered to a device.

Parameters
  • manifest: What the release says about itself.
  • author: The identity signing the release. :returns: The signed envelope.
def verify_envelope(data, public_key):

Verifies an envelope against a key and reads the manifest inside it.

Raises when the signature is not from that key.