diff --git a/Cargo.lock b/Cargo.lock index ba86403c9..44242e2a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4071,9 +4071,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -4085,9 +4085,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -4100,9 +4100,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -4111,9 +4111,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -4122,9 +4122,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -4132,9 +4132,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -4146,9 +4146,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", "sha2 0.11.0", @@ -4159,9 +4159,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.18", @@ -4171,9 +4171,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -4188,9 +4188,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -4199,9 +4199,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -4214,9 +4214,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -4225,9 +4225,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 1d4fb9c76..4ad564ff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,12 +120,13 @@ web-time = "1.1" zeroize = { version = "1.8.1", features = ["derive"] } # This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } -vitaminc = { version = "0.5.0", features = ["random", "protected", "encrypt"] } -vitaminc-aead = "0.5.0" +vitaminc = { version = "0.5.1", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.5.1" # The dynamic value model (`FfiValue`) and its FFI transport codec, shared by # every language binding. Optional in stack-encrypt (the `dynamic` feature). -vitaminc-aead-value = "0.5.0" -vitaminc-encrypt = "0.5.0" -vitaminc-hmac = "0.5.0" -vitaminc-prf = "0.5.0" -vitaminc-protected = "0.5.0" +# 0.5.1 adds `ValueKind`, the field-type vocabulary a dynamic plan declares. +vitaminc-aead-value = "0.5.1" +vitaminc-encrypt = "0.5.1" +vitaminc-hmac = "0.5.1" +vitaminc-prf = "0.5.1" +vitaminc-protected = "0.5.1" diff --git a/docs/fuzzing.md b/docs/fuzzing.md index c206f6431..0c168aac6 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -25,7 +25,7 @@ Current targets: | `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | | `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | | `fuzz:sealed-value` | `stack-encrypt` | `sealed_value_decode` | `SealedValue::from_bytes` (frozen v1 leaf); accepted input must re-encode to itself | -| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` `from_bytes`) | +| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm` `from_bytes`) | | `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) | Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]` diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock index eee72b24b..2f72f6198 100644 --- a/languages/golang/stackauth/guest/Cargo.lock +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -2230,9 +2230,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2244,9 +2244,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2259,9 +2259,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2281,9 +2281,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2291,9 +2291,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2305,9 +2305,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2322,9 +2322,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2333,9 +2333,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2348,9 +2348,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2359,9 +2359,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 7488fea75..152221f21 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -2444,9 +2444,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2458,9 +2458,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2473,9 +2473,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2484,9 +2484,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -2495,9 +2495,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2505,9 +2505,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2519,9 +2519,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", "sha2 0.11.0", @@ -2532,9 +2532,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.20", @@ -2544,9 +2544,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2561,9 +2561,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2572,9 +2572,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2587,9 +2587,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2598,9 +2598,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 69f82059e..e676076a6 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -42,8 +42,8 @@ zerokms-protocol = "=0.12.31" # (see the comment in packages/stack-encrypt/Cargo.toml). A different # version here fails at the `FfiValue: Decrypt` bound, since the aead crate # would be duplicated. -vitaminc-aead-value = "0.5.0" -vitaminc-protected = "0.5.0" +vitaminc-aead-value = "0.5.1" +vitaminc-protected = "0.5.1" futures = { version = "0.3", default-features = false, features = ["executor"] } serde = "1" diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index da2dce0e1..c294d796a 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -31,7 +31,9 @@ //! ABI's numeric term kinds, and the mapping from a library error to a //! status code. -use stack_encrypt::dynamic::{self, Scalar, Scope, TermKind}; +use stack_encrypt::dynamic::{self, Scalar, Scope}; +use stack_encrypt::sem::MatchOptions; +use stack_encrypt::target::IndexSpec; use stack_encrypt::{ BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, SealedValue, StackCipherText, }; @@ -173,25 +175,29 @@ where // `STATUS_ENCODING` here, before any derivation. let context = dynamic::context(decode_value(context)?).map_err(|e| status_for_dynamic(&e))?; let (scalar, kind) = parse_term(decode_value(value)?, kind)?; - dynamic::term(cipher, scalar, kind, context) + dynamic::term(cipher, scalar, &kind, context) .await .map_err(|e| status_for_dynamic(&e)) } /// The static half of a term: the kind is one of the ABI's table, the value /// is a scalar, and the scheme defines the pair -/// ([`TermKind::supports`]). Shared by [`term`] and [`validate::term`] so +/// ([`IndexSpec::supports`]). Shared by [`term`] and [`validate::term`] so /// the ABI refuses exactly what the operation would, before any keyset is /// resolved. -fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, TermKind), u32> { +/// +/// The ABI's kind codes carry no options, so [`TERM_MATCH`] is the match +/// index under the default options, the same index a plan's bare `"match"` +/// names. +fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, IndexSpec), u32> { let kind = match kind { - TERM_EQUALITY => TermKind::Equality, - TERM_MATCH => TermKind::Match, - TERM_ORE => TermKind::Ore, - TERM_OPE => TermKind::Ope, + TERM_EQUALITY => IndexSpec::Equality, + TERM_MATCH => IndexSpec::Match(MatchOptions::default()), + TERM_ORE => IndexSpec::Ore, + TERM_OPE => IndexSpec::Ope, _ => return Err(STATUS_ENCODING), }; - let scalar = Scalar::of(&value, kind).map_err(|e| status_for_dynamic(&e))?; + let scalar = Scalar::of(&value, &kind).map_err(|e| status_for_dynamic(&e))?; if !kind.supports(&scalar) { return Err(STATUS_ENCODING); } @@ -283,7 +289,7 @@ pub mod validate { /// A term's inputs, as [`term`] takes them: the context decodes and is /// non-empty, the kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`], and /// the value is a scalar the scheme defines that term for - /// ([`TermKind::supports`]). + /// ([`IndexSpec::supports`]). pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { dynamic::context(decode_value(context)?) .map(drop) diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 700c009ce..b985c0434 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -318,13 +318,15 @@ mod tests { #[test] fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { - use stack_encrypt::dynamic::{Error, TermKind}; + use stack_encrypt::dynamic::Error; + use stack_encrypt::sem::MatchOptions; + use stack_encrypt::target::IndexSpec; for (label, err) in [ ("a bad context", Error::Context), ( "a bad term request", Error::Term { - kind: TermKind::Match, + kind: IndexSpec::Match(MatchOptions::default()), }, ), ("a bad plan", Error::Plan), diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 003abc66e..0c8971758 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -955,7 +955,7 @@ dependencies = [ "url", "utoipa", "uuid", - "vitaminc 0.5.0", + "vitaminc 0.5.1", ] [[package]] @@ -4212,8 +4212,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.5.0", - "vitaminc-protected 0.5.0", + "vitaminc 0.5.1", + "vitaminc-protected 0.5.1", "web-time", "zeroize", "zerokms-protocol", @@ -4230,11 +4230,11 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead 0.5.0", - "vitaminc-encrypt 0.5.0", + "vitaminc-aead 0.5.1", + "vitaminc-encrypt 0.5.1", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -4269,8 +4269,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.5.0", - "vitaminc-protected 0.5.0", + "vitaminc 0.5.1", + "vitaminc-protected 0.5.1", "zeroize", "zerokms-protocol", ] @@ -5020,16 +5020,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ - "vitaminc-aead 0.5.0", + "vitaminc-aead 0.5.1", "vitaminc-context", - "vitaminc-encrypt 0.5.0", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", - "vitaminc-traits 0.5.0", + "vitaminc-encrypt 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", ] [[package]] @@ -5047,24 +5047,24 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", "vitaminc-aead-derive", "vitaminc-context", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -5073,12 +5073,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", ] [[package]] @@ -5097,41 +5097,41 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.5.0", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac 0.13.0", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.20", "vitaminc-context", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", ] [[package]] @@ -5151,9 +5151,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -5162,7 +5162,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.5.0", + "vitaminc-protected-derive 0.5.1", "zeroize", ] @@ -5179,9 +5179,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -5204,16 +5204,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.20", - "vitaminc-protected 0.5.0", - "vitaminc-random-derives 0.5.0", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", "zeroize", ] @@ -5230,9 +5230,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -5257,17 +5257,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock index 756d51a69..7aa19f3a5 100644 --- a/packages/stack-auth/fuzz/Cargo.lock +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -3112,9 +3112,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -3126,9 +3126,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -3141,9 +3141,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -3152,9 +3152,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -3162,9 +3162,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -3176,9 +3176,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -3193,9 +3193,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -3204,9 +3204,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3219,9 +3219,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -3230,9 +3230,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 59e404604..66084d5b0 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -19,6 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 whose result type does not fix the mode must name it; `Borrowed` is the old behaviour. In return `ciphertext`, `equality`, `ore` and `ope` no longer ask `S: Clone` themselves: only borrowed mode does. +- `sem::MatchTerm` is renamed `MatchTerms`: a match index produces a set of + terms, not one. `MatchTerm` remains as a deprecated alias. +- `TermBytesError::OddMatchTermLength` is renamed `OddMatchTermsLength`. An + enum variant cannot be aliased, so a `match` that names it must change. +- `dynamic::TermKind` is gone; `target::IndexSpec` is the one data form of + an index. `Output::Term`, `Error::Term`'s `kind`, `Scalar::of` and + `dynamic::term` take an `IndexSpec` (the last two by reference), and + `Output` is no longer `Copy`. A plan's wire form is unchanged: a bare + `"match"` still means the default options. ### Added @@ -28,6 +37,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Owned` mode a description is handed the plaintext by value, so a single operation consumes it with no copy and a plaintext that is not `Clone` (a zeroizing FFI value) can be sealed or indexed. The traits are sealed. +- Indexes as types: `target::{Index, Indexes, Equality, Match, Ore, Ope}`, + `indexed`, `Encrypted`, `Select` / `At` / `Whole`. A match index on an + integer does not compile, and an index set is one index or a tuple of two + to four, never `()`. `Index::spec` lowers an index to its `IndexSpec`. +- `target::passthrough`: a field carried unsealed and unauthenticated. +- `KeysetCipher::run_decryption` and `StackCipher::run_decryption`: run a + `Decryption` held in a variable. +- A dynamic plan's match index can carry options, as + `{"match": {"tokenizer", "downcase", "k", "m"}}`. +- A dynamic plan field may declare its type, as `"type": ""`. The + vocabulary is vitaminc's `ValueKind` (re-exported as + `dynamic::ValueKind`), not a new enum, so this crate now needs vitaminc + 0.5.1. A declared type refuses an index it is not defined for + (`dynamic::admits`) and a value of another kind, on encrypt and on + decrypt. `dynamic::read` reads a query value as a kind; + `FieldPlan::with_type` and `field_type` set and read the declaration. ## [0.2.0] - 2026-10-04 diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index eaf3e1425..ea9c805d6 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -24,7 +24,7 @@ stack-auth = { workspace = true, optional = true } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive", version = "0.2.0" } -# The workspace vitaminc (0.5.0): one canonical context encoding shared by +# The workspace vitaminc (0.5.1): one canonical context encoding shared by # the AEAD and PRF derivations (`IntoContext`, cipherstash/vitaminc#339), # `NonEmpty::with`, `From for NonEmpty` (#314) and the parts view # (#318), all of which this crate relies on. All five must share one diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 74c556b96..514cca254 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -18,7 +18,7 @@ //! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the //! `zerokms_auth` example for the lookup order). -use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::EncryptInto; use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText}; @@ -72,13 +72,13 @@ async fn main() -> Result<(), Box> { // its positions are a subset of the stored term's (Bloom semantics: false // positives possible, false negatives not). - let bio: MatchTerm = "alice, senior cryptography engineer" + let bio: MatchTerms = "alice, senior cryptography engineer" .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("bio")) .await?; for query in ["crypto", "engineer", "plumber"] { - let probe: MatchTerm = query + let probe: MatchTerms = query .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("bio")) .await?; @@ -132,7 +132,7 @@ async fn main() -> Result<(), Box> { struct SearchableEmail { c: StackCipherText, eq: EqualityTerm, - text: MatchTerm, + text: MatchTerms, ord: OreTerm, } @@ -140,7 +140,7 @@ async fn main() -> Result<(), Box> { .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("email")) .await?; - let probe: MatchTerm = "example" + let probe: MatchTerms = "example" .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("email")) .await?; diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 9a2612b77..ca039cf5c 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2468,9 +2468,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2482,9 +2482,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2497,9 +2497,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2508,9 +2508,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -2519,9 +2519,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2529,9 +2529,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2543,9 +2543,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", "sha2 0.11.0", @@ -2556,9 +2556,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.18", @@ -2568,9 +2568,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2585,9 +2585,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2596,9 +2596,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -2611,9 +2611,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2622,9 +2622,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs index 45d7c0b0e..7a5334331 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs @@ -1,7 +1,7 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerms, OpeTerm, OreTerm}; // Fuzz the SEM index-term byte decoders: stored terms come back through these // before comparison, so the bytes are attacker-controlled and decoding must @@ -19,7 +19,7 @@ fuzz_target!(|bytes: &[u8]| { "EqualityTerm decode is not lossless" ); } - let _ = MatchTerm::::from_bytes(bytes); + let _ = MatchTerms::::from_bytes(bytes); // One fixed-width and one variable-width CLLW output each for ORE and OPE. if let Ok(term) = OreTerm::::from_bytes(bytes) { assert_eq!( diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs new file mode 100644 index 000000000..e3cc30869 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -0,0 +1,471 @@ +//! What a plan's declared field type means to the engine. +//! +//! A typed host knows what `34` is: a Rust `u32`, a Go `int64`. A dynamically +//! typed host (JavaScript, PHP, Ruby) does not, and index semantics depend on +//! it: an ORE term of the integer `34` and of the float `34.0` differ, a +//! JavaScript number is a float, and match is defined over text alone. So a +//! plan field may say what its values are, in its `"type"` key. +//! +//! The vocabulary is vitaminc's, not this crate's: a declared type is a +//! [`ValueKind`] (`vitaminc_aead_value::ValueKind`, re-exported here), whose +//! [`name`](ValueKind::name)s (`"uint64"`, `"string"`, …, `"array"`, +//! `"object"`) are frozen wire format beside vitaminc's tag table, and whose +//! [`holds`](ValueKind::holds) and [`FfiValue::kind`] say whether a value is +//! of a kind. What vitaminc deliberately leaves to the declaring layer is the +//! two rules this module adds: +//! +//! - [`admits`]: which indexes a kind is defined for, so a plan asking for +//! match on an integer or equality on a float is refused when it is built; +//! - [`read`]: how a query value is read as a field's kind, converting a +//! number only when the conversion is exact. +//! +//! The engine then uses a declared kind three times: +//! +//! - **encrypt** admits only the indexes the kind is defined for (when the +//! plan is built), and refuses a value of any other kind (when it runs), +//! so the term bytes are the declared kind's and no binding is trusted to +//! have tagged the value right; +//! - **query** is meant to read the query value *as* the field's kind +//! ([`read`]): `34` against a `uint64` field is the `u64` term, whatever +//! number type the host handed over. The engine does not do this for the +//! caller yet; see [`read`]; +//! - **decrypt** refuses an opened value of any other kind, so a host with no +//! types of its own can rely on the declaration for what it gets back (an +//! integer, not a float; bytes, not a string). +//! +//! A field with no `"type"` is dispatched on each value's own tag. That is +//! transitional; see [`record::plan`](super::record::plan()). +use vitaminc_aead_value::{FfiValue, ValueKind}; + +use super::Error; +use crate::target::IndexSpec; + +/// Whether the scheme defines an `index` term for values of `kind`. +/// +/// The same table as [`IndexSpec::supports`], stated over kinds instead of +/// values, so a plan can be refused when it is built rather than when its +/// first value arrives: equality over every integer, text and bytes (no +/// floats, no booleans); match over text alone; ORE and OPE over every +/// scalar. A composite (`array`, `object`) has no term. Only the index's +/// kind matters: a match index's options do not change what it applies to. +/// +/// ``` +/// use stack_encrypt::dynamic::{admits, ValueKind}; +/// use stack_encrypt::target::IndexSpec; +/// +/// assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Float64, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Object, &IndexSpec::Ore)); +/// ``` +pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { + use ValueKind::*; + match index { + IndexSpec::Equality => matches!(kind, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), + IndexSpec::Match(_) => kind == String, + IndexSpec::Ore | IndexSpec::Ope => !matches!(kind, Array | Object), + } +} + +/// Read a query value as `kind`. +/// +/// A value already of this kind is returned as it is. A number of another +/// width or kind is converted when the conversion is exact: an integer in +/// range, or a float with no fractional part in range, for an integer kind +/// (a JavaScript `34` arrives as a float); an integer or float the target +/// float kind represents exactly, for a float kind. A NaN is never +/// converted, in either float direction: it equals nothing, so no +/// conversion of it is exact. Nothing else converts: a string is never +/// parsed as a number, and a number never becomes a string. +/// +/// This is for a query: what a host hands over to search with. A value +/// being sealed is not converted; it must already be of the field's kind, +/// and is refused otherwise. +/// +/// **The engine does not call this.** [`term`](super::term()) takes a +/// [`Scalar`](super::Scalar) and an [`IndexSpec`] and never sees the field's +/// declared kind, so a binding deriving a query term for a typed field must +/// call `read` first, with [`FieldPlan::field_type`](super::FieldPlan::field_type). +/// One that skips it hands `Float64(34.0)` to a `uint64` field, derives a +/// float term where the stored one is the `u64` term, and matches nothing, +/// with no error. Wiring this into the plan's query path +/// (`query(v).using(&plan)`) is tracked in #1057. +/// +/// ``` +/// use stack_encrypt::dynamic::{read, FfiValue, ValueKind}; +/// +/// // A JavaScript number is a float; against a `uint64` field it is 34. +/// let value = read(ValueKind::UInt64, FfiValue::Float64(34.0))?; +/// assert!(matches!(value, FfiValue::UInt64(34))); +/// assert!(read(ValueKind::UInt64, FfiValue::Float64(34.5)).is_err()); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// ``` +/// +/// # Errors +/// +/// [`Error::Source`] if the value cannot be read as `kind` exactly. +pub fn read(kind: ValueKind, value: FfiValue) -> Result { + if kind.holds(&value) { + return Ok(value); + } + let number = Number::of(&value).ok_or(Error::Source)?; + let read = match kind { + ValueKind::Int32 => number + .integer() + .and_then(|i| i32::try_from(i).ok()) + .map(FfiValue::Int32), + ValueKind::Int64 => number + .integer() + .and_then(|i| i64::try_from(i).ok()) + .map(FfiValue::Int64), + ValueKind::UInt32 => number + .integer() + .and_then(|i| u32::try_from(i).ok()) + .map(FfiValue::UInt32), + ValueKind::UInt64 => number + .integer() + .and_then(|i| u64::try_from(i).ok()) + .map(FfiValue::UInt64), + ValueKind::Float64 => number.exact_f64().map(FfiValue::Float64), + ValueKind::Float32 => number.exact_f32().map(FfiValue::Float32), + _ => None, + }; + read.ok_or(Error::Source) +} + +/// A numeric leaf, widened without loss: every integer variant fits an +/// `i128`, and an `f32` widens to an `f64` exactly. +#[derive(Clone, Copy)] +enum Number { + Integer(i128), + Float(f64), +} + +impl Number { + fn of(value: &FfiValue) -> Option { + Some(match value { + FfiValue::Int32(v) => Number::Integer((*v).into()), + FfiValue::Int64(v) => Number::Integer((*v).into()), + FfiValue::UInt32(v) => Number::Integer((*v).into()), + FfiValue::UInt64(v) => Number::Integer((*v).into()), + FfiValue::Float32(v) => Number::Float((*v).into()), + FfiValue::Float64(v) => Number::Float(*v), + _ => return None, + }) + } + + /// The integer this number is exactly, if it is one. A float outside + /// `i128` saturates, which no target integer type then accepts. + fn integer(self) -> Option { + match self { + Number::Integer(i) => Some(i), + Number::Float(f) if f.is_finite() && f.fract() == 0.0 => Some(f as i128), + Number::Float(_) => None, + } + } + + /// The `f64` this number is exactly, if it is one: an integer whose + /// conversion does not round, or a float that is not a NaN. A NaN is + /// never exact, because it does not equal itself. + fn exact_f64(self) -> Option { + match self { + Number::Float(f) => (!f.is_nan()).then_some(f), + Number::Integer(i) => { + let f = i as f64; + (f as i128 == i).then_some(f) + } + } + } + + /// The `f32` this number is exactly, if it is one. A NaN is refused by + /// `exact_f64` before it gets here. + fn exact_f32(self) -> Option { + let f = self.exact_f64()?; + let narrowed = f as f32; + (f64::from(narrowed) == f).then_some(narrowed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::Scalar; + use crate::sem::{MatchOptions, Tokenizer}; + use vitaminc_protected::Protected; + + // What a kind is, its name and its tags, and which values it holds are + // vitaminc's to test (`ValueKind`). These test the two rules this crate + // adds over the vocabulary. + + /// One value of every kind. + fn sample(kind: ValueKind) -> FfiValue { + match kind { + ValueKind::Bool => FfiValue::Bool(true), + ValueKind::Int32 => FfiValue::Int32(-3), + ValueKind::Int64 => FfiValue::Int64(-4), + ValueKind::UInt32 => FfiValue::UInt32(34), + ValueKind::UInt64 => FfiValue::UInt64(35), + ValueKind::Float32 => FfiValue::Float32(1.5), + ValueKind::Float64 => FfiValue::Float64(2.5), + ValueKind::String => FfiValue::String("alice".into()), + ValueKind::Bytes => FfiValue::Bytes(Protected::new(b"ab".to_vec())), + ValueKind::Array => FfiValue::Array(vec![FfiValue::UInt32(1)]), + ValueKind::Object => FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), + } + } + + fn indexes() -> [IndexSpec; 4] { + [ + IndexSpec::Equality, + IndexSpec::Match(MatchOptions::default()), + IndexSpec::Ore, + IndexSpec::Ope, + ] + } + + /// `admits` is `IndexSpec::supports` stated over kinds: the two tables + /// cannot disagree about any scalar, and a composite admits nothing. + #[test] + fn admits_agrees_with_supports_for_every_scalar_kind() { + for kind in ValueKind::ALL { + for index in indexes() { + match Scalar::of(&sample(kind), &index) { + Ok(scalar) => { + assert_eq!( + admits(kind, &index), + index.supports(&scalar), + "{kind} and {index}" + ) + } + Err(_) => assert!(!admits(kind, &index), "{kind} is not a scalar"), + } + } + } + // Spelled out, so the table reads without the cross-check. + assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); + assert!(!admits(ValueKind::Float64, &IndexSpec::Equality)); + assert!(!admits(ValueKind::Bool, &IndexSpec::Equality)); + assert!(admits( + ValueKind::String, + &IndexSpec::Match(MatchOptions::default()) + )); + assert!(!admits( + ValueKind::Bytes, + &IndexSpec::Match(MatchOptions::default()) + )); + assert!(admits(ValueKind::Float64, &IndexSpec::Ore)); + assert!(!admits(ValueKind::Object, &IndexSpec::Ope)); + assert!(!admits(ValueKind::Array, &IndexSpec::Ore)); + } + + /// A match index's options are not part of what it applies to: text + /// admits it under any options, and nothing else does. + #[test] + fn admits_ignores_match_options() { + let wide = IndexSpec::Match(MatchOptions { + tokenizer: Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + }); + for kind in ValueKind::ALL { + assert_eq!( + admits(kind, &wide), + kind == ValueKind::String, + "{kind} admits a non-default match exactly when it is text" + ); + } + } + + fn read_ok(kind: ValueKind, value: FfiValue) -> Option { + read(kind, value).ok() + } + + #[test] + fn read_returns_a_value_of_the_kind_as_it_is() { + for kind in ValueKind::ALL { + let read = read_ok(kind, sample(kind)).expect("its own kind"); + assert!(kind.holds(&read)); + } + // Already of the kind: returned as it is, not converted, so a NaN + // the host sent as the field's own float kind is not refused here. + assert!( + matches!(read_ok(ValueKind::Float32, FfiValue::Float32(f32::NAN)), Some(FfiValue::Float32(f)) if f.is_nan()) + ); + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Float64(f64::NAN)), Some(FfiValue::Float64(f)) if f.is_nan()) + ); + } + + #[test] + fn read_converts_an_exact_number_to_an_integer_kind() { + // Every numeric variant is a source, each read into another width. + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::Int32(-5)), + Some(FfiValue::Int64(-5)) + )); + assert!(matches!( + read_ok(ValueKind::Int32, FfiValue::UInt64(5)), + Some(FfiValue::Int32(5)) + )); + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Int32(-5)), Some(FfiValue::Float64(f)) if f == -5.0) + ); + // A JavaScript number is a float. + assert!(matches!( + read_ok(ValueKind::UInt64, FfiValue::Float64(34.0)), + Some(FfiValue::UInt64(34)) + )); + assert!(matches!( + read_ok(ValueKind::Int32, FfiValue::Float32(-2.0)), + Some(FfiValue::Int32(-2)) + )); + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::UInt32(7)), + Some(FfiValue::Int64(7)) + )); + assert!(matches!( + read_ok(ValueKind::UInt32, FfiValue::Int64(7)), + Some(FfiValue::UInt32(7)) + )); + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::Float64(-0.0)), + Some(FfiValue::Int64(0)) + )); + assert!(matches!( + read_ok(ValueKind::UInt64, FfiValue::Int64(i64::MAX)), + Some(FfiValue::UInt64(v)) if v == i64::MAX as u64 + )); + assert!(matches!( + read_ok(ValueKind::Int32, FfiValue::Int64(i32::MIN.into())), + Some(FfiValue::Int32(i32::MIN)) + )); + assert!(matches!( + read_ok(ValueKind::UInt32, FfiValue::UInt64(u32::MAX.into())), + Some(FfiValue::UInt32(u32::MAX)) + )); + } + + #[test] + fn read_refuses_an_inexact_or_out_of_range_integer() { + let refused = [ + (ValueKind::UInt64, FfiValue::Float64(34.5)), + (ValueKind::Int64, FfiValue::Float64(f64::NAN)), + (ValueKind::Int64, FfiValue::Float64(f64::INFINITY)), + (ValueKind::Int64, FfiValue::Float64(1e30)), + (ValueKind::UInt64, FfiValue::Int64(-1)), + (ValueKind::UInt32, FfiValue::UInt64(u64::from(u32::MAX) + 1)), + (ValueKind::Int32, FfiValue::Int64(i64::from(i32::MAX) + 1)), + (ValueKind::Int32, FfiValue::Int64(i64::from(i32::MIN) - 1)), + (ValueKind::Int64, FfiValue::UInt64(u64::MAX)), + ]; + for (at, (kind, value)) in refused.into_iter().enumerate() { + assert!( + matches!(read(kind, value), Err(Error::Source)), + "case {at}, as {kind}" + ); + } + } + + /// The first float past each integer range is refused, and the last + /// one inside it converts exactly. A float cast straight to the integer + /// type (`f as u64`) would saturate instead: a JavaScript 2^64 would + /// read as `u64::MAX` and derive a term for a different number. + #[test] + fn read_refuses_a_float_at_the_first_value_past_an_integer_range() { + let two_63 = 9_223_372_036_854_775_808.0_f64; + let two_64 = 18_446_744_073_709_551_616.0_f64; + assert!(read_ok(ValueKind::Int64, FfiValue::Float64(two_63)).is_none()); + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::Float64(-two_63)), + Some(FfiValue::Int64(i64::MIN)) + )); + assert!(read_ok(ValueKind::UInt64, FfiValue::Float64(two_64)).is_none()); + assert!(matches!( + read_ok( + ValueKind::UInt64, + FfiValue::Float64(18_446_744_073_709_549_568.0) + ), + Some(FfiValue::UInt64(18_446_744_073_709_549_568)) + )); + assert!(read_ok(ValueKind::UInt32, FfiValue::Float64(4_294_967_296.0)).is_none()); + assert!(matches!( + read_ok(ValueKind::UInt32, FfiValue::Float64(4_294_967_295.0)), + Some(FfiValue::UInt32(u32::MAX)) + )); + } + + #[test] + fn read_converts_an_exactly_representable_number_to_a_float_kind() { + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Int64(34)), Some(FfiValue::Float64(f)) if f == 34.0) + ); + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Float32(1.5)), Some(FfiValue::Float64(f)) if f == 1.5) + ); + assert!( + matches!(read_ok(ValueKind::Float32, FfiValue::Float64(1.5)), Some(FfiValue::Float32(f)) if f == 1.5) + ); + assert!( + matches!(read_ok(ValueKind::Float32, FfiValue::UInt32(16_777_216)), Some(FfiValue::Float32(f)) if f == 16_777_216.0) + ); + assert!(matches!( + read_ok(ValueKind::Float64, FfiValue::UInt64(1 << 53)), + Some(FfiValue::Float64(f)) if f == 9_007_199_254_740_992.0 + )); + } + + #[test] + fn read_refuses_a_number_a_float_kind_would_round() { + let refused = [ + (ValueKind::Float64, FfiValue::UInt64((1 << 53) + 1)), + (ValueKind::Float64, FfiValue::UInt64(u64::MAX)), + (ValueKind::Float32, FfiValue::UInt32(16_777_217)), + (ValueKind::Float32, FfiValue::Float64(0.1)), + ]; + for (at, (kind, value)) in refused.into_iter().enumerate() { + assert!( + matches!(read(kind, value), Err(Error::Source)), + "case {at}, as {kind}" + ); + } + } + + /// A NaN converts in neither float direction: it equals nothing, so no + /// conversion of it is exact, and a query for one could match nothing. + #[test] + fn read_refuses_a_nan_in_both_float_directions() { + assert!(matches!( + read(ValueKind::Float32, FfiValue::Float64(f64::NAN)), + Err(Error::Source) + )); + assert!(matches!( + read(ValueKind::Float64, FfiValue::Float32(f32::NAN)), + Err(Error::Source) + )); + } + + #[test] + fn read_never_converts_across_kinds() { + let refused = [ + (ValueKind::UInt64, FfiValue::String("34".into())), + (ValueKind::String, FfiValue::UInt64(34)), + (ValueKind::Bool, FfiValue::UInt32(1)), + (ValueKind::UInt32, FfiValue::Bool(true)), + (ValueKind::Bytes, FfiValue::String("ab".into())), + ( + ValueKind::String, + FfiValue::Bytes(Protected::new(b"ab".to_vec())), + ), + (ValueKind::Array, FfiValue::Object(vec![])), + (ValueKind::Object, FfiValue::Array(vec![])), + (ValueKind::UInt32, FfiValue::Null), + (ValueKind::Bool, FfiValue::Float64(1.0)), + ]; + for (at, (kind, value)) in refused.into_iter().enumerate() { + assert!( + matches!(read(kind, value), Err(Error::Source)), + "case {at}, as {kind}" + ); + } + } +} diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 5d7ccab83..e0891c3a9 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -41,27 +41,43 @@ //! them. Their long-term home is beside vitaminc's frozen tag table, which //! already owns this class of constant. //! -//! For the same reason the enums that spell them — [`Output`] and -//! [`TermKind`] — are *not* `#[non_exhaustive]`, against this workspace's +//! A plan field's `"type"` names (`"int64"`, `"string"`, …) are wire format +//! in the same way: a binding spells them, and a stored row opens only under +//! the type it was sealed as. They are not this crate's: a declared type is +//! vitaminc's [`ValueKind`], re-exported here, whose names vitaminc freezes +//! beside its tag table. This crate adds only what a kind means to an index +//! ([`admits`]) and to a query value ([`read`]). A field without `"type"` is +//! dispatched on each value's own tag; that is transitional, and +//! [`record::plan`] says until when. +//! +//! For the same reason the enums that spell them — [`Output`], +//! [`IndexSpec`] and [`ValueKind`] — are *not* `#[non_exhaustive]`, against this workspace's //! usual rule for public enums: a new output is a wire-format addition every //! binding has to be taught, and an exhaustive match is how the compiler //! tells a binding author that. [`Scope`] is exhaustive for a different //! reason, given on the type. mod context; +mod kind; pub mod record; mod term; use std::fmt; pub use context::{borrowed, context}; +pub use kind::{admits, read}; pub use record::{FieldPlan, Output, Plan}; -pub use term::{term, Scalar, TermKind}; +pub use term::{term, Scalar}; /// vitaminc's language-neutral value tree — the runtime value every binding /// funnels through. Its transport codec is `vitaminc_aead_value::transport`, /// which stays the binding's: this crate takes and returns values, never /// encoded bytes. pub use vitaminc_aead_value::FfiValue; +/// vitaminc's value kinds: the type a plan field declares in its `"type"` +/// key. Its names are frozen wire format; see [`admits`] and [`read`] for +/// what a kind means to this engine. +pub use vitaminc_aead_value::ValueKind; +use crate::target::IndexSpec; use crate::{KeysetCipher, StackCipher}; /// Which cipher an opening operation decrypts through: the client, or one @@ -113,7 +129,10 @@ fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { /// The split that matters to a caller is malformed input versus something /// else: every variant but [`Cipher`](Error::Cipher) and /// [`Internal`](Error::Internal) is a statement about the value or the -/// request, decided before any key is minted or retrieved. `Cipher` is the +/// request, decided before any key is minted or retrieved — save one: a +/// typed field's opened value is checked against its declared type once it +/// is open (the type tag is inside the AEAD envelope), and a mismatch is +/// still [`Record`](Error::Record), a statement about the stored data. `Cipher` is the /// operation failing; `Internal` is this module's own bug. A binding maps /// them to its own status codes on those lines, and must not report /// `Internal` as the caller's fault. @@ -132,23 +151,26 @@ pub enum Error { /// container, null or passthrough (which have no term semantics at all), /// or a scalar outside the kind's domain — equality over a float or a /// boolean, match over anything but text. See - /// [`TermKind::supports`]. + /// [`IndexSpec::supports`]. #[error("no {kind} term is defined for this value")] Term { - /// The kind that was asked for. - kind: TermKind, + /// The index that was asked for. + kind: IndexSpec, }, /// A record plan is malformed: not an object of field specs, empty, - /// missing or duplicating an output, or carrying a key that is not - /// `"context"` or `"outputs"`. + /// missing or duplicating an output, carrying a key that is not + /// `"context"`, `"outputs"` or `"type"`, naming a type that is not one, + /// or asking for an index its declared type is not defined for. #[error("record plan is malformed")] Plan, /// A record source does not fit its plan: not an object (or an array of /// them), a field the plan does not name, a plan field the source does /// not carry or carries twice, or a passthrough or a repeated map key - /// under a field the plan seals. + /// under a field the plan seals, or a value of another type than its + /// field declares. Also a query value that cannot be read as its field's + /// type ([`read`]). #[error("record source does not fit the plan")] Source, @@ -156,7 +178,8 @@ pub enum Error { /// them), a ciphertext-bearing field that is absent or given twice, or /// has no `"c"` node or two of them, a repeated map key under `"c"`, or /// a passthrough under `"c"` — which would hand back unauthenticated - /// bytes as if they had been opened. + /// bytes as if they had been opened — or a typed field that opens to a + /// value of another type than it declares. #[error("stored record does not fit the plan")] Record, diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index f7474897d..6040a57ae 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -50,43 +50,56 @@ //! plaintext and have it reported as a successful decrypt. use stack_kms::DataKeySource; -use vitaminc_aead_value::FfiValue; +use vitaminc_aead_value::{FfiValue, ValueKind}; use vitaminc_protected::Protected; -use super::{borrowed, term, utf8, Error, Scalar, Scope, TermKind}; -use crate::target::Pending; +use super::{admits, borrowed, term, utf8, Error, Scalar, Scope}; +use crate::target::{IndexSpec, Pending}; use crate::{ BoxedPassthrough, CipherText, ContextPiece, Encrypt, KeysetCipher, NonEmpty, StackCipherText, }; /// What a plan field asks for. /// -/// The strings are wire format twice over: they are how a binding spells an +/// The keys are wire format twice over: they are how a binding spells an /// output, *and* the keys of the per-field output map in the stored result. /// That is why this enum is exhaustive — see the [module docs](super#stability). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +#[derive(Clone, PartialEq, Eq, Debug, Hash)] pub enum Output { /// `"c"` — the field's [`StackCipherText`]. Ciphertext, - /// An index term: `"eq"`, `"match"`, `"ore"` or `"ope"`. - Term(TermKind), + /// An index term, keyed `"eq"`, `"match"`, `"ore"` or `"ope"`: the + /// index, with its options, that derives it. + Term(IndexSpec), } impl Output { - /// The output a key names, or `None` for a key that is not one. + /// The output a bare key names, or `None` for a key that is not one. A + /// `"match"` key names the match index under default options; a plan + /// spells other options in the object form [`from_value`](Self::from_value) + /// reads. pub fn parse(s: &str) -> Option { - Some(match s { - "c" => Output::Ciphertext, - "eq" => Output::Term(TermKind::Equality), - "match" => Output::Term(TermKind::Match), - "ore" => Output::Term(TermKind::Ore), - "ope" => Output::Term(TermKind::Ope), - _ => return None, - }) + match s { + "c" => Some(Output::Ciphertext), + _ => IndexSpec::parse(s).map(Output::Term), + } + } + + /// Read one entry of a plan's `"outputs"` list: `"c"`, or an index in + /// its wire form ([`IndexSpec::from_value`]; see [`plan`]). + /// + /// # Errors + /// + /// [`Error::Plan`] for anything else. + pub fn from_value(value: &FfiValue) -> Result { + match value { + FfiValue::String(s) if utf8(s) == Some("c") => Ok(Output::Ciphertext), + _ => IndexSpec::from_value(value).map(Output::Term), + } } /// The map key this output rides under. - pub fn key(self) -> &'static str { + pub fn key(&self) -> &'static str { match self { Output::Ciphertext => "c", Output::Term(kind) => kind.key(), @@ -95,12 +108,22 @@ impl Output { } /// One field of a record plan: what to call it, what context to bind it -/// under, and what to produce for it. +/// under, what to produce for it, and, optionally, what type its values are. +/// +/// A field with a declared type (a [`ValueKind`]) admits only the indexes +/// that kind is defined for ([`admits`], checked when the +/// plan is built), seals only values of that kind and opens only to one +/// (checked per value), so the engine verifies what a binding hands it +/// rather than trusting the binding's tagging. A field with no declared type +/// is dispatched on each value's own type, as every field was before types +/// existed; that keeps the plans existing bindings send valid, and is +/// transitional (see [`plan`]). #[derive(Clone, Debug)] pub struct FieldPlan { name: String, context: NonEmpty>, outputs: Vec, + field_type: Option, } impl FieldPlan { @@ -115,7 +138,10 @@ impl FieldPlan { /// /// # Errors /// - /// [`Error::Plan`] if `outputs` is empty or names an output twice. + /// [`Error::Plan`] if `outputs` is empty or names an output twice. Two + /// outputs with the same [key](Output::key) are the same output — two + /// match indexes under different options would both ride under + /// `"match"` — so they are refused too. pub fn new( name: impl Into, context: NonEmpty>, @@ -125,7 +151,10 @@ impl FieldPlan { return Err(Error::Plan); } for (at, output) in outputs.iter().enumerate() { - if outputs[..at].contains(output) { + if outputs[..at] + .iter() + .any(|prior| prior.key() == output.key()) + { return Err(Error::Plan); } } @@ -133,9 +162,29 @@ impl FieldPlan { name: name.into(), context, outputs, + field_type: None, }) } + /// Declare the type of this field's values. + /// + /// # Errors + /// + /// [`Error::Plan`] if the field asks for an index the kind is not + /// defined for ([`admits`]): match on an integer, + /// equality on a float, any index on a composite. + pub fn with_type(mut self, field_type: ValueKind) -> Result { + for output in &self.outputs { + if let Output::Term(index) = output { + if !admits(field_type, index) { + return Err(Error::Plan); + } + } + } + self.field_type = Some(field_type); + Ok(self) + } + /// The field's name — its key in the source and in the result. pub fn name(&self) -> &str { &self.name @@ -151,6 +200,13 @@ impl FieldPlan { &self.outputs } + /// The declared type of the field's values, if the plan declares one. + /// A host with no types of its own reads this to know what a decrypted + /// value is. + pub fn field_type(&self) -> Option { + self.field_type + } + /// Whether the field has a ciphertext to seal and open. pub fn has_ciphertext(&self) -> bool { self.outputs.contains(&Output::Ciphertext) @@ -212,9 +268,46 @@ impl Plan { /// The plan is an [`FfiValue::Object`]: /// /// ```text -/// { : { "context": , "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// { : { "context": , "outputs": [ "c" | , ... ], "type": }, ... } /// ``` /// +/// `` is an index in its wire form, which is its key — `"eq"`, +/// `"match"`, `"ore"` or `"ope"` — save for a match index with options other +/// than the defaults, which is a one-entry object mapping `"match"` to them: +/// +/// ```text +/// { "match": { "tokenizer": "standard" | { "ngram": }, +/// "downcase": , "k": , "m": } } +/// ``` +/// +/// A bare `"match"` is the default options ([`MatchOptions::default`]: +/// 3-grams, downcased, `k = 3`, `m = 256`), so a plan written before options +/// had a wire form means what it always meant. In the object form each +/// option is optional and defaults the same way; an unknown or repeated +/// option, or a set the match scheme refuses (`k` outside `3..=16`, `m` not +/// a power of two in `32..=65536`, a zero n-gram length), is refused. The +/// other three indexes have no options and no object form. A field names +/// each output key at most once, so it carries at most one match index. +/// [`IndexSpec::to_value`] writes this form and [`IndexSpec::from_value`] +/// reads it. +/// +/// [`MatchOptions::default`]: crate::sem::MatchOptions::default +/// +/// `"type"` is optional, and names a [`ValueKind`] (`"int64"`, `"string"`, +/// …; see [`ValueKind::name`]): vitaminc's vocabulary, not one of this +/// crate's. Declared, it is checked against the field's outputs here +/// ([`admits`]) and against every value sealed into or opened +/// from the field. +/// +/// **An indexed field without `"type"` is dispatched on each value's own +/// tag**, so for that field the engine trusts the binding to tag every value +/// the same way: a `34` sent once as a `Float64` and once as an `Int64` under +/// one `"ore"` field is accepted both times and stores two different terms. +/// This is transitional. It keeps the plans the Go binding sends today, which +/// carry no `"type"`, valid until that binding fills `"type"` from its struct +/// types; then `"type"` becomes required on every field with a term output +/// (#1082). +/// /// `` is defined once, in [`super::context`](super::context()): a /// string, bytes, an integer, or a list of those, with what each spells in /// Rust and the emptiness rule. @@ -222,7 +315,8 @@ impl Plan { /// # Examples /// /// ``` -/// use stack_encrypt::dynamic::{record, FfiValue, Output, TermKind}; +/// use stack_encrypt::dynamic::{record, FfiValue, Output}; +/// use stack_encrypt::target::IndexSpec; /// /// // As a binding would decode it from its caller: seal `age` under the /// // pair ("users", "age") and index it for equality. @@ -250,7 +344,7 @@ impl Plan { /// assert_eq!(plan.fields()[0].name(), "age"); /// assert_eq!( /// plan.fields()[0].outputs(), -/// [Output::Ciphertext, Output::Term(TermKind::Equality)] +/// [Output::Ciphertext, Output::Term(IndexSpec::Equality)] /// ); /// # Ok::<(), stack_encrypt::dynamic::Error>(()) /// ``` @@ -259,9 +353,12 @@ impl Plan { /// /// [`Error::Plan`] for a plan that is not an object of field specs, an /// empty plan, a field named twice, a spec with a key other than -/// `"context"` and `"outputs"` or with either given twice or missing, or -/// an output list that is not a list of known output names, is empty, or -/// names an output twice. [`Error::Context`] for a `"context"` that is +/// `"context"`, `"outputs"` and `"type"` or with one given twice, missing +/// `"context"` or `"outputs"`, an output list that is not a list of +/// outputs (`"c"` or an index in its wire form, above), is empty, or names +/// an output key twice, a `"type"` that is not +/// a string naming a [`ValueKind`], or a type that does not admit one of +/// the field's index outputs. [`Error::Context`] for a `"context"` that is /// present but is not a context, or renders empty. /// /// The transport codec refuses duplicate object keys before a binding's @@ -279,6 +376,7 @@ pub fn plan(value: FfiValue) -> Result { }; let mut context: Option>> = None; let mut outputs: Option> = None; + let mut field_type: Option = None; for (key, value) in spec { match key.as_str() { "context" if context.is_none() => context = Some(super::context(value)?), @@ -286,25 +384,32 @@ pub fn plan(value: FfiValue) -> Result { let FfiValue::Array(items) = value else { return Err(Error::Plan); }; - let mut parsed = Vec::with_capacity(items.len()); - for item in &items { - let FfiValue::String(s) = item else { - return Err(Error::Plan); - }; - let key = utf8(s).ok_or(Error::Plan)?; - parsed.push(Output::parse(key).ok_or(Error::Plan)?); - } + let parsed = items + .iter() + .map(Output::from_value) + .collect::, _>>()?; outputs = Some(parsed); } - // An unknown key, or one of the two given twice. + "type" if field_type.is_none() => { + let FfiValue::String(s) = &value else { + return Err(Error::Plan); + }; + let name = utf8(s).ok_or(Error::Plan)?; + field_type = Some(name.parse().map_err(|_| Error::Plan)?); + } + // An unknown key, or one of the three given twice. _ => return Err(Error::Plan), } } - fields.push(FieldPlan::new( + let field = FieldPlan::new( name, context.ok_or(Error::Plan)?, outputs.ok_or(Error::Plan)?, - )?); + )?; + fields.push(match field_type { + Some(field_type) => field.with_type(field_type)?, + None => field, + }); } // The whole-plan rules — non-empty, no name twice — are `Plan::new`'s, // so a parsed plan and a hand-built one are refused alike. @@ -467,11 +572,14 @@ where K: DataKeySource + Sync + 'static, { let Rows { rows, batched } = record_leaves(record, plan)?; - let contexts = plan + let opened = plan .fields .iter() .filter(|field| field.has_ciphertext()) - .map(FieldPlan::view) + .collect::>(); + let contexts = opened + .iter() + .map(|field| field.view()) .collect::, Error>>()?; // Per row, per ciphertext-bearing plan field (in plan order, as @@ -507,7 +615,17 @@ where .map(|row_names| { let entries = row_names .into_iter() - .map(|name| Ok((name, values.next()?))) + .zip(&opened) + .map(|(name, field)| { + let value = values.next()?; + // The tag is authenticated, so a mismatch is not + // tampering: the row was sealed as another type than + // the plan now declares. + match field.field_type { + Some(declared) if !declared.holds(&value) => Err(Error::Record), + _ => Ok((name, value)), + } + }) .collect::, Error>>()?; Ok(FfiValue::Object(entries)) }) @@ -539,6 +657,9 @@ pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { /// Check a stored record against a plan without opening it — everything /// [`decrypt`] checks before it consults the cipher. See [`check_source`]. /// +/// A typed field's declared type is not among them: the type is the sealed +/// leaf's tag, inside the AEAD envelope, so only [`decrypt`] can check it. +/// /// # Errors /// /// As [`decrypt`], minus the cipher. @@ -784,18 +905,24 @@ fn source_rows(source: FfiValue, plan: &Plan) -> Result>, Err }) } -/// A source value against its plan field: every term output needs a scalar -/// the scheme defines the term for ([`TermKind::supports`]), and a -/// ciphertext output refuses a passthrough, or a repeated map key, anywhere -/// in the value ([`check_tree`]). +/// A source value against its plan field: a typed field needs a value of +/// its type, every term output needs a scalar the scheme defines the term +/// for ([`IndexSpec::supports`]), and a ciphertext output refuses a +/// passthrough, or a repeated map key, anywhere in the value +/// ([`check_tree`]). fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { + if let Some(declared) = field.field_type { + if !declared.holds(value) { + return Err(Error::Source); + } + } for output in &field.outputs { match output { Output::Ciphertext => check_tree(value)?, Output::Term(kind) => { - let scalar = Scalar::of(value, *kind)?; + let scalar = Scalar::of(value, kind)?; if !kind.supports(&scalar) { - return Err(Error::Term { kind: *kind }); + return Err(Error::Term { kind: kind.clone() }); } } } @@ -872,7 +999,7 @@ where .outputs .iter() .find_map(|o| match o { - Output::Term(kind) => Some(*kind), + Output::Term(kind) => Some(kind), Output::Ciphertext => None, }) .map(|kind| Scalar::of(&value, kind)) @@ -887,7 +1014,7 @@ where let scalar = scalar.clone().ok_or(Error::Internal)?; outputs.push(( output.key(), - Slot::Term(term(cipher, scalar, *kind, context.clone()).await?), + Slot::Term(term(cipher, scalar, kind, context.clone()).await?), )); } @@ -1177,8 +1304,8 @@ mod tests { plan.fields()[0].outputs(), [ Output::Ciphertext, - Output::Term(TermKind::Equality), - Output::Term(TermKind::Ore) + Output::Term(IndexSpec::Equality), + Output::Term(IndexSpec::Ore) ], "outputs keep their spelled order" ); @@ -1189,7 +1316,9 @@ mod tests { ); assert_eq!( plan.fields()[2].outputs(), - [Output::Term(TermKind::Match)], + [Output::Term(IndexSpec::Match( + crate::sem::MatchOptions::default() + ))], "a field can be indexed and never sealed" ); assert!( @@ -1330,7 +1459,7 @@ mod tests { FieldPlan::new( "age", ctx.clone(), - vec![Output::Term(TermKind::Ore), Output::Term(TermKind::Ore)] + vec![Output::Term(IndexSpec::Ore), Output::Term(IndexSpec::Ore)] ), Err(Error::Plan) ), @@ -1342,6 +1471,101 @@ mod tests { ); } + /// A match index with non-default options is spelled in a plan as + /// an object, parses to the index carrying them, and still rides + /// under the `"match"` key; the bare key stays the defaults. + #[test] + fn a_match_index_with_options_parses_from_its_object_form() { + let wide = obj(vec![( + "match", + obj(vec![ + ("tokenizer", s("standard")), + ("downcase", FfiValue::Bool(false)), + ("k", FfiValue::UInt32(6)), + ("m", FfiValue::UInt32(1024)), + ]), + )]); + let plan = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ("outputs", FfiValue::Array(vec![s("c"), wide])), + ]), + )])) + .expect("parses"); + let options = crate::sem::MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + }; + assert_eq!( + plan.fields()[0].outputs(), + [Output::Ciphertext, Output::Term(IndexSpec::Match(options))] + ); + assert_eq!(plan.fields()[0].outputs()[1].key(), "match"); + } + + /// Two match indexes in one field would both ride under `"match"`, + /// so a field names an output key once whatever the options. + #[test] + fn a_field_refuses_two_match_indexes_under_different_options() { + let wide = obj(vec![("match", obj(vec![("k", FfiValue::UInt32(6))]))]); + let parsed = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ("outputs", FfiValue::Array(vec![s("match"), wide])), + ]), + )])); + assert!( + matches!(parsed, Err(Error::Plan)), + "two match outputs are one key twice" + ); + let ctx = context(s("users/nick")).expect("context"); + let by_hand = FieldPlan::new( + "nick", + ctx, + vec![ + Output::Term(IndexSpec::Match(crate::sem::MatchOptions::default())), + Output::Term(IndexSpec::Match(crate::sem::MatchOptions { + k: 6, + ..crate::sem::MatchOptions::default() + })), + ], + ); + assert!(matches!(by_hand, Err(Error::Plan)), "and by hand alike"); + } + + /// An output list entry is `"c"` or an index in its wire form, and + /// nothing else; a malformed match object is a plan error. + #[test] + fn an_output_that_is_not_one_is_refused() { + for (label, output) in [ + ("an unknown key", s("cc")), + ("a number", FfiValue::UInt32(1)), + ( + "an object for the ciphertext", + obj(vec![("c", obj(vec![]))]), + ), + ( + "match options out of bounds", + obj(vec![("match", obj(vec![("k", FfiValue::UInt32(2))]))]), + ), + ] { + let parsed = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ("outputs", FfiValue::Array(vec![output])), + ]), + )])); + assert!(matches!(parsed, Err(Error::Plan)), "{label}"); + } + assert_eq!(Output::parse("c"), Some(Output::Ciphertext)); + assert_eq!(Output::parse("cc"), None); + } + /// The whole-plan rules hold for a plan built by hand, not only for /// a parsed one: a hand-built plan reaches the same `encrypt` and /// `check_source`, which rely on them. @@ -1459,7 +1683,7 @@ mod tests { matches!( e, Error::Term { - kind: TermKind::Match + kind: IndexSpec::Match(_) } ) }, @@ -1475,7 +1699,7 @@ mod tests { matches!( e, Error::Term { - kind: TermKind::Match + kind: IndexSpec::Match(_) } ) }, @@ -1515,7 +1739,7 @@ mod tests { matches!( err, Some(Error::Term { - kind: TermKind::Match + kind: IndexSpec::Match(_) }) ), "encrypt refuses a value with no such term: {err:?}" @@ -1539,7 +1763,7 @@ mod tests { matches!( err, Some(Error::Term { - kind: TermKind::Equality + kind: IndexSpec::Equality }) ), "no PRF encoding exists for a float: {err:?}" @@ -1631,7 +1855,7 @@ mod tests { let eq = term( &keyset, Scalar::U32(34), - TermKind::Equality, + &IndexSpec::Equality, age_ctx.clone(), ) .await @@ -1641,7 +1865,7 @@ mod tests { eq, "the equality term is the standalone derivation under the plan context" ); - let ore = term(&keyset, Scalar::U32(34), TermKind::Ore, age_ctx) + let ore = term(&keyset, Scalar::U32(34), &IndexSpec::Ore, age_ctx) .await .expect("standalone ore term"); assert_eq!( @@ -1649,10 +1873,19 @@ mod tests { ore, "the ore term is the standalone derivation under the plan context" ); - let scalar = Scalar::of(&s("al smith"), TermKind::Match).expect("text"); - let matched = term(&keyset, scalar, TermKind::Match, nick_ctx) - .await - .expect("standalone match term"); + let scalar = Scalar::of( + &s("al smith"), + &IndexSpec::Match(crate::sem::MatchOptions::default()), + ) + .expect("text"); + let matched = term( + &keyset, + scalar, + &IndexSpec::Match(crate::sem::MatchOptions::default()), + nick_ctx, + ) + .await + .expect("standalone match term"); assert_eq!( term_bytes(&node(&mut nick, "match")), matched, @@ -1660,6 +1893,45 @@ mod tests { ); } + /// A plan's match options reach the term: the stored `"match"` + /// term is the derivation under those options, not the defaults. + #[tokio::test] + async fn a_match_term_derives_under_the_plan_options() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let options = crate::sem::MatchOptions { + k: 6, + m: 1024, + ..crate::sem::MatchOptions::default() + }; + let plan = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ( + "outputs", + FfiValue::Array(vec![IndexSpec::Match(options.clone()).to_value()]), + ), + ]), + )])) + .expect("parses"); + let ctx = plan.fields()[0].context().clone(); + let row = obj(vec![("nick", s("al smith"))]); + let mut fields = map(encrypt(&keyset, row, &plan).await.expect("encrypt")); + let mut nick = map(node(&mut fields, "nick")); + let stored = term_bytes(&node(&mut nick, "match")); + + let kind = IndexSpec::Match(options); + let scalar = Scalar::of(&s("al smith"), &kind).expect("text"); + let expected = term(&keyset, scalar.clone(), &kind, ctx.clone()) + .await + .expect("standalone"); + assert_eq!(stored, expected, "the term is the plan options' derivation"); + let default = IndexSpec::Match(crate::sem::MatchOptions::default()); + let under_default = term(&keyset, scalar, &default, ctx).await.expect("default"); + assert_ne!(stored, under_default, "and not the defaults'"); + } + #[tokio::test] async fn opens_back_to_its_ciphertext_bearing_fields_in_plan_order() { let cipher = cipher().await; @@ -2045,4 +2317,364 @@ mod tests { "a bare plan string is the typed literal" ); } + + mod given_a_typed_field { + use super::*; + + fn typed(context: &str, outputs: &[&str], ty: &str) -> FfiValue { + obj(vec![ + ("context", s(context)), + ("outputs", strings(outputs)), + ("type", s(ty)), + ]) + } + + fn age_plan(ty: &str) -> Plan { + plan(obj(vec![( + "age", + typed("users/age", &["c", "eq", "ore"], ty), + )])) + .expect("a typed plan parses") + } + + #[test] + fn the_plan_parses_the_type_and_an_untyped_field_has_none() { + let parsed = plan(obj(vec![ + ("age", typed("users/age", &["c", "ore"], "uint64")), + ("bio", typed("users/bio", &["c", "match"], "string")), + ("notes", spec(s("users/notes"), &["c"])), + ])) + .expect("parses"); + let types: Vec<_> = parsed.fields().iter().map(FieldPlan::field_type).collect(); + assert_eq!( + types, + [Some(ValueKind::UInt64), Some(ValueKind::String), None] + ); + } + + /// The parser applies `"type"` after it has read every key, so a + /// spec that declares its type before its outputs is checked the + /// same way: admitted when the type admits the indexes, refused + /// when it does not. + #[test] + fn the_plan_reads_a_type_given_before_the_outputs() { + let early = |ty: &str| { + obj(vec![ + ("type", s(ty)), + ("outputs", strings(&["c", "eq"])), + ("context", s("users/age")), + ]) + }; + let parsed = plan(obj(vec![("age", early("uint64"))])).expect("parses"); + assert_eq!(parsed.fields()[0].field_type(), Some(ValueKind::UInt64)); + assert_eq!( + parsed.fields()[0].outputs(), + [Output::Ciphertext, Output::Term(IndexSpec::Equality)] + ); + let refused = plan(obj(vec![("age", early("float64"))])); + assert!( + matches!(refused, Err(Error::Plan)), + "equality on a float is refused whatever the key order: {refused:?}" + ); + } + + #[test] + fn the_plan_refuses_an_unresolvable_type_or_one_that_does_not_admit_an_index() { + let refused: [(&str, FfiValue); 8] = [ + ("an unknown type", typed("users/x", &["c"], "u64")), + ( + "a type in the wrong case", + typed("users/x", &["c"], "UInt64"), + ), + ( + "a type that is not a string", + obj(vec![ + ("context", s("users/x")), + ("outputs", strings(&["c"])), + ("type", FfiValue::UInt32(6)), + ]), + ), + ( + "a type given twice", + obj(vec![ + ("context", s("users/x")), + ("outputs", strings(&["c"])), + ("type", s("string")), + ("type", s("string")), + ]), + ), + ( + "match on an integer", + typed("users/x", &["c", "match"], "int64"), + ), + ( + "equality on a float", + typed("users/x", &["c", "eq"], "float64"), + ), + ("equality on a bool", typed("users/x", &["eq"], "bool")), + ( + "order on a composite", + typed("users/x", &["c", "ore"], "object"), + ), + ]; + for (label, field) in refused { + let result = plan(obj(vec![("x", field)])); + assert!(matches!(result, Err(Error::Plan)), "{label}: {result:?}"); + } + } + + #[test] + fn a_hand_built_field_takes_a_type_its_indexes_admit_and_refuses_one_they_do_not() { + let field = FieldPlan::new( + "age", + context(s("users/age")).expect("context"), + vec![Output::Ciphertext, Output::Term(IndexSpec::Equality)], + ) + .expect("field"); + assert_eq!(field.field_type(), None, "untyped until declared"); + let typed = field + .clone() + .with_type(ValueKind::UInt32) + .expect("equality admits a u32"); + assert_eq!(typed.field_type(), Some(ValueKind::UInt32)); + assert!(matches!( + field.with_type(ValueKind::Float32), + Err(Error::Plan) + )); + let sealed_only = FieldPlan::new( + "doc", + context(s("users/doc")).expect("context"), + vec![Output::Ciphertext], + ) + .expect("field") + .with_type(ValueKind::Object) + .expect("a composite with no index is a plain sealed field"); + assert_eq!(sealed_only.field_type(), Some(ValueKind::Object)); + } + + /// The engine verifies the tag rather than trusting the binding: a + /// `u32` is not a `uint64`, however small. Refused before any key + /// is minted, by `check_source` alike. + #[tokio::test] + async fn encrypt_refuses_a_value_of_another_type_with_no_key_request() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = age_plan("uint64"); + let check = check_source(obj(vec![("age", FfiValue::UInt32(34))]), &plan); + assert!( + matches!(check, Err(Error::Source)), + "check_source refuses it too" + ); + for (label, value) in [ + ("a u32 for a uint64 field", FfiValue::UInt32(34)), + ("a float for a uint64 field", FfiValue::Float64(34.0)), + ("null for a uint64 field", FfiValue::Null), + ] { + let result = encrypt(&keyset, obj(vec![("age", value)]), &plan).await; + assert!(matches!(result, Err(Error::Source)), "{label}: {result:?}"); + } + assert_eq!(generates(&cipher), 0, "refused before any key request"); + } + + #[tokio::test] + async fn a_value_of_the_type_round_trips_and_its_terms_are_the_typed_terms() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = age_plan("uint64"); + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &plan) + .await + .expect("seal"); + let mut row = map(sealed); + let mut age = map(node(&mut row, "age")); + let eq = term_bytes(&node(&mut age, "eq")); + let typed = keyset + .equality_term(34u64, nonempty!("users/age")) + .await + .expect("typed"); + assert_eq!(eq, typed.into_bytes().to_vec(), "the u64 term"); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &plan) + .await + .expect("seal"); + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("open"); + let fields = object(opened); + assert!(matches!(&fields[..], [(name, FfiValue::UInt64(34))] if name == "age")); + } + + /// A row sealed as one type and read under a plan declaring another + /// is refused, not handed back as the wrong type. The tag is inside + /// the AEAD envelope, so this is a plan disagreeing with its data, + /// never tampering, and it is caught once the leaf is open. + #[tokio::test] + async fn decrypt_refuses_a_value_that_opens_to_another_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let untyped = plan(obj(vec![("age", spec(s("users/age"), &["c"]))])).expect("plan"); + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + let as_uint64 = + plan(obj(vec![("age", typed("users/age", &["c"], "uint64"))])).expect("plan"); + let result = decrypt(Scope::Client(&cipher), sealed, &as_uint64).await; + assert!(matches!(result, Err(Error::Record)), "{:?}", result.err()); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + let as_uint32 = + plan(obj(vec![("age", typed("users/age", &["c"], "uint32"))])).expect("plan"); + let opened = decrypt(Scope::Client(&cipher), sealed, &as_uint32) + .await + .expect("the declared type opens"); + assert_eq!(u32_of(&object(opened)[0].1), 34); + } + + /// In a batch, each opened value is checked against its own field, + /// not the first field's. + #[tokio::test] + async fn decrypt_checks_each_field_against_its_own_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(obj(vec![ + ("age", typed("users/age", &["c"], "uint32")), + ("name", typed("users/name", &["c"], "string")), + ])) + .expect("plan"); + let rows = FfiValue::Array(vec![ + obj(vec![("age", FfiValue::UInt32(1)), ("name", s("a"))]), + obj(vec![("age", FfiValue::UInt32(2)), ("name", s("b"))]), + ]); + let sealed = encrypt(&keyset, rows, &plan).await.expect("seal"); + let opened = array( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("open"), + ); + assert_eq!(opened.len(), 2); + let second = object(opened.into_iter().nth(1).expect("row")); + assert_eq!(keys(&second), ["age", "name"]); + assert_eq!(u32_of(&second[0].1), 2); + assert_eq!(text_of(&second[1].1), "b"); + } + + /// The query side: a host's `34` (a float, from JavaScript) read as + /// the field's type derives the term the field stores. + #[tokio::test] + async fn a_query_value_read_as_the_field_type_finds_the_stored_term() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = age_plan("uint64"); + let field = &plan.fields()[0]; + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &plan) + .await + .expect("seal"); + let mut row = map(sealed); + let mut age = map(node(&mut row, "age")); + let stored = term_bytes(&node(&mut age, "eq")); + + let declared = field.field_type().expect("typed"); + let value = + crate::dynamic::read(declared, FfiValue::Float64(34.0)).expect("an exact 34"); + let scalar = Scalar::of(&value, &IndexSpec::Equality).expect("scalar"); + let query = term( + &keyset, + scalar, + &IndexSpec::Equality, + field.view().expect("view"), + ) + .await + .expect("query term"); + assert_eq!(query, stored); + } + + /// An index-only field has no ciphertext, so it is not among the + /// opened values. The typed field after it is checked against its + /// own type, not against the index-only field's. + #[tokio::test] + async fn decrypt_skips_an_index_only_field_when_it_checks_types() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(obj(vec![ + ("age", typed("users/age", &["eq"], "uint64")), + ("name", typed("users/name", &["c"], "string")), + ])) + .expect("plan"); + let sealed = encrypt( + &keyset, + obj(vec![("age", FfiValue::UInt64(34)), ("name", s("bob"))]), + &plan, + ) + .await + .expect("seal"); + let opened = object( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("name is checked as a string, not as a uint64"), + ); + assert_eq!(keys(&opened), ["name"]); + assert_eq!(text_of(&opened[0].1), "bob"); + } + + /// A typed composite field opens as its declared kind, with its + /// entries, including when it is empty: an empty `[]` that opened + /// as `{}` would fail every read of an `"array"` field. + #[tokio::test] + async fn a_typed_composite_field_round_trips_even_when_empty() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + for (ty, value, len) in [ + ("object", obj(vec![("home", s("a@x"))]), 1), + ("object", FfiValue::Object(vec![]), 0), + ( + "array", + FfiValue::Array(vec![s("a"), FfiValue::UInt32(1)]), + 2, + ), + ("array", FfiValue::Array(vec![]), 0), + ] { + let plan = plan(obj(vec![("doc", typed("users/doc", &["c"], ty))])).expect("plan"); + let sealed = encrypt(&keyset, obj(vec![("doc", value)]), &plan) + .await + .expect("seal"); + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("a composite opens as its declared kind"); + let (_, doc) = object(opened).into_iter().next().expect("doc"); + match (ty, doc) { + ("object", FfiValue::Object(entries)) => assert_eq!(entries.len(), len), + ("array", FfiValue::Array(items)) => assert_eq!(items.len(), len), + (ty, _) => panic!("a {ty} field opened as another kind"), + } + } + } + + /// `check_record` does not open anything, so it cannot see a + /// typed field's type: the tag is inside the AEAD envelope. A record + /// sealed as another type passes it, and only `decrypt` refuses it. + #[tokio::test] + async fn check_record_accepts_a_record_sealed_as_another_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let untyped = plan(obj(vec![("age", spec(s("users/age"), &["c"]))])).expect("plan"); + let as_uint64 = + plan(obj(vec![("age", typed("users/age", &["c"], "uint64"))])).expect("plan"); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + check_record(sealed, &as_uint64).expect("the type is not visible without opening"); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + let result = decrypt(Scope::Client(&cipher), sealed, &as_uint64).await; + assert!( + matches!(result, Err(Error::Record)), + "decrypt is where the type is checked: {:?}", + result.err() + ); + } + } } diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 5a6e75d08..9e6aa0a90 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -14,69 +14,169 @@ //! going to be **stored** should come from the record path, where it shares //! one context with the ciphertext beside it (ADR-0004). -use std::fmt; - use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; use super::{utf8, Error}; -use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; +use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchOptions, Tokenizer}; +use crate::target::IndexSpec; use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; -/// Which index term to derive. +/// The runtime half of [`IndexSpec`]: the domain table, and the index's wire +/// form in a record plan. /// -/// The `key` strings are wire format, and that is why this enum is -/// exhaustive — see the [module docs](super#stability). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] -pub enum TermKind { - /// `"eq"` — equality (exact match). Raw 32 PRF bytes. - Equality, - /// `"match"` — full-text match under the default tokenizer config. LE - /// `u16` bit positions. - Match, - /// `"ore"` — order-revealing comparison. Raw CLLW bytes. - Ore, - /// `"ope"` — order-preserving comparison. Raw CLLW bytes. - Ope, -} +/// # Wire form +/// +/// An index is its key, a string: `"eq"`, `"match"`, `"ore"` or `"ope"`. +/// `"match"` is a match index under the default options +/// ([`MatchOptions::default`]), so every plan written before options had a +/// wire form reads as it always did. +/// +/// A match index under other options is a one-entry object, the key +/// `"match"` mapped to its options: +/// +/// ```text +/// { "match": { "tokenizer": "standard" | { "ngram": }, +/// "downcase": , "k": , "m": } } +/// ``` +/// +/// Every option is optional and defaults to [`MatchOptions::default`]'s +/// value; an unknown option, an option given twice, or options that fail +/// the match scheme's bounds (`k` in `3..=16`, `m` a power of two in +/// `[32, 65536]`, a non-zero n-gram length) are refused. The other three +/// indexes have no options and no object form. +/// +/// [`to_value`](Self::to_value) writes the string whenever the options are +/// the defaults and the object, with all four options, otherwise, so a +/// default plan is byte-for-byte what it was before options had a wire form. +impl IndexSpec { + /// The index a bare key names, under default options, or `None` for a + /// string that is not an index key. + pub fn parse(key: &str) -> Option { + Some(match key { + "eq" => IndexSpec::Equality, + "match" => IndexSpec::Match(MatchOptions::default()), + "ore" => IndexSpec::Ore, + "ope" => IndexSpec::Ope, + _ => return None, + }) + } + + /// Read an index from its wire form (see the [type docs](Self#wire-form)). + /// + /// # Errors + /// + /// [`Error::Plan`] for a value that is neither an index key nor a match + /// options object, or whose options are unknown, repeated, mistyped or + /// out of bounds. + pub fn from_value(value: &FfiValue) -> Result { + match value { + FfiValue::String(s) => Self::parse(utf8(s).ok_or(Error::Plan)?).ok_or(Error::Plan), + FfiValue::Object(entries) => match entries.as_slice() { + [(key, FfiValue::Object(options))] if key == "match" => { + Ok(IndexSpec::Match(match_options(options)?)) + } + _ => Err(Error::Plan), + }, + _ => Err(Error::Plan), + } + } -impl TermKind { - /// The map key this term rides under in a record, and the string a - /// binding spells it as. - pub fn key(self) -> &'static str { + /// Write this index in its wire form (see the [type docs](Self#wire-form)): + /// the key, or for a match index under non-default options, the object + /// carrying all four of them. [`from_value`](Self::from_value) reads it + /// back to an equal index. + pub fn to_value(&self) -> FfiValue { match self { - TermKind::Equality => "eq", - TermKind::Match => "match", - TermKind::Ore => "ore", - TermKind::Ope => "ope", + IndexSpec::Match(options) if *options != MatchOptions::default() => { + let tokenizer = match options.tokenizer { + Tokenizer::Standard => FfiValue::String("standard".into()), + Tokenizer::Ngram { length } => FfiValue::Object(vec![( + "ngram".to_string(), + FfiValue::UInt64(length as u64), + )]), + }; + FfiValue::Object(vec![( + "match".to_string(), + FfiValue::Object(vec![ + ("tokenizer".to_string(), tokenizer), + ("downcase".to_string(), FfiValue::Bool(options.downcase)), + ("k".to_string(), FfiValue::UInt64(options.k as u64)), + ("m".to_string(), FfiValue::UInt64(u64::from(options.m))), + ]), + )]) + } + _ => FfiValue::String(self.key().into()), } } - /// Whether the scheme defines this term for `scalar`. + /// Whether the scheme defines this index's term for `scalar`. /// /// No PRF encoding exists for floats (equality on IEEE-754 values is a /// modelling error) or booleans; match is text-only; the ordering /// schemes take every scalar. This is the one table — [`term`]'s arms /// mirror it and are unreachable for a pair it refuses — and it is /// consulted before any cipher work, so a binding can reject a bad - /// request at its boundary without minting anything. - pub fn supports(self, scalar: &Scalar) -> bool { + /// request at its boundary without minting anything. A match index's + /// options do not change its domain. + pub fn supports(&self, scalar: &Scalar) -> bool { match self { - TermKind::Equality => { + IndexSpec::Equality => { !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)) } - TermKind::Match => matches!(scalar, Scalar::Text(_)), - TermKind::Ore | TermKind::Ope => true, + IndexSpec::Match(_) => matches!(scalar, Scalar::Text(_)), + IndexSpec::Ore | IndexSpec::Ope => true, } } } -impl fmt::Display for TermKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.key()) +/// The options object of a match index's wire form: each key at most once, +/// each defaulting, and the whole checked against the scheme's bounds. +fn match_options(entries: &[(String, FfiValue)]) -> Result { + let mut options = MatchOptions::default(); + let mut seen: Vec<&str> = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if seen.contains(&key.as_str()) { + return Err(Error::Plan); + } + seen.push(key); + match (key.as_str(), value) { + ("tokenizer", FfiValue::String(s)) if utf8(s) == Some("standard") => { + options.tokenizer = Tokenizer::Standard; + } + ("tokenizer", FfiValue::Object(tokenizer)) => match tokenizer.as_slice() { + [(name, length)] if name == "ngram" => { + options.tokenizer = Tokenizer::Ngram { + length: integer(length)?, + }; + } + _ => return Err(Error::Plan), + }, + ("downcase", FfiValue::Bool(downcase)) => options.downcase = *downcase, + ("k", k) => options.k = integer(k)?, + ("m", m) => options.m = integer(m)?, + _ => return Err(Error::Plan), + } } + if options.validate().is_err() { + return Err(Error::Plan); + } + Ok(options) +} + +/// A non-negative integer leaf of any width, as the target type, or +/// [`Error::Plan`]. +fn integer>(value: &FfiValue) -> Result { + let wide = match value { + FfiValue::Int32(v) => u64::try_from(*v).ok(), + FfiValue::Int64(v) => u64::try_from(*v).ok(), + FfiValue::UInt32(v) => Some(u64::from(*v)), + FfiValue::UInt64(v) => Some(*v), + _ => None, + }; + wide.and_then(|v| T::try_from(v).ok()).ok_or(Error::Plan) } /// A term-able scalar lifted out of an [`FfiValue`] leaf. @@ -118,8 +218,8 @@ impl Scalar { /// [`Error::Term`] for a container, null, undefined or passthrough: /// those have no term semantics at all, whatever the kind. `kind` names /// the term the caller was after, for the error only — whether that kind - /// is defined for the scalar is [`TermKind::supports`]. - pub fn of(value: &FfiValue, kind: TermKind) -> Result { + /// is defined for the scalar is [`IndexSpec::supports`]. + pub fn of(value: &FfiValue, kind: &IndexSpec) -> Result { Ok(match value { FfiValue::Bool(v) => Scalar::Bool(*v), FfiValue::Int32(v) => Scalar::I32(*v), @@ -129,11 +229,13 @@ impl Scalar { FfiValue::Float32(v) => Scalar::F32(*v), FfiValue::Float64(v) => Scalar::F64(*v), FfiValue::String(s) => Scalar::Text(Zeroizing::new( - utf8(s).ok_or(Error::Term { kind })?.to_string(), + utf8(s) + .ok_or_else(|| Error::Term { kind: kind.clone() })? + .to_string(), )), FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), // Containers, nulls and passthroughs have no term semantics. - _ => return Err(Error::Term { kind }), + _ => return Err(Error::Term { kind: kind.clone() }), }) } } @@ -146,7 +248,8 @@ impl Scalar { /// derives for the same value under the same context: /// /// ``` -/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar, TermKind}; +/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar}; +/// use stack_encrypt::target::IndexSpec; /// use stack_encrypt::StackCipher; /// use stack_encrypt::kms::FakeDataKeySource; /// @@ -161,7 +264,7 @@ impl Scalar { /// FfiValue::String("users".into()), /// FfiValue::String("age".into()), /// ]))?; -/// let probe = term(&keyset, Scalar::U32(34), TermKind::Equality, ctx.clone()).await?; +/// let probe = term(&keyset, Scalar::U32(34), &IndexSpec::Equality, ctx.clone()).await?; /// let typed = keyset.equality_term(34u32, ctx).await?; /// assert_eq!(probe, typed.into_bytes().to_vec()); /// # Ok::<(), stack_encrypt::dynamic::Error>(()) @@ -171,13 +274,13 @@ impl Scalar { /// # Errors /// /// [`Error::Term`] if the scheme defines no such term for the scalar -/// ([`TermKind::supports`] is the table, and checking it first is how a +/// ([`IndexSpec::supports`] is the table, and checking it first is how a /// binding turns this into a boundary rejection). [`Error::Cipher`] if the /// derivation itself fails. pub async fn term<'c, K, D>( cipher: &KeysetCipher<'_, K>, scalar: Scalar, - kind: TermKind, + kind: &IndexSpec, context: NonEmpty, ) -> Result, Error> where @@ -185,14 +288,14 @@ where D: IntoPrfContext<'c>, { match kind { - TermKind::Equality => equality(cipher, scalar, context).await, - TermKind::Match => match_term(cipher, scalar, context).await, - TermKind::Ore => ore_of(cipher, scalar, context).await, - TermKind::Ope => ope_of(cipher, scalar, context).await, + IndexSpec::Equality => equality(cipher, scalar, context).await, + IndexSpec::Match(options) => match_term(cipher, scalar, options, context).await, + IndexSpec::Ore => ore_of(cipher, scalar, context).await, + IndexSpec::Ope => ope_of(cipher, scalar, context).await, } } -/// [`TermKind::Equality`] per scalar: one PRF block over the value, for +/// [`IndexSpec::Equality`] per scalar: one PRF block over the value, for /// every integer width, text and bytes. async fn equality<'c, K, D>( cipher: &KeysetCipher<'_, K>, @@ -218,17 +321,21 @@ where // values is a modelling error) or booleans. Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { return Err(Error::Term { - kind: TermKind::Equality, + kind: IndexSpec::Equality, }) } }?; Ok(term.into_bytes().to_vec()) } -/// [`TermKind::Match`] per scalar: text only. +/// [`IndexSpec::Match`] per scalar: text only, under the index's options. +/// Under the default options the bytes are the typed +/// `match_terms::`'s; under others, those of a +/// [`MatchConfig`](crate::sem::MatchConfig) returning the same options. async fn match_term<'c, K, D>( cipher: &KeysetCipher<'_, K>, scalar: Scalar, + options: &MatchOptions, context: NonEmpty, ) -> Result, Error> where @@ -237,16 +344,16 @@ where { match scalar { Scalar::Text(t) => Ok(cipher - .match_terms::(&t, context) + .match_terms_under::(&t, context, options.clone()) .await .map(|t| t.to_bytes())?), _ => Err(Error::Term { - kind: TermKind::Match, + kind: IndexSpec::Match(options.clone()), }), } } -/// [`TermKind::Ore`] per scalar: every scalar has an ORE encoding. +/// [`IndexSpec::Ore`] per scalar: every scalar has an ORE encoding. /// /// The text and bytes arms hand the encryptor the `Zeroizing` operand /// itself, not a bare clone of its contents: the CLLW encryptors take @@ -276,7 +383,7 @@ where } } -/// [`TermKind::Ope`] per scalar; see [`ore_of`] for why the text and bytes +/// [`IndexSpec::Ope`] per scalar; see [`ore_of`] for why the text and bytes /// arms pass the wrapper. async fn ope_of<'c, K, D>( cipher: &KeysetCipher<'_, K>, @@ -352,6 +459,12 @@ mod tests { .expect("build cipher") } + /// A match index under the default options: what a plan's bare + /// `"match"` names. + fn default_match() -> IndexSpec { + IndexSpec::Match(MatchOptions::default()) + } + fn s(value: &str) -> FfiValue { FfiValue::String(value.into()) } @@ -385,16 +498,18 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ctx = context(s("users/x")).expect("context"); - let dynamic = |value: &FfiValue, kind: TermKind| { - let scalar = Scalar::of(value, kind).expect("a scalar"); - term(&keyset, scalar, kind, ctx.clone()) + let ks = &keyset; + let dynamic = |value: &FfiValue, kind: IndexSpec| { + let scalar = Scalar::of(value, &kind).expect("a scalar"); + let ctx = ctx.clone(); + async move { term(ks, scalar, &kind, ctx).await } }; let eq = |t: crate::sem::EqualityTerm| t.into_bytes().to_vec(); // Equality, per PRF-encodable variant. let typed = keyset.equality_term(-3i32, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::Int32(-3), TermKind::Equality) + dynamic(&FfiValue::Int32(-3), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -402,7 +517,7 @@ mod tests { ); let typed = keyset.equality_term(-4i64, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::Int64(-4), TermKind::Equality) + dynamic(&FfiValue::Int64(-4), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -410,7 +525,7 @@ mod tests { ); let typed = keyset.equality_term(34u32, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::UInt32(34), TermKind::Equality) + dynamic(&FfiValue::UInt32(34), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -418,7 +533,7 @@ mod tests { ); let typed = keyset.equality_term(35u64, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::UInt64(35), TermKind::Equality) + dynamic(&FfiValue::UInt64(35), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -428,7 +543,7 @@ mod tests { .equality_term("alice".to_string(), nonempty!("users/x")) .await; assert_eq!( - dynamic(&s("alice"), TermKind::Equality).await.expect("eq"), + dynamic(&s("alice"), IndexSpec::Equality).await.expect("eq"), eq(typed.expect("typed")), "text equality" ); @@ -436,7 +551,7 @@ mod tests { .equality_term(Protected::new(b"ab".to_vec()), nonempty!("users/x")) .await; assert_eq!( - dynamic(&bytes(b"ab"), TermKind::Equality) + dynamic(&bytes(b"ab"), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -449,7 +564,7 @@ mod tests { .await .expect("typed"); assert_eq!( - dynamic(&s("alice smith"), TermKind::Match) + dynamic(&s("alice smith"), default_match()) .await .expect("match"), typed.to_bytes(), @@ -462,13 +577,13 @@ mod tests { ($value:expr, $leaf:expr, $label:literal) => { let typed = keyset.ore_term($value, nonempty!("users/x")).await; assert_eq!( - dynamic(&$leaf, TermKind::Ore).await.expect("ore"), + dynamic(&$leaf, IndexSpec::Ore).await.expect("ore"), typed.expect("typed").as_ref().to_vec(), concat!($label, " ore") ); let typed = keyset.ope_term($value, nonempty!("users/x")).await; assert_eq!( - dynamic(&$leaf, TermKind::Ope).await.expect("ope"), + dynamic(&$leaf, IndexSpec::Ope).await.expect("ope"), typed.expect("typed").as_ref().to_vec(), concat!($label, " ope") ); @@ -488,23 +603,29 @@ mod tests { #[test] fn supports_is_true() { for (label, leaf) in every_scalar() { - let scalar = Scalar::of(&leaf, TermKind::Ore).expect("a scalar"); - assert!(TermKind::Ore.supports(&scalar), "{label} takes an ore term"); - assert!(TermKind::Ope.supports(&scalar), "{label} takes an ope term"); + let scalar = Scalar::of(&leaf, &IndexSpec::Ore).expect("a scalar"); + assert!( + IndexSpec::Ore.supports(&scalar), + "{label} takes an ore term" + ); + assert!( + IndexSpec::Ope.supports(&scalar), + "{label} takes an ope term" + ); } for (label, leaf) in every_scalar() { - let scalar = Scalar::of(&leaf, TermKind::Equality).expect("a scalar"); + let scalar = Scalar::of(&leaf, &IndexSpec::Equality).expect("a scalar"); let prf_encodable = !matches!( leaf, FfiValue::Bool(_) | FfiValue::Float32(_) | FfiValue::Float64(_) ); assert_eq!( - TermKind::Equality.supports(&scalar), + IndexSpec::Equality.supports(&scalar), prf_encodable, "{label} takes an equality term exactly when it has a PRF encoding" ); assert_eq!( - TermKind::Match.supports(&scalar), + default_match().supports(&scalar), matches!(leaf, FfiValue::String(_)), "{label} takes a match term exactly when it is text" ); @@ -524,22 +645,22 @@ mod tests { let keyset = cipher.default_keyset(); let ctx = context(s("users/x")).expect("context"); let refused = [ - ("a bool", FfiValue::Bool(true), TermKind::Equality), - ("an f32", FfiValue::Float32(1.5), TermKind::Equality), - ("an f64", FfiValue::Float64(2.5), TermKind::Equality), - ("a bool", FfiValue::Bool(true), TermKind::Match), - ("a u32", FfiValue::UInt32(34), TermKind::Match), - ("bytes", bytes(b"ab"), TermKind::Match), + ("a bool", FfiValue::Bool(true), IndexSpec::Equality), + ("an f32", FfiValue::Float32(1.5), IndexSpec::Equality), + ("an f64", FfiValue::Float64(2.5), IndexSpec::Equality), + ("a bool", FfiValue::Bool(true), default_match()), + ("a u32", FfiValue::UInt32(34), default_match()), + ("bytes", bytes(b"ab"), default_match()), ]; for (label, leaf, kind) in refused { - let scalar = Scalar::of(&leaf, kind).expect("a scalar"); + let scalar = Scalar::of(&leaf, &kind).expect("a scalar"); assert!( !kind.supports(&scalar), "{label} must not take a {kind} term" ); - let result = term(&keyset, scalar, kind, ctx.clone()).await; + let result = term(&keyset, scalar, &kind, ctx.clone()).await; assert!( - matches!(result, Err(Error::Term { kind: k }) if k == kind), + matches!(&result, Err(Error::Term { kind: k }) if *k == kind), "{label} asked for a {kind} term must be refused as that kind: {result:?}" ); } @@ -566,14 +687,14 @@ mod tests { ]; for (label, value) in not_scalars { for kind in [ - TermKind::Equality, - TermKind::Match, - TermKind::Ore, - TermKind::Ope, + IndexSpec::Equality, + default_match(), + IndexSpec::Ore, + IndexSpec::Ope, ] { - let result = Scalar::of(&value, kind); + let result = Scalar::of(&value, &kind); assert!( - matches!(result, Err(Error::Term { kind: k }) if k == kind), + matches!(&result, Err(Error::Term { kind: k }) if *k == kind), "{label} has no {kind} term: {result:?}" ); } @@ -581,28 +702,269 @@ mod tests { } } - mod given_a_term_kind { + mod given_an_index_spec { use super::*; + fn non_default_match() -> MatchOptions { + MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + } + } + + /// The four keys are wire format: a plan spells each index by + /// exactly this string, and the stored record keys its term by it. + #[test] + fn its_keys_are_the_four_wire_strings() { + let cases = [ + (IndexSpec::Equality, "eq"), + (default_match(), "match"), + (IndexSpec::Match(non_default_match()), "match"), + (IndexSpec::Ore, "ore"), + (IndexSpec::Ope, "ope"), + ]; + for (spec, key) in cases { + assert_eq!(spec.key(), key, "{spec:?} rides under {key}"); + assert_eq!( + spec.to_string(), + key, + "the display form is the key, for error messages" + ); + } + } + + /// Every index under default options writes as its bare key and + /// reads back from it, so a plan written before options had a wire + /// form means what it always meant. #[test] - fn its_key_is_how_a_plan_spells_it() { - for kind in [ - TermKind::Equality, - TermKind::Match, - TermKind::Ore, - TermKind::Ope, + fn a_default_index_round_trips_through_its_bare_key() { + for spec in [ + IndexSpec::Equality, + default_match(), + IndexSpec::Ore, + IndexSpec::Ope, ] { + let wire = spec.to_value(); + assert!( + matches!(&wire, FfiValue::String(k) if utf8(k) == Some(spec.key())), + "{spec} writes as its bare key" + ); assert_eq!( - Output::parse(kind.key()), - Some(Output::Term(kind)), - "a plan spelling {kind} by its key names that term" + IndexSpec::from_value(&wire).expect("reads back"), + spec, + "{spec} reads back from its key" ); + assert_eq!(IndexSpec::parse(spec.key()), Some(spec.clone())); assert_eq!( - kind.to_string(), - kind.key(), - "the display form is the key, for error messages" + Output::parse(spec.key()), + Some(Output::Term(spec.clone())), + "a plan spelling {spec} by its key names that index" ); } + assert_eq!(IndexSpec::parse("c"), None, "the ciphertext is no index"); + assert_eq!(IndexSpec::parse("Match"), None, "keys are case-sensitive"); + } + + /// Non-default match options have a wire form, and it is lossless: + /// each option written, each read back. + #[test] + fn non_default_match_options_round_trip() { + let cases = [ + non_default_match(), + MatchOptions { + tokenizer: crate::sem::Tokenizer::Ngram { length: 4 }, + ..MatchOptions::default() + }, + MatchOptions { + downcase: false, + ..MatchOptions::default() + }, + MatchOptions { + k: 4, + ..MatchOptions::default() + }, + MatchOptions { + m: 512, + ..MatchOptions::default() + }, + ]; + for options in cases { + let spec = IndexSpec::Match(options.clone()); + let wire = spec.to_value(); + assert!( + matches!(&wire, FfiValue::Object(_)), + "{options:?} is not the default, so it writes as an object" + ); + assert_eq!( + IndexSpec::from_value(&wire).expect("reads back"), + spec, + "{options:?} round-trips" + ); + } + } + + /// The object form spells each option by name and defaults the + /// rest; the exact shape here is what a binding writes. + #[test] + fn the_object_form_defaults_omitted_options() { + let obj = |entries: Vec<(&str, FfiValue)>| { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + }; + let wire = obj(vec![( + "match", + obj(vec![ + ("tokenizer", obj(vec![("ngram", FfiValue::UInt32(4))])), + ("k", FfiValue::Int64(5)), + ("m", FfiValue::Int32(64)), + ]), + )]); + assert_eq!( + IndexSpec::from_value(&wire).expect("reads"), + IndexSpec::Match(MatchOptions { + tokenizer: crate::sem::Tokenizer::Ngram { length: 4 }, + k: 5, + m: 64, + ..MatchOptions::default() + }) + ); + let wire = obj(vec![( + "match", + obj(vec![ + ("tokenizer", s("standard")), + ("downcase", FfiValue::Bool(false)), + ("m", FfiValue::UInt64(2048)), + ]), + )]); + assert_eq!( + IndexSpec::from_value(&wire).expect("reads"), + IndexSpec::Match(MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + m: 2048, + ..MatchOptions::default() + }) + ); + // An empty options object is the defaults, and reads as the + // bare key does. + assert_eq!( + IndexSpec::from_value(&obj(vec![("match", obj(vec![]))])).expect("reads"), + default_match() + ); + } + + #[test] + fn a_malformed_wire_form_is_error_plan() { + let obj = |entries: Vec<(&str, FfiValue)>| { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + }; + let opts = |entries: Vec<(&str, FfiValue)>| obj(vec![("match", obj(entries))]); + let refused = [ + ("an unknown key", s("eqq")), + ("the ciphertext key", s("c")), + ("a number", FfiValue::UInt32(1)), + ("an object for eq", obj(vec![("eq", obj(vec![]))])), + ( + "match options that are not an object", + obj(vec![("match", s("x"))]), + ), + ( + "two entries", + obj(vec![("match", obj(vec![])), ("ore", obj(vec![]))]), + ), + ("an empty object", obj(vec![])), + ("an unknown option", opts(vec![("q", FfiValue::UInt32(1))])), + ( + "an option twice", + opts(vec![("k", FfiValue::UInt32(4)), ("k", FfiValue::UInt32(5))]), + ), + ( + "an unknown tokenizer", + opts(vec![("tokenizer", s("words"))]), + ), + ( + "a tokenizer object that is not ngram", + opts(vec![( + "tokenizer", + obj(vec![("words", FfiValue::UInt32(3))]), + )]), + ), + ( + "a zero n-gram", + opts(vec![( + "tokenizer", + obj(vec![("ngram", FfiValue::UInt32(0))]), + )]), + ), + ("a mistyped downcase", opts(vec![("downcase", s("yes"))])), + ("a negative k", opts(vec![("k", FfiValue::Int32(-3))])), + ("a float k", opts(vec![("k", FfiValue::Float64(3.0))])), + ("k out of bounds", opts(vec![("k", FfiValue::UInt32(17))])), + ( + "m not a power of two", + opts(vec![("m", FfiValue::UInt32(300))]), + ), + ( + "m too wide for u16", + opts(vec![("m", FfiValue::UInt64(1 << 17))]), + ), + ]; + for (label, wire) in refused { + assert!( + matches!(IndexSpec::from_value(&wire), Err(Error::Plan)), + "{label} is not an index" + ); + } + } + + /// A match index's options reach the derivation: the default is the + /// typed default's bytes, and other options derive other bytes — + /// those of a typed config naming the same options. + #[tokio::test] + async fn match_options_drive_the_derivation() { + struct Wide; + impl crate::sem::MatchConfig for Wide { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + } + } + } + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let text = || Scalar::of(&s("Alice Smith"), &default_match()).expect("text"); + let wide = term( + &keyset, + text(), + &IndexSpec::Match(non_default_match()), + ctx.clone(), + ) + .await + .expect("wide"); + let typed = keyset + .match_terms::("Alice Smith", nonempty!("users/x")) + .await + .expect("typed"); + assert_eq!(wide, typed.to_bytes(), "the options are the typed config's"); + let default = term(&keyset, text(), &default_match(), ctx) + .await + .expect("default"); + assert_ne!(wide, default, "other options derive other terms"); } } @@ -613,7 +975,7 @@ mod tests { fn debug_prints_none_of_it() { let rendered = format!( "{:?}", - Scalar::of(&s("hunter2"), TermKind::Equality).expect("a scalar") + Scalar::of(&s("hunter2"), &IndexSpec::Equality).expect("a scalar") ); assert!( !rendered.contains("hunter2"), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 10c4d17e7..7485d8176 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -292,7 +292,8 @@ pub use keyset::KeysetCipher; pub use stack_kms as kms; pub use target::{ CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, - EncryptFrom, EncryptInto, Encryption, Pending, PendingFuture, Request, Responses, + EncryptFrom, EncryptInto, Encrypted, Encryption, Equality, Index, IndexSpec, Indexes, Match, + Ope, Ore, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 9e00a8d74..bde08aae7 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -11,7 +11,7 @@ //! ``` //! //! * [`EqualityTerm`] — a PRF of the whole value; exact-match queries. -//! * [`MatchTerm`] — the value is tokenized locally, each token is PRF'd, and +//! * [`MatchTerms`] — the value is tokenized locally, each token is PRF'd, and //! the outputs fold into Bloom-filter bit positions; full-text match //! queries. Tokenizer/filter parameters are a *type-level* config //! ([`MatchConfig`]) so write-time and query-time terms agree by @@ -87,9 +87,9 @@ //! ([`as_bytes`](EqualityTerm::as_bytes) / //! [`to_bytes`](EqualityTerm::to_bytes) / //! [`from_bytes`](EqualityTerm::from_bytes)). -//! * [`MatchTerm`] — the sorted, de-duplicated bit positions, each a -//! little-endian `u16` ([`to_bytes`](MatchTerm::to_bytes) / -//! [`from_bytes`](MatchTerm::from_bytes)). +//! * [`MatchTerms`] — the sorted, de-duplicated bit positions, each a +//! little-endian `u16` ([`to_bytes`](MatchTerms::to_bytes) / +//! [`from_bytes`](MatchTerms::from_bytes)). //! * [`OreTerm`] / [`OpeTerm`] — the raw CLLW ciphertext bytes, unframed //! ([`as_bytes`](OreTerm::as_bytes) / [`to_bytes`](OreTerm::to_bytes) / //! [`from_bytes`](OreTerm::from_bytes)). @@ -98,7 +98,7 @@ //! [`TermBytesError`]; [`EqualityTerm`] additionally keeps an infallible //! [`from_bytes`](EqualityTerm::from_bytes) over a `[u8; 32]`. `as_bytes` //! exists only where the term *is* a contiguous buffer (equality, ORE, OPE); -//! a [`MatchTerm`] is canonically a position list, so it has none. +//! a [`MatchTerms`] is canonically a position list, so it has none. //! //! For equality and ORE/OPE the transport bytes are also the stored form, //! and they share the *shape* of the v1 / `cipherstash-client` encodings — a @@ -106,7 +106,7 @@ //! hex-encodes into its `hm` / `oc` / `op` fields with its hex and JSON //! framing sitting *above* them. A match term is the exception: what is //! stored and queried is the position list -//! ([`positions`](MatchTerm::positions)), which maps to an integer-array +//! ([`positions`](MatchTerms::positions)), which maps to an integer-array //! column (EQL sends `bf` as a JSON integer array) — no column holds the //! `u16` byte string, which is stack-encrypt's own shape and exists so a //! binding can carry the term across the boundary without inventing a @@ -214,7 +214,7 @@ pub enum TermBytesError { /// Match-term bytes are not a whole number of little-endian `u16` /// positions. #[error("match-term bytes must be little-endian u16 positions, got an odd length of {0}")] - OddMatchTermLength(usize), + OddMatchTermsLength(usize), /// A decoded position lies outside the Bloom filter the term's /// [`MatchConfig`] fixes. Genuine positions are always masked into /// `0..m`, so an out-of-range one means the bytes were not written by @@ -367,7 +367,7 @@ where /// Options controlling match-term generation. The defaults mirror the existing /// match indexer: 3-gram tokens, downcased, `k = 3` hash slices into an /// `m = 256`-bit filter. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct MatchOptions { /// How text splits into tokens. pub tokenizer: Tokenizer, @@ -397,7 +397,7 @@ impl MatchOptions { // Bounds mirror the v1 match indexer (`cipherstash-core`'s // `bloom_filter`: K_MIN/K_MAX/M_MIN/M_MAX) so the same configuration // validates identically across the two stacks. - fn validate(&self) -> Result { + pub(crate) fn validate(&self) -> Result { if let Tokenizer::Ngram { length: 0 } = self.tokenizer { return Err(TermError::InvalidOptions( "n-gram length must be at least 1", @@ -416,7 +416,7 @@ impl MatchOptions { } } -/// Type-level match configuration: the [`MatchOptions`] a [`MatchTerm`] +/// Type-level match configuration: the [`MatchOptions`] a [`MatchTerms`] /// is generated with. Putting the configuration on the *type* means a record /// field and the query probing it agree on tokenizer and filter parameters by /// construction. Define your own by implementing this on a marker type. @@ -434,15 +434,24 @@ impl MatchConfig for DefaultMatch { } } -/// A match (full-text) index term: the set bit positions of a Bloom filter over -/// the PRF outputs of the value's tokens, generated under the [`MatchConfig`] -/// `O`. Positions are sorted and de-duplicated. -pub struct MatchTerm { +/// The match (full-text) terms of a value: the set bit positions of a Bloom +/// filter over the PRF outputs of the value's tokens, generated under the +/// [`MatchConfig`] `O`. Positions are sorted and de-duplicated. +/// +/// The name is plural because the value is a *set*: every token contributes +/// `k` positions, and a query matches when its positions are a subset of the +/// stored ones ([`contains`](Self::contains)). An equality or ORE term is one +/// comparand; this is many. (It was named `MatchTerm` in stack-encrypt 0.2.) +pub struct MatchTerms { positions: Vec, _config: PhantomData O>, } -impl MatchTerm { +/// Renamed to [`MatchTerms`]: the value is a set of terms, not one. +#[deprecated(since = "0.3.0", note = "renamed to `MatchTerms`")] +pub type MatchTerm = MatchTerms; + +impl MatchTerms { /// Wrap positions that are already known to be in range — sorting and /// de-duplicating them into the canonical order. Private because nothing /// outside can know the range holds: the generator's positions are masked @@ -491,7 +500,7 @@ impl MatchTerm { /// query. Term generation already refuses to build such a term /// ([`TermError::EmptyTermText`]); this guards any other /// (e.g. deserialized) source of an empty term. - pub fn contains(&self, query: &MatchTerm) -> bool { + pub fn contains(&self, query: &MatchTerms) -> bool { !query.positions.is_empty() && query .positions @@ -503,7 +512,7 @@ impl MatchTerm { /// Rebuilding a term needs the [`MatchConfig`]: it fixes the filter size `m` /// every genuine position is below, and a position outside it is a decoding /// bug rather than a term. -impl MatchTerm { +impl MatchTerms { /// Rebuild a term from stored positions — the inverse of /// [`positions`](Self::positions) / /// [`into_positions`](Self::into_positions), for terms persisted @@ -534,7 +543,7 @@ impl MatchTerm { /// never matches. Rejects an odd-length buffer. pub fn from_bytes(bytes: &[u8]) -> Result { if !bytes.len().is_multiple_of(2) { - return Err(TermBytesError::OddMatchTermLength(bytes.len())); + return Err(TermBytesError::OddMatchTermsLength(bytes.len())); } Self::from_positions( bytes @@ -545,8 +554,8 @@ impl MatchTerm { } } -/// [`MatchTerm::from_bytes`] as a std conversion — the same decoder. -impl TryFrom<&[u8]> for MatchTerm { +/// [`MatchTerms::from_bytes`] as a std conversion — the same decoder. +impl TryFrom<&[u8]> for MatchTerms { type Error = TermBytesError; fn try_from(bytes: &[u8]) -> Result { @@ -554,27 +563,27 @@ impl TryFrom<&[u8]> for MatchTerm { } } -impl fmt::Debug for MatchTerm { +impl fmt::Debug for MatchTerms { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MatchTerm") + f.debug_struct("MatchTerms") .field("positions", &self.positions) .finish() } } -impl Clone for MatchTerm { +impl Clone for MatchTerms { fn clone(&self) -> Self { Self::normalised(self.positions.clone()) } } -impl PartialEq for MatchTerm { +impl PartialEq for MatchTerms { fn eq(&self, other: &Self) -> bool { self.positions == other.positions } } -impl Eq for MatchTerm {} +impl Eq for MatchTerms {} /// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into /// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, @@ -604,7 +613,7 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { // `k <= 16` (checked in `MatchOptions::validate`), so the `k` // 2-byte slices are disjoint; masking to `m - 1` maps each u16 into // the filter's `m` positions. The same token in a query text hits - // the same `k` positions, which is what `MatchTerm::contains` tests. + // the same `k` positions, which is what `MatchTerms::contains` tests. for i in 0..self.k { let chunk = [block[2 * i], block[2 * i + 1]]; positions.push(u16::from_le_bytes(chunk) & self.mask); @@ -630,7 +639,7 @@ fn match_term( text: &str, context: Context<'_>, options: MatchOptions, -) -> Result, TermError> { +) -> Result, TermError> { let mask = options.validate()?; let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); if tokens.is_empty() { @@ -644,14 +653,14 @@ fn match_term( .map_err(TermError::from_prf)?; // Every position came out of the visitor masked to `m - 1`, so the range // check `from_positions` applies is already satisfied by construction. - Ok(MatchTerm::normalised(positions)) + Ok(MatchTerms::normalised(positions)) } /// A match term of any text source, generated under `O`'s options. Under /// the local HMAC backend, derived during the synchronous build — the /// returned [`Pending`] carries no requests (tokenize makes the one /// necessary copy of the text). -impl<'c, S, K, O, T> Term> for MatchTerm +impl<'c, S, K, O, T> Term> for MatchTerms where S: AsRef, O: MatchConfig, @@ -708,7 +717,7 @@ macro_rules! index_term { } index_term!(EqualityTerm); -index_term!(MatchTerm, O: MatchConfig); +index_term!(MatchTerms, O: MatchConfig); index_term!(OreTerm, T: CllwOreEncrypt); index_term!(OpeTerm, T: CllwOpeEncrypt); @@ -1060,7 +1069,7 @@ impl KeysetCipher<'_, K> { /// define a marker type implementing [`MatchConfig`]. /// /// The same call serves both write time (index the stored text) and query - /// time (index the probe text, then test [`MatchTerm::contains`] + /// time (index the probe text, then test [`MatchTerms::contains`] /// server-side). /// /// Fails with [`TermError::EmptyTermText`] — as @@ -1072,16 +1081,30 @@ impl KeysetCipher<'_, K> { &self, text: &str, descriptor: NonEmpty>, - ) -> Pending<'_, MatchTerm, K> + ) -> Pending<'_, MatchTerms, K> where O: MatchConfig + MaybeSend, { - let term = match_term( - self.prf(), - text, - descriptor.into_prf_context(), - O::options(), - ); + self.match_terms_under(text, descriptor, O::options()) + } + + /// [`match_terms`](Self::match_terms) under runtime options, for the + /// `dynamic` path, whose plan carries a match index's options as data + /// (an `IndexSpec::Match`). Not public: the typed API keeps the options + /// on the type so a stored field and its probe cannot disagree, and a + /// binding reaches this only through a plan that names the options for + /// both. + #[cfg_attr(not(feature = "dynamic"), allow(dead_code))] + pub(crate) fn match_terms_under<'c, O>( + &self, + text: &str, + descriptor: NonEmpty>, + options: MatchOptions, + ) -> Pending<'_, MatchTerms, K> + where + O: MatchConfig + MaybeSend, + { + let term = match_term(self.prf(), text, descriptor.into_prf_context(), options); Pending::ready(self, term.map_err(Error::from)) } diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index 19ac3c119..1d5986fe5 100644 --- a/packages/stack-encrypt/src/sem/tokenize.rs +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -11,7 +11,7 @@ //! split produces, which only add noise bits to every filter). /// How text is split into tokens before each token is run through the PRF. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tokenizer { /// Sliding character n-grams of the given length over the whole text /// (whitespace included). Text shorter than `length` yields **no tokens**, diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs new file mode 100644 index 000000000..bb0117bb5 --- /dev/null +++ b/packages/stack-encrypt/src/target/index.rs @@ -0,0 +1,485 @@ +//! Indexes as types: what a field is searchable by, composed once in the +//! engine instead of once per caller. +//! +//! An *index* is an operation that derives a search term beside a +//! ciphertext: [`Equality`], [`Match`], [`Ore`] or [`Ope`]. Each is a type +//! implementing [`Index`] for exactly the plaintexts its scheme is defined +//! over, so an index that does not apply does not compile: `Match` is text +//! only, so a match index on an integer is a type error, not a runtime one. +//! +//! A field's indexes are one value: a single index, or a tuple of them +//! ([`Indexes`]). `()` is deliberately not a set of indexes, so asking for +//! an indexed field with no index is a compile error rather than a quiet +//! ciphertext-only field; a field with no index is [`ciphertext`] alone. +//! +//! [`indexed`] does the composition every caller used to spell by hand — +//! `ciphertext().accepting().zip(equality()).zip(ore()).map(..)`, with its +//! nested-pair bookkeeping — and produces an [`Encrypted`]: the +//! ciphertext, and the terms as a tuple read by destructuring. +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; +//! use stack_encrypt::target::{indexed, Borrowed, CallerContext, Encrypted, Equality, Ore}; +//! use stack_encrypt::{nonempty, StackCipher}; +//! +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! +//! let age = 34u32; +//! let out: Encrypted<(EqualityTerm, OreTerm)> = keyset +//! .run( +//! indexed::((Equality, Ore)), +//! &age, +//! CallerContext::from(nonempty!("users/age")), +//! ) +//! .await?; +//! let (eq, ore) = out.terms; +//! # let _ = (eq, ore, out.ciphertext); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! On the query side a field's indexes answer one at a time, with no +//! ciphertext: [`Indexes::select`] picks the index by type and +//! [`Index::operation`] is its term alone. +//! +//! For the FFI and for saved plans an index lowers to data, +//! [`IndexSpec`], through [`Index::spec`]. Parameters live on the index +//! (a [`Match`]'s tokenizer and filter size, through its [`MatchConfig`]) +//! and lower with it. +use std::fmt; +use std::marker::PhantomData; + +use super::context::{AeadContext, CallerContext}; +use super::operations::{ciphertext, equality, matching, ope, open, ore}; +use super::source::{ConsumeSource, ShareSource}; +use super::{DecryptInto, Decryption, Encryption}; +use crate::sem::{ + DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, +}; +use crate::StackCipherText; + +/// An index lowered to data: what crosses the FFI boundary and what a saved +/// plan holds. The Rust side keeps the type ([`Index`]); a binding, which +/// has no type to name, speaks this. It is the one data form of an index: +/// the `dynamic` record plan, its term derivation and the Go guest all +/// spell an index as an `IndexSpec`, options included. +/// +/// The [`key`](Self::key) strings are the output keys of the `dynamic` record +/// format (`"eq"`, `"match"`, `"ore"`, `"ope"`), so they are wire format; +/// that is why this enum is exhaustive: a new index is something every +/// binding has to be taught. With the `dynamic` feature, a plan spells an +/// index as its key, or, for a match index with non-default options, as an +/// object carrying them; `dynamic::record::plan` documents that shape, and +/// `IndexSpec::from_value` / `IndexSpec::to_value` read and write it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum IndexSpec { + /// Equality (exact match). + Equality, + /// Full-text match, with the options its terms are generated under. + Match(MatchOptions), + /// Order-revealing comparison. + Ore, + /// Order-preserving comparison. + Ope, +} + +impl IndexSpec { + /// The output key this index's term rides under in a record. + /// + /// This is the kind alone. It is the whole wire form of every index but + /// a match index with non-default options, whose options it drops; to + /// write an index to a plan, use the serialiser (`IndexSpec::to_value`, + /// with the `dynamic` feature), which keeps them. + pub fn key(&self) -> &'static str { + match self { + IndexSpec::Equality => "eq", + IndexSpec::Match(_) => "match", + IndexSpec::Ore => "ore", + IndexSpec::Ope => "ope", + } + } +} + +impl fmt::Display for IndexSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.key()) + } +} + +/// An operation that derives one search term of `S`. +/// +/// Implemented for exactly the plaintexts the index's scheme is defined +/// over, which is what makes an inapplicable index a compile error: there is +/// no `Index` for [`Match`], because match is defined over text. +/// +/// An index does not seal anything. [`indexed`] puts a ciphertext beside the +/// indexes of a field; [`operation`](Self::operation) on its own is the term +/// alone, which is the query side. +/// +/// The trait is open: an index defined in another crate implements it by +/// composing this module's public operations, as the four here do. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not an index of `{S}`", + label = "this index is not defined over `{S}`", + note = "an index applies only to the plaintexts its scheme is defined over: `Match` takes text, and `Equality`, `Ore` and `Ope` take the types their schemes encode" +)] +pub trait Index { + /// The term this index derives. + type Term: 'static; + /// This index as data, with its parameters. + fn spec(&self) -> IndexSpec; + /// The description that derives this index's term of `S`, under the + /// [`CallerContext`] it is handed when it runs. + /// + /// A fresh description per call: a description is single-use, and an + /// index is not, so a saved plan asks again each time it runs. + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's; +} + +/// The equality (exact-match) index: one PRF block over the whole value. +/// Defined for every `S` with a PRF encoding. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Equality; + +/// The order-revealing index: a CLLW ORE ciphertext of the value, under a +/// key derived from the field's context. Defined for every `S` the scheme +/// can order (text and integers among them). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Ore; + +/// The order-preserving index; see [`Ore`]. Defined for the same `S`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Ope; + +/// The full-text match index, over text only, with the tokenizer and filter +/// parameters its [`MatchConfig`] `O` fixes. +/// +/// `Match::default()` is the default configuration ([`DefaultMatch`]); +/// `Match::::new()` is another. The configuration is on the type so +/// that a stored field and the query probing it agree on it by construction. +pub struct Match(PhantomData O>); + +impl Match { + /// The match index under the configuration `O`. + pub const fn new() -> Self { + Self(PhantomData) + } +} +impl Default for Match { + fn default() -> Self { + Self::new() + } +} +// By hand, so `O` (a marker type) need not be `Clone`, `Debug` or `PartialEq`. +impl Clone for Match { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Match {} +impl fmt::Debug for Match { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Match") + } +} + +impl Index for Equality { + type Term = EqualityTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Equality + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + equality() + } +} +impl, O: MatchConfig + 'static> Index for Match { + type Term = MatchTerms; + fn spec(&self) -> IndexSpec { + IndexSpec::Match(O::options()) + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + matching() + } +} +impl Index for Ore +where + S: cllw_ore::CllwOreEncrypt + Send + 'static, + S::Output: Send + 'static, +{ + type Term = OreTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Ore + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + ore() + } +} +impl Index for Ope +where + S: cllw_ore::CllwOpeEncrypt + Send + 'static, + S::Output: Send + 'static, +{ + type Term = OpeTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Ope + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + ope() + } +} + +/// A non-empty set of indexes over `S`: one [`Index`], or a tuple of two to +/// four of them. +/// +/// Not implemented for `()`. An indexed field with no index would be a +/// ciphertext-only field that says otherwise; that field is [`ciphertext`] +/// alone, and asking [`indexed`] for one does not compile. +/// +/// The terms come out in the order the indexes are named: one index's +/// [`Terms`](Self::Terms) is its term, a tuple's is the tuple of its terms. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a set of indexes over `{S}`", + label = "expected one index, or a tuple of two to four, each defined over `{S}`", + note = "`()` is not a set of indexes: a field with no index is `ciphertext()` alone" +)] +pub trait Indexes { + /// What the indexes derive: one term, or a tuple of terms in order. + type Terms: 'static; + /// The indexes as data, in order. + fn specs(&self) -> Vec; + /// The description that derives every term, under the one + /// [`CallerContext`] it is handed, settling them in one batch. + /// + /// Several indexes over one owned plaintext share it, so in + /// [`Owned`](super::Owned) mode `S` must be `Clone` (see [`ShareSource`]). + fn operations<'s, K: 'static, M>(&self) -> Encryption<'s, S, Self::Terms, K, CallerContext, M> + where + S: 's, + M: ConsumeSource<'s, S> + ShareSource<'s, S>; + /// The one index of type `I` in this set, for the query side, where a + /// query asks one index for one term and needs no ciphertext; its + /// [`operation`](Index::operation) is that term alone. + /// + /// `P` is where `I` sits (the position [`Select`] names) and is inferred; + /// leave it `_`. Asking for an index the set does not hold does not + /// compile, and neither does asking for one the set holds twice, since + /// its place is then ambiguous. + /// + /// A concrete tuple is a set of indexes over many plaintexts, so there + /// the plaintext is named: + /// + /// ``` + /// use stack_encrypt::target::{Equality, Index, IndexSpec, Indexes, Ore}; + /// + /// let indexes = (Equality, Ore); + /// let ore = Indexes::::select::(&indexes); + /// assert_eq!(Index::::spec(ore), IndexSpec::Ore); + /// ``` + /// + /// In code generic over the set, the position is a type parameter too, + /// and the set must be bounded by [`Select`] for it: `X: Indexes` + /// alone does not let the call compile. + /// + /// ``` + /// use stack_encrypt::target::{Equality, Index, IndexSpec, Indexes, Ore, Select}; + /// + /// fn ore_spec(indexes: &X) -> IndexSpec + /// where + /// X: Indexes + Select, + /// { + /// Index::::spec(indexes.select::()) + /// } + /// assert_eq!(ore_spec(&(Equality, Ore)), IndexSpec::Ore); + /// ``` + fn select, P>(&self) -> &I + where + Self: Select, + { + Select::get(self) + } +} + +/// Where an index sits in a set of indexes, for [`Indexes::select`]: the +/// set *is* the index ([`Whole`]), or holds it at position `N` ([`At`]). +/// Implemented for every index and every tuple position; the position is +/// inferred, so a caller never names it. +pub trait Select { + /// The index at that position. + fn get(&self) -> &I; +} + +/// [`Select`]'s position for a set that is one index. +#[derive(Debug)] +pub enum Whole {} + +/// [`Select`]'s position for the `N`th index of a tuple, counting from zero. +#[derive(Debug)] +pub struct At; + +impl Select for I { + fn get(&self) -> &I { + self + } +} + +/// One index is a set of one. Implemented per index rather than as a +/// blanket over every `Index`, which would overlap the tuple impls below. +macro_rules! single_index { + ($([$($generics:tt)*] $index:ty),+ $(,)?) => {$( + impl Indexes for $index + where + $index: Index, + { + type Terms = <$index as Index>::Term; + fn specs(&self) -> Vec { + vec![self.spec()] + } + fn operations<'s, K: 'static, M>( + &self, + ) -> Encryption<'s, S, Self::Terms, K, CallerContext, M> + where + S: 's, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + { + self.operation() + } + } + )+}; +} +single_index!([] Equality, [O] Match, [] Ore, [] Ope); + +/// A tuple of indexes: each index's operation, zipped left to right, and the +/// nested pairs `zip` builds flattened back into one tuple of terms. +macro_rules! tuple_of_indexes { + ($(($($name:ident $at:tt),+) => $nested:pat,)+) => {$( + impl),+> Indexes for ($($name,)+) { + type Terms = ($($name::Term,)+); + fn specs(&self) -> Vec { + vec![$(self.$at.spec()),+] + } + fn operations<'s, K: 'static, M>( + &self, + ) -> Encryption<'s, S, Self::Terms, K, CallerContext, M> + where + S: 's, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + { + tuple_of_indexes!(@zip self, $($at),+) + .map(|$nested| ($($name,)+)) + } + } + tuple_of_indexes!(@select [$($name),+] $($name $at),+); + )+}; + (@zip $self:ident, $first:tt $(, $rest:tt)*) => { + $self.$first.operation()$(.zip($self.$rest.operation()))* + }; + // One `Select` impl per position, each over the whole tuple. + (@select [$($all:ident),+] $name:ident $at:tt $(, $rest:ident $rest_at:tt)*) => { + impl<$($all),+> Select<$name, At<$at>> for ($($all,)+) { + fn get(&self) -> &$name { + &self.$at + } + } + tuple_of_indexes!(@select [$($all),+] $($rest $rest_at),*); + }; + (@select [$($all:ident),+]) => {}; +} +// The closure's pattern re-binds each term under its index's type name, so +// the body can list them in order. +#[allow(non_snake_case)] +mod tuples { + use super::*; + tuple_of_indexes! { + (A 0, B 1) => (A, B), + (A 0, B 1, C 2) => ((A, B), C), + (A 0, B 1, C 2, D 3) => (((A, B), C), D), + } +} + +/// One value, sealed, with the terms of its indexes beside it: what +/// [`indexed`] produces. +/// +/// The terms are a tuple in the order the indexes were named, and are read +/// by destructuring: +/// +/// ```text +/// let (eq, ore) = out.terms; +/// ``` +/// +/// There are deliberately no named accessors (`out.ore()`): they would need +/// a type-level search of the tuple to serve a case that barely exists. A +/// struct with named fields is what `#[derive(EncryptFrom)]` is for. +/// +/// Decrypting it opens the ciphertext: the terms are one-way. +#[derive(Debug)] +pub struct Encrypted { + /// The sealed value. + pub ciphertext: StackCipherText, + /// The terms, one per index, in order. + pub terms: Terms, +} + +impl + 'static, Terms> DecryptInto

