Cryptography

Cryptography is not part of the standard library. Nothing under pitlib or std offers it, and no shop puts it on a fallback list. It lives in shoplib, the shop’s own machinery, and you reach it the way you reach anything in shoplib — by full locator, so that writing the import says where the code came from.

There is no single cryptographic surface, and no import that gathers one. There are four separate modules, and you take only the ones you need:

LocatorWhat it is
shoplib::cryptoEleven primitives implemented in C over Monocypher — BLAKE2b, ChaCha20, Poly1305, XChaCha20-Poly1305, X25519, Ed25519 sign/verify, and the system CSPRNG
shoplib::eddsaEd25519 key derivation — the one thing shoplib::crypto cannot do
shoplib::sha1SHA-1 (RFC 3174), written in ƿit
shoplib::sha256SHA-256 (RFC 6234), written in ƿit
def crypto = use('shoplib::crypto')
def eddsa  = use('shoplib::eddsa')
def sha256 = use('shoplib::sha256')

They are separate because they are separate things. crypto is a native module whose record must not grow once it is baked into firmware, which is why key derivation had to become its own module rather than an extra entry. The SHA modules are pure ƿit, needed by protocols that specify those exact hashes — SHA-1 for the WebSocket handshake, SHA-256 for SSH key exchange — and are not what you should reach for when you get to pick the hash. For that, use crypto.blake2.

For non-security randomness use random — that one is pitlib, reachable by bare name, and is a deterministic convenience PRNG. crypto.random is the one to reach for when the value must be unguessable.

Reading the sizes on this page

Every key, nonce, digest, and message on this page is a blob, and every size is given in bytes. Note that length() on a blob answers in bits, so a 32-byte key measures 256, and the refusal messages these entries raise are phrased in bits too: crypto.lock key: expected 256 bits, got 128.

Empty input is ordinary input. crypto.blake2 of an empty blob is the standard empty digest, 0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8 — the same value b2sum -l 256 prints for an empty file. This matters beyond hashing: the shop names content by its digest, so it is what lets an empty file be stored and published. sign, verify, and lock accept an empty message for the same reason. A fixed-width slot is a different matter — an empty blob is not a 32-byte key, and those still refuse.


shoplib::crypto

Secure random

crypto.random(byte_count)   -> blob

The system CSPRNG. It is what seeds random, and it is the correct source for keys, nonces, and seeds. byte_count may be 0 through 1024; asking for zero bytes gives the empty blob, which is nothing asked for and nothing returned. A negative or oversized ask is a refusal.

var key = crypto.random(32)

Hashing

crypto.blake2(data, digest_bytes)   -> blob of digest_bytes

digest_bytes may be 1 through 64; pass null for the default of 32. A zero-width digest is not defined by the algorithm and is refused.

BLAKE2b is also what the shop uses to content-address artifacts, so a digest computed here matches the names in the object store.

var digest = crypto.blake2(payload, 32)

For SHA-1 and SHA-256 see shoplib::sha1 below — they are different modules, not entries here.

Key agreement (X25519)

crypto.keypair()                        -> {public, private}
crypto.shared(their_public, my_private) -> blob (32 bytes)

keypair returns a record with public and private, each a 32-byte blob. There is no secret field on this record — that name belongs to eddsa.key_pair, and the two records are not interchangeable.

shared takes the public key first. The order is (their_public, my_private)not your own key first. Both arguments are 32-byte blobs, so a reversed call raises nothing, returns a plausible-looking 32-byte blob, and yields a shared secret the other side will never compute. The mistake is silent, and nothing downstream will point at it. Name your variables so the call reads correctly:

var mine = crypto.keypair()
// ... exchange public keys ...
var secret = crypto.shared(their_public, mine.private)

Both sides arrive at the same value: crypto.shared(b.public, a.private) equals crypto.shared(a.public, b.private).

An X25519 keypair is for agreeing on a secret, not for signing. crypto.sign needs a 64-byte Ed25519 secret, which crypto.keypair does not produce — see shoplib::eddsa.

Signing (Ed25519)

crypto.sign(secret, message)              -> blob (64 bytes)
crypto.verify(signature, public, message) -> logical

secret is the 64-byte secret from eddsa.key_pair; a 32-byte blob is refused. verify takes the signature first, then the 32-byte public key, then the message, and returns a logical — test it directly.

def eddsa = use('shoplib::eddsa')

