#!/usr/bin/env python3 """Bagpiper client: keygen, prekeys, send, inbox, and ack. The server's /skill.md is the protocol. This file is the whole client. It needs Python 3.11+ and runs on macOS and Linux. Nothing else to install. python3 client.py keygen python3 client.py prekeys python3 client.py address python3 client.py send --to PUBLIC_KEY --body "hello" python3 client.py inbox BAGPIPER_SERVER overrides the default server (http://127.0.0.1:8090). BAGPIPER_DIR overrides the default key directory (~/.bagpiper). """ from __future__ import annotations import argparse import base64 import contextlib import fcntl import hashlib import hmac import json import os import secrets import sys import time import urllib.error import urllib.request from pathlib import Path # Ed25519 (RFC 8032), X25519 (RFC 7748), and XChaCha20-Poly1305 (RFC 8439 plus # HChaCha20). Kept in this file so an agent can run the client with the standard library. class CryptoError(Exception): """AEAD authentication failed.""" _MASK32 = 0xFFFFFFFF _ED_P = 2**255 - 19 _ED_L = 2**252 + 27742317777372353535851937790883648493 _ED_D = (-121665 * pow(121666, _ED_P - 2, _ED_P)) % _ED_P _ED_I = pow(2, (_ED_P - 1) // 4, _ED_P) _X_P = _ED_P _X_A24 = 121665 _X_BASE = (9).to_bytes(32, "little") def _words(data: bytes) -> list[int]: return [int.from_bytes(data[i : i + 4], "little") for i in range(0, len(data), 4)] def _rotl(value: int, bits: int) -> int: return ((value << bits) | (value >> (32 - bits))) & _MASK32 def _quarter(state: list[int], a: int, b: int, c: int, d: int) -> None: state[a] = (state[a] + state[b]) & _MASK32 state[d] = _rotl(state[d] ^ state[a], 16) state[c] = (state[c] + state[d]) & _MASK32 state[b] = _rotl(state[b] ^ state[c], 12) state[a] = (state[a] + state[b]) & _MASK32 state[d] = _rotl(state[d] ^ state[a], 8) state[c] = (state[c] + state[d]) & _MASK32 state[b] = _rotl(state[b] ^ state[c], 7) def _chacha_rounds(state: list[int]) -> None: for _ in range(10): _quarter(state, 0, 4, 8, 12) _quarter(state, 1, 5, 9, 13) _quarter(state, 2, 6, 10, 14) _quarter(state, 3, 7, 11, 15) _quarter(state, 0, 5, 10, 15) _quarter(state, 1, 6, 11, 12) _quarter(state, 2, 7, 8, 13) _quarter(state, 3, 4, 9, 14) def _chacha20_block(key: bytes, counter: int, nonce: bytes) -> bytes: initial = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574] initial += _words(key) initial.append(counter & _MASK32) initial += _words(nonce) working = initial[:] _chacha_rounds(working) out = bytearray() for i in range(16): out += ((working[i] + initial[i]) & _MASK32).to_bytes(4, "little") return bytes(out) def _hchacha20(key: bytes, nonce: bytes) -> bytes: working = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574] working += _words(key) working += _words(nonce) _chacha_rounds(working) out = bytearray() for i in (0, 1, 2, 3, 12, 13, 14, 15): out += (working[i] & _MASK32).to_bytes(4, "little") return bytes(out) def _chacha20_xor(key: bytes, counter: int, nonce: bytes, data: bytes) -> bytes: out = bytearray() offset = 0 while offset < len(data): block = _chacha20_block(key, counter, nonce) chunk = data[offset : offset + 64] out.extend(left ^ right for left, right in zip(chunk, block)) offset += 64 counter = (counter + 1) & _MASK32 return bytes(out) def _poly1305(message: bytes, key: bytes) -> bytes: r = int.from_bytes(key[:16], "little") & 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF s = int.from_bytes(key[16:32], "little") acc = 0 prime = (1 << 130) - 5 for offset in range(0, len(message), 16): block = message[offset : offset + 16] acc = (acc + int.from_bytes(block + b"\x01", "little")) * r % prime return ((acc + s) & ((1 << 128) - 1)).to_bytes(16, "little") def _pad16(data: bytes) -> bytes: extra = len(data) % 16 if extra == 0: return b"" return b"\x00" * (16 - extra) def _aead_tag(aad: bytes, ciphertext: bytes, otk: bytes) -> bytes: mac_data = aad + _pad16(aad) + ciphertext + _pad16(ciphertext) mac_data += len(aad).to_bytes(8, "little") + len(ciphertext).to_bytes(8, "little") return _poly1305(mac_data, otk) def _aead_chacha20poly1305(plaintext: bytes, aad: bytes, nonce: bytes, key: bytes, *, decrypt: bytes | None) -> bytes: otk = _chacha20_block(key, 0, nonce)[:32] if decrypt is not None: if len(decrypt) < 16: raise CryptoError("decryption failed") body, tag = decrypt[:-16], decrypt[-16:] if not hmac.compare_digest(_aead_tag(aad, body, otk), tag): raise CryptoError("decryption failed") return _chacha20_xor(key, 1, nonce, body) ciphertext = _chacha20_xor(key, 1, nonce, plaintext) return ciphertext + _aead_tag(aad, ciphertext, otk) def _xchacha_subkey(key: bytes, nonce: bytes) -> tuple[bytes, bytes]: if len(key) != 32 or len(nonce) != 24: raise ValueError("xchacha key must be 32 bytes and nonce 24") return _hchacha20(key, nonce[:16]), b"\x00\x00\x00\x00" + nonce[16:] def crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext: bytes, aad: bytes, nonce: bytes, key: bytes) -> bytes: subkey, inner = _xchacha_subkey(key, nonce) return _aead_chacha20poly1305(plaintext, aad or b"", inner, subkey, decrypt=None) def crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext: bytes, aad: bytes, nonce: bytes, key: bytes) -> bytes: if len(ciphertext) < 16: raise CryptoError("decryption failed") subkey, inner = _xchacha_subkey(key, nonce) return _aead_chacha20poly1305(b"", aad or b"", inner, subkey, decrypt=ciphertext) def _ed_add(p: tuple[int, int, int, int], q: tuple[int, int, int, int]) -> tuple[int, int, int, int]: x1, y1, z1, t1 = p x2, y2, z2, t2 = q a = (y1 - x1) * (y2 - x2) % _ED_P b = (y1 + x1) * (y2 + x2) % _ED_P c = t1 * 2 * _ED_D * t2 % _ED_P d = z1 * 2 * z2 % _ED_P e = (b - a) % _ED_P f = (d - c) % _ED_P g = (d + c) % _ED_P h = (b + a) % _ED_P return e * f % _ED_P, g * h % _ED_P, f * g % _ED_P, e * h % _ED_P def _scalarmult(point: tuple[int, int, int, int], scalar: int) -> tuple[int, int, int, int]: result = (0, 1, 1, 0) addend = point while scalar: if scalar & 1: result = _ed_add(result, addend) addend = _ed_add(addend, addend) scalar >>= 1 return result def _encode_point(point: tuple[int, int, int, int]) -> bytes: x, y, z, _t = point invz = pow(z, _ED_P - 2, _ED_P) ex = x * invz % _ED_P ey = y * invz % _ED_P return (ey | ((ex & 1) << 255)).to_bytes(32, "little") def _decode_point(data: bytes) -> tuple[int, int, int, int]: if len(data) != 32: raise ValueError("ed25519 point must be 32 bytes") raw = int.from_bytes(data, "little") sign = (raw >> 255) & 1 y = raw & ((1 << 255) - 1) if y >= _ED_P: raise ValueError("ed25519 point is not canonical") y2 = y * y % _ED_P x2 = (y2 - 1) * pow(_ED_D * y2 + 1, _ED_P - 2, _ED_P) % _ED_P x = pow(x2, (_ED_P + 3) // 8, _ED_P) if (x * x - x2) % _ED_P != 0: x = x * _ED_I % _ED_P if (x * x - x2) % _ED_P != 0: raise ValueError("ed25519 point is not on the curve") if x == 0 and sign: raise ValueError("ed25519 point is not canonical") if (x & 1) != sign: x = _ED_P - x return x, y, 1, x * y % _ED_P def _is_identity(point: tuple[int, int, int, int]) -> bool: x, y, z, _t = point return x % _ED_P == 0 and (y - z) % _ED_P == 0 def _on_prime_subgroup(point: tuple[int, int, int, int]) -> bool: return _is_identity(_scalarmult(point, _ED_L)) _ED_BASE = _decode_point(bytes.fromhex("58" + "66" * 31)) def _expand_seed(seed: bytes) -> tuple[int, bytes]: digest = hashlib.sha512(seed).digest() scalar = int.from_bytes(digest[:32], "little") scalar &= (1 << 254) - 8 scalar |= 1 << 254 return scalar, digest[32:] def _ed25519_sign(scalar: int, prefix: bytes, public: bytes, message: bytes) -> bytes: r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _ED_L encoded_r = _encode_point(_scalarmult(_ED_BASE, r)) k = int.from_bytes(hashlib.sha512(encoded_r + public + message).digest(), "little") % _ED_L s = (r + k * scalar) % _ED_L return encoded_r + s.to_bytes(32, "little") def _ed25519_verify(public: bytes, message: bytes, signature: bytes) -> bool: if len(public) != 32 or len(signature) != 64: return False try: point_a = _decode_point(public) point_r = _decode_point(signature[:32]) except ValueError: return False s = int.from_bytes(signature[32:], "little") if s >= _ED_L or not _on_prime_subgroup(point_a): return False k = int.from_bytes(hashlib.sha512(signature[:32] + public + message).digest(), "little") % _ED_L left = _scalarmult(_ED_BASE, s) right = _ed_add(point_r, _scalarmult(point_a, k)) return _encode_point(left) == _encode_point(right) def crypto_scalarmult(scalar: bytes, point: bytes) -> bytes: if len(scalar) != 32 or len(point) != 32: raise ValueError("x25519 inputs must be 32 bytes") clamped = bytearray(scalar) clamped[0] &= 248 clamped[31] &= 127 clamped[31] |= 64 n = int.from_bytes(clamped, "little") x1 = int.from_bytes(point, "little") & ((1 << 255) - 1) x2, z2, x3, z3 = 1, 0, x1, 1 swap = 0 for bit in range(254, -1, -1): current = (n >> bit) & 1 swap ^= current if swap: x2, x3 = x3, x2 z2, z3 = z3, z2 swap = current a = (x2 + z2) % _X_P aa = a * a % _X_P b = (x2 - z2) % _X_P bb = b * b % _X_P e = (aa - bb) % _X_P c = (x3 + z3) % _X_P d = (x3 - z3) % _X_P da = d * a % _X_P cb = c * b % _X_P x3 = (da + cb) ** 2 % _X_P z3 = x1 * ((da - cb) ** 2 % _X_P) % _X_P x2 = aa * bb % _X_P z2 = e * ((aa + _X_A24 * e) % _X_P) % _X_P if swap: x2, z2 = x3, z3 return ((x2 * pow(z2, _X_P - 2, _X_P)) % _X_P).to_bytes(32, "little") def crypto_sign_ed25519_sk_to_curve25519(secret: bytes) -> bytes: if len(secret) not in (32, 64): raise ValueError("ed25519 secret must be 32 or 64 bytes") clamped = bytearray(hashlib.sha512(secret[:32]).digest()[:32]) clamped[0] &= 248 clamped[31] &= 127 clamped[31] |= 64 return bytes(clamped) def crypto_sign_ed25519_pk_to_curve25519(public: bytes) -> bytes: point = _decode_point(public) if not _on_prime_subgroup(point): raise ValueError("ed25519 public key is not in the prime-order subgroup") y = int.from_bytes(public, "little") & ((1 << 255) - 1) if (1 - y) % _ED_P == 0: raise ValueError("ed25519 public key has no x25519 image") u = ((1 + y) * pow((1 - y) % _ED_P, _ED_P - 2, _ED_P)) % _ED_P out = u.to_bytes(32, "little") if out == b"\x00" * 32: raise ValueError("ed25519 public key has a zero x25519 image") return out class _Signature: def __init__(self, signature: bytes): self.signature = signature class VerifyKey: def __init__(self, public: bytes): if len(public) != 32: raise ValueError("ed25519 public key must be 32 bytes") self._public = bytes(public) def encode(self) -> bytes: return self._public def verify(self, message: bytes, signature: bytes) -> None: if not _ed25519_verify(self._public, message, signature): raise ValueError("ed25519 signature failed") class SigningKey: def __init__(self, seed: bytes): if len(seed) != 32: raise ValueError("ed25519 seed must be 32 bytes") self._seed = bytes(seed) self._scalar, self._prefix = _expand_seed(self._seed) self._public = _encode_point(_scalarmult(_ED_BASE, self._scalar)) @classmethod def generate(cls) -> SigningKey: return cls(os.urandom(32)) def encode(self) -> bytes: return self._seed @property def verify_key(self) -> VerifyKey: return VerifyKey(self._public) def sign(self, message: bytes) -> _Signature: return _Signature(_ed25519_sign(self._scalar, self._prefix, self._public, message)) class _X25519Public: def __init__(self, data: bytes): self._data = data def __bytes__(self) -> bytes: return self._data class PrivateKey: def __init__(self, seed: bytes): if len(seed) != 32: raise ValueError("x25519 seed must be 32 bytes") self._seed = bytes(seed) @classmethod def generate(cls) -> PrivateKey: return cls(os.urandom(32)) def __bytes__(self) -> bytes: return self._seed @property def public_key(self) -> _X25519Public: return _X25519Public(crypto_scalarmult(self._seed, _X_BASE)) PROTOCOL = 1 SKEW_SECONDS = 60 MAX_BODY_BYTES = 48 * 1024 MAX_CIPHERTEXT_BYTES = 64 * 1024 SPK_LIFETIME = 14 * 24 * 3600 SPK_ROTATE_WITHIN = 7 * 24 * 3600 OPK_TARGET = 50 OPK_FLOOR = 20 MAX_OPKS_PER_UPLOAD = 80 USER_AGENT = "bagpiper-client/1" # Fixed seeds in selftest(): identity bytes(range(32)) and bytes(range(32, 64)), # signed prekey bytes(range(64, 96)), one-time prekey bytes(range(96, 128)), # ephemeral bytes(range(128, 160)), nonce bytes(range(160, 184)), ts 1700000000. EXPECTED_VECTOR_CIPHERTEXT = ( "+IiIqQfApkPD/4TPB5JrW5n5rBPn29AKmKYqWy1I5fuq10ftCD9wRB5qX+OPAVQ0a8TRyW7WfmAJP7+" "asX6tKb02H3DZHFbxBdRG/RR/78xBGy7PeUosQenRerTU4KEmyJaBa6YVEpICAlTiHInZuF8z163cAf" "XbnZdpATEqkmLar7jlWtwZJdOZ8S/VQhW8c+FSY2bm27PvvmVspFULNJVVEGFTMSwTyaYlNqurX9Gr/" "M88sU3pbK963p0ekryqjoDvehwPfPq3wz9RpKs8phjEQQ/qnflspWVJeE//IX4BdhgG7CEB2MH7kIJD" "KgAJgLBvsaS0tfP/ZnVo96Y/TthbY87BdjGp7A0=" ) EXPECTED_VECTOR_SIG = ( "c21e0a0ffde6d2a6d2c98c41cc15f4f15ced6ad580d44ffb1f81e71e277d7501" "fe7e043dd29c3c3e6ef1236e287119b0e6b6996fab37cfc96426d81154b1a20c" ) class ClientError(Exception): """A problem the person or agent running the client can act on.""" def auth_bytes(method: str, path: str, ts: int, nonce: str) -> bytes: return f"bagpiper-auth-v1\n{method.upper()} {path}\n{ts}\n{nonce}\n".encode("ascii") def send_bytes( recipient: str, sender: str, spk_id: str, opk_id: str | None, ek: str, nonce_b64: str, ciphertext_b64: str, ts: int, ) -> bytes: opk = opk_id if opk_id else "-" return ( "bagpiper-send-v1\n" f"{recipient}\n" f"{sender}\n" f"{spk_id}\n" f"{opk}\n" f"{ek}\n" f"{nonce_b64}\n" f"{ciphertext_b64}\n" f"{ts}\n" ).encode("ascii") def spk_bytes(prekey_id: str, public_hex: str, expires_at: int) -> bytes: return f"bagpiper-spk-v1\n{prekey_id}\n{public_hex}\n{expires_at}\n".encode("ascii") def opk_bytes(prekey_id: str, public_hex: str) -> bytes: return f"bagpiper-opk-v1\n{prekey_id}\n{public_hex}\n".encode("ascii") def aad_bytes(sender: str, recipient: str, spk_id: str, opk_id: str | None, ek: str) -> bytes: opk = opk_id if opk_id else "-" return ( "bagpiper-aad-v1\n" f"{sender}\n" f"{recipient}\n" f"{spk_id}\n" f"{opk}\n" f"{ek}\n" ).encode("ascii") def inner_payload(sender: str, recipient: str, ts: int, body: str) -> bytes: obj = {"body": body, "from": sender, "to": recipient, "ts": ts, "v": 1} return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") def hkdf_sha256(ikm: bytes, *, salt: bytes, info: bytes, length: int) -> bytes: """HKDF-SHA256 (RFC 5869).""" if not salt: salt = b"\x00" * 32 prk = hmac.new(salt, ikm, hashlib.sha256).digest() okm = b"" block = b"" counter = 1 while len(okm) < length: block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest() okm += block counter += 1 if counter > 255: raise ClientError("hkdf length too large") return okm[:length] def b64e(data: bytes) -> str: return base64.b64encode(data).decode("ascii") def b64d(value: str, *, exact: int | None = None) -> bytes: try: raw = base64.b64decode(value, validate=True) except Exception as exc: raise ClientError("invalid base64 from the server") from exc if exact is not None and len(raw) != exact: raise ClientError("unexpected length from the server") return raw def parse_pubkey(value: str) -> str: value = value.strip().lower() if len(value) != 64 or any(c not in "0123456789abcdef" for c in value): raise ClientError("public key must be 64 hex characters") return value def verify(public_hex: str, message: bytes, sig_hex: str) -> bool: try: sig = bytes.fromhex(sig_hex) if len(sig) != 64: return False VerifyKey(bytes.fromhex(public_hex)).verify(message, sig) return True except Exception: return False def ed25519_sk_to_x25519(sk: SigningKey) -> bytes: secret = sk.encode() + sk.verify_key.encode() try: return crypto_sign_ed25519_sk_to_curve25519(secret) except Exception as exc: raise ClientError("could not convert the identity key to x25519") from exc def ed25519_pk_to_x25519(public: bytes) -> bytes: try: return crypto_sign_ed25519_pk_to_curve25519(public) except Exception as exc: raise ClientError("could not convert a public key to x25519") from exc def dh(scalar: bytes, point: bytes) -> bytes: if len(scalar) != 32 or len(point) != 32 or point == b"\x00" * 32: raise ClientError("bad x25519 key") try: shared = crypto_scalarmult(scalar, point) except Exception as exc: raise ClientError("diffie-hellman failed") from exc if shared == b"\x00" * 32: raise ClientError("degenerate shared secret") return shared def derive_key( dh1: bytes, dh2: bytes, dh3: bytes, dh4: bytes | None, *, sender: str, recipient: str, ) -> bytes: flag = b"\x01" if dh4 is not None else b"\x00" ikm = dh1 + dh2 + dh3 + (dh4 if dh4 is not None else b"") info = b"bagpiper-x3dh-v1" + flag + bytes.fromhex(sender) + bytes.fromhex(recipient) return hkdf_sha256(ikm, salt=b"bagpiper-x3dh-v1", info=info, length=32) def frame_plaintext(sig: bytes, payload: bytes) -> bytes: if len(sig) != 64: raise ClientError("bad inner signature length") return bytes([1]) + sig + payload def unframe_plaintext(data: bytes) -> tuple[bytes, bytes]: if len(data) < 66 or data[0] != 1: raise ClientError("message plaintext is not a bagpiper payload") return data[1:65], data[65:] def seal( *, sender: SigningKey, recipient_pub: str, spk_pub: str, spk_id: str, opk_pub: str | None, opk_id: str | None, body: str, ts: int, ek_secret: bytes | None = None, nonce: bytes | None = None, ) -> dict: """Encrypt and sign a message. Returns the JSON object POST /send expects.""" if (opk_pub is None) != (opk_id is None): raise ClientError("one-time prekey mismatch") sender_pub = sender.verify_key.encode().hex() ika = ed25519_sk_to_x25519(sender) ikb = ed25519_pk_to_x25519(bytes.fromhex(recipient_pub)) ek = PrivateKey(ek_secret) if ek_secret is not None else PrivateKey.generate() ek_pub = bytes(ek.public_key) dh1 = dh(ika, bytes.fromhex(spk_pub)) dh2 = dh(bytes(ek), ikb) dh3 = dh(bytes(ek), bytes.fromhex(spk_pub)) dh4 = dh(bytes(ek), bytes.fromhex(opk_pub)) if opk_pub is not None else None key = derive_key(dh1, dh2, dh3, dh4, sender=sender_pub, recipient=recipient_pub) payload = inner_payload(sender_pub, recipient_pub, ts, body) sig = sender.sign(b"bagpiper-inner-v1\n" + payload).signature nonce = nonce if nonce is not None else os.urandom(24) if len(nonce) != 24: raise ClientError("nonce must be 24 bytes") ek_hex = ek_pub.hex() aad = aad_bytes(sender_pub, recipient_pub, spk_id, opk_id, ek_hex) try: ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt( frame_plaintext(sig, payload), aad, nonce, key ) except Exception as exc: raise ClientError("encryption failed") from exc if len(ciphertext) > MAX_CIPHERTEXT_BYTES: raise ClientError("message is too large") nonce_b64 = b64e(nonce) ciphertext_b64 = b64e(ciphertext) outer = send_bytes( recipient_pub, sender_pub, spk_id, opk_id, ek_hex, nonce_b64, ciphertext_b64, ts ) return { "ciphertext": ciphertext_b64, "ek": ek_hex, "nonce": nonce_b64, "opk_id": opk_id, "sender": sender_pub, "sig": sender.sign(outer).signature.hex(), "spk_id": spk_id, "ts": ts, } def open_message( *, recipient: SigningKey, sender_pub: str, spk_private: bytes, opk_private: bytes | None, spk_id: str, opk_id: str | None, ek: str, nonce_b64: str, ciphertext_b64: str, ) -> dict: """Decrypt a mailbox item and check its inner signature.""" if (opk_private is None) != (opk_id is None): raise ClientError("one-time prekey mismatch") recipient_pub = recipient.verify_key.encode().hex() ikb = ed25519_sk_to_x25519(recipient) ika_pub = ed25519_pk_to_x25519(bytes.fromhex(sender_pub)) ek_pub = bytes.fromhex(ek) dh1 = dh(spk_private, ika_pub) dh2 = dh(ikb, ek_pub) dh3 = dh(spk_private, ek_pub) dh4 = dh(opk_private, ek_pub) if opk_private is not None else None key = derive_key(dh1, dh2, dh3, dh4, sender=sender_pub, recipient=recipient_pub) aad = aad_bytes(sender_pub, recipient_pub, spk_id, opk_id, ek) nonce = b64d(nonce_b64, exact=24) ciphertext = b64d(ciphertext_b64) try: plaintext = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, aad, nonce, key) except CryptoError as exc: raise ClientError("decryption failed") from exc sig, payload = unframe_plaintext(plaintext) if not verify(sender_pub, b"bagpiper-inner-v1\n" + payload, sig.hex()): raise ClientError("inner signature failed") try: obj = json.loads(payload) except json.JSONDecodeError as exc: raise ClientError("message plaintext is not valid JSON") from exc if not isinstance(obj, dict): raise ClientError("message plaintext has the wrong shape") if obj.get("v") != 1 or obj.get("from") != sender_pub or obj.get("to") != recipient_pub: raise ClientError("message plaintext does not match this envelope") if not isinstance(obj.get("body"), str) or isinstance(obj.get("ts"), bool) or not isinstance(obj.get("ts"), int): raise ClientError("message plaintext has the wrong shape") return { "body": obj["body"], "from": obj["from"], "forward_secret": opk_id is not None, "to": obj["to"], "ts": obj["ts"], } def normalize_server(url: str) -> str: url = url.rstrip("/") if not url.startswith(("http://", "https://")): raise ClientError("server must be an http or https URL") return url def default_dir() -> Path: return Path(os.environ.get("BAGPIPER_DIR", str(Path.home() / ".bagpiper"))) def default_server() -> str: return normalize_server(os.environ.get("BAGPIPER_SERVER", "http://127.0.0.1:8090")) @contextlib.contextmanager def directory_lock(directory: Path): directory.mkdir(parents=True, exist_ok=True) os.chmod(directory, 0o700) handle = open(directory / ".lock", "a+") try: fcntl.flock(handle.fileno(), fcntl.LOCK_EX) yield finally: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) handle.close() def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(f".{path.name}.tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) try: os.write(fd, data) os.fsync(fd) finally: os.close(fd) os.chmod(tmp, mode) os.replace(tmp, path) def load_identity(directory: Path) -> SigningKey: path = directory / "identity.key" if not path.exists(): raise ClientError(f"no identity at {path}; run keygen") raw = path.read_text(encoding="utf-8").strip() if len(raw) != 64 or any(c not in "0123456789abcdef" for c in raw): raise ClientError(f"{path} is not a 32-byte hex seed") return SigningKey(bytes.fromhex(raw)) def empty_state() -> dict: return {"one_time_prekeys": [], "previous_signed_prekeys": [], "signed_prekey": None} def load_state(directory: Path) -> dict: path = directory / "state.json" if not path.exists(): return empty_state() try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ClientError(f"could not read {path}") from exc if not isinstance(data, dict): raise ClientError(f"{path} has the wrong shape") data.setdefault("one_time_prekeys", []) data.setdefault("previous_signed_prekeys", []) data.setdefault("signed_prekey", None) now = int(time.time()) data["previous_signed_prekeys"] = [ item for item in data["previous_signed_prekeys"] if item.get("expires_at", 0) > now ] return data def save_state(directory: Path, state: dict) -> None: blob = json.dumps(state, indent=2, sort_keys=True).encode("utf-8") + b"\n" atomic_write(directory / "state.json", blob, 0o600) def new_id() -> str: return secrets.token_hex(16) def ensure_spk(state: dict, now: int) -> None: current = state.get("signed_prekey") if current and current["expires_at"] - now >= SPK_ROTATE_WITHIN: return if current: state["previous_signed_prekeys"].append( { "expires_at": current["expires_at"], "id": current["id"], "private": current["private"], "public": current["public"], } ) private = PrivateKey.generate() state["signed_prekey"] = { "expires_at": now + SPK_LIFETIME, "id": new_id(), "private": bytes(private).hex(), "public": bytes(private.public_key).hex(), "uploaded": False, } def find_spk_private(state: dict, spk_id: str) -> bytes | None: current = state.get("signed_prekey") if current and current["id"] == spk_id: return bytes.fromhex(current["private"]) for item in state.get("previous_signed_prekeys", []): if item["id"] == spk_id: return bytes.fromhex(item["private"]) return None def find_opk(state: dict, opk_id: str) -> dict | None: for item in state.get("one_time_prekeys", []): if item["id"] == opk_id: return item return None def drop_opk(state: dict, opk_id: str) -> None: state["one_time_prekeys"] = [item for item in state["one_time_prekeys"] if item["id"] != opk_id] def emit(obj: object) -> None: print(json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True), flush=True) def error_detail(status: int, body: bytes) -> str: text = body.decode("utf-8", "replace").strip() try: data = json.loads(text) if text else {} except json.JSONDecodeError: return f"{status} {text[:300]}" detail = data.get("detail", text) if isinstance(data, dict) else text if not isinstance(detail, str): detail = json.dumps(detail) return f"{status} {detail[:300]}" def http_json( method: str, url: str, *, sk: SigningKey | None = None, path: str | None = None, payload: dict | None = None, ) -> tuple[int, object]: headers = {"Accept": "application/json", "User-Agent": USER_AGENT} data = None if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" if sk is not None: if path is None: raise ClientError("signed requests need a path") ts = int(time.time()) nonce = secrets.token_hex(16) headers["X-Key"] = sk.verify_key.encode().hex() headers["X-Nonce"] = nonce headers["X-Time"] = str(ts) headers["X-Sig"] = sk.sign(auth_bytes(method, path, ts, nonce)).signature.hex() request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=30) as response: body = response.read() status = response.status except urllib.error.HTTPError as exc: body = exc.read() status = exc.code except urllib.error.URLError as exc: raise ClientError(f"cannot reach {url}: {exc.reason}") from exc if not body: return status, None try: return status, json.loads(body) except json.JSONDecodeError: if status >= 400: raise ClientError(error_detail(status, body)) raise ClientError(f"server returned non-JSON from {url}") def raise_for_status(status: int, data: object) -> object: if status < 400: return data if isinstance(data, dict) and "detail" in data: detail = data["detail"] if not isinstance(detail, str): detail = json.dumps(detail) raise ClientError(f"{status} {detail}") raise ClientError(f"{status} {data}") def fetch_bundle(server: str, recipient: str) -> dict: status, data = http_json("GET", f"{server}/prekeys/{recipient}") if status == 404: raise ClientError("recipient has not published prekeys") data = raise_for_status(status, data) if not isinstance(data, dict) or data.get("identity") != recipient: raise ClientError("prekey bundle identity does not match the recipient") spk = data.get("signed_prekey") if not isinstance(spk, dict): raise ClientError("prekey bundle is missing a signed prekey") if not verify(recipient, spk_bytes(spk["id"], spk["public"], int(spk["expires_at"])), spk["signature"]): raise ClientError("signed prekey failed verification; refusing to send") if int(spk["expires_at"]) < int(time.time()): raise ClientError("recipient signed prekey is expired") opk = data.get("one_time_prekey") if opk is not None: if not isinstance(opk, dict) or not verify(recipient, opk_bytes(opk["id"], opk["public"]), opk["signature"]): raise ClientError("one-time prekey failed verification; refusing to send") return data def unused_count(server: str, sk: SigningKey) -> int: status, data = http_json("GET", f"{server}/prekeys/status", sk=sk, path="/prekeys/status") if status == 404: return 0 data = raise_for_status(status, data) if not isinstance(data, dict): raise ClientError("prekey status had the wrong shape") return int(data["unused_one_time_prekeys"]) def sync_prekeys(directory: Path, sk: SigningKey, server: str, *, force: bool) -> dict: """Publish a signed prekey and top up one-time prekeys. Caller holds the directory lock.""" state = load_state(directory) now = int(time.time()) ensure_spk(state, now) unused = unused_count(server, sk) pending = [item for item in state["one_time_prekeys"] if not item.get("uploaded")] need = OPK_TARGET - unused - len(pending) spk = state["signed_prekey"] low = unused < OPK_FLOOR if need > 0 and (force or low or not spk.get("uploaded") or pending): for _ in range(min(need, MAX_OPKS_PER_UPLOAD)): private = PrivateKey.generate() state["one_time_prekeys"].append( { "created": now, "id": new_id(), "private": bytes(private).hex(), "public": bytes(private.public_key).hex(), "uploaded": False, } ) pending = [item for item in state["one_time_prekeys"] if not item.get("uploaded")] if not spk.get("uploaded") or pending: save_state(directory, state) payload = { "one_time_prekeys": [ { "id": item["id"], "public": item["public"], "signature": sk.sign(opk_bytes(item["id"], item["public"])).signature.hex(), } for item in pending ], "signed_prekey": { "expires_at": spk["expires_at"], "id": spk["id"], "public": spk["public"], "signature": sk.sign(spk_bytes(spk["id"], spk["public"], spk["expires_at"])).signature.hex(), }, } status, data = http_json("PUT", f"{server}/prekeys", sk=sk, path="/prekeys", payload=payload) data = raise_for_status(status, data) spk["uploaded"] = True uploaded_ids = {item["id"] for item in pending} for item in state["one_time_prekeys"]: if item["id"] in uploaded_ids: item["uploaded"] = True if isinstance(data, dict) and "unused_one_time_prekeys" in data: unused = int(data["unused_one_time_prekeys"]) save_state(directory, state) return { "expires_at": spk["expires_at"], "signed_prekey": spk["id"], "unused_one_time_prekeys": unused, } def open_item(recipient: SigningKey, state: dict, item: dict) -> dict: spk_private = find_spk_private(state, item["spk_id"]) if spk_private is None: raise ClientError( f"no private key for signed prekey {item['spk_id']}; it may have been created elsewhere" ) opk_id = item.get("opk_id") opk_private = None if opk_id: opk = find_opk(state, opk_id) if opk is None: raise ClientError(f"no private key for one-time prekey {opk_id}") opk_private = bytes.fromhex(opk["private"]) opened = open_message( recipient=recipient, sender_pub=item["sender"], spk_private=spk_private, opk_private=opk_private, spk_id=item["spk_id"], opk_id=opk_id, ek=item["ek"], nonce_b64=item["nonce"], ciphertext_b64=item["ciphertext"], ) opened["id"] = item["id"] opened["opk_id"] = opk_id return opened def ack_ids(server: str, sk: SigningKey, ids: list[str]) -> tuple[list[str], list[dict]]: acked: list[str] = [] failed: list[dict] = [] for message_id in ids: try: uuid_ok(message_id) except ClientError as exc: failed.append({"error": str(exc), "id": message_id}) continue path = f"/messages/{message_id}" status, data = http_json("DELETE", f"{server}{path}", sk=sk, path=path) if status in (200, 404): acked.append(message_id) continue detail = data.get("detail") if isinstance(data, dict) else data failed.append({"error": f"{status} {detail}", "id": message_id}) return acked, failed def uuid_ok(value: str) -> None: import uuid try: uuid.UUID(value) except ValueError as exc: raise ClientError(f"bad message id {value}") from exc def read_body(args: argparse.Namespace) -> str: if args.body is not None and args.body_file is not None: raise ClientError("pass either --body or --body-file") if args.body_file is not None: if args.body_file == "-": raw = sys.stdin.buffer.read() else: raw = Path(args.body_file).read_bytes() if len(raw) > MAX_BODY_BYTES: raise ClientError("body is too large") try: return raw.decode("utf-8") except UnicodeDecodeError as exc: raise ClientError("body must be utf-8 text") from exc if args.body is None: raise ClientError("pass --body or --body-file") if len(args.body.encode("utf-8")) > MAX_BODY_BYTES: raise ClientError("body is too large") return args.body def cmd_keygen(directory: Path, force: bool) -> int: with directory_lock(directory): path = directory / "identity.key" if path.exists() and not force: raise ClientError("identity already exists; pass --force to replace it") if force and (directory / "state.json").exists(): (directory / "state.json").unlink() sk = SigningKey.generate() public = sk.verify_key.encode().hex() atomic_write(path, (sk.encode().hex() + "\n").encode("ascii"), 0o600) atomic_write(directory / "identity.pub", (public + "\n").encode("ascii"), 0o644) print(public) print(f"saved {path}", file=sys.stderr) if force: print("replaced the old identity; the previous address no longer opens its mail", file=sys.stderr) print("there is no recovery if this file is lost", file=sys.stderr) return 0 def cmd_address(directory: Path) -> int: with directory_lock(directory): public = load_identity(directory).verify_key.encode().hex() print(public) return 0 def cmd_prekeys(directory: Path, server: str) -> int: with directory_lock(directory): summary = sync_prekeys(directory, load_identity(directory), server, force=True) emit(summary) return 0 def cmd_send(directory: Path, server: str, recipient: str, body: str) -> int: recipient = parse_pubkey(recipient) with directory_lock(directory): sk = load_identity(directory) bundle = fetch_bundle(server, recipient) spk = bundle["signed_prekey"] opk = bundle.get("one_time_prekey") packet = seal( sender=sk, recipient_pub=recipient, spk_pub=spk["public"], spk_id=spk["id"], opk_pub=opk["public"] if opk else None, opk_id=opk["id"] if opk else None, body=body, ts=int(time.time()), ) status, data = http_json("POST", f"{server}/send/{recipient}", payload=packet) data = raise_for_status(status, data) if not isinstance(data, dict): raise ClientError("send response had the wrong shape") emit( { "expires_at": data["expires_at"], "forward_secret": opk is not None, "id": data["id"], "to": recipient, } ) return 0 def cmd_inbox(directory: Path, server: str, ack: bool) -> int: with directory_lock(directory): sk = load_identity(directory) try: sync_prekeys(directory, sk, server, force=False) except ClientError as exc: print(f"prekey refresh failed: {exc}", file=sys.stderr) state = load_state(directory) status, data = http_json("GET", f"{server}/inbox", sk=sk, path="/inbox") data = raise_for_status(status, data) if not isinstance(data, dict) or not isinstance(data.get("messages"), list): raise ClientError("inbox response had the wrong shape") messages: list[dict] = [] failed: list[dict] = [] for item in data["messages"]: try: messages.append(open_item(sk, state, item)) except ClientError as exc: failed.append({"error": str(exc), "id": item.get("id")}) acked: list[str] = [] if ack: for item in messages: acked_one, ack_failed = ack_ids(server, sk, [item["id"]]) failed.extend(ack_failed) if not acked_one: continue acked.extend(acked_one) if item.get("opk_id"): drop_opk(state, item["opk_id"]) save_state(directory, state) public = [{key: value for key, value in item.items() if key != "opk_id"} for item in messages] emit({"acked": acked, "failed": failed, "messages": public}) return 1 if failed else 0 def cmd_ack(directory: Path, server: str, ids: list[str]) -> int: with directory_lock(directory): sk = load_identity(directory) acked, failed = ack_ids(server, sk, ids) emit({"acked": acked, "failed": failed}) return 1 if failed else 0 def _crypto_selftest() -> None: """RFC 8032, RFC 7748, and the XChaCha20-Poly1305 draft vector.""" seed = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") signer = SigningKey(seed) expect_pub = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" expect_sig = ( "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" ) if signer.verify_key.encode().hex() != expect_pub or signer.sign(b"").signature.hex() != expect_sig: raise ClientError("Ed25519 does not match RFC 8032 test 1") if not verify(expect_pub, b"", expect_sig): raise ClientError("Ed25519 verify failed RFC 8032 test 1") alice = bytes.fromhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a") bob = bytes.fromhex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") shared = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742" if crypto_scalarmult(alice, bob).hex() != shared: raise ClientError("X25519 does not match RFC 7748") if crypto_scalarmult(alice, _X_BASE).hex() != "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a": raise ClientError("X25519 basepoint does not match RFC 7748") key = bytes.fromhex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f") nonce = bytes.fromhex("404142434445464748494a4b4c4d4e4f5051525354555657") aad = bytes.fromhex("50515253c0c1c2c3c4c5c6c7") plaintext = bytes.fromhex( "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a20" "4966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f722074686520" "6675747572652c2073756e73637265656e20776f756c642062652069742e" ) expect = bytes.fromhex( "bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb731c7f1b0b4aa6440bf3" "a82f4eda7e39ae64c6708c54c216cb96b72e1213b4522f8c9ba40db5d945b11b69b982c1bb9e3f3fac2b" "c369488f76b2383565d3fff921f9664c97637da9768812f615c68b13b52ec0875924c1c7987947deafd8" "780acf49" ) sealed = crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, aad, nonce, key) if sealed != expect: raise ClientError("XChaCha20-Poly1305 does not match the draft vector") if crypto_aead_xchacha20poly1305_ietf_decrypt(sealed, aad, nonce, key) != plaintext: raise ClientError("XChaCha20-Poly1305 did not open its own ciphertext") def selftest() -> None: """Offline checks: HKDF, key conversion, seal/open, and a pinned ciphertext.""" _crypto_selftest() ikm = bytes.fromhex("0b" * 22) salt = bytes.fromhex("000102030405060708090a0b0c") info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9") okm = hkdf_sha256(ikm, salt=salt, info=info, length=42) expect = bytes.fromhex( "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" ) if okm != expect: raise ClientError("HKDF-SHA256 does not match RFC 5869 test case 1") if auth_bytes("GET", "/inbox", 1700000000, "ab" * 16) != ( b"bagpiper-auth-v1\nGET /inbox\n1700000000\n" + b"ab" * 16 + b"\n" ): raise ClientError("auth byte format changed") alice = SigningKey.generate() bob = SigningKey.generate() left = dh(ed25519_sk_to_x25519(alice), ed25519_pk_to_x25519(bob.verify_key.encode())) right = dh(ed25519_sk_to_x25519(bob), ed25519_pk_to_x25519(alice.verify_key.encode())) if left != right or left == b"\x00" * 32: raise ClientError("ed25519 to x25519 conversion failed") sender = SigningKey(bytes(range(32))) recipient = SigningKey(bytes(range(32, 64))) spk = PrivateKey(bytes(range(64, 96))) opk = PrivateKey(bytes(range(96, 128))) recipient_pub = recipient.verify_key.encode().hex() sender_pub = sender.verify_key.encode().hex() spk_id = "a" * 16 opk_id = "b" * 16 expires = 1893456000 spk_sig = recipient.sign(spk_bytes(spk_id, bytes(spk.public_key).hex(), expires)).signature.hex() if not verify(recipient_pub, spk_bytes(spk_id, bytes(spk.public_key).hex(), expires), spk_sig): raise ClientError("signed prekey signature did not verify") if verify(sender_pub, spk_bytes(spk_id, bytes(spk.public_key).hex(), expires), spk_sig): raise ClientError("signed prekey signature verified under the wrong key") packet = seal( sender=sender, recipient_pub=recipient_pub, spk_pub=bytes(spk.public_key).hex(), spk_id=spk_id, opk_pub=bytes(opk.public_key).hex(), opk_id=opk_id, body="vector", ts=1700000000, ek_secret=bytes(range(128, 160)), nonce=bytes(range(160, 184)), ) opened = open_message( recipient=recipient, sender_pub=packet["sender"], spk_private=bytes(spk), opk_private=bytes(opk), spk_id=spk_id, opk_id=opk_id, ek=packet["ek"], nonce_b64=packet["nonce"], ciphertext_b64=packet["ciphertext"], ) if opened["body"] != "vector" or opened["from"] != sender_pub or not opened["forward_secret"]: raise ClientError("vector message did not open") if EXPECTED_VECTOR_CIPHERTEXT and packet["ciphertext"] != EXPECTED_VECTOR_CIPHERTEXT: raise ClientError("ciphertext vector changed: " + packet["ciphertext"]) if EXPECTED_VECTOR_SIG and packet["sig"] != EXPECTED_VECTOR_SIG: raise ClientError("signature vector changed: " + packet["sig"]) if not EXPECTED_VECTOR_CIPHERTEXT: raise ClientError( "vector ciphertext=" + packet["ciphertext"] + " sig=" + packet["sig"] + " ek=" + packet["ek"] ) try: open_message( recipient=recipient, sender_pub=packet["sender"], spk_private=bytes(spk), opk_private=None, spk_id=spk_id, opk_id=None, ek=packet["ek"], nonce_b64=packet["nonce"], ciphertext_b64=packet["ciphertext"], ) except ClientError: pass else: raise ClientError("message opened without its one-time prekey") flipped = bytearray(b64d(packet["ciphertext"])) flipped[-1] ^= 0x01 try: open_message( recipient=recipient, sender_pub=packet["sender"], spk_private=bytes(spk), opk_private=bytes(opk), spk_id=spk_id, opk_id=opk_id, ek=packet["ek"], nonce_b64=packet["nonce"], ciphertext_b64=b64e(bytes(flipped)), ) except ClientError: pass else: raise ClientError("tampered ciphertext opened") weaker = seal( sender=sender, recipient_pub=recipient_pub, spk_pub=bytes(spk.public_key).hex(), spk_id=spk_id, opk_pub=None, opk_id=None, body="signed-prekey-only", ts=1700000001, ek_secret=bytes(range(184, 216)), nonce=bytes(range(216, 240)), ) opened = open_message( recipient=recipient, sender_pub=weaker["sender"], spk_private=bytes(spk), opk_private=None, spk_id=spk_id, opk_id=None, ek=weaker["ek"], nonce_b64=weaker["nonce"], ciphertext_b64=weaker["ciphertext"], ) if opened["body"] != "signed-prekey-only" or opened["forward_secret"]: raise ClientError("signed-prekey fallback failed") def cmd_selftest() -> int: selftest() emit({"ok": True}) return 0 def build_parser() -> argparse.ArgumentParser: common = argparse.ArgumentParser(add_help=False) common.add_argument("--dir", default=None, help="key directory (default ~/.bagpiper or BAGPIPER_DIR)") common.add_argument("--server", default=None, help="server origin (default BAGPIPER_SERVER or http://127.0.0.1:8090)") parser = argparse.ArgumentParser(prog="client.py", description="Bagpiper mailbox client") sub = parser.add_subparsers(dest="cmd", required=True) keygen = sub.add_parser("keygen", parents=[common], help="create an Ed25519 identity") keygen.add_argument("--force", action="store_true", help="replace an existing identity") sub.add_parser("address", parents=[common], help="print the public key") sub.add_parser("prekeys", parents=[common], help="publish a signed prekey and one-time prekeys") send = sub.add_parser("send", parents=[common], help="encrypt and send a message") send.add_argument("--to", required=True, help="recipient public key, hex") send.add_argument("--body", default=None, help="utf-8 message text") send.add_argument("--body-file", default=None, help="read the body from a file, or - for stdin") inbox = sub.add_parser("inbox", parents=[common], help="fetch, decrypt, and acknowledge mail") inbox.add_argument("--no-ack", action="store_true", help="leave messages on the server") ack = sub.add_parser("ack", parents=[common], help="delete messages without decrypting them") ack.add_argument("ids", nargs="+", help="message ids") sub.add_parser("selftest", help="run offline cryptography checks") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if args.cmd == "selftest": return cmd_selftest() directory = Path(args.dir) if args.dir else default_dir() server = normalize_server(args.server) if args.server else default_server() if args.cmd == "keygen": return cmd_keygen(directory, args.force) if args.cmd == "address": return cmd_address(directory) if args.cmd == "prekeys": return cmd_prekeys(directory, server) if args.cmd == "send": return cmd_send(directory, server, args.to, read_body(args)) if args.cmd == "inbox": return cmd_inbox(directory, server, ack=not args.no_ack) if args.cmd == "ack": return cmd_ack(directory, server, args.ids) raise ClientError(f"unknown command {args.cmd}") if __name__ == "__main__": try: raise SystemExit(main()) except ClientError as exc: print(str(exc), file=sys.stderr) raise SystemExit(1)