Repository navigation
feat(chained): encrypt u128/i128 on the chained scheme; drop the panicking legacy impls - #113
Conversation
|
Rebased onto main (#107 added a rustdoc gate to CI). The module docs on |
d0c2bb0 to
434f79b
Compare
There was a problem hiding this comment.
🟡 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.
…; 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.
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.
19878b6 to
730220e
Compare
|
Rebased onto
The |
…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.
…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`.
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.
…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.
…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
left a comment
There was a problem hiding this comment.
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
TooManyBlocksreturn forN > 15inOreAes128. - The
ChainedCipherText<N>/ChainedLeft<N>parsers, theTryFromconversions 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_bytesagainstto_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:
OreAes128Bit6still panics on a too-widePlainText<N>through the publicOreCipher::encrypt/encrypt_left.encrypt_blocksandencrypt_left_blocksinbit2_w6.rs(lines 155 and 180) useassert!(N <= MAX_BLOCKS). I testedOreCipher::encrypt(&[0u8; 15])onOreAes128Bit6ChaCha20at this head. It panics atbit2_w6.rs:180. This PR changes the same panic inOreAes128toOreError::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.
…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.
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.
…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.
|
Addressed in this PR rather than follow-ups (30ad268 is the new head):
|
…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.
…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.
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.
…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.
…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.
…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.


Closes #105.
Problem
OreEncryptforu128/i128onOreAes128has panicked on every call since it was added (ad90f0b): the legacy left-tag input puts the block index at byteNof one AES block, so the scheme takes at most 15 bytes. At 22 six-bit blocks the types do not fitOreAes128Bit6'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>>foru128andi128. Outputs areChainedCipherText<22>/ChainedLeft<22>(U128_BLOCKS,I128_BLOCKS): the same bytes as aVar*of that length, with the block count in the type. TheirOreOutput::from_slice,TryFrom<Var*>and typedcompare_raw_slices/compare_left_to_fullaccept 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_fixedfor anyFixedOrderableBytestype (the routeDecimalcan take later), andVarLeft::from_slice.tests/compat_chained_vectors.rs. The existing vectors are byte-identical.OreEncrypt<T>drops itsT: OreCipherbound so the chained scheme (single key, no constN) can implement it. Existing impls and callers are unaffected.Legacy scheme (
scheme/bit2.rs,encrypt.rs)u128/i128impls are removed. The impl macro assertsENCODED_LEN <= bit2::MAX_BLOCKS(15) at compile time, so a type too wide for the scheme cannot be given an impl again. Verified: re-addingu128fails withassertion failed: U128_LEN <= MAX_BLOCKS.encrypt/encrypt_leftreturnOreError::TooManyBlocksforN > 15instead of indexing out of bounds.Tests per impl. Every
OreEncryptimpl macro (legacy, Bit6, chained) now takes$lo, $hiand 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)Notes for review
0u128andi128::MINencrypt 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.chained.rs.encrypt_fixedcalls the internalencrypt_blocksdirectly, so it must stay on the unpadded path through that rebase.Verification