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:
| Locator | What it is |
|---|---|
shoplib::crypto | Eleven primitives implemented in C over Monocypher — BLAKE2b, ChaCha20, Poly1305, XChaCha20-Poly1305, X25519, Ed25519 sign/verify, and the system CSPRNG |
shoplib::eddsa | Ed25519 key derivation — the one thing shoplib::crypto cannot do |
shoplib::sha1 | SHA-1 (RFC 3174), written in ƿit |
shoplib::sha256 | SHA-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)
chacha20is the DJB variant.keyis 32 bytes andnonceis 8 bytes — not the 24 thatlocktakes.datacomes third andcounterlast; passnullfor a counter of 0. It is its own inverse, so the same call decrypts.chacha20_his HChaCha20 — key derivation, not encryption.inputis 16 bytes, and the result is a 32-byte subkey.poly1305is the raw one-time authenticator over(key, message). A Poly1305 key must never be reused across messages; if you want authenticated encryption, uselock.
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.sharedtakes 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 thatlockwants 24 bytes andchacha20wants 8; they are not the same nonce. - The two keypairs are not interchangeable.
crypto.keypairgives an X25519 pair ({public, private}) forshared.eddsa.key_pairgives an Ed25519 pair ({secret, public}) forsignandverify. The field names differ on purpose. unlockreturning 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.