Skip to content

feat(TC-887): TCE1 segmented AES-256-GCM envelope - #322

Merged
samgbafa merged 8 commits into
mainfrom
skgbafa/tc-887-at-rest-envelope
Oct 17, 2026
Merged

samgbafa merged 8 commits into
mainfrom
skgbafa/tc-887-at-rest-envelope

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Part of TC-887 (encryption at rest). Follows #320 (merged). origin/main is merged in (3122c0a), so the diff vs main is the envelope only.

What changed

New code in tinycloud-core/src/at_rest/ only. Nothing in the node calls it; no config, schema, storage or API change.

  • envelope.rs: the TCE1 format. Header fields are documented as unverified hints until a segment decrypts.
    • Bytes 0..5 = "TCE" ‖ ASCII digit g ‖ byte g identify the format generation (format_version); a well-formed later generation is EnvelopeError::Unsupported, not a header/integrity error. EnvelopeError is #[non_exhaustive] (and DataKeyError).
    • 64-byte header: TCE1, version 1, algorithm 1, flags, dek_id (u32), plaintext_len (u64), 32-byte random salt, segment size (2^16), zero padding. Header::parse refuses any unknown version, algorithm, flag, padding or segment size.
    • Body: 64 KiB plaintext segments, each AES-256-GCM ct ‖ tag16. Nonce = u32 BE index ‖ last-flag ‖ 7 zero bytes. Per-blob key = HKDF-SHA256(DEK, salt, SHA-256(context)). AAD = context ‖ header.
    • Context = length-prefixed fields per BlobIdentity: KvBlock {space, content_hash}, SqlCheckpoint {service, space, name, checkpoint_content_hash, checkpoint_size_bytes}, SqlWal {service, space, name, revision, checkpoint_content_hash, delta_content_hash}.
    • seal / open on a Vec<u8>, in place. open requires the header's dek_id to match the key and the length to be exact, and never returns partial plaintext.
    • ciphertext_len / plaintext_len size conversions (the second for sizing sealed objects from a listing).
  • stream.rs: EncryptReader / DecryptReader over futures::io::AsyncRead, one segment buffered at a time. EncryptReader seals the final segment only after the source reports EOF, so input longer than declared fails before a complete object is yielded. Header::map_range(ByteRangeSpec) resolves against the plaintext length and returns the ciphertext span to fetch; DecryptReader::range decrypts and trims it.
  • names.rs: PayloadEncoding (NULL = legacy, 1 = TCE1, 2..=255 = Unsupported, anything else refused) and sealed_object_name / space_tag (v1/{space_tag}/{object_tag}, HMAC-SHA256 under the object-name key). Legacy vs sealed is decided by name namespace or column, never from the bytes; open on non-TCE1 bytes is an error.
  • testdata/gen_vectors.py + vectors.json: an independent Python implementation (pyca cryptography) written from the format spec, and the vectors it produces: seal vectors, an object-name vector, length conversions, 402 range mappings and 15 negative vectors that the Python opener itself rejects.
  • tinycloud-node-server/tests/did_resolution_single_entry.rs: the resolver guard's allowlist gains the one ByteRangeSpec::resolve call in at_rest/stream.rs::map_range (same treatment as the storage backends' range calls).
  • tinycloud-core gains direct deps hkdf 0.12, hmac 0.12 (both already in Cargo.lock) and dev-dep proptest 1; aes-gcm is raised to 0.10.3 with its zeroize feature, and aes gets zeroize (AES key schedule wiped on drop; the GHASH key, hkdf PRK and hmac state are not, noted in the manifest). test/m1-realdata-e2e/Cargo.lock refreshed.
  • Test-only helpers that take a caller-chosen header (seal_with_header, EncryptReader::with_header) are #[cfg(test)].

How it's tested

