This is part two of the RSA Public Key Manipulation series.

See also Part 1: How to Hide Data Inside RSA Public Keys

Introduction

Part 1 is showing how to embed data in the lower bits of an RSA public key modulus, but this is very lame because the “hidden” data is not encrypted and the size of the message is limited. As shown in SBOM Messaging System to send arbitrary encrypted messages through Rekor, we use chunks and a passphrase to embed arbitrary length of a message in a list of RSA public keys, encrypted.

Encryption Flow

Encryption and Chunking Pipeline

Step 1: Derive Encryption Key from Passphrase

We use a passphrase (like 4-karma-eagle-kettle) as the shared secret. Both sender and receiver derive the same encryption key from it using PBKDF2.

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

KDF_SALT = b"whatever-salt-we-want"
KDF_ITERATIONS = 100_000

def derive_key(passphrase: str) -> bytes:
    """Derive 256-bit AES key from passphrase."""
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,  # 256 bits
        salt=KDF_SALT,
        iterations=KDF_ITERATIONS,
    )
    return kdf.derive(passphrase.encode('utf-8'))

Step 2: Encrypt with AES-GCM

AES-GCM provides both encryption and integrity with authentication tag, but comes with the cost that the encrypted output is slightly larger than the input:

encrypted = nonce (12 bytes) + ciphertext + tag (16 bytes)
import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def encrypt(plaintext: bytes, key: bytes) -> bytes:
    """Encrypt with AES-256-GCM. Returns nonce + ciphertext + tag."""
    aesgcm = AESGCM(key)
    nonce = secrets.token_bytes(12)  # Random 96-bit nonce
    ciphertext = aesgcm.encrypt(nonce, plaintext, None)
    return nonce + ciphertext  # Tag is appended by encrypt()


def decrypt(encrypted: bytes, key: bytes) -> bytes:
    """Decrypt AES-256-GCM. Input is nonce + ciphertext + tag."""
    aesgcm = AESGCM(key)
    nonce = encrypted[:12]
    ciphertext = encrypted[12:]
    return aesgcm.decrypt(nonce, ciphertext, None)

Step 3: Length Header

Before chunking, we need to know the exact size of the encrypted data for reassembly. We add a 4-byte length header:

def prepare_for_chunking(data: bytes) -> bytes:
    """Prepend 4-byte length header."""
    length = len(data)
    return length.to_bytes(4, 'big') + data

Step 4: Chunks

We split the data into fixed size chunks that fit in RSA moduli. Each chunk needs:

  • Sequence number (2 bytes) — for ordering
  • Total count (2 bytes) — to know when we have all chunks
  • Payload (11 bytes) — actual data

Chunk Structure

from dataclasses import dataclass
from typing import List

CHUNK_PAYLOAD_SIZE = 11  # Bytes of actual data per chunk
CHUNK_HEADER_SIZE = 4    # 2 bytes seq + 2 bytes total

@dataclass
class Chunk:
    sequence: int   # 0, 1, 2, ...
    total: int      # Total number of chunks
    payload: bytes  # 11 bytes of data


def chunk_data(data: bytes) -> List[Chunk]:
    """Split data into chunks."""
    total = (len(data) + CHUNK_PAYLOAD_SIZE - 1) // CHUNK_PAYLOAD_SIZE
    if total == 0:
        total = 1
    
    if total > 65535:
        raise ValueError(f"Data too large: {total} chunks needed (max 65535)")
    
    chunks = []
    for i in range(total):
        start = i * CHUNK_PAYLOAD_SIZE
        end = start + CHUNK_PAYLOAD_SIZE
        payload = data[start:end]
        
        # Pad last chunk with zeros
        if len(payload) < CHUNK_PAYLOAD_SIZE:
            payload = payload + b'\x00' * (CHUNK_PAYLOAD_SIZE - len(payload))
        
        chunks.append(Chunk(sequence=i, total=total, payload=payload))
    
    return chunks

We do have a 11 bytes payload limit.

  • RSA-2048 modulus: 2048 bits = 256 bytes
  • We use lower 128 bits = 16 bytes
  • Minus 1 byte for RSA LSB requirement = 15 usable bytes
  • Minus 4 bytes header = 11 bytes payload

Step 5: Encode for RSA Modulus

RSA moduli are always odd (LSB = 1), so we can’t use the lowest bit for data. We shift our data left by 8 bits:

RSA Encoding

def encode_for_rsa(chunk: Chunk) -> bytes:
    """
    Encode chunk to 16 bytes for RSA modulus embedding.
    
    Layout (before shift):
    [seq:2][total:2][payload:11] = 15 bytes
    
    After shift left 8 bits + set LSB:
    [seq:2][total:2][payload:11][0x01] = 16 bytes
    """
    header = chunk.sequence.to_bytes(2, 'big') + chunk.total.to_bytes(2, 'big')
    raw = header + chunk.payload  # 15 bytes
    
    # Shift left 8 bits and set LSB to 1
    value = int.from_bytes(raw, 'big')
    shifted = (value << 8) | 0x01
    
    return shifted.to_bytes(16, 'big')


def decode_from_rsa(data: bytes) -> Chunk:
    """Extract chunk from 16 bytes of RSA modulus."""
    value = int.from_bytes(data, 'big')
    
    # Shift right 8 bits to remove padding byte
    shifted = value >> 8
    raw = shifted.to_bytes(15, 'big')
    
    sequence = int.from_bytes(raw[:2], 'big')
    total = int.from_bytes(raw[2:4], 'big')
    payload = raw[4:]
    
    return Chunk(sequence=sequence, total=total, payload=payload)

Step 6: Generate RSA Key with Embedded Chunk

For each chunk, we generate an RSA key where the modulus contains our encoded data. This is detailed in Part 1.

Receiving

def reassemble(chunks: List[Chunk]) -> bytes:
    """Reassemble chunks into original data."""
    # Sort by sequence
    chunks.sort(key=lambda c: c.sequence)
    
    # Verify completeness
    total = chunks[0].total
    sequences = {c.sequence for c in chunks}
    missing = set(range(total)) - sequences
    if missing:
        raise ValueError(f"Missing chunks: {sorted(missing)}")
    
    # Concatenate payloads
    raw = b''.join(c.payload for c in chunks)
    
    # Extract length and trim padding
    length = int.from_bytes(raw[:4], 'big')
    encrypted = raw[4:4 + length]
    
    return encrypted

Then decrypt with the derived key:

# Receiver has the passphrase
key = derive_key(passphrase)
encrypted = reassemble(chunks)
plaintext = decrypt(encrypted, key)

Complete Example

# === SENDER ===
passphrase = "4-karma-eagle-kettle"
message = b"Hello, World!"  # 13 bytes

# 1. Derive encryption key
key = derive_key(passphrase)
# key = bytes(32)

# 2. Encrypt
encrypted = encrypt(message, key)
# encrypted = 13 + 12 + 16 = 41 bytes

# 3. Prepend length
with_length = prepare_for_chunking(encrypted)
# with_length = 4 + 41 = 45 bytes

# 4. Chunk (45 bytes / 11 bytes per chunk = 5 chunks)
chunks = chunk_data(with_length)
# chunks = [Chunk(0, 5, b'...'), Chunk(1, 5, b'...'), ...]

# 5. For each chunk: encode and generate RSA key
for chunk in chunks:
    chunk_bytes = encode_for_rsa(chunk)  # 16 bytes
    private_key = generate_key_with_chunk(chunk_bytes)
    # Upload public key somewhere (CT logs, Rekor, etc.)
# === RECEIVER ===
passphrase = "4-karma-eagle-kettle"

# 1. Derive same key
key = derive_key(passphrase)

# 2. Retrieve public keys and extract chunks
chunks = []
for public_key in retrieved_keys:
    n = public_key.public_numbers().n
    chunk_bytes = extract_from_modulus(n)  # Lower 128 bits
    chunk = decode_from_rsa(chunk_bytes)
    chunks.append(chunk)

# 3. Reassemble
encrypted = reassemble(chunks)

# 4. Decrypt
plaintext = decrypt(encrypted, key)
print(plaintext)  # b'Hello, World!'

SecertCert

See secertcert for full implementaion.