var pair = eddsa.key_pair(crypto.random(32))
var sig  = crypto.sign(pair.secret, message)
var ok   = crypto.verify(sig, pair.public, message)

Authenticated encryption (XChaCha20-Poly1305)

crypto.lock(key, nonce, message, ad)   -> ciphertext ++ 16-byte MAC
crypto.unlock(key, nonce, sealed, ad)  -> blob, or null

key is 32 bytes, nonce is 24 bytes, and ad is optional associated data — pass null when there is none. lock returns the ciphertext with its 16-byte authentication tag appended, so the result is 16 bytes longer than the message. unlock returns the plaintext when the tag verifies and null when it does not.

var nonce  = crypto.random(24)
var sealed = crypto.lock(key, nonce, plaintext, null)
var opened = crypto.unlock(key, nonce, sealed, null)   // null if tampered with

Always check for null rather than assuming success — a failed unlock is how tampering reports itself, and it is a security result, not an inconvenience.

A sealed blob with fewer than 16 bytes is refused rather than returning null: there is no MAC in it to check.

The lower-level pieces

Available when you are implementing a protocol that specifies them.

crypto.chacha20(key, nonce, data, counter) -> blob (same length as data)
crypto.chacha20_h(key, input)              -> blob (32 bytes)
crypto.poly1305(key, message)              -> blob (16 bytes)
  • chacha20 is the DJB variant. key is 32 bytes and nonce is 8 bytes — not the 24 that lock takes. data comes third and counter last; pass null for a counter of 0. It is its own inverse, so the same call decrypts.
  • chacha20_h is HChaCha20 — key derivation, not encryption. input is 16 bytes, and the result is a 32-byte subkey.
  • poly1305 is the raw one-time authenticator over (key, message). A Poly1305 key must never be reused across messages; if you want authenticated encryption, use lock.

shoplib::eddsa

eddsa.key_pair(seed) -> {secret, public}

The single entry of this module. seed is a 32-byte blob — draw it from crypto.random, or store it as the thing your identity is. The result is a record with secret (64 bytes, what crypto.sign takes) and public (32 bytes, what crypto.verify takes). There is no private field on this record.

Derivation is deterministic: the same seed always yields the same pair, which is what makes a stored seed a durable identity. The seed blob you pass is copied, not consumed — it is still intact after the call.

def crypto = use('shoplib::crypto')
def eddsa  = use('shoplib::eddsa')

var seed = crypto.random(32)
var pair = eddsa.key_pair(seed)

var sig = crypto.sign(pair.secret, message)
var ok  = crypto.verify(sig, pair.public, message)

This module exists because shoplib::crypto is a native module whose record is baked into firmware and must not grow, and because it has no randomness of its own — the caller supplies the seed.

shoplib::sha1 and shoplib::sha256

Two small modules written in ƿit, each exporting a digest and a hex convenience:

sha1.sha1(input)       -> blob (20 bytes)
sha1.sha1_hex(input)   -> text (40 lowercase hex characters)

sha256.sha256(input)     -> blob (32 bytes)
sha256.sha256_hex(input) -> text (64 lowercase hex characters)

input may be a stone blob or text; text is encoded to a blob first.

def sha256 = use('shoplib::sha256')

var digest = sha256.sha256_hex("abc")
// ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad

Reach for these when a protocol names the hash — SHA-1 for the WebSocket Sec-WebSocket-Accept handshake, SHA-256 for the SSH curve25519-sha256 key exchange. When the choice is yours, crypto.blake2 is faster, native, and is what the shop itself uses. SHA-1 in particular is here for wire compatibility only; it is not collision resistant, and nothing new should depend on it for security.


Using it safely

A few properties are worth stating, because they are the ones that bite:

  • crypto.shared takes the public key first. Reversing the arguments is silent — see the warning above.
  • A nonce must never repeat under one key. Draw it from crypto.random, or count it, but do not reuse it. Note that lock wants 24 bytes and chacha20 wants 8; they are not the same nonce.
  • The two keypairs are not interchangeable. crypto.keypair gives an X25519 pair ({public, private}) for shared. eddsa.key_pair gives an Ed25519 pair ({secret, public}) for sign and verify. The field names differ on purpose.
  • unlock returning null is a security result, not an inconvenience. Treat it as a rejected message.
  • Comparing secrets with == compares in variable time. When you compare a token or a tag yourself, compare in constant time; the timing of an ordinary comparison is observable. See Security and Threat Model for what the runtime does and does not defend.