Module nh_signature

Signing a packbeam, and checking one.

Description

Signing a packbeam, and checking one.

Packbeam has no signature of its own: atomvm_packbeam writes none and avmpack_is_valid compares the 24 byte magic and nothing else. This adds one as a convention, in the shape fwup uses -- the signature travels inside the archive, as a sibling of what it signs.

  magic
  entry, entry, ...                 the archive as built
  nerves_hub/signature   (data)     appended
  terminator

The signed range is every byte before the signature entry begins. That is the one rule a signature scheme has to get right: the signed bytes must exclude the signature itself, or it cannot be checked without knowing what it was.

Signing is therefore a pure append. Nothing before the signature moves, so both ends compute the same range without needing to agree on anything else.

Why it does not break anything

The signature is a data entry, the same class as priv/application.bin, which AtomVM already skips when it looks for code. A signed archive boots on a stock AtomVM that knows nothing about signing, and on a device that does not verify. Signing can only ever add a check, never take a device away.

Reading it is unaffected too: nh_packbeam:byte_length/1 walks past the entry to the terminator like any other.

Versioned, and namespaced

AtomVM may define its own signing one day. The entry is named under nerves_hub/ so it cannot collide with whatever that turns out to be, and the payload leads with a magic and a version so a second scheme is additive rather than a breaking change.

  <<"NH1", Version:8, Signature:64/binary>>
Ed25519, because that is what an organization's fwup keys already are -- a NervesHub organization can sign a packbeam with the key it already has.

Function Index

available/0Whether this VM can check an Ed25519 signature at all.
entry_name/0The entry a signature is carried in.
private_key/1Read a signing key, as the seed sign/2 wants.
public_key/1Normalise a configured public key to the 32 raw bytes.
sign/2Sign an archive with an Ed25519 private key, returning a signed archive.
signed_range/1The bytes a signature covers, and the signature payload itself.
strip/1The archive without its signature, as it was before signing.
verify/2Check an archive against a list of Ed25519 public keys.
verify_parts/3Check a signature against a range that has already been located.
version/0

Function Details

available/0

available() -> boolean()

Whether this VM can check an Ed25519 signature at all.

AtomVM's Ed25519 lives behind AVM_USE_LIBSODIUM, which is **off** in a default build: crypto:verify(eddsa, ...) then raises rather than returning false. Firmware built for signature checking has to be running an AtomVM compiled with -DAVM_USE_LIBSODIUM=ON.

Asked before checking, so that "this VM cannot verify" is never reported as "this firmware is not what it claims to be". They are different problems and they need different fixes, and confusing them sends someone hunting for a tampered archive that does not exist.

Probed with a signature that cannot pass, so a supported VM answers false without raising and an unsupported one raises.

entry_name/0

entry_name() -> binary()

The entry a signature is carried in.

private_key/1

private_key(Key::binary() | string()) -> {ok, binary()} | {error, term()}

Read a signing key, as the seed sign/2 wants.

Takes fwup's 64 byte secret key or a bare 32 byte seed, base64 or raw. An fwup private key is a seed followed by its public key, which is libsodium's layout, and only the seed is used to sign.

The counterpart of public_key/1, and deliberately not the same function. That one refuses a 64 byte key, because what it parses ends up compiled into firmware and quietly accepting a private key there would put a signing key on every device in the fleet. This one is for a build machine, where a private key is the point.

public_key/1

public_key(Key::binary() | string()) -> {ok, binary()} | {error, term()}

Normalise a configured public key to the 32 raw bytes.

Accepts the base64 an fwup .pub file holds, or the raw bytes.

A 64 byte value is refused rather than helpfully taking its second half. That is the layout of an fwup *private* key, and the keys handed to a device are compiled into firmware -- quietly accepting one would put a signing key on every device in the fleet.

sign/2

sign(Archive::binary(), PrivateKey::binary()) -> {ok, binary()} | {error, term()}

Sign an archive with an Ed25519 private key, returning a signed archive.

Signing an already signed archive replaces the signature rather than nesting one inside another, so re-signing after a key rotation is the same operation.

signed_range/1

signed_range(Archive::binary()) -> {ok, binary(), binary()} | none | {error, term()}

The bytes a signature covers, and the signature payload itself.

none when the archive carries no signature entry.

strip/1

strip(Archive::binary()) -> {ok, binary()} | {error, term()}

The archive without its signature, as it was before signing.

verify/2

verify(Archive::binary(), PublicKeys::[binary()]) -> {ok, binary()} | {error, term()}

Check an archive against a list of Ed25519 public keys.

Returns the key that verified, so a caller can record which one it was. {error, unsigned} and {error, invalid_signature} are deliberately different: an archive nobody signed and an archive signed by someone else are not the same problem.

verify_parts/3

verify_parts(Signed::binary(), Payload::binary(), PublicKeys::[binary()]) -> {ok, binary()} | {error, term()}

Check a signature against a range that has already been located.

Separate from verify/2 because a device does not have the archive in memory. It maps the signed range out of flash with esp:partition_mmap/3, which costs a few words of heap rather than the size of the archive, and hands the mapped binary straight to this.

That matters more than it looks. Ed25519 signs a message, not a digest, and neither OTP nor AtomVM offers an incremental verify — so there is no way to check one of these a chunk at a time. Mapping is what makes the scheme workable on a device with a few hundred kilobytes of RAM and an archive measured in hundreds.

version/0

version() -> pos_integer()


Generated by EDoc