Public-key encryption for MCAP robotics logs.
MCAP is the native format for Foxglove Studio and ROS 2. It has excellent tooling but no built-in encryption. mcap-encrypt protects chunk payloads with XChaCha20-Poly1305 while keeping schemas, channels, and timestamps readable for routing and inspection without a key.
📌 Status: v0.x, experimental, not externally audited.
✅ Best for: MCAP logs at rest; Foxglove Studio visualization via bridge; schemas and channels always accessible.
🚫 Not for: hiding ROS topic names, schema definitions, or chunk-level timestamps. Those stay readable by design regardless of which encryption level you choose.
For a plain-English summary for security buyers, see Security limitations.
mcap-encrypt bridge is built into this project. No separate install, no extra dependency. It works the same way as foxglove-bridge for ROS 2 -- start it, connect Studio, done.
mcap-encrypt bridge --key mykey.priv.pem recording.mcap
# listening: ws://localhost:8765Open Foxglove Studio, add a Foxglove WebSocket connection, enter ws://localhost:8765. Camera feeds, lidar, plots, timeline scrubbing -- everything works. The decrypted data stays in RAM on your machine. No persistent decrypted file is written to disk. Nothing reaches Foxglove's servers.
foxglove-bridge |
mcap-encrypt bridge |
|
|---|---|---|
| Data source | Live ROS 2 robot | Encrypted MCAP file |
| Studio connection | ws://localhost:8765 |
ws://localhost:8765 |
| Private key required | No | Yes |
| Decrypted file on disk | n/a | Never |
| Multiple Studio clients | Yes | Yes |
Full walkthrough and Go API: docs/foxglove.md.
| Level | CLI flag | Encrypted | Readable without a key |
|---|---|---|---|
| 1️⃣ Data only | (default) | Chunk payloads (sensor data, camera, lidar) | Schemas, channels, timestamps, Metadata records |
| 2️⃣ Data + metadata map | --metadata encrypt |
Chunk payloads + Metadata key-value pairs | Schemas, channels, timestamps, Metadata names |
| 3️⃣ Data + full metadata | --metadata encrypt-all |
Chunk payloads + Metadata names + map | Schemas, channels, timestamps only |
Each chunk gets its own random 24-byte nonce; nonce reuse is impossible. The symmetric key is wrapped once per recipient and stored before the first chunk. An unencrypted ChunkIndex lets any MCAP reader seek by time range without a key.
# Generate a key pair (RSA-4096)
mcap-encrypt keygen --out mykey
# Encrypt
mcap-encrypt encrypt --key mykey.pub.pem input.mcap encrypted.mcap
# Decrypt to a standard MCAP file
mcap-encrypt decrypt --key mykey.priv.pem encrypted.mcap output.mcap
# Visualize in Foxglove Studio without decrypting to disk
mcap-encrypt bridge --key mykey.priv.pem encrypted.mcap
# Connect Foxglove Studio to ws://localhost:8765If the output file already exists, encrypt and decrypt fail. Pass --force to overwrite.
Already have an SSH key? If ~/.ssh/id_ed25519.pub exists you can use it directly as a recipient without generating a new key: mcap-encrypt encrypt --key ~/.ssh/id_ed25519.pub input.mcap encrypted.mcap and decrypt with --key ~/.ssh/id_ed25519. Ed25519 keys are converted to X25519 internally per RFC 7748; RSA SSH keys (ssh-rsa) are accepted directly if at least 4096 bits. Passphrase-protected keys are not supported yet; decrypt them first with ssh-keygen -p -f <keyfile>.
Need a test MCAP? This repo ships examples/sample.mcap (4.7 KB, 100 messages, two channels). Use it as the input above. To regenerate or modify the sample, run go run ./examples/gen-sample from the repo root.
For Foxglove-blessed test data, the foxglove/mcap conformance suite holds hundreds of structural variants. The files are stored in Git LFS, so clone with git lfs install && git clone https://github.com/foxglove/mcap to pull the binaries. For real-world ROS recordings, the Foxglove documentation and the Foxglove community link to public datasets.
Go CLI
go install github.com/remete618/mcap-encrypt/cmd/mcap-encrypt@latestGo library
go get github.com/remete618/mcap-encrypt/pkg/mcapencryptRequires Go 1.26+.
TypeScript / Node.js
npm install mcap-encryptRequires Node.js 18+. Works in modern browsers without polyfills.
Python
pip install mcap-encryptRequires Python 3.10+.
AWS KMS backend
For enterprise deployments where the RSA-4096 private key must stay inside an HSM, mcap-encrypt can unwrap via AWS KMS rather than reading the key from disk. The private key never leaves the KMS boundary; only the 32-byte symmetric key returns to the host. See docs/kms.md for setup, IAM policy, and CLI examples.
mcap-encrypt keygen --out <basename>
mcap-encrypt encrypt --key <pub.pem> [--key <pub2.pem>...] [--metadata plaintext|encrypt|encrypt-all] [--force] <input.mcap> <output.mcap>
mcap-encrypt decrypt (--key <priv.pem> | --kms <uri>) [--force] <input.mcap> <output.mcap>
mcap-encrypt rotate (--old-key <priv.pem> | --old-kms <uri>) --new-key <pub.pem> [--new-key <pub2.pem>...] [--force] <input.mcap> <output.mcap>
mcap-encrypt inspect <input.mcap>
mcap-encrypt bridge (--key <priv.pem> | --kms <uri>) [--addr <host:port>] [--streaming] <encrypted.mcap>
Generates an RSA-4096 key pair. Writes <basename>.pub.pem (0644) and <basename>.priv.pem (0600). For X25519 key pairs use GenerateX25519KeyPair in the Go library.
Accepts RSA-4096 and X25519 public keys. Repeat --key for multiple recipients; any private key from the set decrypts the file.
# Single recipient
mcap-encrypt encrypt --key alice.pub.pem input.mcap encrypted.mcap
# Multiple recipients
mcap-encrypt encrypt --key alice.pub.pem --key bob.pub.pem input.mcap encrypted.mcap
# Encrypt metadata key-value map (name stays readable)
mcap-encrypt encrypt --key alice.pub.pem --metadata encrypt input.mcap encrypted.mcap
# Encrypt metadata fully (name + map both hidden)
mcap-encrypt encrypt --key alice.pub.pem --metadata encrypt-all input.mcap encrypted.mcapA live progress bar shows throughput and ETA. Press Ctrl-Z to pause mid-operation; fg to resume.
Produces a standard, fully-indexed MCAP readable by any MCAP tool. Accepts --force to overwrite.
Re-wraps the symmetric key for new recipients without decrypting any chunk data. O(file size) I/O; zero message decryption.
mcap-encrypt rotate --old-key old.priv.pem --new-key new.pub.pem enc.mcap rotated.mcapNote: rotate changes who can decrypt. To replace the data-encryption key itself, decrypt then re-encrypt.
Prints file metadata (encryption status, format version, file ID, chunk count, recipients) without decrypting. No private key required. Runs at disk read speed.
Decrypts to memory, serves over the Foxglove WebSocket protocol. Connect Foxglove Studio to ws://localhost:8765. No persistent decrypted file on disk. See docs/foxglove.md for the full walkthrough and comparison with foxglove-bridge.
Add --streaming to decrypt chunks on demand instead of loading the full file. This trades a small per-subscription latency for RAM that is bounded by a fixed-size chunk cache rather than the file size. The on-the-wire ws-protocol is identical; Foxglove Studio sees no difference.
import "github.com/remete618/mcap-encrypt/pkg/mcapencrypt"
// Key generation
mcapencrypt.GenerateKeyPair("mykey") // RSA-4096: mykey.pub.pem + mykey.priv.pem
mcapencrypt.GenerateX25519KeyPair("mykey-x25519")
// Encrypt
mcapencrypt.Encrypt("input.mcap", "encrypted.mcap", "mykey.pub.pem")
mcapencrypt.EncryptMulti("input.mcap", "encrypted.mcap", []string{
"alice.pub.pem", "bob.pub.pem",
})
mcapencrypt.EncryptWithOptions("input.mcap", "enc.mcap", []string{"mykey.pub.pem"}, mcapencrypt.EncryptOptions{
MetadataMode: mcapencrypt.MetadataEncrypt,
Progress: func(n int64) { fmt.Printf("%d bytes\n", n) },
})
// Encrypt from any io.Reader / io.Writer (PEM strings, no file I/O)
mcapencrypt.EncryptStream(r, w, []string{pubKeyPem})
// Decrypt
mcapencrypt.Decrypt("encrypted.mcap", "output.mcap", "mykey.priv.pem")
mcapencrypt.DecryptWithOptions(r, w, "mykey.priv.pem", mcapencrypt.DecryptOptions{
WarnFunc: func(msg string) { log.Println(msg) },
})
// Rotate keys
mcapencrypt.RotateKeyFile("encrypted.mcap", "rotated.mcap", "old.priv.pem", []string{"new.pub.pem"})
// Inspect without a key
res, _ := mcapencrypt.InspectFile("encrypted.mcap")
// res.IsEncrypted, res.FileID, res.ChunkCount, res.Recipients
// Bridge
state, _ := mcapencrypt.LoadBridgeState("encrypted.mcap", "mykey.priv.pem")
mcapencrypt.ServeBridge(ctx, state, "localhost:8765")Full API reference: docs/api.md.
import { generateKeyPair, generateX25519KeyPair, encryptMcap, decryptMcap, rotateMcapKeys, inspectMcap, iterateMessages } from "mcap-encrypt";
const { publicKeyPem, privateKeyPem } = await generateKeyPair(); // RSA-4096
// Encrypt (single or multi-recipient)
const encrypted = await encryptMcap(plain, publicKeyPem);
const encrypted2 = await encryptMcap(plain, [alicePem, bobPem]);
const encrypted3 = await encryptMcap(plain, publicKeyPem, { metadataMode: "encrypt" });
// Decrypt to a fully-indexed MCAP buffer
const decrypted = await decryptMcap(encrypted, privateKeyPem);
// Rotate keys without re-encrypting
const rotated = await rotateMcapKeys(encrypted, oldPrivKeyPem, [newPubKeyPem]);
// Inspect without a key
const info = inspectMcap(encrypted);
// Stream messages without materializing output
for await (const { schema, channel, message } of iterateMessages(encrypted, privateKeyPem)) {
console.log(channel.topic, message.logTime);
}Works in Node.js 18+ and modern browsers (Web Crypto API, no WASM). Does not support LZ4 source files; use the Go CLI to normalize those first. Full API reference: docs/api.md.
from mcap_encrypt import (
encrypt_mcap, decrypt_mcap, iterate_messages,
inspect_mcap, rotate_mcap_keys,
generate_key_pair, generate_x25519_key_pair,
)
pub_pem, priv_pem = generate_key_pair() # RSA-4096
# Encrypt (single or multi-recipient, optional metadata encryption)
encrypted = encrypt_mcap(plain_bytes, pub_pem)
encrypted2 = encrypt_mcap(plain_bytes, [alice_pem, bob_pem])
encrypted3 = encrypt_mcap(plain_bytes, pub_pem, metadata="encrypt")
# Decrypt
decrypted = decrypt_mcap(encrypted, priv_pem)
# Rotate keys
rotated = rotate_mcap_keys(encrypted, old_priv_pem, [new_pub_pem])
# Inspect without a key
info = inspect_mcap(encrypted)
# Stream messages
for schema, channel, message in iterate_messages(encrypted, priv_pem):
print(channel.topic, message.log_time)Server-side only. Requires libsodium via pynacl. Full API reference: docs/api.md.
| Approach | Works on MCAP today? | Studio can scrub the timeline without a key? | Multi-recipient? | Re-key without re-encrypt? |
|---|---|---|---|---|
age-encrypted MCAP file |
Yes, but the whole file is opaque | No, the file is one ciphertext blob | Yes (age recipients) | No |
| GPG-encrypted MCAP file | Yes, but the whole file is opaque | No | Yes | No |
| Manual envelope encryption (encrypt each topic) | Custom code per project | No, schemas and timestamps need a key too | DIY | DIY |
mcap-encrypt |
Native, drop-in | Yes, ChunkIndex stays in the clear | Yes (RSA + X25519 mix) | Yes (rotate) |
The trade is explicit: mcap-encrypt keeps topic names, timestamps, and
schemas readable so MCAP tooling (Foxglove Studio, the mcap CLI, third-
party readers) keeps working. Encrypt the whole file with age if you
need to hide everything including the timeline, and accept that no MCAP
tool can route by topic without a key.
| Layer | Algorithm | Purpose |
|---|---|---|
| Chunk encryption | XChaCha20-Poly1305 | Authenticated encryption; 24-byte nonce eliminates random-nonce collision risk at scale |
| Key wrapping (RSA) | RSA-4096-OAEP-SHA-256 | Wraps the per-file symmetric key for RSA recipients |
| Key wrapping (X25519) | X25519-HKDF-SHA256-XChaCha20Poly1305 | Wraps the per-file symmetric key for X25519 recipients |
| Integrity binding | AEAD additional data | Binds each chunk to its file, position, timing, and compression metadata |
| Truncation detection | HMAC-SHA-256 manifest | Detects tail truncation and manifest strip attacks |
Protected: message payloads, attachment data, Metadata records (when requested), ciphertext integrity, chunk order, cross-file transplants, tail truncation.
Not protected (readable without a key):
| Data | Why |
|---|---|
| Schemas and channels | Required for MCAP tooling compatibility |
| Topic names and message timestamps | Required for timeline indexing |
| Attachment name and media type | Plaintext for enumeration; data is encrypted |
| Metadata records | Plaintext by default; use --metadata flags to protect them |
| Ciphertext length | Chunks are not padded |
For the full threat model, algorithm rationale, and test coverage details see SECURITY.md.
Release binaries are built reproducibly: a step-by-step recipe to rebuild a published release locally and compare its SHA-256 against the signed checksums.txt is in docs/reproducible-builds.md.
This project has not been externally audited. Do not use it as the only protection layer for highly sensitive production data without independent review.
Benchmarks on Apple M3 (arm64), Go 1.26, zstd compression.
| Scenario | File size | Encrypt | Decrypt |
|---|---|---|---|
| Small: 100 msgs x 1 KB | ~105 KB | 5 MB/s | 0.4 MB/s |
| Medium: 1,000 msgs x 4 KB | ~4 MB | 16 MB/s | 1.4 MB/s |
| Large: 5,000 msgs x 64 KB | ~236 MB | 6.5 MB/s | 0.9 MB/s |
go test ./pkg/mcapencrypt/ -run='^$' -bench='BenchmarkEncrypt|BenchmarkDecrypt' -benchtime=5sDecrypt is slower because it decompresses and rebuilds a fully-indexed MCAP from scratch. TypeScript bulk cipher is 2-4x slower than Go for large files; use the Go CLI for recordings over 500 MB.
| Limitation | Workaround |
|---|---|
rotate re-wraps the same DEK; to replace the key itself, decrypt then re-encrypt |
mcap-encrypt decrypt ... && mcap-encrypt encrypt ... |
| Input must be a chunked MCAP | Re-encode with chunking enabled (Foxglove CLI and most writers do this by default) |
EncryptStream spools input to a temp file (two passes); peak RAM is O(1 chunk) but disk usage is proportional to input size |
Use file-based Encrypt/EncryptMulti if temp disk overhead is not acceptable |
| Bridge (default mode) loads the decrypted file into memory; the bridge hard-rejects input files larger than 5 GiB | Pass --streaming to decrypt chunks on demand (lower RAM, bounded by an in-process LRU cache), or use decrypt to write a plaintext MCAP and open it in Foxglove Studio directly |
| TypeScript: in-memory only; no LZ4 source support | Use the Go CLI for large files or LZ4 sources |
| Message | What it means | What to do |
|---|---|---|
warning: private key file ... has insecure permissions 0644 |
The private key file is readable by others on the system. The CLI continues, but treat the key as compromised. | chmod 600 mykey.priv.pem |
private key does not match any of the N recipient key(s) in this file |
The private key you passed was not used at encrypt time. | Use a private key whose matching public key was passed via --key during encrypt. |
input is not an encrypted MCAP file (no wrapped key attachment present) |
You ran decrypt on a plain MCAP. There is nothing to decrypt. |
Open the file directly, or check you meant a different file. |
RSA public key is N bits; minimum is 4096 bits |
The public key you provided is shorter than the format requires. | Use mcap-encrypt keygen (which always produces RSA-4096) or generate openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096. |
input file is X.X GiB which exceeds the bridge limit of 5 GiB |
The bridge loads everything into RAM. Above 5 GiB this is likely to OOM. | Run decrypt to write a plaintext MCAP and open it in Foxglove Studio directly. |
| Lost private key | There is no recovery path. The chunks are encrypted with a symmetric key that only the private key can unwrap. | Restore from a backup, or re-encrypt the original from source if available. Always back up .priv.pem files. |
The outer file is a valid MCAP. Standard readers open it and see schemas, channels, and the timeline. Chunk data is opaque without a key.
[magic] [Header] [Schema]* [Channel]* [WrappedKeyAttachment]+
[EncryptedChunk (0x81)]* [EncryptedAttachment (0x82)]* [EncryptedMetadata (0x83)]* [ManifestAttachment] [DataEnd]
[Schema]* [Channel]* [Statistics] [ChunkIndex]* [SummaryOffset]* [Footer]
[magic]
Full binary specification: FORMAT.md.
Issues and PRs welcome at github.com/remete618/mcap-encrypt. Please read CONTRIBUTING.md first.
go test ./... # Go
cd ts && npm test # TypeScript
cd py && pip install -e ".[dev]" && pytest # Python
cd ts && npm run test:interop # cross-language interopTest counts: 85+ Go, 83 TypeScript, 48 Python (44 unit + 4 interop), 4 Go fuzz targets, 5 Python Hypothesis fuzz targets, 8 Go/TypeScript interop tests.
MIT.
Radu Cioplea · radu@cioplea.com · eyepaq.com · github.com/remete618