Signing FreeBSD pkg Repositories with a YubiKey
FreeBSD’s pkg supports cryptographic signing of package repositories.
It uses its own signing protocol with a double-hash scheme and a specific
stdin/stdout contract for signing commands. This article walks through signing a
pkg repo with a key stored on a YubiKey’s PIV applet via PKCS#11, including
socket forwarding for remote builds.
The scripts and tools described here are part of the banzai-plugins repository.
Note
An earlier version of this setup used the YubiKey’s GPG applet with
gpg-connect-agent and Assuan protocol over a forwarded gpg-agent socket.
That approach works but is fragile: gpg-agent socket forwarding requires
killing the remote agent in a separate SSH call, the Assuan protocol requires
percent-encoding and S-expression parsing, and gpg-agent socket conflicts
with SSH auth when both use the same agent. The PIV approach described here
uses an independent applet with a standard PKCS#11 interface and a simple
custom socket protocol.
Why hardware-backed signing?
A pkg repository signing key is a high-value target. If the private key is compromised, an attacker can push malicious packages to every machine that trusts the repo. Storing the signing key on a YubiKey means the private key never exists on disk – signing operations happen on the hardware token, which is designed to prevent extraction of private keys.
How pkg signing actually works
Before writing any code, you need to understand what pkg repo actually does
when it signs. The
pkg-repo(8)
man page [1] documents the output format (SIGNATURE/CERT/END) and
states that the signing command receives “the SHA256 of the repository catalogue
on its stdin.” However, it does not describe the double-hash verification
scheme, the fact that stdin remains open, or how fingerprints are computed.
When you run:
pkg repo /path/to/repo/ signing_command: /path/to/sign.sh
pkg computes SHA256(data) as a 64-character lowercase hex string and
pipes it to your signing command’s stdin. Your command must output a response in
this format:
SIGNATURE
<binary signature bytes>
CERT
<PEM public key>
END
The critical detail: pkg keeps stdin open while it waits for the signing
command’s response, only closing it during cleanup [5]. This means cat will
deadlock waiting for EOF. The official example script [3] in the pkg repository
uses read -t 2 (with a 2-second timeout) to handle this.
The double hash
pkg sends SHA256_hex(data) – a hex string –
on stdin. But when it verifies the signature, it computes
SHA256(hex_string) and checks the signature against that. The hash that
gets signed is:
SHA256(SHA256_hex(data))
This is a double hash: SHA256 of the data produces a hex string, then SHA256 of that hex string produces the 32-byte digest that ends up in the RSA signature’s DigestInfo structure.
If you sign the hex string directly (without hashing it again),
openssl dgst -verify will validate your signature – but pkg will
reject it. openssl dgst -verify tests a single hash, not the double hash
that pkg expects, so it is not a valid test for pkg signatures.
The correct signature format is:
RSA(PKCS#1 v1.5(DigestInfo(SHA256_OID, SHA256(hex_string))))
This is confirmed by the verification code in libpkg/pkgsign_ossl.c [4].
The function ossl_verify_cb calls pkg_checksum_fd() to get the hex hash,
then passes the 64-byte hex string directly to EVP_PKEY_verify with a
custom digest EVP_md_pkg_sha1(). This custom digest has the SHA-1 OID but
an overridden result size of 64. In practice with OpenSSL 3.x, signatures using
the SHA-256 OID and 32-byte hash also verify correctly.
Fingerprints
pkg identifies trusted signing keys by fingerprint. The fingerprint is
SHA256 of the entire PEM file – including the
-----BEGIN/END----- headers and trailing newline:
# Correct:
shasum -a 256 repo.pub
# WRONG -- gives a different hash:
openssl rsa -pubin -outform DER < repo.pub | shasum -a 256
The fingerprint is not a hash of the DER-encoded public key material. It’s a hash of the PEM file as-is [6].
Setting up the PIV signing key
The signing key lives in the YubiKey’s PIV applet, slot 9c (Digital Signature). PIV is independent from the GPG applet – no contention with GPG/SSH auth.
Check your PIV slot:
ykman piv info
You should see something like:
Slot 9C (SIGNATURE):
Private key type: RSA2048
Public key type: RSA2048
Subject DN: CN=repo signing
PIN required: ALWAYS
Touch required: ALWAYS
If you need to generate a key in the PIV slot:
ykman piv keys generate -a RSA2048 --touch-policy ALWAYS --pin-policy ALWAYS 9c pubkey.pem
Export the public key in PEM format:
ykman piv keys export 9c repo.pub
This gives you a standard PKCS#8 PEM file that pkg can use directly – no format conversion needed (unlike GPG keys which require S-expression parsing and DER construction).
The PIV signing agent
The signing agent
(piv-sign-agent.py)
runs on your local workstation (where the YubiKey is plugged in) and listens on
a Unix socket. It signs digests using the YubiKey PIV slot via PKCS#11
(libykcs11), using Python’s ctypes to call the PKCS#11 functions
directly – no pip dependencies required beyond what Homebrew’s
yubico-piv-tool provides.
flowchart LR
subgraph remote["Remote build host"]
sign["sign-repo.py"]
end
subgraph local["Local workstation"]
agent["piv-sign-agent.py"]
yubikey[("YubiKey PIV")]
agent --- yubikey
end
sign -- " ssh -R socket " --> agent
The agent protocol is line-based:
Request: SIGN SHA256 <hex-encoded-32-byte-digest>\n
Response: OK <base64-encoded-raw-PKCS1-signature>\n
Request: PUBKEY\n
Response: OK <base64-encoded-PEM-public-key>\n
Errors: ERR <message>\n
A fresh PKCS#11 session is opened for each signing request to avoid stale
session issues (e.g. after ykman calls or YubiKey re-insertion).
Starting the agent:
# With a password manager command for PIN retrieval:
python3 piv-sign-agent.py --pin-command "your-pin-retrieval-command"
# With PIN from environment:
PIV_PIN=123456 python3 piv-sign-agent.py
# Interactive (prompts for PIN at each signing request):
python3 piv-sign-agent.py
# Self-test (sign and verify a test digest):
python3 piv-sign-agent.py --test --pin-command "your-pin-retrieval-command"
# Suppress touch prompts (for YubiKeys with touch disabled):
python3 piv-sign-agent.py --no-touch --pin-command "..."
PKCS#11 signing flow
The PKCS#11 flow for each signing request:
C_Initialize(None)
C_GetSlotList(tokenPresent=True) -> slot
C_OpenSession(slot, CKF_SERIAL_SESSION | CKF_RW_SESSION) -> session
C_Login(session, CKU_USER, pin)
C_FindObjectsInit(session, [CKA_CLASS=CKO_PRIVATE_KEY, CKA_SIGN=True])
C_FindObjects(session) -> key_handle
C_FindObjectsFinal(session)
C_SignInit(session, CKM_RSA_PKCS, key_handle)
C_Sign(session, digest_info, sig_buf) -> signature # touch required here
CKM_RSA_PKCS performs raw PKCS#1 v1.5 padding – the caller must provide
the DER-encoded DigestInfo structure:
SHA256_DER_PREFIX = bytes([
0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86,
0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05,
0x00, 0x04, 0x20
])
digest_info = SHA256_DER_PREFIX + sha256_hash # 19 + 32 = 51 bytes
The signing command
The signing command
(sign-repo.py)
runs on the remote build host, called by pkg repo as the
signing_command. It connects to the forwarded PIV agent socket, sends the
double-hashed digest, and outputs the result in pkg’s expected format.
#!/usr/bin/env python3
import base64
import hashlib
import os
import socket
import sys
def die(msg):
sys.stderr.write(f"ERROR: {msg}\n")
sys.exit(1)
def agent_request(sock_path, request):
"""Send a request to the PIV signing agent."""
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(60)
s.connect(sock_path)
s.sendall((request + "\n").encode())
data = b""
while b"\n" not in data:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
s.close()
except socket.error as e:
die(f"cannot connect to PIV agent at {sock_path}: {e}")
line = data.split(b"\n")[0].decode()
if line.startswith("OK "):
return line[3:]
elif line.startswith("ERR "):
die(f"agent error: {line[4:]}")
else:
die(f"unexpected agent response: {line}")
def find_repo_pub(sock_path):
"""Get the public key PEM: from file or from the agent."""
env_path = os.environ.get("REPO_PUB")
if env_path and os.path.isfile(env_path):
with open(env_path, "rb") as f:
return f.read()
script_dir = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(script_dir, "repo.pub")
if os.path.isfile(path):
with open(path, "rb") as f:
return f.read()
# Ask the agent
b64 = agent_request(sock_path, "PUBKEY")
return base64.b64decode(b64)
def main():
sock_path = os.environ.get("PIV_AGENT_SOCK",
"/tmp/piv-sign-agent.sock")
pub_pem = find_repo_pub(sock_path)
# pkg repo sends SHA256(data) as a hex string on stdin.
# It doesn't close stdin, so read one line only.
# pkg verifies against SHA256(hex_string), so hash it again.
hex_hash = sys.stdin.readline().strip()
if not hex_hash:
die("no hash received on stdin")
double_hash = hashlib.sha256(hex_hash.encode()).digest()
# Sign via PIV agent
sig_b64 = agent_request(sock_path,
f"SIGN SHA256 {double_hash.hex()}")
sig = base64.b64decode(sig_b64)
# Output in the format pkg expects
sys.stdout.buffer.write(b"SIGNATURE\n")
sys.stdout.buffer.write(sig)
sys.stdout.buffer.write(b"\nCERT\n")
sys.stdout.buffer.write(pub_pem)
sys.stdout.buffer.write(b"END\n")
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()
Socket forwarding for remote builds
In many setups, packages are built on a remote FreeBSD machine but the YubiKey
is plugged into your local workstation. SSH socket forwarding solves this: the
agent’s Unix socket is forwarded to the remote host via ssh -R.
# Start the PIV signing agent locally
python3 piv-sign-agent.py --pin-command "your-pin-retrieval-command" &
# Forward the agent socket to the remote host
LOCAL_PIV_SOCK="${HOME}/.piv-sign-agent/agent.sock"
REMOTE_PIV_SOCK="/tmp/piv-sign-agent.sock"
# Remove stale remote socket, then connect with forwarding
ssh remote-host "rm -f ${REMOTE_PIV_SOCK}"
ssh -R "${REMOTE_PIV_SOCK}:${LOCAL_PIV_SOCK}" remote-host \
"PIV_AGENT_SOCK=${REMOTE_PIV_SOCK} \
pkg repo /path/to/repo/ signing_command: /path/to/sign-repo.py"
Unlike GPG agent forwarding, this approach is straightforward: there is no remote agent to kill, the socket is just a file, and the protocol is a simple line-based exchange over the forwarded socket.
Putting it together: the build script
Here’s the signing section of a build script that builds packages on a remote FreeBSD host and signs the repo via PIV agent socket forwarding:
# Upload the signing script and public key
rsync -aq -e ssh tools/sign-repo.py Keys/repo.pub \
"${FIREWALL}:${REMOTE_REPO_DIR}/"
# Ensure piv-sign-agent.py is running locally
LOCAL_PIV_SOCK="${PIV_AGENT_SOCK:-${HOME}/.piv-sign-agent/agent.sock}"
if [ ! -S "${LOCAL_PIV_SOCK}" ]; then
echo "ERROR: PIV signing agent not running" >&2
exit 1
fi
REMOTE_PIV_SOCK="/tmp/piv-sign-agent.sock"
# Remove stale remote socket before forwarding
ssh "${FIREWALL}" "rm -f ${REMOTE_PIV_SOCK}"
# Sign with forwarded PIV agent socket
ssh -R "${REMOTE_PIV_SOCK}:${LOCAL_PIV_SOCK}" "${FIREWALL}" \
"PIV_AGENT_SOCK=${REMOTE_PIV_SOCK} \
pkg repo ${REMOTE_REPO_DIR}/ signing_command: ${REMOTE_REPO_DIR}/sign-repo.py"
# Verify signing succeeded (pkg repo exits 0 even on failure)
ssh "${FIREWALL}" "test -f ${REMOTE_REPO_DIR}/meta.conf" || {
echo "ERROR: Repo signing failed" >&2
exit 1
}
Note the meta.conf check: pkg repo exits 0 even when signing fails, so
you need to verify the output explicitly.
Client-side setup
On each machine that should trust the repo, install the fingerprint [2]:
mkdir -p /usr/local/etc/pkg/fingerprints/myrepo/trusted
mkdir -p /usr/local/etc/pkg/fingerprints/myrepo/revoked
# Fingerprint is SHA256 of the PEM file (not the DER key)
FINGERPRINT=$(shasum -a 256 repo.pub | awk '{print $1}')
cat > /usr/local/etc/pkg/fingerprints/myrepo/trusted/repo.fingerprint <<EOF
function: sha256
fingerprint: ${FINGERPRINT}
EOF
Then add the repository configuration:
cat > /usr/local/etc/pkg/repos/myrepo.conf <<'EOF'
myrepo: {
url: "https://example.com/packages/${ABI}/repo",
signature_type: "fingerprints",
fingerprints: "/usr/local/etc/pkg/fingerprints/myrepo",
enabled: yes
}
EOF
pkg update -f -r myrepo
Verifying signatures manually
You can’t use openssl dgst -verify – it tests the wrong thing. Instead,
extract and inspect the DigestInfo:
# Extract signature components from the repo archive
tar -xf data.pkg data data.pub data.sig
# Decrypt the signature to see the DigestInfo
openssl rsautl -verify -pubin -inkey data.pub -in data.sig 2>/dev/null \
| od -A x -t x1 | tail -4
# Compute the expected hash (double SHA256):
printf '%s' "$(openssl dgst -sha256 -hex data | awk '{print $NF}')" \
| openssl dgst -sha256 -hex | awk '{print $NF}'
The last 32 bytes of the DigestInfo from the first command should match the hash from the second command.
Summary of pitfalls
Pitfall |
Symptom |
Fix |
|---|---|---|
Signing hex hash directly |
|
Hash the hex string again with SHA256 before signing |
Using |
Signing command deadlocks |
Use |
Fingerprint from DER key |
“No trusted public keys found” |
Fingerprint is |
Testing with |
Valid signatures appear to fail |
Tests single hash, not pkg’s double hash; use manual DigestInfo extraction |
|
Build succeeds but repo is unsigned |
Check for |
|
|
Export public key with |
Touch timeout on YubiKey |
|
Touch the YubiKey when prompted; |