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 frame_update_block as _frame_block
 29from pamoja._native import sign_delegation as _sign_delegation
 30from pamoja._native import split_update_block as _split_block
 31from pamoja._native import update_block_descriptor as _block_descriptor
 32from pamoja._native import image_digest as _image_digest
 33from pamoja._native import sign_manifest as _sign_manifest
 34from pamoja._native import update_format_raw as _format_raw
 35from pamoja._native import update_structure_version as _structure_version
 36from pamoja.security import DeviceIdentity
 37
 38__all__ = [
 39    "BLOCK_DESCRIPTOR",
 40    "FORMAT_RAW",
 41    "STRUCTURE_VERSION",
 42    "BootAction",
 43    "BootDecision",
 44    "Delegation",
 45    "ImageVerifier",
 46    "Manifest",
 47    "Progress",
 48    "SlotRecord",
 49    "SlotState",
 50    "Updater",
 51    "decode_manifest",
 52    "encode_manifest",
 53    "envelope_body",
 54    "open_delegation",
 55    "sign_delegation",
 56    "frame_block",
 57    "image_digest",
 58    "sign_manifest",
 59    "split_block",
 60    "verify_envelope",
 61]
 62
 63#: The manifest structure version this build writes.
 64STRUCTURE_VERSION = _structure_version()
 65
 66#: The payload format meaning the payload is the image itself, byte for byte.
 67FORMAT_RAW = _format_raw()
 68
 69
 70class SlotState(str, enum.Enum):
 71    """What a device believes about one slot."""
 72
 73    #: Nothing has been written here.
 74    EMPTY = "Empty"
 75    #: An image is arriving, and ``written`` says how much of it has.
 76    RECEIVING = "Receiving"
 77    #: A complete image that matched its manifest, not yet tried.
 78    STAGED = "Staged"
 79    #: Being tried for the first time; it reverts unless it confirms.
 80    PENDING = "Pending"
 81    #: Tried and confirmed working.
 82    CONFIRMED = "Confirmed"
 83    #: Tried and did not confirm, so it will not be tried again.
 84    FAILED = "Failed"
 85
 86
 87class BootAction(str, enum.Enum):
 88    """What a bootloader should do with what it found."""
 89
 90    #: Nothing new to try; run the confirmed image.
 91    CONFIRMED = "Confirmed"
 92    #: A staged image is being tried for the first time.
 93    TRYING = "Trying"
 94    #: A pending image never confirmed, so it was failed.
 95    REVERTED = "Reverted"
 96
 97
 98def image_digest(image: bytes) -> bytes:
 99    """Hash a complete image, for a publisher filling in a manifest.
100
101    The manifest commits to a SHA-256 over the image, and this is that hash, so a
102    publisher does not need a hashing library of its own just to name the image it
103    is releasing.
104
105    :param image: The complete image the release carries.
106    :returns: The 32-byte digest to put in a :class:`Manifest`.
107    """
108    return _image_digest(bytes(image))
109
110
111def sign_manifest(manifest: Manifest, author: DeviceIdentity) -> bytes:
112    """Sign a manifest into the envelope that is offered to a device.
113
114    :param manifest: What the release says about itself.
115    :param author: The identity signing the release.
116    :returns: The signed envelope.
117    """
118    return _sign_manifest(manifest, DeviceIdentity.native(author))
119
120
121def sign_delegation(delegation: Delegation, anchor: DeviceIdentity) -> bytes:
122    """Sign a delegation, naming a release key the anchor stands behind.
123
124    Keeping the anchor offline and rotating a release key under it is the
125    arrangement to prefer, because the key that signs day to day is the one most
126    likely to be stolen.
127
128    :param delegation: The statement to sign.
129    :param anchor: The anchor identity, which is the root of the trust.
130    :returns: The signed delegation envelope.
131    """
132    return _sign_delegation(delegation, DeviceIdentity.native(anchor))
133
134
135#: What a transport calls a block carrying a signed update, for a field that names the
136#: convention.
137BLOCK_DESCRIPTOR = _block_descriptor()
138
139
140def frame_block(envelope: bytes, image: bytes) -> bytes:
141    """Write a signed update into one block, for a transport that moves blocks.
142
143    The block is the signed manifest and the image behind a header that says where each
144    begins. Nothing in the header is trusted: every rule that decides whether the image
145    runs is still the manifest's.
146
147    :param envelope: The signed manifest.
148    :param image: The image it describes.
149    :returns: The block to hand to a transport.
150    """
151    return _frame_block(bytes(envelope), bytes(image))
152
153
154def split_block(block: bytes) -> tuple[bytes, bytes]:
155    """Read a block back into the signed manifest and the image.
156
157    :param block: The block as it arrived.
158    :returns: The signed manifest and the image.
159    :raises Refusal: If the block does not carry this convention's header.
160    """
161    return _split_block(bytes(block))
BLOCK_DESCRIPTOR = 'PJU1'
FORMAT_RAW = 1
STRUCTURE_VERSION = 1
class BootAction(builtins.str, enum.Enum):
88class BootAction(str, enum.Enum):
89    """What a bootloader should do with what it found."""
90
91    #: Nothing new to try; run the confirmed image.
92    CONFIRMED = "Confirmed"
93    #: A staged image is being tried for the first time.
94    TRYING = "Trying"
95    #: A pending image never confirmed, so it was failed.
96    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.

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.

fallback

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

class Delegation:

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

expires

When the delegation stops being honored, 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 authorized it.

release_key

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

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.

expires

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

sequence

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

digest

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

vendor_id

Who built the image, as 16 bytes.

class_id

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

structure_version

Which iteration of the manifest format this is.

format

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

storage

Which slot the payload belongs in.

size

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

class Progress:

How much of an image has arrived.

total

The total the manifest declares.

written

The bytes stored so far.

class SlotRecord:

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

written

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

digest

The digest of the image.

size

The length of the image in bytes.

state

The state of the slot, by name.

sequence

The sequence number of the image in the slot.

class SlotState(builtins.str, enum.Enum):
71class SlotState(str, enum.Enum):
72    """What a device believes about one slot."""
73
74    #: Nothing has been written here.
75    EMPTY = "Empty"
76    #: An image is arriving, and ``written`` says how much of it has.
77    RECEIVING = "Receiving"
78    #: A complete image that matched its manifest, not yet tried.
79    STAGED = "Staged"
80    #: Being tried for the first time; it reverts unless it confirms.
81    PENDING = "Pending"
82    #: Tried and confirmed working.
83    CONFIRMED = "Confirmed"
84    #: Tried and did not confirm, so it will not be tried again.
85    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's 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 every call after this one checks it again, so a release overtaken or expired while it arrives stops arriving. The hash of what has arrived is carried from one call to the next, so an image taken in many small pieces costs no more than one taken whole.

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.

delegation

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

installed_sequence

The highest sequence number the device already holds.

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:
122def sign_delegation(delegation: Delegation, anchor: DeviceIdentity) -> bytes:
123    """Sign a delegation, naming a release key the anchor stands behind.
124
125    Keeping the anchor offline and rotating a release key under it is the
126    arrangement to prefer, because the key that signs day to day is the one most
127    likely to be stolen.
128
129    :param delegation: The statement to sign.
130    :param anchor: The anchor identity, which is the root of the trust.
131    :returns: The signed delegation envelope.
132    """
133    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 frame_block(envelope: bytes, image: bytes) -> bytes:
141def frame_block(envelope: bytes, image: bytes) -> bytes:
142    """Write a signed update into one block, for a transport that moves blocks.
143
144    The block is the signed manifest and the image behind a header that says where each
145    begins. Nothing in the header is trusted: every rule that decides whether the image
146    runs is still the manifest's.
147
148    :param envelope: The signed manifest.
149    :param image: The image it describes.
150    :returns: The block to hand to a transport.
151    """
152    return _frame_block(bytes(envelope), bytes(image))

Write a signed update into one block, for a transport that moves blocks.

The block is the signed manifest and the image behind a header that says where each begins. Nothing in the header is trusted: every rule that decides whether the image runs is still the manifest's.

Parameters
  • envelope: The signed manifest.
  • image: The image it describes. :returns: The block to hand to a transport.
def image_digest(image: bytes) -> bytes:
 99def image_digest(image: bytes) -> bytes:
100    """Hash a complete image, for a publisher filling in a manifest.
101
102    The manifest commits to a SHA-256 over the image, and this is that hash, so a
103    publisher does not need a hashing library of its own just to name the image it
104    is releasing.
105
106    :param image: The complete image the release carries.
107    :returns: The 32-byte digest to put in a :class:`Manifest`.
108    """
109    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:
112def sign_manifest(manifest: Manifest, author: DeviceIdentity) -> bytes:
113    """Sign a manifest into the envelope that is offered to a device.
114
115    :param manifest: What the release says about itself.
116    :param author: The identity signing the release.
117    :returns: The signed envelope.
118    """
119    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 split_block(block: bytes) -> tuple[bytes, bytes]:
155def split_block(block: bytes) -> tuple[bytes, bytes]:
156    """Read a block back into the signed manifest and the image.
157
158    :param block: The block as it arrived.
159    :returns: The signed manifest and the image.
160    :raises Refusal: If the block does not carry this convention's header.
161    """
162    return _split_block(bytes(block))

Read a block back into the signed manifest and the image.

Parameters
  • block: The block as it arrived. :returns: The signed manifest and the image.
Raises
  • Refusal: If the block does not carry this convention's header.
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.