threadpanic/rustpg
1
0
Fork
You've already forked rustpg
0
2 Wire Formats
Bert van der Weerd edited this page 2026年05月18日 17:37:36 +02:00

Wire Formats

rustpg uses three binary ciphertext formats (RPGS, RPGA, RPGX), three signature formats, and an ASCII armor layer. Dispatch is always on the 4-byte magic at data[0..4] — never on data[5].


RPGS — Symmetric Encryption

Cipher: AES-256-GCM
KDF: Argon2id (RFC 9106 second profile)

Offset Len Field
────── ─── ─────────────────────────────────────
0 4 Magic: "RPGS"
4 1 Format version: 0x01
5 1 Variant: 0x01 (AES-256-GCM; 0x02 reserved for ChaCha20)
6 1 KDF id: 0x01 (Argon2id)
7 2 Argon2 m_cost in MiB, u16 little-endian (currently 64)
9 1 Argon2 t_cost (iterations, currently 3)
10 1 Argon2 p_cost (parallelism, currently 1)
11 16 Argon2 salt (random per file)
27 12 AES-GCM nonce (random per file)
39 ... AES-256-GCM ciphertext + 16-byte tag

Header size: 39 bytes. All 39 bytes are passed as AEAD additional authenticated data (AAD) — tampering with any header byte causes a MAC failure indistinguishable from a wrong passphrase.

Key derivation: Argon2id(passphrase, salt, m_cost, t_cost, p_cost) → 32-byte AES key. KDF parameters are stored in the header, so files remain decryptable if default params change later.


RPGA — Asymmetric Encryption

Key exchange: X25519 ECDH (ephemeral sender key)
KDF: HKDF-SHA256
Cipher: AES-256-GCM

Offset Len Field
────── ─── ─────────────────────────────────────
0 4 Magic: "RPGA"
4 1 Format version: 0x01
5 1 Variant: 0x01 (X25519+AES-256-GCM; 0x02 reserved for ML-KEM)
6 32 Ephemeral sender X25519 public key
38 12 AES-GCM nonce (random per file)
50 ... AES-256-GCM ciphertext + 16-byte tag

Header size: 50 bytes. All 50 bytes are AEAD AAD.

Key derivation: A fresh ephemeral X25519 keypair is generated per encryption. The shared secret from ECDH(ephemeral_privkey, recipient_pubkey) is fed into HKDF-SHA256 with the nonce as salt and "rustpg-v1-enc" as info to produce the 32-byte AES key. Using the nonce as HKDF salt binds the derived key to this specific ciphertext.

Forward secrecy: The ephemeral private key is never stored. Compromise of the recipient's long-term private key does not expose past messages encrypted with different ephemeral keys.


Sign-then-Encrypt (RPGA containing a signed blob)

Operations → Asymmetric + Sign... and rpg --encrypt --sign produce a standard RPGA file whose plaintext payload is a binary-signed blob:

RPGA ciphertext
 └─ decrypts to ─→ RUSTPGSIG + <64-byte Ed25519 sig> + <original plaintext>

The decrypt path auto-strips the signature wrapper before writing output. If --recipient is given, the embedded signature is verified against that identity's Ed25519 key and the result is reported to stderr.


Signatures

Binary (embedded)

Offset Len Field
────── ─── ────────────────────────────────
0 10 Magic: "RUSTPGSIG\x02"
10 64 Ed25519 signature over the payload
74 ... Original payload (any bytes)

The \x02 version byte distinguishes from hypothetical legacy formats. Signature is over the raw payload bytes only.

ClearSign (text)

-----BEGIN RUSTPG SIGNED MESSAGE-----
Hash: SHA512
<message body — trailing newlines stripped before signing>
-----BEGIN RUSTPG SIGNATURE-----
<base64-encoded 64-byte Ed25519 signature>
-----END RUSTPG SIGNATURE-----

The signature covers body.trim_end_matches('\n').as_bytes(). Only Hash: SHA512 is accepted; other hash headers are rejected (ed25519-dalek uses SHA-512 internally).

Detached

Raw 64-byte Ed25519 signature with no framing. Stored separately from the data file.


RPGX — Streaming Symmetric Encryption (Large File Mode)

Cipher: ChaCha20-Poly1305
KDF: Argon2id (same profile as RPGS)
Use case: Files too large to load into RAM — streaming file-to-file encryption.

Offset Len Field
────── ─── ─────────────────────────────────────────────────────────
0 4 Magic: "RPGX"
4 1 Format version: 0x01
5 1 Algorithm: 0x01 (ChaCha20-Poly1305)
6 12 Base nonce (random per file)
18 2 Reserved (zero)
── KDF block (26 bytes) ───────────────────────────────────────────────
20 1 KDF id: 0x01 (Argon2id)
21 2 Argon2 m_cost in MiB, u16 little-endian (currently 64)
23 1 Argon2 t_cost (iterations, currently 3)
24 1 Argon2 p_cost (parallelism, currently 1)
25 16 Argon2 salt (random per file)
41 5 Reserved (zero)
── Chunks (repeated) ──────────────────────────────────────────────────
46 4 Chunk payload length, u32 little-endian (0 = end sentinel)
50 N ChaCha20-Poly1305 ciphertext + 16-byte Poly1305 tag

Total header: 46 bytes. Default chunk size: 64 KiB.

Nonce construction: Each chunk uses base_nonce XOR little_endian_64(chunk_counter). Nonce reuse within a session is structurally impossible.

KDF: Argon2id(passphrase, salt, m_cost, t_cost, p_cost) → 32-byte ChaCha20 key. KDF parameters are stored in every file, so files remain decryptable if defaults change.

End sentinel: A 0x00000000 4-byte length field terminates the chunk sequence.

Detection: is_lfm_data(data) checks for the RPGX magic. RPGX files cannot be decrypted via the buffer — use file-to-file LFM operations only.


ASCII Armor

OpenPGP-style armor (RFC 4880 §6). Base64-encoded body, 76 characters per line, with a CRC24 checksum.

-----BEGIN RUSTPG SYMMETRIC MESSAGE----- ← RPGS ciphertext
-----BEGIN RUSTPG ASYMMETRIC MESSAGE----- ← RPGA ciphertext
-----BEGIN RUSTPG MESSAGE----- ← anything else

Dearmor accepts any -----BEGIN ... ----- header — including PGP blocks — and verifies the CRC24 checksum before returning data.

CRC24 (RFC 4880 §6.1): polynomial 0x1864CFB, init 0xB704CE, result emitted as =<base64(3 bytes)> on its own line before the footer.