for Encrypted { + type Context = AeadContext; + fn decryption(self, context: Self::Context) -> Decryption { + open(self.ciphertext, context) + } +} + +/// Seal `S` and derive the terms of `indexes` beside it, under the one +/// [`CallerContext`] the description is handed: the ciphertext takes its +/// AEAD half, every term the whole. +/// +/// This is `ciphertext().accepting().zip(..).map(..)` composed once, here, +/// so a field with indexes is one line and its output is an +/// [`Encrypted`]. The bytes are exactly the hand composition's. +/// +/// The ciphertext and every term share the plaintext, so in +/// [`Owned`](super::Owned) mode `S` must be `Clone`; a plaintext that must +/// never be copied can still be sealed alone ([`ciphertext`]) or indexed +/// alone ([`Index::operation`]). +/// +/// The type parameters are in the order [`ciphertext`]'s are, with the +/// indexes last: `indexed::((Equality, Ore))`. +pub fn indexed<'s, S, K, M, X>( + indexes: X, +) -> Encryption<'s, S, Encrypted, K, CallerContext, M> +where + S: crate::Encrypt + 's, + K: 'static, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + X: Indexes, +{ + ciphertext::() + .accepting::() + .zip(indexes.operations()) + .map(|(ciphertext, terms)| Encrypted { ciphertext, terms }) +} diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 038b92c79..9dbffd417 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -87,6 +87,7 @@ //! `EncryptTarget` and `DecryptTarget` are no longer extension points. mod context; pub(crate) mod core; +mod index; mod operations; mod pending; mod request; @@ -95,9 +96,12 @@ pub mod transcode; pub(crate) use self::core::{decipher_pending, seal_pending}; pub use context::{AeadContext, CallerContext, DeclaredContext, ExpectedContext, Extends}; +pub use index::{ + indexed, At, Encrypted, Equality, Index, IndexSpec, Indexes, Match, Ope, Ore, Select, Whole, +}; pub use operations::{ - ciphertext, equality, matching, ope, open, ore, DecryptField, DecryptFrom, DecryptInto, - Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, + ciphertext, equality, matching, ope, open, ore, passthrough, DecryptField, DecryptFrom, + DecryptInto, Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, }; pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index fc9bca95c..32b386fac 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -367,6 +367,32 @@ pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S ), } } +/// Carry the plaintext through unchanged: the output is the source itself, +/// and the context the description is handed is ignored. +/// +/// This is a field that is present in a record's output so the record is +/// whole, and is **not encrypted and not authenticated**: nothing binds it +/// to the ciphertexts beside it, so whoever can write the stored record can +/// change it undetected. A field that must stay readable but be +/// tamper-evident is not a passthrough: seal it, with an equality index +/// beside it for lookup ([`indexed`](super::indexed) with +/// [`Equality`](super::Equality)). +/// +/// In [`Owned`](super::Owned) mode the value is moved through; in the +/// default [`Borrowed`] mode it is cloned once, which is what +/// `M: ConsumeSource<'s, S>` asks. `Ctx` is whatever the surrounding tree +/// hands its fields, so a passthrough zips beside any of them. +pub fn passthrough<'s, S, K, M, Ctx>() -> Encryption<'s, S, S, K, Ctx, M> +where + S: MaybeSend + 'static, + K: 'static, + M: ConsumeSource<'s, S>, + Ctx: 's, +{ + Encryption { + build: Box::new(move |source, cipher, _| Pending::ready(cipher, Ok(M::take(source)))), + } +} /// A term operation: `$function` produces `$output` from any `S` satisfying /// the bounds, under the [`CallerContext`] the tree hands it. /// @@ -422,7 +448,7 @@ term_operation!( term_operation!( /// The match term of any text `S` under the context the tree hands it, /// tokenised and hashed as `O` declares. - matching, crate::sem::MatchTerm, view, [O: crate::sem::MatchConfig + 'static], [S: AsRef] + matching, crate::sem::MatchTerms, view, [O: crate::sem::MatchConfig + 'static], [S: AsRef] ); term_operation!( /// The order-revealing term of `S` under the context the tree hands it. @@ -585,6 +611,11 @@ impl KeysetCipher<'_, K> { /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) /// # } /// ``` + /// + /// The opening side is [`run_decryption`](Self::run_decryption): an + /// [`Encryption`] is run with its source and context, a [`Decryption`] + /// already holds both. Either is single-use; a saved plan builds a fresh + /// one per call. pub fn run<'a, 's, S: 's, T: 'static, Ctx, M: SourceMode<'s, S>>( &'a self, encryption: Encryption<'s, S, T, K, Ctx, M>, @@ -593,6 +624,35 @@ impl KeysetCipher<'_, K> { ) -> Pending<'a, T, K> { (encryption.build)(source, self, context) } + /// Run a [`Decryption`] held in a variable under this keyset: the + /// counterpart of [`run`](Self::run), and what + /// [`decrypt_as`](Self::decrypt_as) does with a type's declaration. A + /// leaf sealed under another keyset is refused + /// ([`Error::ForeignKeyset`]) before any key is retrieved. + /// + /// ``` + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::target::{self, AeadContext, Decryption}; + /// use stack_encrypt::{nonempty, StackCipher, StackCipherText}; + /// + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let keyset = cipher.default_keyset(); + /// let context = || AeadContext::from(nonempty!("users/email")); + /// let sealed: StackCipherText = keyset.encrypt_as(&"bob@example.com".to_string(), context()).await?; + /// + /// let opening: Decryption = target::open(sealed, context()); + /// assert_eq!(keyset.run_decryption(opening).await?, "bob@example.com"); + /// # Ok(()) + /// # } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); + /// ``` + pub fn run_decryption<'a, P: 'static>( + &'a self, + decryption: Decryption, + ) -> Pending<'a, P, K> { + decryption.open_in(self) + } /// Recover `P` from `source`, as its declaration describes. A leaf sealed /// under another keyset is refused ([`Error::ForeignKeyset`]) before any /// key is retrieved. @@ -608,6 +668,15 @@ impl KeysetCipher<'_, K> { } } impl StackCipher { + /// Run a [`Decryption`] held in a variable through the client: leaves + /// from any of its keysets open here. See + /// [`KeysetCipher::run_decryption`], which refuses a foreign one. + pub fn run_decryption<'a, P: 'static>( + &'a self, + decryption: Decryption, + ) -> Pending<'a, P, K> { + decryption.open_in(self) + } /// Recover `P` from `source`, as its declaration describes. Leaves from /// any of the client's keysets open here. pub fn decrypt_as<'a, P: 'static, T>( @@ -735,7 +804,7 @@ impl EncryptFrom for crate::sem::EqualityT } } impl, O: crate::sem::MatchConfig + 'static> EncryptFrom - for crate::sem::MatchTerm + for crate::sem::MatchTerms { type Context = CallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs index 53949a1f2..c99071d09 100644 --- a/packages/stack-encrypt/src/target/transcode.rs +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -9,7 +9,7 @@ //! What a reader hands over is what the cipher produced, no more: a sealed //! leaf or marker is authenticated ciphertext, but passthrough metadata and //! terms are not, and a visitor must not present them as such. -use crate::sem::{EqualityTerm, MatchConfig, MatchTerm, OpeTerm, OreTerm}; +use crate::sem::{EqualityTerm, MatchConfig, MatchTerms, OpeTerm, OreTerm}; use crate::{BoxedPassthrough, CipherText, Error, SealedValue, StackCipherText}; /// An encrypted output that can drive a destination visitor. @@ -107,7 +107,7 @@ pub trait Visitor: Sized { /// # Errors /// /// Refuses with [`Error::UnsupportedShape`] unless overridden. - fn matching(self, _: MatchTerm) -> Result { + fn matching(self, _: MatchTerms) -> Result { Err(Error::UnsupportedShape) } /// An order-revealing term. One-way, and not authenticated. @@ -195,7 +195,7 @@ impl Reader for EqualityTerm { visitor.equality(self) } } -impl Reader for MatchTerm { +impl Reader for MatchTerms { fn read(self, visitor: V) -> Result { visitor.matching(self) } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 375190fa4..9f7ab97a1 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -10,7 +10,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; -use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; use stack_encrypt::{ nonempty, ContextPiece, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, @@ -63,13 +63,13 @@ async fn a_derived_record_is_the_hand_written_one() { } /// No `plaintext`: one impl generic over it, accepting whatever every leaf -/// accepts — here any text type, since `MatchTerm` wants `AsRef` — and +/// accepts — here any text type, since `MatchTerms` wants `AsRef` — and /// decrypting to whatever the ciphertext field opens to. #[derive(EncryptFrom, DecryptInto)] struct SearchableText { c: StackCipherText, hm: EqualityTerm, - m: MatchTerm, + m: MatchTerms, } /// Tuple structs assign by index. @@ -127,7 +127,7 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); - let m: MatchTerm = "alice" + let m: MatchTerms = "alice" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/name")) .await diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index b5833b27b..f930043a0 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -22,7 +22,9 @@ use std::borrow::Cow; use stack_encrypt::nonempty; -use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm, TermBytesError}; +use stack_encrypt::sem::{ + DefaultMatch, EqualityTerm, MatchTerms, OpeTerm, OreTerm, TermBytesError, +}; use stack_encrypt::target::EncryptInto; use stack_encrypt::{CipherText, Error, LeafBytesError, SealedValue, StackCipher}; use stack_kms::{ @@ -379,12 +381,12 @@ async fn match_term_bytes_are_pinned() { "040005000a000d000e001e002700350038003d0055005e005f0064006f007d007f009c00ad00bc00bd00ca00d000e000e500ef00" ); assert_eq!( - MatchTerm::::from_bytes(&bytes).expect("decode match term"), + MatchTerms::::from_bytes(&bytes).expect("decode match term"), term ); // The std conversion is the same decoder. assert_eq!( - MatchTerm::::try_from(bytes.as_slice()).expect("TryFrom decode"), + MatchTerms::::try_from(bytes.as_slice()).expect("TryFrom decode"), term ); } @@ -393,10 +395,10 @@ async fn match_term_bytes_are_pinned() { /// else. #[test] fn match_term_debug_is_its_positions() { - let term = MatchTerm::::from_positions(vec![17, 3]).expect("in range"); + let term = MatchTerms::::from_positions(vec![17, 3]).expect("in range"); assert_eq!( format!("{term:?}"), - "MatchTerm { positions: [3, 17] }", + "MatchTerms { positions: [3, 17] }", "Debug should show only the sorted positions" ); } @@ -404,8 +406,8 @@ fn match_term_debug_is_its_positions() { #[test] fn match_term_from_bytes_rejects_odd_length() { assert_eq!( - MatchTerm::::from_bytes(&[0x21]), - Err(TermBytesError::OddMatchTermLength(1)) + MatchTerms::::from_bytes(&[0x21]), + Err(TermBytesError::OddMatchTermsLength(1)) ); } @@ -416,14 +418,14 @@ fn match_term_from_bytes_rejects_positions_outside_the_filter() { // and the 0xffff a wrong-endian decoder produces, are both rejected — // they would otherwise decode cleanly and then silently never match. assert_eq!( - MatchTerm::::from_bytes(&[0x00, 0x01]), + MatchTerms::::from_bytes(&[0x00, 0x01]), Err(TermBytesError::MatchPositionOutOfRange { position: 256, filter_size: 256, }) ); assert_eq!( - MatchTerm::::from_bytes(&[0xff, 0xff]), + MatchTerms::::from_bytes(&[0xff, 0xff]), Err(TermBytesError::MatchPositionOutOfRange { position: 0xffff, filter_size: 256, @@ -432,20 +434,20 @@ fn match_term_from_bytes_rejects_positions_outside_the_filter() { // Byte-swapping a genuine term is exactly that failure: position 0x21 // becomes 0x2100. assert!(matches!( - MatchTerm::::from_bytes(&[0x00, 0x21]), + MatchTerms::::from_bytes(&[0x00, 0x21]), Err(TermBytesError::MatchPositionOutOfRange { .. }) )); // In-range positions round-trip, through both constructors. let positions = vec![0u16, 1, 255]; - let term = MatchTerm::::from_positions(positions.clone()).expect("in range"); + let term = MatchTerms::::from_positions(positions.clone()).expect("in range"); assert_eq!(term.positions(), positions.as_slice()); assert_eq!( - MatchTerm::::from_bytes(&term.to_bytes()).expect("decode"), + MatchTerms::::from_bytes(&term.to_bytes()).expect("decode"), term ); assert_eq!( - MatchTerm::::from_positions(vec![256]), + MatchTerms::::from_positions(vec![256]), Err(TermBytesError::MatchPositionOutOfRange { position: 256, filter_size: 256, diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs new file mode 100644 index 000000000..6ba6f04d8 --- /dev/null +++ b/packages/stack-encrypt/tests/index.rs @@ -0,0 +1,519 @@ +//! The engine pieces the plan builder lowers onto: indexes as types +//! (`Index`, `Indexes`, `indexed`, `Encrypted`), `passthrough`, and +//! running a description held in a variable (`run`, `run_decryption`). +//! +//! The load-bearing claim is byte identity: `indexed` is the hand +//! composition `ciphertext().accepting().zip(..)` and what the derive emits, +//! composed once — same terms, a ciphertext either opens — so a field +//! written one way is found and opened the other. + +mod common; + +use std::sync::atomic::Ordering; + +use common::{counting_cipher, stack_cipher}; +use stack_encrypt::sem::{ + DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, Tokenizer, +}; +use stack_encrypt::target::{ + ciphertext, equality, indexed, matching, ope, ore, passthrough, AeadContext, Borrowed, + CallerContext, DecryptFrom, Decryption, Encrypted, Encryption, Equality, Index, IndexSpec, + Indexes, Match, Ope, Ore, Owned, +}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipherText}; +use stack_kms::{FakeDataKeySource, IdentifiedBy}; +use vitaminc_protected::{Controlled, Protected}; + +fn caller() -> CallerContext { + CallerContext::from(nonempty!("users/email")) +} +fn aead() -> AeadContext { + AeadContext::from(nonempty!("users/email")) +} + +/// What the derive emits today for a field with equality and ORE beside its +/// ciphertext: the struct `indexed::((Equality, Ore))` replaces. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String)] +struct DerivedEmail { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm, +} + +mod given_equality_and_ore_over_a_string { + use super::*; + + #[tokio::test] + async fn indexed_derives_the_terms_the_hand_composition_and_the_derive_derive() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let email = "bob@example.com".to_string(); + + let out: Encrypted<(EqualityTerm, OreTerm)> = keyset + .run( + indexed::((Equality, Ore)), + &email, + caller(), + ) + .await + .expect("indexed"); + + let hand = ciphertext::() + .accepting::() + .zip(equality::()) + .zip(ore::()); + let ((hand_c, hand_eq), hand_ore) = keyset.run(hand, &email, caller()).await.expect("hand"); + + let derived: DerivedEmail = keyset.encrypt_as(&email, caller()).await.expect("derived"); + + let (eq, ore) = out.terms; + assert_eq!(eq, hand_eq, "equality term matches the hand composition"); + assert_eq!(ore, hand_ore, "ORE term matches the hand composition"); + assert_eq!(eq, derived.hm, "equality term matches the derive"); + assert_eq!(ore, derived.ob, "ORE term matches the derive"); + + // A ciphertext is sealed under a fresh data key, so two seals are + // never byte-equal; what must agree is that each opens under the + // same context to the same value, by the other path's reader. + let as_derived = DerivedEmail { + c: out.ciphertext, + hm: eq, + ob: ore, + }; + let opened: String = as_derived + .decrypt_into(&cipher, caller()) + .await + .expect("the derive's reader opens indexed's ciphertext"); + assert_eq!(opened, email); + let opened: String = Encrypted { + ciphertext: derived.c, + terms: (), + } + .decrypt_into(&cipher, aead()) + .await + .expect("Encrypted's reader opens the derive's ciphertext"); + assert_eq!(opened, email); + let opened: String = keyset + .decrypt_as(hand_c, aead()) + .await + .expect("the hand composition's ciphertext opens"); + assert_eq!(opened, email); + } + + #[tokio::test] + async fn owned_mode_derives_the_same_terms_and_seals_from_one_key_request() { + let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "bob@example.com".to_string(); + + let owned = keyset + .run( + indexed::((Equality, Ore)), + email.clone(), + caller(), + ) + .await + .expect("owned"); + assert_eq!(generates.load(Ordering::SeqCst), 1, "one batched request"); + let borrowed = keyset + .run( + indexed::((Equality, Ore)), + &email, + caller(), + ) + .await + .expect("borrowed"); + + assert_eq!( + owned.terms, borrowed.terms, + "the mode does not change a term" + ); + let opened: String = owned.decrypt_into(&cipher, aead()).await.expect("open"); + assert_eq!(opened, email); + } +} + +mod given_a_set_of_indexes { + use super::*; + + /// A configuration other than the default, so a spec that dropped the + /// options, or a term derived under the default ones, would show. + struct Words; + impl MatchConfig for Words { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: Tokenizer::Standard, + downcase: false, + k: 4, + m: 512, + } + } + } + + #[test] + fn specs_list_each_index_in_order_with_its_parameters() { + assert_eq!( + >::specs(&Equality), + [IndexSpec::Equality] + ); + assert_eq!(>::specs(&Ore), [IndexSpec::Ore]); + assert_eq!(>::specs(&Ope), [IndexSpec::Ope]); + assert_eq!( + >::specs(&Match::default()), + [IndexSpec::Match(MatchOptions::default())] + ); + assert_eq!( + as Indexes>::specs(&Match::::new()), + [IndexSpec::Match(Words::options())], + "a match index lowers its own configuration" + ); + assert_eq!( + <(Ore, Equality) as Indexes>::specs(&(Ore, Equality)), + [IndexSpec::Ore, IndexSpec::Equality] + ); + assert_eq!( + <(Ope, Ore, Equality) as Indexes>::specs(&(Ope, Ore, Equality)), + [IndexSpec::Ope, IndexSpec::Ore, IndexSpec::Equality] + ); + assert_eq!( + <(Match, Ope, Ore, Equality) as Indexes>::specs(&( + Match::::new(), + Ope, + Ore, + Equality + )), + [ + IndexSpec::Match(Words::options()), + IndexSpec::Ope, + IndexSpec::Ore, + IndexSpec::Equality + ] + ); + } + + #[test] + fn a_match_index_debugs_by_name_whatever_its_configuration() { + assert_eq!(format!("{:?}", Match::default()), "Match"); + assert_eq!(format!("{:?}", Match::::new()), "Match"); + let copied = Match::::new(); + let again = copied; + assert_eq!( + as Index>::spec(&copied), + as Index>::spec(&again), + "a match index is a plain value: copying it keeps its configuration" + ); + } + + #[test] + fn a_spec_is_keyed_and_displayed_as_the_record_format_spells_it() { + for (spec, key) in [ + (IndexSpec::Equality, "eq"), + (IndexSpec::Match(MatchOptions::default()), "match"), + (IndexSpec::Ore, "ore"), + (IndexSpec::Ope, "ope"), + ] { + assert_eq!(spec.key(), key); + assert_eq!(spec.to_string(), key); + } + } + + /// Each index's term is the standalone operation's, in the order the + /// indexes were named, for every tuple size. + #[tokio::test] + async fn every_tuple_yields_the_standalone_terms_in_order() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let text = "Alice Smith".to_string(); + + let eq: EqualityTerm = keyset + .run(equality::(), &text, caller()) + .await + .unwrap(); + let ore_t: OreTerm = keyset + .run(ore::(), &text, caller()) + .await + .unwrap(); + let ope_t: OpeTerm = keyset + .run(ope::(), &text, caller()) + .await + .unwrap(); + let words: MatchTerms = keyset + .run(matching::(), &text, caller()) + .await + .unwrap(); + let default_match: MatchTerms = keyset + .run( + matching::(), + &text, + caller(), + ) + .await + .unwrap(); + assert_ne!( + words.positions(), + default_match.positions(), + "the two configurations derive different terms, so the test can tell them apart" + ); + + let one = keyset + .run(indexed::(Ope), &text, caller()) + .await + .unwrap(); + assert_eq!(one.terms, ope_t, "one index's terms are its term"); + + let one = keyset + .run( + indexed::(Match::::new()), + &text, + caller(), + ) + .await + .unwrap(); + assert_eq!( + one.terms, words, + "a match index derives under its own configuration" + ); + + let two = keyset + .run( + indexed::((Ore, Equality)), + &text, + caller(), + ) + .await + .unwrap(); + assert_eq!(two.terms, (ore_t.clone(), eq.clone())); + + let three = keyset + .run( + indexed::((Ope, Equality, Ore)), + &text, + caller(), + ) + .await + .unwrap(); + assert_eq!(three.terms, (ope_t.clone(), eq.clone(), ore_t.clone())); + + let four = keyset + .run( + indexed::((Match::::new(), Ore, Ope, Equality)), + &text, + caller(), + ) + .await + .unwrap(); + let (m, o, p, e) = four.terms; + assert_eq!((m, o, p, e), (words, ore_t, ope_t, eq)); + let opened: String = four.ciphertext.decrypt_into(&cipher, aead()).await.unwrap(); + assert_eq!(opened, text); + } + + /// The query side: one index, picked by type, its term alone. + #[tokio::test] + async fn select_picks_each_index_and_its_operation_is_the_term_alone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let age = 34u32; + let indexes = (Equality, Ore, Ope); + let stored = keyset + .run(indexed::(indexes), &age, caller()) + .await + .unwrap(); + let (eq, ore_t, ope_t) = stored.terms; + + let q: EqualityTerm = keyset + .run( + Index::::operation::<_, Borrowed>(Indexes::::select::( + &indexes, + )), + &age, + caller(), + ) + .await + .unwrap(); + assert_eq!(q, eq, "position 0"); + let q: OreTerm = keyset + .run( + Index::::operation::<_, Borrowed>(Indexes::::select::(&indexes)), + &age, + caller(), + ) + .await + .unwrap(); + assert_eq!(q, ore_t, "position 1"); + let q: OpeTerm = keyset + .run( + Index::::operation::<_, Borrowed>(Indexes::::select::(&indexes)), + &age, + caller(), + ) + .await + .unwrap(); + assert_eq!(q, ope_t, "position 2"); + + let four = (Equality, Ore, Ope, Match::default()); + assert_eq!( + Index::::spec(Indexes::::select::(&four)), + IndexSpec::Match(MatchOptions::default()), + "position 3" + ); + assert_eq!( + Index::::spec(>::select::(&Ore)), + IndexSpec::Ore, + "one index selects itself" + ); + assert_eq!( + Index::::spec(<(Ope, Ore) as Indexes>::select::(&(Ope, Ore))), + IndexSpec::Ope, + "a pair's first" + ); + assert_eq!( + Index::::spec(<(Ope, Ore) as Indexes>::select::(&(Ope, Ore))), + IndexSpec::Ore, + "a pair's second" + ); + } + + /// The query side needs no copy: a term over an owned plaintext that is + /// not `Clone` runs from the selected index alone. + #[tokio::test] + async fn a_selected_index_runs_over_an_owned_plaintext_without_clone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let q: EqualityTerm = keyset + .run( + Index::>::operation::<_, Owned>(&Equality), + Protected::new("bob".to_string()), + caller(), + ) + .await + .unwrap(); + let typed: EqualityTerm = keyset + .run( + equality::(), + &"bob".to_string(), + caller(), + ) + .await + .unwrap(); + assert_eq!(q, typed); + } +} + +mod given_a_passthrough { + use super::*; + + /// A value that is moved, never copied. + struct Unclonable(Protected); + + #[tokio::test] + async fn borrowed_hands_back_a_copy_with_no_key_request_whatever_the_context() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let id = "row-7".to_string(); + // `()` is a context no operation can run under, so a passthrough + // that consulted its context could not run here at all. + let out: String = keyset + .run(passthrough::(), &id, ()) + .await + .expect("passthrough"); + assert_eq!(out, id); + assert_eq!(generates.load(Ordering::SeqCst), 0, "nothing is sealed"); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn owned_moves_the_value_through_without_clone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let out = keyset + .run( + passthrough::(), + Unclonable(Protected::new("row-7".to_string())), + caller(), + ) + .await + .expect("passthrough"); + assert_eq!(out.0.risky_ref(), "row-7"); + } + + /// Beside a sealed field it rides in the same run, which seals only the + /// ciphertext: one key request, and the carried value is the source. + #[tokio::test] + async fn beside_a_sealed_field_it_is_carried_unsealed() { + let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let value = "carried".to_string(); + let both: Encryption<'_, String, (StackCipherText, String), _, AeadContext> = + ciphertext::().zip(passthrough::()); + let (sealed, carried) = keyset.run(both, &value, aead()).await.expect("run"); + assert_eq!(carried, value); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "one batch, for the ciphertext" + ); + let opened: String = keyset.decrypt_as(sealed, aead()).await.expect("open"); + assert_eq!(opened, value); + } +} + +mod given_a_decryption_held_in_a_variable { + use super::*; + + #[tokio::test] + async fn run_decryption_opens_it_through_a_keyset_or_the_client() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let sealed: StackCipherText = keyset + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + let again: StackCipherText = keyset + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + + let opening: Decryption = + stack_encrypt::target::open(sealed, aead()); + assert_eq!(keyset.run_decryption(opening).await.unwrap(), "secret"); + let opening: Decryption = + stack_encrypt::target::open(again, aead()); + assert_eq!(cipher.run_decryption(opening).await.unwrap(), "secret"); + } + + #[tokio::test] + async fn a_keyset_refuses_another_keysets_leaf_and_the_client_opens_it() { + let cipher = stack_cipher().await; + let acme = cipher + .keyset(IdentifiedBy::Name("acme".to_string().into())) + .await + .unwrap(); + let globex = cipher + .keyset(IdentifiedBy::Name("globex".to_string().into())) + .await + .unwrap(); + let sealed: StackCipherText = acme + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + let again: StackCipherText = acme + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + + let result = globex + .run_decryption(stack_encrypt::target::open::(sealed, aead())) + .await; + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); + let opened = cipher + .run_decryption(stack_encrypt::target::open::(again, aead())) + .await + .unwrap(); + assert_eq!(opened, "secret"); + } +} diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index aebd1ad2f..1ce6b799f 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -4,7 +4,7 @@ use std::cmp::Ordering; use stack_encrypt::nonempty; -use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerm, Tokenizer}; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerms, Tokenizer}; use stack_encrypt::{Error, StackCipher}; use stack_kms::{FakeDataKeySource, IdentifiedBy}; use uuid::Uuid; @@ -152,7 +152,7 @@ async fn match_query_terms_are_contained_in_stored_terms() { #[test] fn match_containment_needs_every_query_position() { let term = |positions: &[u16]| { - MatchTerm::::from_positions(positions.to_vec()) + MatchTerms::::from_positions(positions.to_vec()) .expect("positions inside the default filter") }; let stored = term(&[3, 17, 200]); diff --git a/packages/stack-encrypt/tests/source_mode.rs b/packages/stack-encrypt/tests/source_mode.rs index 1b2d0f80f..0489248b0 100644 --- a/packages/stack-encrypt/tests/source_mode.rs +++ b/packages/stack-encrypt/tests/source_mode.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use stack_encrypt::sem::{EqualityTerm, MatchTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms}; use stack_encrypt::target::{ ciphertext, equality, matching, AeadContext, Borrowed, CallerContext, Encryption, Owned, }; @@ -169,12 +169,12 @@ async fn a_plaintext_that_is_not_clone_derives_a_match_term_alone() { let cipher = stack_cipher().await; let keyset = cipher.default_keyset(); - let term: MatchTerm = keyset + let term: MatchTerms = keyset .run(matching::<_, _, Owned, _>(), Secret::new(NUMBER), caller()) .await .unwrap(); - let expected: MatchTerm = keyset.match_terms(NUMBER, context()).await.unwrap(); + let expected: MatchTerms = keyset.match_terms(NUMBER, context()).await.unwrap(); assert_eq!( term, expected, "an owned match term should be byte-identical to the same text's term" @@ -310,11 +310,11 @@ async fn a_match_term_never_copies_its_text() { let keyset = cipher.default_keyset(); let (value, clones) = Counted::new(NUMBER); - let _: MatchTerm = keyset + let _: MatchTerms = keyset .run(matching::<_, _, Borrowed, _>(), &value, caller()) .await .unwrap(); - let _: MatchTerm = keyset + let _: MatchTerms = keyset .run(matching::<_, _, Owned, _>(), value, caller()) .await .unwrap(); @@ -430,12 +430,12 @@ async fn an_owned_plaintext_is_dropped_before_its_key_request_is_sent() { 1, "matching: dropped before run returns" ); - let term: MatchTerm = pending.await.unwrap(); + let term: MatchTerms = pending.await.unwrap(); assert_eq!( drops.load(Ordering::SeqCst), 1, "matching: dropped exactly once" ); - let expected: MatchTerm = keyset.match_terms(NUMBER, context()).await.unwrap(); + let expected: MatchTerms = keyset.match_terms(NUMBER, context()).await.unwrap(); assert_eq!(term, expected, "the term is the text's term"); } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 419fce494..dba4be098 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -8,7 +8,7 @@ use std::cmp::Ordering; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::Arc; -use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OreTerm}; use stack_encrypt::target::{ ciphertext, equality, CallerContext, DeclaredContext, DecryptFrom, DecryptInto, Decryption, EncryptFrom, EncryptInto, Encryption, Pending, Request, @@ -164,12 +164,12 @@ async fn match_leaf_supports_containment_queries() { let generator = generator().await; let generator = generator.default_keyset(); - let stored: MatchTerm = "alice wonderland" + let stored: MatchTerms = "alice wonderland" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); - let query: MatchTerm = "wonder" + let query: MatchTerms = "wonder" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -195,7 +195,7 @@ async fn match_leaf_config_is_type_level() { let generator = generator().await; let generator = generator.default_keyset(); - let term: MatchTerm = "a longer piece of text" + let term: MatchTerms = "a longer piece of text" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -204,7 +204,7 @@ async fn match_leaf_config_is_type_level() { // The same text under the default config is a different (larger-filter) // term — and a different type, so the two cannot be compared by mistake. - let default_term: MatchTerm = "a longer piece of text" + let default_term: MatchTerms = "a longer piece of text" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -712,7 +712,7 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { let cipher = cipher.default_keyset(); let b = "b".to_string(); - let zipped = MatchTerm::::encrypt_from(&"", &cipher, nonempty!("users/x")) + let zipped = MatchTerms::::encrypt_from(&"", &cipher, nonempty!("users/x")) .zip(StackCipherText::encrypt_from( &b, &cipher, @@ -862,12 +862,12 @@ async fn terms_rehydrate_from_persisted_parts() { let rehydrated = EqualityTerm::from_bytes(eq.clone().into_bytes()); assert_eq!(eq, rehydrated); - let stored: MatchTerm = "alice wonderland" + let stored: MatchTerms = "alice wonderland" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); - let query: MatchTerm = "wonder" + let query: MatchTerms = "wonder" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -876,7 +876,7 @@ async fn terms_rehydrate_from_persisted_parts() { // range-checks against the config's filter size). let mut positions = stored.clone().into_positions(); positions.reverse(); - let rehydrated: MatchTerm = MatchTerm::from_positions(positions).unwrap(); + let rehydrated: MatchTerms = MatchTerms::from_positions(positions).unwrap(); assert_eq!(stored, rehydrated); assert!(rehydrated.contains(&query)); } diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr index e2e9fda35..3f56416c7 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -12,7 +12,7 @@ help: the trait `DecryptField` is not implemented for `Opaqu = help: the following other types implement trait `DecryptField`: `CipherText>` implements `DecryptField` `EqualityTerm` implements `DecryptField` - `MatchTerm` implements `DecryptField` + `MatchTerms` implements `DecryptField` `OpeTerm` implements `DecryptField` `Option` implements `DecryptField, Ctx>` `OreTerm` implements `DecryptField` @@ -35,7 +35,7 @@ help: the trait `Decryptable` is not implemented for `Opaque` = help: the following other types implement trait `Decryptable`: CipherText> EqualityTerm - MatchTerm + MatchTerms OpeTerm Option OreTerm diff --git a/packages/stack-encrypt/tests/ui/indexes_empty_set.rs b/packages/stack-encrypt/tests/ui/indexes_empty_set.rs new file mode 100644 index 000000000..f724dbc76 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/indexes_empty_set.rs @@ -0,0 +1,8 @@ +//! `()` is not a set of indexes: an indexed field with no index would be a +//! ciphertext-only field that says otherwise. That field is `ciphertext()`. +use stack_encrypt::target::{indexed, Borrowed}; +use stack_kms::FakeDataKeySource; + +fn main() { + let _ = indexed::(()); +} diff --git a/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr b/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr new file mode 100644 index 000000000..9cdc9ca92 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr @@ -0,0 +1,59 @@ +error[E0277]: `()` is not a set of indexes over `u32` + --> tests/ui/indexes_empty_set.rs:7:57 + | +7 | let _ = indexed::(()); + | ^ expected one index, or a tuple of two to four, each defined over `u32` + | + = help: the trait `Indexes` is not implemented for `()` + = note: `()` is not a set of indexes: a field with no index is `ciphertext()` alone +help: the following other types implement trait `Indexes` + --> src/target/index.rs + | + | impl),+> Indexes for ($($name,)+) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | | + | `(A, B)` + | `(A, B, C)` + | `(A, B, C, D)` +... + | / tuple_of_indexes! { + | | (A 0, B 1) => (A, B), + | | (A 0, B 1, C 2) => ((A, B), C), + | | (A 0, B 1, C 2, D 3) => (((A, B), C), D), + | | } + | |_____- in this macro invocation +note: required by a bound in `indexed` + --> src/target/index.rs + | + | pub fn indexed<'s, S, K, M, X>( + | ------- required by a bound in this function +... + | X: Indexes, + | ^^^^^^^^^^ required by this bound in `indexed` + = note: this error originates in the macro `tuple_of_indexes` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `()` is not a set of indexes over `u32` + --> tests/ui/indexes_empty_set.rs:7:13 + | +7 | let _ = indexed::(()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected one index, or a tuple of two to four, each defined over `u32` + | + = help: the trait `Indexes` is not implemented for `()` + = note: `()` is not a set of indexes: a field with no index is `ciphertext()` alone +help: the following other types implement trait `Indexes` + --> src/target/index.rs + | + | impl),+> Indexes for ($($name,)+) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | | + | `(A, B)` + | `(A, B, C)` + | `(A, B, C, D)` +... + | / tuple_of_indexes! { + | | (A 0, B 1) => (A, B), + | | (A 0, B 1, C 2) => ((A, B), C), + | | (A 0, B 1, C 2, D 3) => (((A, B), C), D), + | | } + | |_____- in this macro invocation + = note: this error originates in the macro `tuple_of_indexes` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs new file mode 100644 index 000000000..6b363cb74 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs @@ -0,0 +1,8 @@ +//! One inapplicable index makes the whole set inapplicable: equality is +//! defined over `u32`, match is not. +use stack_encrypt::target::{indexed, Borrowed, Equality, Match}; +use stack_kms::FakeDataKeySource; + +fn main() { + let _ = indexed::((Equality, Match::default())); +} diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr new file mode 100644 index 000000000..cb38e177b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr @@ -0,0 +1,12 @@ +error[E0277]: the trait bound `u32: AsRef` is not satisfied + --> tests/ui/match_in_a_tuple_on_integer.rs:7:13 + | +7 | let _ = indexed::((Equality, Match::default())); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` + | +help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` + --> src/target/index.rs + | + | impl, O: MatchConfig + 'static> Index for Match { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `stack_encrypt::Match` to implement `stack_encrypt::Index` diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.rs b/packages/stack-encrypt/tests/ui/match_on_integer.rs new file mode 100644 index 000000000..37298c7ee --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_on_integer.rs @@ -0,0 +1,8 @@ +//! Match is defined over text, so a match index on an integer does not +//! compile. +use stack_encrypt::target::{indexed, Borrowed, Match}; +use stack_kms::FakeDataKeySource; + +fn main() { + let _ = indexed::(Match::default()); +} diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_on_integer.stderr new file mode 100644 index 000000000..309411cc5 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_on_integer.stderr @@ -0,0 +1,12 @@ +error[E0277]: the trait bound `u32: AsRef` is not satisfied + --> tests/ui/match_on_integer.rs:7:13 + | +7 | let _ = indexed::(Match::default()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` + | +help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` + --> src/target/index.rs + | + | impl, O: MatchConfig + 'static> Index for Match { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `stack_encrypt::Match` to implement `stack_encrypt::Index` diff --git a/packages/stack-encrypt/tests/ui/pass/indexes.rs b/packages/stack-encrypt/tests/ui/pass/indexes.rs new file mode 100644 index 000000000..1b0ca1cd7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/indexes.rs @@ -0,0 +1,26 @@ +//! The index sets that must compile: one index, tuples of two to four, a +//! default and a configured match index, over the plaintexts each is +//! defined for, in either source mode. +use stack_encrypt::sem::{MatchConfig, MatchOptions}; +use stack_encrypt::target::{indexed, Borrowed, Equality, Match, Ope, Ore, Owned}; +use stack_kms::FakeDataKeySource; + +struct Words; +impl MatchConfig for Words { + fn options() -> MatchOptions { + MatchOptions::default() + } +} + +fn main() { + let _ = indexed::(Equality); + let _ = indexed::((Equality, Ore)); + let _ = indexed::((Equality, Ore, Ope)); + let _ = indexed::(Match::default()); + let _ = indexed::(( + Equality, + Match::::new(), + Ore, + Ope, + )); +} diff --git a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs index ea6331265..4ae01d79f 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -1,7 +1,7 @@ //! A single operation in owned mode asks nothing of the plaintext beyond the //! operation's own capability: no `Clone`, so a value that is moved and //! wiped can be sealed or indexed. -use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, EqualityTerm, MatchTerms, OpeTerm, OreTerm}; use stack_encrypt::target::{ ciphertext, equality, matching, ope, ore, CallerContext, Owned, Pending, }; @@ -23,7 +23,7 @@ fn index<'a, S: vitaminc_prf::PrfValue>( fn search<'a, S: AsRef>( keyset: &'a KeysetCipher<'_, FakeDataKeySource>, value: S, -) -> Pending<'a, MatchTerm, FakeDataKeySource> { +) -> Pending<'a, MatchTerms, FakeDataKeySource> { keyset.run(matching::<_, _, Owned, _>(), value, CallerContext::from(nonempty!("column"))) } // Bounded only by the scheme's own trait: `CllwOreEncrypt` and diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index aaa4ad420..93e2a0d9b 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -2356,9 +2356,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2370,9 +2370,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2385,9 +2385,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2396,9 +2396,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2406,9 +2406,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2420,9 +2420,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2437,9 +2437,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2448,9 +2448,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -2463,9 +2463,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2474,9 +2474,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes",