Skip to content

Feature/0-secure-storage#543

Draft
0xB19 wants to merge 25 commits intodevfrom
feature/secure-storage
Draft

Feature/0-secure-storage#543
0xB19 wants to merge 25 commits intodevfrom
feature/secure-storage

Conversation

@0xB19
Copy link
Copy Markdown
Collaborator

@0xB19 0xB19 commented Mar 27, 2026

No description provided.

@0xB19 0xB19 changed the title Feature/secure storage Feature/secure-storage Mar 27, 2026
@0xB19 0xB19 changed the title Feature/secure-storage Feature/0-secure-storage Mar 30, 2026
@0xB19 0xB19 force-pushed the feature/secure-storage branch 5 times, most recently from 97bf0cf to b5da725 Compare March 31, 2026 14:30
0xB19 and others added 23 commits April 1, 2026 23:25
* feat(bordercrypt): add constants, types, and error foundations

Scaffold the bordercrypt crate with size constants derived from
crypto-aead and pq-rerand, a validated SessionIndex newtype, and
the unified BordercryptError enum.

* feat(bordercrypt): add domain separation strings

Unique labels for every KDF/AEAD context. Block-level functions
write into a caller-provided buffer to avoid per-block allocations.

* feat(bordercrypt): add BlockStorage/KeypairStorage traits and MemoryStorage backend

* feat(bordercrypt): add FsStorage backend for native integration tests

* fix(bordercrypt): pin pq-rerand, validate block file size, add missing tests

- Pin pq-rerand to exact git rev
- Validate block file size is multiple of BLOCK_SIZE
- Add const assertion SessionIndex fits SESSION_COUNT
- Use SESSION_COUNT constant in test instead of hard-coded 5
- Zeroize keypair bytes on read (read_keypair returns Zeroizing<Vec<u8>>)
- Add zeroize dependency
* feat(bordercrypt): add BlockStorage/KeypairStorage traits and MemoryStorage backend

* feat(bordercrypt): add FsStorage backend for native integration tests

* feat(bordercrypt): add KDF and pq-rerand crypto wrappers

- Add derive_block_aead_key (HKDF-SHA256 per-block key derivation)
- Add pq-rerand wrapper: keygen, encrypt, decrypt, rerand
- Add PqPublicKey/PqSecretKey with serialization
- Zeroize all sensitive intermediates and return types
- PqSecretKey: manual Drop zeroize, no Debug/Clone
- Compile-time assert PQ_CT_SIZE == BLOCK_SIZE
- 14 new tests (5 KDF + 9 pq-rerand)

* feat(bordercrypt): use compile-time NTT tables

Replace 4 runtime NttContext::new() calls with a single const NTT_CTX.
Point pq-rerand dep to local clone pending upstream merge.

* refactor(bordercrypt): delegate key serialization to pq-rerand

Bump pq-rerand to 1d5f9d1 which adds ZeroizeOnDrop on SecretKey
and to_bytes/from_bytes on both key types.  Remove the manual Drop
impl, PK_BYTE_SIZE/SK_BYTE_SIZE constants, and field-by-field
serialization loops from pq.rs — the newtypes now forward to
upstream methods.

* test(bordercrypt): add different_domains KDF test

Addresses Copilot review comment on PR #432 — verifies that
different domain strings produce different derived keys.

* chore(bordercrypt): clean up redundant doc comments in pq.rs

Remove size constraint comments (enforced by type signatures),
remove "does not implement Debug/Clone" comment (compiler enforces),
add OsRng justification comment.

* chore(bordercrypt): remove design justification comments

Strip inline doc comments that explain design rationale rather than
documenting API behavior. Design decisions belong in spec docs.

* chore(bordercrypt): add #[must_use] to pq free functions

pq_keygen, pq_encrypt, pq_decrypt, pq_rerand are all pure functions
whose results should never be silently discarded.

* fix(bordercrypt): restore security doc comment on BordercryptError

* fix(bordercrypt): add error message to compile-time size assertion

* fix(bordercrypt): add explicit getrandom/js dep for WASM portability