cargo test -p tinycloud-core --lib at_rest: 30 tests (3 from #320).

  • Known answers: 8 vectors (empty, 1 byte, short, 64 KiB − 1, 64 KiB, 64 KiB + 1, 3-segment SQL checkpoint, WAL with dek_id = u32::MAX) and one object-name vector, all produced by the Python reference. Rust seal, EncryptReader, open and DecryptReader must reproduce or invert them byte for byte.
  • Properties (proptest): seal/open round trip at segment edges; streaming output equals whole-buffer output under random read sizes with Pending interleaved; plaintext_len(ciphertext_len(p)) == p and the converse; random Inclusive/From/Suffix ranges across segment edges equal the plaintext slice, and unsatisfiable exactly when ByteRangeSpec::resolve(plaintext_len) is; any single-byte flip anywhere is rejected by open and the stream.
  • Rejection: dropped last segment(s), header-only, cut tag, cut mid-segment and one trailing byte (whole-buffer, stream and range read); swapped, duplicated and last-into-middle segments; a segment or header spliced from another blob with the same key, identity and plaintext; every identity field changed (space, hash, name, service, size, revision, base and delta hash, purpose, bytes moved between adjacent fields); wrong key bytes and wrong dek_id; each malformed header byte; legacy SQLite/plain bytes; EncryptReader input shorter or longer than declared, and over-long input never releases the final segment (over_long_input_never_yields_the_final_segment, declared 0 … 2×64 KiB, read sizes 1/3/64 KiB+100; failed before the fix with 80 of 80 bytes released for declared 0); a SegmentRange from another header.
  • Reference vectors beyond seal: length_vectors_match_reference (0 … 2^48, 2^48+1, u64::MAX; invalid ciphertext lengths), range_vectors_match_reference (402 specs over 7 lengths incl. 0 and k·64Ki±1, each satisfiable one also decrypted), negative_vectors_are_refused_like_the_reference (truncations, tag/body/salt/length flips, swapped segments, trailing byte, TCE2 header → same error kind whole-buffer and streaming).
  • Review round: edge_biased_ranges_equal_plaintext_slice (lengths and endpoints at k·64Ki±1 and len±1), empty_blob_has_no_satisfiable_range, failing_segment_releases_nothing_and_errors_stick (output before the error is exactly the verified prefix, whole and range; next read fails), range_with_trailing_bytes_is_refused, size_limits_at_two_to_the_48, later_generations_are_unsupported_not_corrupt.
  • Also run with PROPTEST_CASES=1500 at d327906: 30/30 pass (138 s).
  • cargo clippy -p tinycloud-core --all-targets -- -D warnings and cargo fmt --check: clean.
  • Regenerate vectors: python3 tinycloud-core/src/at_rest/testdata/gen_vectors.py > tinycloud-core/src/at_rest/testdata/vectors.json (no diff).

Merge order: after the current main release is cut (TC-887 plan §7).

Adds tinycloud_core::at_rest with DekId, DataKey and ObjectNameKey
(zeroized on drop, redacted Debug), the DataKeyProvider trait with
DataKeyError, and a deterministic FixedKeyProvider for tests.
Nothing in the node uses the module yet.
@samgbafa

Copy link
Copy Markdown
Contributor Author

REQUEST_CHANGES: see TC-887 (reviewed head 641e2fe)

FixedKeyProvider moves to at_rest/testing.rs, compiled only for this
crate's tests or with the new test-util feature. Its Debug no longer
prints the seed. Refresh test/m1-realdata-e2e/Cargo.lock for zeroize.
Adds the at_rest envelope: 64-byte TCE1 header, 64 KiB segments with
counter nonces and a last-segment flag, per-blob HKDF-SHA256 key from the
DEK and a random salt, AAD binding to the blob identity (kv-block,
sql-checkpoint, sql-wal) plus the header. Whole-buffer seal/open in
place, streaming EncryptReader/DecryptReader, plaintext-to-segment Range
mapping, PayloadEncoding column flag, and NK-derived sealed object names.
Known-answer vectors come from an independent Python reference.
Nothing in the node calls the module yet.
EncryptReader now probes the source for EOF before sealing the final
segment, so over-long input fails before a complete object is yielded.
Enable aes/aes-gcm zeroize (aes-gcm >= 0.10.3). seal_with_header and
EncryptReader::with_header are test-only. Document that header fields
are unverified until a segment decrypts. Refresh the m1 lockfile.
@samgbafa
samgbafa force-pushed the skgbafa/tc-887-at-rest-envelope branch from 641e2fe to 7050fb8 Compare October 17, 2026 06:27
@samgbafa
samgbafa changed the base branch from skgbafa/tc-887-at-rest-keys to main October 17, 2026 06:27
@samgbafa

Copy link
Copy Markdown
Contributor Author

REQUEST_CHANGES: see TC-887 (reviewed head 7050fb8)

Add EnvelopeError::Unsupported for a well-formed later TCE generation
(format_version dispatches on the first 5 bytes) and for payload
encodings 2..=255. Mark EnvelopeError and DataKeyError non_exhaustive.
seal reserves exactly ciphertext_len; DecryptReader trims without
32-bit overflow; docs say any segment authenticates the header and
readers hold two segment buffers.

Tests: edge-biased range proptest, empty-blob ranges, no output from a
failing segment and sticky errors, range with trailing bytes, the 2^48
size limits, later generations. The Python reference now also emits
length, range-mapping and negative vectors (checked by its own opener).
…lver guard

SegmentRange keeps the ResolvedByteRange from map_range, so map_range
holds the module's only ByteRangeSpec::resolve call; add that exact
call to did_resolution_single_entry's allowlist like the storage
backends' range calls.
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-887 (reviewed head 9966fe2)

Correct the aes-gcm zeroize comment: polyval keeps H without a Drop,
listed as an accepted residual with the hkdf PRK and hmac state. Note
on EnvelopeError::Unsupported that callers must fail closed.
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-887 (head a018b5ec9051d904eac54668c60c76ab81d9c402; Linear comment ab751767)

# Conflicts:
#	tinycloud-core/Cargo.toml
#	tinycloud-core/src/at_rest/key.rs
#	tinycloud-core/src/at_rest/mod.rs
#	tinycloud-core/src/at_rest/provider.rs
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-887 (head 3122c0a)

@samgbafa
samgbafa merged commit ecf88fa into main Oct 17, 2026
20 checks passed
samgbafa added a commit that referenced this pull request Oct 17, 2026
…kfiles in CI (#332)

* fix(TC-887): refresh the w5 lockfile and check separate workspace lockfiles in CI

test/w5-policy-runtime-node-e2e/Cargo.lock lacked tinycloud-core's new
aes, hkdf and zeroize dependencies and the aes/aes-gcm zeroize features
(#320, #322), so cargo --locked failed there. Regenerated minimally.

CI built only the main workspace and m1-realdata-e2e. A new lockfiles
job resolves every fetchable workspace with --locked and runs
scripts/ci/check-separate-lockfiles.py, which checks the w5 lockfile
(its private git dependency is not fetchable in CI) for in-repo crates'
direct dependencies and requested dependency features.

* ci(TC-887): drop the hand-rolled w5 lockfile checker

Keep the w5 lockfile fix and the --locked resolves of main, m1 and
test/load/signer. w5 coverage follows separately with real cargo.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant