Skip to content

feat(chained): encrypt u128/i128 on the chained scheme; drop the panicking legacy impls - #113

Merged
coderdan merged 7 commits into
mainfrom
fix/u128-chained
Oct 19, 2026
Merged

coderdan merged 7 commits into
mainfrom
fix/u128-chained

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Closes #105.

Problem

OreEncrypt for u128/i128 on OreAes128 has panicked on every call since it was added (ad90f0b): the legacy left-tag input puts the block index at byte N of one AES block, so the scheme takes at most 15 bytes. At 22 six-bit blocks the types do not fit OreAes128Bit6's 14-block packed prefix either. The chained scheme's CMAC accumulator has no length cap, so they go there, as the v2 plan intended (cmac spec §5, architecture doc "not yet in").

Changes

Chained scheme (scheme/chained.rs)

  • OreEncrypt<OreAes128Bit6Chained<R>> for u128 and i128. Outputs are ChainedCipherText<22> / ChainedLeft<22> (U128_BLOCKS, I128_BLOCKS): the same bytes as a Var* of that length, with the block count in the type. Their OreOutput::from_slice, TryFrom<Var*> and typed compare_raw_slices / compare_left_to_full accept only that count, so the typed path never orders an integer ciphertext against a string (spec §7 length rejection). The untyped comparators still order them lexicographically.
  • encrypt_fixed / encrypt_left_fixed for any FixedOrderableBytes type (the route Decimal can take later), and VarLeft::from_slice.
  • Wire vectors for both types pinned in tests/compat_chained_vectors.rs. The existing vectors are byte-identical.

OreEncrypt<T> drops its T: OreCipher bound so the chained scheme (single key, no const N) can implement it. Existing impls and callers are unaffected.

Legacy scheme (scheme/bit2.rs, encrypt.rs)

  • The u128/i128 impls are removed. The impl macro asserts ENCODED_LEN <= bit2::MAX_BLOCKS (15) at compile time, so a type too wide for the scheme cannot be given an impl again. Verified: re-adding u128 fails with assertion failed: U128_LEN <= MAX_BLOCKS.
  • encrypt / encrypt_left return OreError::TooManyBlocks for N > 15 instead of indexing out of bounds.

Tests per impl. Every OreEncrypt impl macro (legacy, Bit6, chained) now takes $lo, $hi and emits a round-trip, ordering and equality-across-nonces test for the type it implements, so a type cannot be listed as supported without being exercised (the gap the issue identified).

Cost (chained, per docs/benchmarks/2026-06-15-chained-results.md)

u128 / i128
blocks 22
encrypt ~15 µs (0.69 µs/block)
full ciphertext 570 bytes
left only 378 bytes

Notes for review

  • 0u128 and i128::MIN encrypt to identical bytes under one key: the orderable-bytes encodings coincide. The legacy and Bit6 schemes have the same property for 64-bit types. The block count in the type guards against mixing with strings, not against mixing the two 128-bit types; the comparator doc says so. Type-level domain separation is a separate discussion.
  • feat(chained): pad strings to a length bucket (powers of two, 16-byte floor) #110 (padding) also edits chained.rs. encrypt_fixed calls the internal encrypt_blocks directly, so it must stay on the unpadded path through that rebase.

Verification

cargo test -p ore-rs --all-features    165 lib + 48 integration + 6 doctests pass
cargo clippy --all-features --all-targets    clean
cargo doc    same 6 pre-existing warnings as main, none in new code

@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto main (#107 added a rustdoc gate to CI). The module docs on scheme::chained used bare intra-doc links, which rustdoc resolves in the parent module's scope when outer and inner docs are combined; now absolute paths. RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features passes locally along with the rest of the CI sequence.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Fixed-value encryptors construct potentially oversized stack encodings before checking the wire-length limit.

2 open findings
What changed in this PR

Adds working u128/i128 encryption through the chained scheme, addressing #105’s legacy encryption panics.

Changes:

  • Adds block-count-typed chained outputs and fixed-value encryptors.
  • Removes unsupported legacy implementations and rejects oversized plaintexts.
  • Adds per-type tests, pinned wire vectors, and updated documentation.
File Description
packages/​ore-rs/​tests/​compat_chained_vectors/​vectors.rs Pins 128-bit integer ciphertext bytes.
packages/​ore-rs/​tests/​compat_chained_vectors.rs Tests integer vectors, parsing, and comparisons.
packages/​ore-rs/​src/​scheme/​chained.rs Adds fixed-value encryption and typed outputs.
packages/​ore-rs/​src/​scheme/​bit2.rs Rejects plaintexts exceeding 15 bytes.
packages/​ore-rs/​src/​scheme/​bit2_w6.rs Adds per-type tests and updates support documentation.
packages/​ore-rs/​src/​lib.rs Documents chained support for 128-bit integers.
packages/​ore-rs/​src/​encrypt.rs Relaxes the trait bound and removes panicking implementations.
packages/​ore-rs/​README.md Updates supported types and scheme guidance.
packages/​ore-rs/​CHANGELOG.md Records API additions and legacy fixes.
docs/​plans/​2026-06-12-ore-v2-architecture.md Updates chained implementation status.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/ore-rs/src/scheme/chained.rs
Comment thread packages/ore-rs/src/scheme/chained.rs Outdated
coderdan added a commit that referenced this pull request Oct 16, 2026
…; say what the count guards

Review of #113 (Copilot):

- `encrypt_fixed` / `encrypt_left_fixed` made the owned encoding before
  `bytes_to_blocks` could refuse it, and `[u8; N]` is a
  `FixedOrderableBytes` for any `N`, so a wide type copied its whole
  encoding onto the stack before returning `TooManyBlocks`. Both now
  refuse `T::ENCODED_LEN > MAX_STR_BYTES` first, from the type alone.
- The module docs, `encrypt_fixed` docs and README said the typed outputs
  keep an integer from being compared against a string. They pin the block
  count, which is a length guard: a 16-byte string and an `i128` are 22
  blocks too, and under one key equal encodings encrypt identically. The
  docs now say so and tell callers to use one key per column.
…cking legacy impls

`OreEncrypt` for `u128`/`i128` on `OreAes128` has panicked on every call since it
was added (ad90f0b): the legacy left-tag input puts the block index at byte `N`
of one AES block, so the scheme takes at most 15 bytes. At 22 six-bit blocks the
types do not fit `OreAes128Bit6`'s 14-block packed prefix either. The chained
scheme's CMAC accumulator has no length cap, so they go there.

Chained scheme:
- `OreEncrypt<OreAes128Bit6Chained<R>>` for `u128` and `i128`. Outputs are
  `ChainedCipherText<22>` / `ChainedLeft<22>` (`U128_BLOCKS`, `I128_BLOCKS`): the
  same bytes as a `Var*` of that length, with the block count in the type. Their
  `OreOutput::from_slice`, `TryFrom<Var*>` and typed `compare_raw_slices` /
  `compare_left_to_full` accept only that count, so the typed path never orders
  an integer ciphertext against a string (spec §7). The untyped comparators still
  order them lexicographically.
- `encrypt_fixed` / `encrypt_left_fixed` for any `FixedOrderableBytes` type, and
  `VarLeft::from_slice`.
- Wire vectors for both types pinned in `tests/compat_chained_vectors.rs`; the
  existing vectors are unchanged.

`OreEncrypt<T>` drops its `T: OreCipher` bound so the chained scheme (single key,
no const `N`) can implement it. Existing impls and callers are unaffected.

Legacy scheme:
- The `u128`/`i128` impls are removed and the impl macro asserts
  `ENCODED_LEN <= bit2::MAX_BLOCKS` (15) at compile time, so a type too wide for
  the scheme cannot be given an impl again.
- `encrypt` / `encrypt_left` return `OreError::TooManyBlocks` for `N > 15`
  instead of indexing out of bounds.

Every `OreEncrypt` impl macro (legacy, Bit6, chained) now emits a round-trip,
ordering and equality-across-nonces test for the type it implements, so a type
cannot be listed as supported without being exercised.

Closes #105.

Rebased over #110 (chained padding and the string ceiling). The fixed-length
path stays unpadded: a value of one type always has the same length, so there
is nothing to hide, and the cipher's `Options` (padding policy, string
ceiling) do not apply to it. Its ciphertexts carry the `Padding::None` tag in
the 7-byte header; `ChainedCipherText` / `ChainedLeft` accept only that tag
and the pinned count, so a padded string of the same block count is refused
by the typed path as well as by the policy check in the untyped comparators.
The `u128`/`i128` wire vectors are re-pinned for the padded header.
…; say what the count guards

Review of #113 (Copilot):

- `encrypt_fixed` / `encrypt_left_fixed` made the owned encoding before
  `bytes_to_blocks` could refuse it, and `[u8; N]` is a
  `FixedOrderableBytes` for any `N`, so a wide type copied its whole
  encoding onto the stack before returning `TooManyBlocks`. Both now
  refuse `T::ENCODED_LEN > MAX_STR_BYTES` first, from the type alone.
- The module docs, `encrypt_fixed` docs and README said the typed outputs
  keep an integer from being compared against a string. They pin the block
  count, which is a length guard: a 16-byte string and an `i128` are 22
  blocks too, and under one key equal encodings encrypt identically. The
  docs now say so and tell callers to use one key per column.
coderdan added a commit that referenced this pull request Oct 16, 2026
The fixed-N v2 scheme took two keys while the chained scheme took one
(issue #89), and neither took a salt, so a caller keying several columns
from one key had no way to separate them: under one key, `0u64` and
`i64::MIN` (equal encodings) encrypt to identical bytes. vitaminc-ore
(vitaminc#455, ADR 0002) binds a per-column salt and asks a scheme without
a salt of its own to derive its keys under it.

Both v2 schemes now have `init(key)` and `init_with_salt(key, salt)`:

    k_root = AES_key( ROOT_LABEL )                             only use of `key`
    k_salt = CMAC_{k_root}( len(salt):u64 ‖ salt ‖ 0… ‖ "ORE.v2.salt.done" )
    Bit6:    prf1 = AES_{AES_{k_salt}("ORE.v2.bit6.prf1")},  prf2 likewise
    chained: k_acc = AES_{k_salt}("ORE.v2.chain.acc")

Root labels (`ORE.v2.bit6.root`, `ORE.v2.chainroot`) end in `'t'`, outside
every legacy PRF input's byte 15, so a key shared with the legacy scheme
never publishes a root key; the caller's key is evaluated on nothing else.
The salt is absorbed as full blocks of the existing CMAC chain behind a
length prefix, so `k_salt` is a CMAC tag over a prefix-free message.
`init(key)` is the empty salt. All at init time. Code: `scheme/keys.rs`.

Nothing on the wire records the salt, so the comparators cannot refuse a
cross-salt pair; both constructors say so. There is no per-type separation
inside one salt: an earlier revision of this branch added a per-type subkey
and a header type tag, withdrawn because vitaminc-ore's domain-and-context
salt is strictly stronger and the ADR places type identity there.

`OreCipher::init(k1, k2)` leaves the trait. The legacy scheme keeps the
same signature as an inherent constructor (frozen wire, raw keys); Bit6
loses its second key. Every call site (tests, benches, examples, docs,
ct-taint, ct-dudect, vector harnesses) is updated.

Bit6 and chained vectors are re-pinned (both formats unfrozen; the layout
is unchanged, only the keys); the legacy vectors are byte-identical. CMAC
spec §3, the architecture plan and the crypto review brief (§5A, A5)
describe the stages and what needs scrutiny.

Closes #89.

Rebased over #110 and the rebased #113. On the chained scheme the salt is a
field of `Options` (`Options::with_salt`, `Options::salt`), beside the padding
policy and the ceiling, so `init_with(key, options)` is the one constructor
that takes everything a column needs; `init_with_salt(key, salt)` remains as
the convenience that mirrors `OreAes128Bit6`. `Options` holds the salt as an
owned `Vec<u8>`, so it is `Clone` but no longer `Copy`, and `options()`
returns a reference. The chained vectors are re-pinned once more, for the
salted derivation under the padded header.
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #110 merged (now 730220e). Two decisions in the merge, both in the first commit's message:

  • The fixed-length path (encrypt_fixed, the u128/i128 impls) stays unpadded and ignores the cipher's Options: every value of a type has the same length, so there is nothing to hide, and the string ceiling is a cost guard for caller-supplied strings. Its ciphertexts carry the Padding::None tag in feat(chained): pad strings to a length bucket (powers of two, 16-byte floor) #110's 7-byte header.
  • ChainedCipherText<N> / ChainedLeft<N> accept only that tag along with the pinned count, so a padded string of 22 blocks is refused by the typed path too (the untyped comparators already refuse it on the policy tag). An unpadded 16-byte string still passes: the count is a length guard, as the docs say.

The u128/i128 vectors are re-pinned for the padded header; the string vectors from #110 are unchanged.

@coderdan
coderdan requested a review from auxesis October 16, 2026 08:29
@coderdan
coderdan added this pull request to stack #116 October 16, 2026 08:29
coderdan added a commit that referenced this pull request Oct 17, 2026
…feature

ADR 0002 in cipherstash/stack puts vitaminc at the bottom of the order
index: vitaminc-ore decides the encoding of every kind of value, seals it
as an OrderPlaintext only it can build, and defines OrderScheme, which the
scheme crates implement. This is the trial run of that trait here, against
vitaminc's feat/ore-crate branch, to be pointed at the published release
when it lands.

Two schemes implement it. BlockOre is the frozen fixed-width scheme at
each kind's own width, so an i32 is four blocks and a decimal fourteen; it
refuses text, bytes and the 16-byte kinds by domain, since its block cap
is 15. ChainedBlockOre is the variable-length scheme and takes every kind,
through a new encrypt_bytes entry point beside encrypt_str: an order
plaintext is bytes already encoded so byte order is value order, not a
string. Neither scheme takes a salt, so each derives its keys per term
with HMAC-SHA256 under the salt vitaminc binds, and a term compares only
within its scheme, key, domain and context. Terms keep the serialised
ciphertext and compare with the scheme's own comparator, so the nonce in
the right half does not make equal values unequal.

Off by default: cipherstash-client takes no vitaminc dependency and the
typed API is unchanged. The lockfile unlocks the proc-macro and
wasm-bindgen families the vitaminc crates need.

Restacked on #109 and #114 (2026-10-17), which folds in the resolution
the dropped merge of main used to carry. The six-bit scheme now takes one
16-byte key (#114), so it is keyed by the first half of its derived
material, as the chained scheme already was. The chained scheme's padded
decomposition is padded_bytes_to_blocks, which str_to_blocks and
encrypt_bytes share; bytes_to_blocks stays the unpadded one the
fixed-length path (#113) uses.
@coderdan
coderdan requested a balanced review from Copilot October 18, 2026 11:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Accepted large arrays can leave plaintext copies outside the stack scrub’s coverage in both fixed-length encryptors.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread packages/ore-rs/src/scheme/chained.rs
…copied past the scrub

`encrypt_fixed` and `encrypt_left_fixed` called `to_fixed_orderable_bytes`,
which copies a `[u8; N]` onto the stack. Up to `MAX_STR_BYTES` (48 KiB) is
accepted, more than the 16 KiB the optimised stack scrub zeroes, so most of
the plaintext stayed in dead stack. Both now take `to_orderable_bytes`,
which borrows a byte array and gives the same bytes; integers still build
their 16 bytes inside the scrub.

Residue tests encrypt a `MAX_STR_BYTES` array from the heap through each
entry point and search the dead stack for it, with a positive control
showing the owned copy survives the scrub in optimised builds. Both fail
against the previous code under `--release`.
coderdan added a commit that referenced this pull request Oct 18, 2026
The fixed-N v2 scheme took two keys while the chained scheme took one
(issue #89), and neither took a salt, so a caller keying several columns
from one key had no way to separate them: under one key, `0u64` and
`i64::MIN` (equal encodings) encrypt to identical bytes. vitaminc-ore
(vitaminc#455, ADR 0002) binds a per-column salt and asks a scheme without
a salt of its own to derive its keys under it.

Both v2 schemes now have `init(key)` and `init_with_salt(key, salt)`:

    k_root = AES_key( ROOT_LABEL )                             only use of `key`
    k_salt = CMAC_{k_root}( len(salt):u64 ‖ salt ‖ 0… ‖ "ORE.v2.salt.done" )
    Bit6:    prf1 = AES_{AES_{k_salt}("ORE.v2.bit6.prf1")},  prf2 likewise
    chained: k_acc = AES_{k_salt}("ORE.v2.chain.acc")

Root labels (`ORE.v2.bit6.root`, `ORE.v2.chainroot`) end in `'t'`, outside
every legacy PRF input's byte 15, so a key shared with the legacy scheme
never publishes a root key; the caller's key is evaluated on nothing else.
The salt is absorbed as full blocks of the existing CMAC chain behind a
length prefix, so `k_salt` is a CMAC tag over a prefix-free message.
`init(key)` is the empty salt. All at init time. Code: `scheme/keys.rs`.

Nothing on the wire records the salt, so the comparators cannot refuse a
cross-salt pair; both constructors say so. There is no per-type separation
inside one salt: an earlier revision of this branch added a per-type subkey
and a header type tag, withdrawn because vitaminc-ore's domain-and-context
salt is strictly stronger and the ADR places type identity there.

`OreCipher::init(k1, k2)` leaves the trait. The legacy scheme keeps the
same signature as an inherent constructor (frozen wire, raw keys); Bit6
loses its second key. Every call site (tests, benches, examples, docs,
ct-taint, ct-dudect, vector harnesses) is updated.

Bit6 and chained vectors are re-pinned (both formats unfrozen; the layout
is unchanged, only the keys); the legacy vectors are byte-identical. CMAC
spec §3, the architecture plan and the crypto review brief (§5A, A5)
describe the stages and what needs scrutiny.

Closes #89.

Rebased over #110 and the rebased #113. On the chained scheme the salt is a
field of `Options` (`Options::with_salt`, `Options::salt`), beside the padding
policy and the ceiling, so `init_with(key, options)` is the one constructor
that takes everything a column needs; `init_with_salt(key, salt)` remains as
the convenience that mirrors `OreAes128Bit6`. `Options` holds the salt as an
owned `Vec<u8>`, so it is `Clone` but no longer `Copy`, and `options()`
returns a reference. The chained vectors are re-pinned once more, for the
salted derivation under the padded header.
coderdan added a commit that referenced this pull request Oct 18, 2026
…feature

ADR 0002 in cipherstash/stack puts vitaminc at the bottom of the order
index: vitaminc-ore decides the encoding of every kind of value, seals it
as an OrderPlaintext only it can build, and defines OrderScheme, which the
scheme crates implement. This is the trial run of that trait here, against
vitaminc's feat/ore-crate branch, to be pointed at the published release
when it lands.

Two schemes implement it. BlockOre is the frozen fixed-width scheme at
each kind's own width, so an i32 is four blocks and a decimal fourteen; it
refuses text, bytes and the 16-byte kinds by domain, since its block cap
is 15. ChainedBlockOre is the variable-length scheme and takes every kind,
through a new encrypt_bytes entry point beside encrypt_str: an order
plaintext is bytes already encoded so byte order is value order, not a
string. Neither scheme takes a salt, so each derives its keys per term
with HMAC-SHA256 under the salt vitaminc binds, and a term compares only
within its scheme, key, domain and context. Terms keep the serialised
ciphertext and compare with the scheme's own comparator, so the nonce in
the right half does not make equal values unequal.

Off by default: cipherstash-client takes no vitaminc dependency and the
typed API is unchanged. The lockfile unlocks the proc-macro and
wasm-bindgen families the vitaminc crates need.

Restacked on #109 and #114 (2026-10-17), which folds in the resolution
the dropped merge of main used to carry. The six-bit scheme now takes one
16-byte key (#114), so it is keyed by the first half of its derived
material, as the chained scheme already was. The chained scheme's padded
decomposition is padded_bytes_to_blocks, which str_to_blocks and
encrypt_bytes share; bytes_to_blocks stays the unpadded one the
fixed-length path (#113) uses.
@coderdan
coderdan requested a balanced review from Copilot October 18, 2026 11:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new cryptographic entry points and plaintext-handling paths warrant final human security review.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

coderdan added a commit that referenced this pull request Oct 18, 2026
…feature

ADR 0002 in cipherstash/stack puts vitaminc at the bottom of the order
index: vitaminc-ore decides the encoding of every kind of value, seals it
as an OrderPlaintext only it can build, and defines OrderScheme, which the
scheme crates implement. This is the trial run of that trait here, against
vitaminc's feat/ore-crate branch, to be pointed at the published release
when it lands.

Two schemes implement it. BlockOre is the frozen fixed-width scheme at
each kind's own width, so an i32 is four blocks and a decimal fourteen; it
refuses text, bytes and the 16-byte kinds by domain, since its block cap
is 15. ChainedBlockOre is the variable-length scheme and takes every kind,
through a new encrypt_bytes entry point beside encrypt_str: an order
plaintext is bytes already encoded so byte order is value order, not a
string. Neither scheme takes a salt, so each derives its keys per term
with HMAC-SHA256 under the salt vitaminc binds, and a term compares only
within its scheme, key, domain and context. Terms keep the serialised
ciphertext and compare with the scheme's own comparator, so the nonce in
the right half does not make equal values unequal.

Off by default: cipherstash-client takes no vitaminc dependency and the
typed API is unchanged. The lockfile unlocks the proc-macro and
wasm-bindgen families the vitaminc crates need.

Restacked on #109 and #114 (2026-10-17), which folds in the resolution
the dropped merge of main used to carry. The six-bit scheme now takes one
16-byte key (#114), so it is keyed by the first half of its derived
material, as the chained scheme already was. The chained scheme's padded
decomposition is padded_bytes_to_blocks, which str_to_blocks and
encrypt_bytes share; bytes_to_blocks stays the unpadded one the
fixed-length path (#113) uses.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I examined the full diff and all earlier feedback. I built the PR head (8712cf3) and ran cargo test -p ore-rs --all-features. All tests pass.

I examined these items:

  • The compile-time assert in the legacy impl macro, and the TooManyBlocks return for N > 15 in OreAes128.
  • The ChainedCipherText<N> / ChainedLeft<N> parsers, the TryFrom conversions and the typed comparators. They refuse other block counts and padded tags.
  • The ordering at the boundaries: 0, 1, -1, u128::MAX, i128::MIN, i128::MAX, through the property tests and the pinned vectors.
  • The relaxed OreEncrypt<T> bound. It only removes a requirement, so existing callers still compile.
  • to_orderable_bytes against to_fixed_orderable_bytes. The traits are sealed, and the two encodings are the same bytes for every type.

I found no blocking issue. The earlier points (length check before the copy, length guard against type guard, large arrays and the stack scrub) are fixed.

One related point is not in this diff. Please do it in a follow-up PR:

  • OreAes128Bit6 still panics on a too-wide PlainText<N> through the public OreCipher::encrypt / encrypt_left. encrypt_blocks and encrypt_left_blocks in bit2_w6.rs (lines 155 and 180) use assert!(N <= MAX_BLOCKS). I tested OreCipher::encrypt(&[0u8; 15]) on OreAes128Bit6ChaCha20 at this head. It panics at bit2_w6.rs:180. This PR changes the same panic in OreAes128 to OreError::TooManyBlocks. Make the 6-bit scheme do the same, so that both schemes behave the same way.

The inline comments are nits (non-blocking). Please do them in a follow-up PR, not in this one.

Comment thread packages/ore-rs/src/scheme/chained.rs
Comment thread packages/ore-rs/src/scheme/chained.rs Outdated
Comment thread packages/ore-rs/src/scheme/chained.rs
Comment thread packages/ore-rs/CHANGELOG.md Outdated
…list the removed impls

Review of #113 (non-blocking nits):

- The module docs' fixed-length paragraph had no blank line before it, so
  rustdoc ran it into the raw-symbols paragraph; one of the two blank lines
  after the example is gone.
- A comment rustfmt had aligned at column 57 is on its own line.
- The doc comment of `prop_equality_across_nonces` had ended up on
  `prop_u128_order`; each test has its own again.
- The CHANGELOG lists the dropped `OreEncrypt<OreAes128>` impls for
  `u128`/`i128` under Removed, as a breaking change, not only under Fixed.
…ad of panicking

Review of #113. `OreAes128Bit6` asserted `N <= MAX_BLOCKS` in
`encrypt_blocks` and `encrypt_left_blocks`, so `OreCipher::encrypt(&[0u8; 15])`
panicked. Both now return `OreError::TooManyBlocks`, as `OreAes128` does
since the first commit of this PR. The `OreEncrypt` impls are unaffected:
their block counts are checked at compile time.
Review of #113: library code still panicked through `assert!`,
`debug_assert!` and `expect`. Each is now a type, an error or gone:

- SIMD kernels, the Z2 hashes and the PRP builders take fixed-size arrays
  (`&mut [u8; 32]`, `&[AesBlock; 256]`, `&[u8; 16]`, a whole draw stream)
  instead of slices whose lengths they asserted. `Hash::hash_all_into` and
  the scalar packs are generic over the block and byte counts, checked equal
  at compile time (`Packed<D, B>`); `Prp` and `RightBitVec` name their
  mask type and `BlockWidth` ties the two, so a right block's bitvector is
  the PRP's mask by type.
- The draw stream is `STREAM_BYTES = 512` (the keystream every caller
  already has; the builders read the first 504), so
  `LemireFyPrp::from_stream` cannot fail. The two `expect`s in `ct_bench`
  and the chained scheme's `?` on `prp_at` go with it.
- The wire header takes a `u16` count: a const `N` that does not fit is a
  compile error (`WireCount`).
- `decompose_6bit` reads past the end of its input as zero, which is what
  `decompose_padded` did by hand.
- `RightBlock32::set_bit` takes a `u8`. The `get_bit`s and the comparator's
  resolve step select obliviously and cannot index out of bounds; their
  debug asserts are gone, as are the decimal encoder's, whose invariants
  the comments already prove.

Tests that only checked a length panic are removed: those lengths are now
types. The wire format is unchanged; every pinned vector passes.

Both crates deny `clippy::{panic, unwrap_used, expect_used, unreachable,
todo, unimplemented, panic_in_result_fn}` outside `cfg(test)`. Clippy has
no lint for `assert!`, so `scripts/no-runtime-asserts.py`, a new CI step,
rejects runtime assertions outside tests and kani proofs; compile-time
`const NAME: () = assert!(..)` checks stay allowed.
coderdan added a commit that referenced this pull request Oct 18, 2026
The fixed-N v2 scheme took two keys while the chained scheme took one
(issue #89), and neither took a salt, so a caller keying several columns
from one key had no way to separate them: under one key, `0u64` and
`i64::MIN` (equal encodings) encrypt to identical bytes. vitaminc-ore
(vitaminc#455, ADR 0002) binds a per-column salt and asks a scheme without
a salt of its own to derive its keys under it.

Both v2 schemes now have `init(key)` and `init_with_salt(key, salt)`:

    k_root = AES_key( ROOT_LABEL )                             only use of `key`
    k_salt = CMAC_{k_root}( len(salt):u64 ‖ salt ‖ 0… ‖ "ORE.v2.salt.done" )
    Bit6:    prf1 = AES_{AES_{k_salt}("ORE.v2.bit6.prf1")},  prf2 likewise
    chained: k_acc = AES_{k_salt}("ORE.v2.chain.acc")

Root labels (`ORE.v2.bit6.root`, `ORE.v2.chainroot`) end in `'t'`, outside
every legacy PRF input's byte 15, so a key shared with the legacy scheme
never publishes a root key; the caller's key is evaluated on nothing else.
The salt is absorbed as full blocks of the existing CMAC chain behind a
length prefix, so `k_salt` is a CMAC tag over a prefix-free message.
`init(key)` is the empty salt. All at init time. Code: `scheme/keys.rs`.

Nothing on the wire records the salt, so the comparators cannot refuse a
cross-salt pair; both constructors say so. There is no per-type separation
inside one salt: an earlier revision of this branch added a per-type subkey
and a header type tag, withdrawn because vitaminc-ore's domain-and-context
salt is strictly stronger and the ADR places type identity there.

`OreCipher::init(k1, k2)` leaves the trait. The legacy scheme keeps the
same signature as an inherent constructor (frozen wire, raw keys); Bit6
loses its second key. Every call site (tests, benches, examples, docs,
ct-taint, ct-dudect, vector harnesses) is updated.

Bit6 and chained vectors are re-pinned (both formats unfrozen; the layout
is unchanged, only the keys); the legacy vectors are byte-identical. CMAC
spec §3, the architecture plan and the crypto review brief (§5A, A5)
describe the stages and what needs scrutiny.

Closes #89.

Rebased over #110 and the rebased #113. On the chained scheme the salt is a
field of `Options` (`Options::with_salt`, `Options::salt`), beside the padding
policy and the ceiling, so `init_with(key, options)` is the one constructor
that takes everything a column needs; `init_with_salt(key, salt)` remains as
the convenience that mirrors `OreAes128Bit6`. `Options` holds the salt as an
owned `Vec<u8>`, so it is `Clone` but no longer `Copy`, and `options()`
returns a reference. The chained vectors are re-pinned once more, for the
salted derivation under the padded header.
coderdan added a commit that referenced this pull request Oct 18, 2026
…I from docs and harnesses

- `keys::root` debug-asserted that a root label's byte 15 is outside the
  legacy PRF inputs. Each scheme now checks its label with a const
  assertion beside its definition, so a bad label does not compile and
  library code has no runtime assertion (#113's rule).
- Review of #114 (nits James marked for a follow-up, done here): the Bit6
  vectors' module docs say `init`, not the removed `OreCipher::init`; the
  accumulator spec says `init` takes one key and `init_with_salt` adds a
  salt; `ct-dudect` drops the unused `K2`; `ct-taint`'s `keys()` becomes
  `key()`, since both callers discarded the second key.
@coderdan

Copy link
Copy Markdown
Contributor Author

Addressed in this PR rather than follow-ups (30ad268 is the new head):

  • Bit6 panic (7402a93): OreAes128Bit6::encrypt_blocks / encrypt_left_blocks return OreError::TooManyBlocks for N > MAX_BLOCKS, as OreAes128 does. Test too_many_blocks_is_an_error_not_a_panic covers OreCipher::encrypt(&[0u8; 15]) and the left path.
  • Nits (88b46e0): all four inline points.
  • No runtime panics in library code (30ad268). Every assert!, debug_assert!, unwrap and expect outside tests and kani proofs is gone:
    • SIMD kernels, the Z2 hashes and the PRP builders take fixed-size arrays rather than asserting slice lengths. Hash::hash_all_into and the scalar packs are generic over block/byte counts, checked equal at compile time.
    • Prp::Mask and RightBitVec::Bits tie the PRP mask to the right block by type.
    • The draw stream is a 512-byte array, so LemireFyPrp::from_stream cannot fail.
    • The wire header count is a u16, with a const check on N.
    • Breaking: RightBlock32::set_bit takes a u8.
  • Lint: both crates deny clippy::{panic, unwrap_used, expect_used, unreachable, todo, unimplemented, panic_in_result_fn} outside cfg(test). Clippy cannot lint assert!, so a new CI step (scripts/no-runtime-asserts.py) rejects runtime assertions outside tests and kani; const _: () = assert!(..) stays allowed.
  • Checks: wire format unchanged, and every pinned vector passes. Bit6 encrypt and compare and legacy encrypt benchmark the same as before (within noise). Tests pass on aarch64 and x86_64 (debug and release).

#114, #109 and #115 are restacked on top.

coderdan added a commit that referenced this pull request Oct 19, 2026
…feature

ADR 0002 in cipherstash/stack puts vitaminc at the bottom of the order
index: vitaminc-ore decides the encoding of every kind of value, seals it
as an OrderPlaintext only it can build, and defines OrderScheme, which the
scheme crates implement. This is the trial run of that trait here, against
vitaminc's feat/ore-crate branch, to be pointed at the published release
when it lands.

Two schemes implement it. BlockOre is the frozen fixed-width scheme at
each kind's own width, so an i32 is four blocks and a decimal fourteen; it
refuses text, bytes and the 16-byte kinds by domain, since its block cap
is 15. ChainedBlockOre is the variable-length scheme and takes every kind,
through a new encrypt_bytes entry point beside encrypt_str: an order
plaintext is bytes already encoded so byte order is value order, not a
string. Neither scheme takes a salt, so each derives its keys per term
with HMAC-SHA256 under the salt vitaminc binds, and a term compares only
within its scheme, key, domain and context. Terms keep the serialised
ciphertext and compare with the scheme's own comparator, so the nonce in
the right half does not make equal values unequal.

Off by default: cipherstash-client takes no vitaminc dependency and the
typed API is unchanged. The lockfile unlocks the proc-macro and
wasm-bindgen families the vitaminc crates need.

Restacked on #109 and #114 (2026-10-17), which folds in the resolution
the dropped merge of main used to carry. The six-bit scheme now takes one
16-byte key (#114), so it is keyed by the first half of its derived
material, as the chained scheme already was. The chained scheme's padded
decomposition is padded_bytes_to_blocks, which str_to_blocks and
encrypt_bytes share; bytes_to_blocks stays the unpadded one the
fixed-length path (#113) uses.
coderdan added a commit that referenced this pull request Oct 19, 2026
…ount at compile time

#113 removed runtime assertions from library code. `encrypt_bytes_as`
debug-asserted that its block count matched the byte count it was called
for; it now takes both as const parameters, and `BlocksFor<N, B>` checks
`B == num_blocks_6bit(N)` at compile time.
…ng it a column

ore-rs is a general-purpose library; "column" is a database word. The
chained scheme's docs, the README and the CHANGELOG now say "the values
compared together" and "one key per kind of value" instead. The design
notes and benchmark write-ups under `docs/`, which describe database
deployments, are unchanged.
coderdan added a commit that referenced this pull request Oct 19, 2026
The fixed-N v2 scheme took two keys while the chained scheme took one
(issue #89), and neither took a salt, so a caller keying several columns
from one key had no way to separate them: under one key, `0u64` and
`i64::MIN` (equal encodings) encrypt to identical bytes. vitaminc-ore
(vitaminc#455, ADR 0002) binds a per-column salt and asks a scheme without
a salt of its own to derive its keys under it.

Both v2 schemes now have `init(key)` and `init_with_salt(key, salt)`:

    k_root = AES_key( ROOT_LABEL )                             only use of `key`
    k_salt = CMAC_{k_root}( len(salt):u64 ‖ salt ‖ 0… ‖ "ORE.v2.salt.done" )
    Bit6:    prf1 = AES_{AES_{k_salt}("ORE.v2.bit6.prf1")},  prf2 likewise
    chained: k_acc = AES_{k_salt}("ORE.v2.chain.acc")

Root labels (`ORE.v2.bit6.root`, `ORE.v2.chainroot`) end in `'t'`, outside
every legacy PRF input's byte 15, so a key shared with the legacy scheme
never publishes a root key; the caller's key is evaluated on nothing else.
The salt is absorbed as full blocks of the existing CMAC chain behind a
length prefix, so `k_salt` is a CMAC tag over a prefix-free message.
`init(key)` is the empty salt. All at init time. Code: `scheme/keys.rs`.

Nothing on the wire records the salt, so the comparators cannot refuse a
cross-salt pair; both constructors say so. There is no per-type separation
inside one salt: an earlier revision of this branch added a per-type subkey
and a header type tag, withdrawn because vitaminc-ore's domain-and-context
salt is strictly stronger and the ADR places type identity there.

`OreCipher::init(k1, k2)` leaves the trait. The legacy scheme keeps the
same signature as an inherent constructor (frozen wire, raw keys); Bit6
loses its second key. Every call site (tests, benches, examples, docs,
ct-taint, ct-dudect, vector harnesses) is updated.

Bit6 and chained vectors are re-pinned (both formats unfrozen; the layout
is unchanged, only the keys); the legacy vectors are byte-identical. CMAC
spec §3, the architecture plan and the crypto review brief (§5A, A5)
describe the stages and what needs scrutiny.

Closes #89.

Rebased over #110 and the rebased #113. On the chained scheme the salt is a
field of `Options` (`Options::with_salt`, `Options::salt`), beside the padding
policy and the ceiling, so `init_with(key, options)` is the one constructor
that takes everything a column needs; `init_with_salt(key, salt)` remains as
the convenience that mirrors `OreAes128Bit6`. `Options` holds the salt as an
owned `Vec<u8>`, so it is `Clone` but no longer `Copy`, and `options()`
returns a reference. The chained vectors are re-pinned once more, for the
salted derivation under the padded header.
coderdan added a commit that referenced this pull request Oct 19, 2026
…I from docs and harnesses

- `keys::root` debug-asserted that a root label's byte 15 is outside the
  legacy PRF inputs. Each scheme now checks its label with a const
  assertion beside its definition, so a bad label does not compile and
  library code has no runtime assertion (#113's rule).
- Review of #114 (nits James marked for a follow-up, done here): the Bit6
  vectors' module docs say `init`, not the removed `OreCipher::init`; the
  accumulator spec says `init` takes one key and `init_with_salt` adds a
  salt; `ct-dudect` drops the unused `K2`; `ct-taint`'s `keys()` becomes
  `key()`, since both callers discarded the second key.
coderdan added a commit that referenced this pull request Oct 19, 2026
…feature

ADR 0002 in cipherstash/stack puts vitaminc at the bottom of the order
index: vitaminc-ore decides the encoding of every kind of value, seals it
as an OrderPlaintext only it can build, and defines OrderScheme, which the
scheme crates implement. This is the trial run of that trait here, against
vitaminc's feat/ore-crate branch, to be pointed at the published release
when it lands.

Two schemes implement it. BlockOre is the frozen fixed-width scheme at
each kind's own width, so an i32 is four blocks and a decimal fourteen; it
refuses text, bytes and the 16-byte kinds by domain, since its block cap
is 15. ChainedBlockOre is the variable-length scheme and takes every kind,
through a new encrypt_bytes entry point beside encrypt_str: an order
plaintext is bytes already encoded so byte order is value order, not a
string. Neither scheme takes a salt, so each derives its keys per term
with HMAC-SHA256 under the salt vitaminc binds, and a term compares only
within its scheme, key, domain and context. Terms keep the serialised
ciphertext and compare with the scheme's own comparator, so the nonce in
the right half does not make equal values unequal.

Off by default: cipherstash-client takes no vitaminc dependency and the
typed API is unchanged. The lockfile unlocks the proc-macro and
wasm-bindgen families the vitaminc crates need.

Restacked on #109 and #114 (2026-10-17), which folds in the resolution
the dropped merge of main used to carry. The six-bit scheme now takes one
16-byte key (#114), so it is keyed by the first half of its derived
material, as the chained scheme already was. The chained scheme's padded
decomposition is padded_bytes_to_blocks, which str_to_blocks and
encrypt_bytes share; bytes_to_blocks stays the unpadded one the
fixed-length path (#113) uses.
coderdan added a commit that referenced this pull request Oct 19, 2026
…ount at compile time

#113 removed runtime assertions from library code. `encrypt_bytes_as`
debug-asserted that its block count matched the byte count it was called
for; it now takes both as const parameters, and `BlocksFor<N, B>` checks
`B == num_blocks_6bit(N)` at compile time.
@coderdan
coderdan merged commit 81b024a into main Oct 19, 2026
11 checks passed
@coderdan
coderdan deleted the fix/u128-chained branch October 19, 2026 00:57
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.

OreEncrypt for u128/i128 panics on OreAes128 (never worked, no tests)

4 participants