OsRng delegates to getrandom internally, which needs the `js` feature
on wasm32 to use crypto.getRandomValues. Previously this worked via
the transitive crypto-aead dependency; make it explicit so bordercrypt
is self-contained.
Binary format: [version: u32 BE] [pq_pk] [sk_nonce: 16B] [sk_ct: remaining]
with serialize/deserialize and storage helpers.
Double encryption: AEAD (AES-SIV) for integrity + pq-rerand for
post-quantum deniability. Cover blocks use throwaway AEAD keys.
Password → Argon2id → HKDF → try AEAD-unwrap each session's secret
key in random order. Reads total_data_length from block 0 on success.
- Add Zeroize + ZeroizeOnDrop to UnlockedSession, PqSecretKey, SessionIndex
- Add Zeroize impl for PqPublicKey (needed for UnlockedSession derive)
- Use let-else pattern for fallible operations in unlock loop
- Move sk_wrap_aead_key construction outside the loop
- Use `for i in indices` instead of `for &i in &indices`
- Add ROOT_BLOCK_KEY_SIZE constant (spec alignment)
- Add TODO for decrypt_session_data_block spec gap
…try-all-sessions

- derive_block_aead_key returns (key, block_scope) tuple, internalizes buffer
- encrypt_block/decrypt_block/create_cover_block take aad_root (block scope)
  and append :block_aead internally
- Add ZeroizeOnDrop to PqPublicKey (manual Drop + ZeroizeOnDrop impl)
- Extract decrypt_session_data_block with version check
- unlock_session tries all slots even after match (timing protection)
- Clippy fix: deref in MemoryStorage::write_block
…t BLOCK_AEAD_SUFFIX const

- Inline decrypt_session_data_block into read_total_length in unlock.rs
  (the only caller), removing the duplicate that will exist in read.rs
- Add empty blockstream guard (return 0) for freshly allocated sessions
- Extract ":block_aead" suffix as BLOCK_AEAD_SUFFIX const in block.rs
Implements decrypt_session_data_block, read_total_length, and
read_session_data with cross-block pread semantics.
- Introduced read_total_length_raw to handle decryption of block 0 and version checks.
- Updated unlock_session to utilize read_total_length_raw, improving clarity and reducing redundancy.
- Adjusted slice calculations in read_session_data for better handling of block boundaries.
- Ensured compatibility with freshly allocated sessions by returning 0 when no blocks exist.
- Merge read_total_length_raw + read_total_length into single function
  with block_count == 0 check inside (removes wrapper indirection)
- Remove if/else guard in unlock.rs — handled by read_total_length now
- Rename aead_key → aead_sk to match spec variable name
- Rewrite slice computation with block_end, take_start, take_end
  for 1-to-1 mapping to spec §10.2
The wasm-bordercrypt generated package.json creates a package boundary
inside the source tree, causing Vite to resolve React from the wrong
location in browser tests. resolve.dedupe forces a single React instance.
* ci: increase test thread stack to 16 MiB for pq-rerand NTT operations

* feat(bordercrypt): add shrink_session_data (spec §14)

Shrink session data to a new total, converting freed blocks to cover
blocks indistinguishable from blocks allocated by other sessions.
All touched block indices are updated across ALL sessions in randomized
order for snapshot resistance.

* refactor(bordercrypt): align shrink with spec — block_scope, Zeroizing coercion

- Fix shrink_session_data: use block_scope instead of block_aead_aad
  for cover blocks (create_cover_block appends :block_aead internally)
- Fix Zeroizing<[u8; N]> coercion: use .as_ref() for copy_from_slice

* refactor(bordercrypt): align write path with spec — timing uniformity, naming

- Compute read_session_version_and_pk and block_scope unconditionally
  for all sessions in encrypt/extend/shrink loops (spec §11.2, §12.3, §14.3)
- Rename end_pos → end_pos_excl to mirror spec variable names
- Remove shrink new_total==0 special case — always try decrypt first per spec §14.3
- Deduplicate read_total_length into shared pub(crate) read_total_length_raw
- Use .saturating_sub() / .min() for idiomatic slice bounds

* refactor(bordercrypt): align write.rs variable names with spec

Rename variables to match spec naming conventions:
- aead_key → aead_sk (§11.2, §12.3)
- buf → cur_aad_root (§11.2, §12.2, §12.3, §14.3)
- existing_ct → cur_ct (§11.2, §14.3)
- block_start/block_end → block_start_pos/block_end_pos (§13.2)
- for &i in &indices → for i in indices (Rust best practices)

Also update read_total_length call sites in tests to match
the unified 6-arg signature from the read-path refactor.

* chores(bordercrypt): cargo fmt

* fix(bordercrypt): handle empty writes that extend logical length (spec §13.2)

The data.is_empty() early return was a spec deviation: calling
write_session_data(offset=1000, data=b"") should extend
total_data_length to 1000, allocate blocks, and update the
block 0 length header. The naive early return skipped all of that.

Split into two cases:
- empty && new_total == old_total: true no-op
- empty && new_total > old_total: allocate blocks, update header

* fix(bordercrypt): address PR #467 review — spec alignment, expect messages, edge cases

- .expect() messages now describe what went wrong, not what was expected (10 instances)
- Add spec comment: "Genuine block content is random padding; header is set only for block 0."
- Add atomicity note to encrypt_session_data_block doc comment
- Add assert!(w_start < w_end) per spec
- Empty writes are now always no-ops (remove ftruncate semantics not in spec)
- Add 5 boundary edge-case tests for required_last_block calculation
* feat(bordercrypt): add lifecycle and cover traffic

Implements provision_storage (non-unlockable slots), allocate_session
(password-protected slot creation), and cover_traffic_tick
(periodic rerandomization for activity masking).

* refactor(bordercrypt): align lifecycle with spec — block_scope, field names, empty session

- Fix cover_traffic_tick: use block_scope instead of block_aead_aad
  (create_cover_block appends :block_aead internally)
- Fix allocate_session: use pq_rerand_pk/pq_rerand_sk field names
- Handle freshly allocated sessions with no blocks in read_total_length

* fix(bordercrypt): timing uniformity and style fixes in lifecycle.rs

- Hoist block_scope before match in cover_traffic_tick (spec §15 requires
  unconditional computation for timing uniformity)
- Replace .expect() with ? for SessionIndex::new in prod code
- Use `for i in indices` instead of `for &i in &indices` (Copy types)
- Wrap two_sessions_different_passwords test in larger stack thread

* refactor(bordercrypt): align cover_traffic_tick variable names with spec

Rename buf → cur_aad_root and existing_ct → cur_ct to mirror §15
pseudocode variable names.

* chores(bordercrypt): cargo fmt

* fix(bordercrypt): zeroize serialized secret key in allocate_session

Wrap pq_rerand_sk.to_bytes() in Zeroizing<> to prevent serialized
secret key bytes lingering in freed heap memory.

* fix(bordercrypt): use random bytes for provisioned sk_ct

Replace AEAD encrypt under throwaway key with pure random bytes for
sk_nonce and sk_ct in provision_storage. Add init_blockstream() to
BlockStorage trait for explicit blockstream creation at provisioning.

* Update wasm/bordercrypt/src/storage.rs

Co-authored-by: Soulthym <thybault.alabarbe@gmail.com>

* fix(bordercrypt): use AEAD encryption for provisioned sk_ct, not random bytes

Pure random bytes are structurally distinguishable from real AEAD
ciphertext (different size due to auth tag), breaking plausible
deniability. Use actual AEAD encryption under an ephemeral throwaway
key so decoy slots are indistinguishable from allocated ones.

Reverts the approach from d1670b9 while keeping init_blockstream.

* refactor(bordercrypt): extract derive_session_keys and KeypairFile::build_wrapped helpers

Extract duplicated password KDF flow into kdf::derive_session_keys()
and AEAD wrapping into KeypairFile::build_wrapped(), reducing ~110
lines of repeated crypto boilerplate across lifecycle, unlock, and
test helpers. Also fix pre-existing test stack overflows from ML-KEM
by adding run_with_stack() helper uniformly across all PQ-heavy tests.

---------

Co-authored-by: Soulthym <thybault.alabarbe@gmail.com>
9 end-to-end scenarios: happy path, session isolation, snapshot
resistance, cover traffic preservation, corruption healing,
blockstream alignment, brute force resistance, and re-unlock.
Add run() helper that spawns every test on a 4 MiB thread, fixing
stack overflow on the default thread stack from PQ crypto operations.
- keypair file sizes indistinguishable (provisioned vs allocated)
- re-allocating a slot invalidates the old password
- shrink then grow past original size
- all SESSION_COUNT slots allocated and independently readable
- cover traffic touches all sessions (50 ticks, not just "at least one")
@0xB19 0xB19 force-pushed the feature/secure-storage branch from b5da725 to 3df21d4 Compare April 1, 2026 16:25
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.

2 participants